mirror of
https://github.com/suitenumerique/drive.git
synced 2026-08-17 20:15:40 +02:00
🐛(frontend) stop drag events leaking out of the share modal
The share modal is portaled to the body but React still bubbles its drag events up the component tree, so dragging a contacts file over the import zone raised the explorer "drop your files here" toast behind the modal. Wrap the modal in a barrier stopping drag events, which also prevents a missed drop from sending the file to the current folder.
This commit is contained in:
+209
-203
@@ -39,6 +39,7 @@ import { Alert, VariantType } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { errorToString } from "@/features/api/APIError";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@/features/auth/Auth";
|
||||
import { DragEventBarrier } from "@/features/ui/components/drag-event-barrier/DragEventBarrier";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
type WorkspaceShareModalProps = {
|
||||
@@ -315,223 +316,228 @@ export const ItemShareModal = ({
|
||||
const updateLinkConfiguration = useMutationUpdateLinkConfiguration();
|
||||
|
||||
return (
|
||||
<ShareModal
|
||||
isOpen={isOpen}
|
||||
loading={isLoadingUsers ?? false}
|
||||
onClose={onClose}
|
||||
aria-label="Share modal"
|
||||
modalTitle={`${t("explorer.actions.share.modal.title")} ${removeFileExtension(item?.title ?? "")}`}
|
||||
canUpdate={item?.abilities.accesses_manage}
|
||||
canView={item?.abilities.accesses_view}
|
||||
accesses={accessesData}
|
||||
invitations={invitationsData}
|
||||
invitationRoles={rolesOptions}
|
||||
onDeleteAccess={(access) =>
|
||||
deleteAccess({
|
||||
itemId: itemId,
|
||||
accessId: access.id,
|
||||
})
|
||||
}
|
||||
onDeleteInvitation={(invitation) =>
|
||||
deleteInvitation({
|
||||
itemId: itemId,
|
||||
invitationId: invitation.id,
|
||||
})
|
||||
}
|
||||
onUpdateInvitation={(invitation, role) =>
|
||||
updateInvitation({
|
||||
itemId: itemId,
|
||||
invitationId: invitation.id,
|
||||
role: role as Role,
|
||||
})
|
||||
}
|
||||
onUpdateAccess={(access, role) => {
|
||||
// TODO: This should be added in the ui kit directly?
|
||||
if (role === access.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!access.is_explicit) {
|
||||
onInviteUser([access.user], role as Role);
|
||||
} else {
|
||||
updateAccess({
|
||||
<DragEventBarrier>
|
||||
<ShareModal
|
||||
isOpen={isOpen}
|
||||
loading={isLoadingUsers ?? false}
|
||||
onClose={onClose}
|
||||
aria-label="Share modal"
|
||||
modalTitle={`${t("explorer.actions.share.modal.title")} ${removeFileExtension(item?.title ?? "")}`}
|
||||
canUpdate={item?.abilities.accesses_manage}
|
||||
canView={item?.abilities.accesses_view}
|
||||
accesses={accessesData}
|
||||
invitations={invitationsData}
|
||||
invitationRoles={rolesOptions}
|
||||
onDeleteAccess={(access) =>
|
||||
deleteAccess({
|
||||
itemId: itemId,
|
||||
accessId: access.id,
|
||||
role: role as Role,
|
||||
user_id: access.user.id,
|
||||
});
|
||||
})
|
||||
}
|
||||
}}
|
||||
onSearchUsers={onSearch}
|
||||
hasNextMembers={false}
|
||||
hasNextInvitations={hasNextInvitations}
|
||||
searchUsersResult={queryValue === "" ? undefined : users}
|
||||
onInviteUser={(users, role) => onInviteUser(users, role as Role)}
|
||||
accessRoleTopMessage={(access) => {
|
||||
const availableRoles = access.abilities.set_role_to;
|
||||
const maxNbRoles = Object.values(Role).length;
|
||||
const isLastOwner =
|
||||
ownerCount === 1 &&
|
||||
availableRoles.length === 0 &&
|
||||
access.role === Role.OWNER;
|
||||
if (isLastOwner) {
|
||||
// If the current user is not the last owner, we don't show the message
|
||||
if (user?.id !== access.user.id) {
|
||||
onDeleteInvitation={(invitation) =>
|
||||
deleteInvitation({
|
||||
itemId: itemId,
|
||||
invitationId: invitation.id,
|
||||
})
|
||||
}
|
||||
onUpdateInvitation={(invitation, role) =>
|
||||
updateInvitation({
|
||||
itemId: itemId,
|
||||
invitationId: invitation.id,
|
||||
role: role as Role,
|
||||
})
|
||||
}
|
||||
onUpdateAccess={(access, role) => {
|
||||
// TODO: This should be added in the ui kit directly?
|
||||
if (role === access.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!access.is_explicit) {
|
||||
onInviteUser([access.user], role as Role);
|
||||
} else {
|
||||
updateAccess({
|
||||
itemId: itemId,
|
||||
accessId: access.id,
|
||||
role: role as Role,
|
||||
user_id: access.user.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onSearchUsers={onSearch}
|
||||
hasNextMembers={false}
|
||||
hasNextInvitations={hasNextInvitations}
|
||||
searchUsersResult={queryValue === "" ? undefined : users}
|
||||
onInviteUser={(users, role) => onInviteUser(users, role as Role)}
|
||||
accessRoleTopMessage={(access) => {
|
||||
const availableRoles = access.abilities.set_role_to;
|
||||
const maxNbRoles = Object.values(Role).length;
|
||||
const isLastOwner =
|
||||
ownerCount === 1 &&
|
||||
availableRoles.length === 0 &&
|
||||
access.role === Role.OWNER;
|
||||
if (isLastOwner) {
|
||||
// If the current user is not the last owner, we don't show the message
|
||||
if (user?.id !== access.user.id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return t("share_modal.options.top_message.only_owner");
|
||||
}
|
||||
|
||||
if (access.is_explicit) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return t("share_modal.options.top_message.only_owner");
|
||||
}
|
||||
const canDelete = access.abilities.destroy && access.is_explicit;
|
||||
const showRedirection =
|
||||
!canDelete || availableRoles.length < maxNbRoles;
|
||||
|
||||
if (access.is_explicit) {
|
||||
if (showRedirection) {
|
||||
return (
|
||||
<RedirectionToParentItem
|
||||
itemId={access.max_ancestors_role_item_id}
|
||||
afterRedirect={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
ownerCount === 1 &&
|
||||
availableRoles.length === 0 &&
|
||||
access.role === Role.OWNER
|
||||
) {
|
||||
return t("share_modal.options.top_message.only_owner");
|
||||
}
|
||||
if (availableRoles.length === 0 && access.role !== Role.OWNER) {
|
||||
return t("share_modal.options.top_message.to_lower_role");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}}
|
||||
getAccessRoles={(access) => {
|
||||
const availableRoles = access.abilities.set_role_to;
|
||||
|
||||
const canDelete = access.abilities.destroy && access.is_explicit;
|
||||
const showRedirection =
|
||||
!canDelete || availableRoles.length < maxNbRoles;
|
||||
const isDisabled = (role: Role) => {
|
||||
return !availableRoles.includes(role) && access.role !== role;
|
||||
};
|
||||
|
||||
if (showRedirection) {
|
||||
return (
|
||||
<RedirectionToParentItem
|
||||
itemId={access.max_ancestors_role_item_id}
|
||||
afterRedirect={onClose}
|
||||
return [
|
||||
{
|
||||
value: Role.READER,
|
||||
subText: t("share_modal.options.subtext.reader"),
|
||||
isDisabled: isDisabled(Role.READER),
|
||||
label: t("roles.reader"),
|
||||
},
|
||||
{
|
||||
value: Role.EDITOR,
|
||||
subText: t("share_modal.options.subtext.editor"),
|
||||
isDisabled: isDisabled(Role.EDITOR),
|
||||
label: t("roles.editor"),
|
||||
},
|
||||
{
|
||||
value: Role.ADMIN,
|
||||
subText: t("share_modal.options.subtext.admin"),
|
||||
isDisabled: isDisabled(Role.ADMIN),
|
||||
label: t("roles.administrator"),
|
||||
},
|
||||
{
|
||||
value: Role.OWNER,
|
||||
subText: t("share_modal.options.subtext.owner"),
|
||||
isDisabled: isDisabled(Role.OWNER),
|
||||
label: t("roles.owner"),
|
||||
},
|
||||
];
|
||||
}}
|
||||
outsideSearchContent={
|
||||
<>
|
||||
<ShareModalCopyLinkFooter
|
||||
onCopyLink={() => {
|
||||
if (item?.type === ItemType.FILE) {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/explorer/items/files/${itemId}`,
|
||||
);
|
||||
} else {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/explorer/items/${itemId}`,
|
||||
);
|
||||
}
|
||||
posthog.capture("click_copy_link", {
|
||||
item_id: itemId,
|
||||
item_title: item?.title,
|
||||
item_size: item?.size,
|
||||
item_mimetype: item?.mimetype,
|
||||
item_type: item?.type,
|
||||
item_link_reach:
|
||||
item?.computed_link_reach ?? item?.link_reach,
|
||||
item_link_role: item?.computed_link_role ?? item?.link_role,
|
||||
});
|
||||
}}
|
||||
onOk={() => {
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
</>
|
||||
}
|
||||
|
||||
if (
|
||||
ownerCount === 1 &&
|
||||
availableRoles.length === 0 &&
|
||||
access.role === Role.OWNER
|
||||
) {
|
||||
return t("share_modal.options.top_message.only_owner");
|
||||
}
|
||||
if (availableRoles.length === 0 && access.role !== Role.OWNER) {
|
||||
return t("share_modal.options.top_message.to_lower_role");
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
getAccessRoles={(access) => {
|
||||
const availableRoles = access.abilities.set_role_to;
|
||||
|
||||
const isDisabled = (role: Role) => {
|
||||
return !availableRoles.includes(role) && access.role !== role;
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
value: Role.READER,
|
||||
subText: t("share_modal.options.subtext.reader"),
|
||||
isDisabled: isDisabled(Role.READER),
|
||||
label: t("roles.reader"),
|
||||
},
|
||||
{
|
||||
value: Role.EDITOR,
|
||||
subText: t("share_modal.options.subtext.editor"),
|
||||
isDisabled: isDisabled(Role.EDITOR),
|
||||
label: t("roles.editor"),
|
||||
},
|
||||
{
|
||||
value: Role.ADMIN,
|
||||
subText: t("share_modal.options.subtext.admin"),
|
||||
isDisabled: isDisabled(Role.ADMIN),
|
||||
label: t("roles.administrator"),
|
||||
},
|
||||
{
|
||||
value: Role.OWNER,
|
||||
subText: t("share_modal.options.subtext.owner"),
|
||||
isDisabled: isDisabled(Role.OWNER),
|
||||
label: t("roles.owner"),
|
||||
},
|
||||
];
|
||||
}}
|
||||
outsideSearchContent={
|
||||
<>
|
||||
<ShareModalCopyLinkFooter
|
||||
onCopyLink={() => {
|
||||
if (item?.type === ItemType.FILE) {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/explorer/items/files/${itemId}`,
|
||||
);
|
||||
} else {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/explorer/items/${itemId}`,
|
||||
);
|
||||
}
|
||||
posthog.capture("click_copy_link", {
|
||||
item_id: itemId,
|
||||
item_title: item?.title,
|
||||
item_size: item?.size,
|
||||
item_mimetype: item?.mimetype,
|
||||
item_type: item?.type,
|
||||
item_link_reach: item?.computed_link_reach ?? item?.link_reach,
|
||||
item_link_role: item?.computed_link_role ?? item?.link_role,
|
||||
});
|
||||
}}
|
||||
onOk={() => {
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
linkSettings={true}
|
||||
accessRoleKey="max_role"
|
||||
linkReachChoices={linkReachChoices}
|
||||
linkRoleChoices={linkRoleChoices}
|
||||
showLinkRole={true}
|
||||
linkReach={item?.computed_link_reach ?? item?.link_reach}
|
||||
linkRole={item?.computed_link_role ?? item?.link_role}
|
||||
topLinkReachMessage={linkReachTopMessage}
|
||||
topLinkRoleMessage={linkRoleTopMessage}
|
||||
onUpdateLinkRole={(value) => {
|
||||
updateLinkConfiguration.mutate({
|
||||
itemId: itemId,
|
||||
link_reach:
|
||||
item?.computed_link_reach ??
|
||||
item?.link_reach ??
|
||||
LinkReach.RESTRICTED,
|
||||
link_role: value as LinkRole,
|
||||
});
|
||||
}}
|
||||
onUpdateLinkReach={(value) => {
|
||||
const linkRole =
|
||||
value === LinkReach.RESTRICTED
|
||||
? undefined
|
||||
: (item?.computed_link_role ?? item?.link_role ?? LinkRole.READER);
|
||||
updateLinkConfiguration.mutate({
|
||||
itemId: itemId,
|
||||
link_reach: value as LinkReach,
|
||||
link_role: linkRole,
|
||||
});
|
||||
}}
|
||||
allowFileImport={config.ALLOW_SHARE_IMPORT_FILE ?? false}
|
||||
onImportContacts={async (rows) => {
|
||||
try {
|
||||
await batchShare({
|
||||
itemId,
|
||||
rows: rows.map((row) => ({
|
||||
email: row.email,
|
||||
role: row.role as Role,
|
||||
})),
|
||||
linkSettings={true}
|
||||
accessRoleKey="max_role"
|
||||
linkReachChoices={linkReachChoices}
|
||||
linkRoleChoices={linkRoleChoices}
|
||||
showLinkRole={true}
|
||||
linkReach={item?.computed_link_reach ?? item?.link_reach}
|
||||
linkRole={item?.computed_link_role ?? item?.link_role}
|
||||
topLinkReachMessage={linkReachTopMessage}
|
||||
topLinkRoleMessage={linkRoleTopMessage}
|
||||
onUpdateLinkRole={(value) => {
|
||||
updateLinkConfiguration.mutate({
|
||||
itemId: itemId,
|
||||
link_reach:
|
||||
item?.computed_link_reach ??
|
||||
item?.link_reach ??
|
||||
LinkReach.RESTRICTED,
|
||||
link_role: value as LinkRole,
|
||||
});
|
||||
posthog.capture("import_share_contacts", {
|
||||
item_id: itemId,
|
||||
row_count: rows.length,
|
||||
}}
|
||||
onUpdateLinkReach={(value) => {
|
||||
const linkRole =
|
||||
value === LinkReach.RESTRICTED
|
||||
? undefined
|
||||
: (item?.computed_link_role ??
|
||||
item?.link_role ??
|
||||
LinkRole.READER);
|
||||
updateLinkConfiguration.mutate({
|
||||
itemId: itemId,
|
||||
link_reach: value as LinkReach,
|
||||
link_role: linkRole,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
setImportModalChildren(
|
||||
<Alert type={VariantType.ERROR}>{errorToString(error)}</Alert>,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
importModalChildren={importModalChildren}
|
||||
onImportFileChange={() => setImportModalChildren(undefined)}
|
||||
>
|
||||
{!item?.abilities.accesses_manage && <HorizontalSeparator />}
|
||||
</ShareModal>
|
||||
}}
|
||||
allowFileImport={config.ALLOW_SHARE_IMPORT_FILE ?? false}
|
||||
onImportContacts={async (rows) => {
|
||||
try {
|
||||
await batchShare({
|
||||
itemId,
|
||||
rows: rows.map((row) => ({
|
||||
email: row.email,
|
||||
role: row.role as Role,
|
||||
})),
|
||||
});
|
||||
posthog.capture("import_share_contacts", {
|
||||
item_id: itemId,
|
||||
row_count: rows.length,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
setImportModalChildren(
|
||||
<Alert type={VariantType.ERROR}>{errorToString(error)}</Alert>,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
importModalChildren={importModalChildren}
|
||||
onImportFileChange={() => setImportModalChildren(undefined)}
|
||||
>
|
||||
{!item?.abilities.accesses_manage && <HorizontalSeparator />}
|
||||
</ShareModal>
|
||||
</DragEventBarrier>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { DragEvent, PropsWithChildren } from "react";
|
||||
|
||||
const stopPropagation = (event: DragEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops drag events from bubbling up the React tree past this component.
|
||||
*
|
||||
* Content portaled from inside the explorer (modals, previews) still
|
||||
* bubbles its synthetic drag events to the explorer dropzone, which
|
||||
* shows the global upload toast. Wrap such content with this barrier
|
||||
* when it handles file drags itself.
|
||||
*/
|
||||
export const DragEventBarrier = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "contents" }}
|
||||
onDragEnter={stopPropagation}
|
||||
onDragOver={stopPropagation}
|
||||
onDragLeave={stopPropagation}
|
||||
onDrop={stopPropagation}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -77,7 +77,9 @@ test.describe("Share modal contacts import", () => {
|
||||
await expect(
|
||||
importModal.getByText("2 rows ready to be imported."),
|
||||
).toBeVisible();
|
||||
await importModal.getByRole("button", { name: "Import", exact: true }).click();
|
||||
await importModal
|
||||
.getByRole("button", { name: "Import", exact: true })
|
||||
.click();
|
||||
|
||||
await expect(importModal).toBeHidden();
|
||||
await expectUserInMembersList(page, "user@webkit.test", "Editor");
|
||||
@@ -87,6 +89,39 @@ test.describe("Share modal contacts import", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("dragging a file over the import modal does not trigger the explorer upload toast", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clearDb();
|
||||
await mockConfig(page, true);
|
||||
await login(page, "drive@example.com");
|
||||
await goToNewFolder(page, "Import drag folder");
|
||||
|
||||
const importModal = await openImportModal(page);
|
||||
const dropzone = importModal.locator(".c__file-uploader__dropzone");
|
||||
const dataTransfer = await page.evaluateHandle(() => {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(
|
||||
new File(["email,role"], "contacts.csv", { type: "text/csv" }),
|
||||
);
|
||||
return dt;
|
||||
});
|
||||
|
||||
await dropzone.dispatchEvent("dragenter", { dataTransfer });
|
||||
await dropzone.dispatchEvent("dragover", { dataTransfer });
|
||||
|
||||
// The import dropzone reacts to the drag, proving the events flowed...
|
||||
await expect(
|
||||
importModal.locator(".c__file-uploader__dropzone--dragging"),
|
||||
).toBeVisible();
|
||||
// ...but the explorer upload toast behind the modal must stay hidden.
|
||||
await expect(
|
||||
page.getByText("Drop your files here to transfer them in", {
|
||||
exact: false,
|
||||
}),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("a failed import shows the backend error inside the import modal", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -112,7 +147,9 @@ test.describe("Share modal contacts import", () => {
|
||||
|
||||
const importModal = await openImportModal(page);
|
||||
await importModal.locator('input[type="file"]').setInputFiles(CONTACTS_CSV);
|
||||
await importModal.getByRole("button", { name: "Import", exact: true }).click();
|
||||
await importModal
|
||||
.getByRole("button", { name: "Import", exact: true })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
importModal.getByText("This import is not valid."),
|
||||
|
||||
Reference in New Issue
Block a user