🐛(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:
Nathan Vasse
2026-08-03 17:16:51 +02:00
parent 60fe2236c8
commit 92199e0fcc
3 changed files with 275 additions and 205 deletions
@@ -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>
);
};
@@ -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."),