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."),