diff --git a/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx b/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx index b831524c..9a618911 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx @@ -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 ( - - 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({ + + + 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 ( + + ); + } + + 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 ( - + { + 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={ - <> - { - 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( - {errorToString(error)}, - ); - return false; - } - }} - importModalChildren={importModalChildren} - onImportFileChange={() => setImportModalChildren(undefined)} - > - {!item?.abilities.accesses_manage && } - + }} + 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( + {errorToString(error)}, + ); + return false; + } + }} + importModalChildren={importModalChildren} + onImportFileChange={() => setImportModalChildren(undefined)} + > + {!item?.abilities.accesses_manage && } + + ); }; diff --git a/src/frontend/apps/drive/src/features/ui/components/drag-event-barrier/DragEventBarrier.tsx b/src/frontend/apps/drive/src/features/ui/components/drag-event-barrier/DragEventBarrier.tsx new file mode 100644 index 00000000..225409dd --- /dev/null +++ b/src/frontend/apps/drive/src/features/ui/components/drag-event-barrier/DragEventBarrier.tsx @@ -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 ( +
+ {children} +
+ ); +}; diff --git a/src/frontend/apps/e2e/__tests__/app-drive/share-import.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/share-import.spec.ts index 6e8f33e9..4e7c61f1 100644 --- a/src/frontend/apps/e2e/__tests__/app-drive/share-import.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-drive/share-import.spec.ts @@ -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."),