(frontend) share an item with contacts imported from a file

Plug the ui-kit share modal file import onto the new batch share
endpoint. The option only shows up when ALLOW_SHARE_IMPORT_FILE is
enabled in the config. Errors are rendered inside the import modal
instead of the global toast so the user can fix the file and retry
without losing context.

The e2e environment enables the flag to cover the flow end to end.
This commit is contained in:
Nathan Vasse
2026-07-28 11:37:10 +02:00
parent 41e796b2a9
commit ab8e0ebc39
10 changed files with 203 additions and 1 deletions
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to
- ✨(frontend) show the messages widget button on the homepage - ✨(frontend) show the messages widget button on the homepage
- ✨(frontend) open the messages widget from the help menu - ✨(frontend) open the messages widget from the help menu
- ✨(backend) add an item batch share endpoint gated by ALLOW_SHARE_IMPORT_FILE - ✨(backend) add an item batch share endpoint gated by ALLOW_SHARE_IMPORT_FILE
- ✨(frontend) share an item with contacts imported from a file
### Changed ### Changed
+2
View File
@@ -3,3 +3,5 @@ FRONTEND_HELP_MENU_CONFIG={"documentationUrl": "https://docs.numerique.gouv.fr/d
WOPI_CLIENTS="collabora" WOPI_CLIENTS="collabora"
# Report uploads as safe synchronously so files are ready instantly in e2e. # Report uploads as safe synchronously so files are ready instantly in e2e.
MALWARE_DETECTION_BACKEND=lasuite.malware_detection.backends.dummy.DummyBackend MALWARE_DETECTION_BACKEND=lasuite.malware_detection.backends.dummy.DummyBackend
# Enable the share modal contacts file import tested by share-import.spec.ts
ALLOW_SHARE_IMPORT_FILE=True
@@ -18,6 +18,14 @@ export type DTODeleteAccess = {
accessId: string; accessId: string;
}; };
export type DTOBatchShare = {
itemId: string;
rows: {
email: string;
role: Role;
}[];
};
export type DTOUpdateLinkConfiguration = { export type DTOUpdateLinkConfiguration = {
itemId: string; itemId: string;
link_reach: LinkReach; link_reach: LinkReach;
@@ -1,5 +1,6 @@
import { ExplorerFilterModifiedValue } from "../explorer/components/filters/ExplorerFilterModified"; import { ExplorerFilterModifiedValue } from "../explorer/components/filters/ExplorerFilterModified";
import { import {
DTOBatchShare,
DTOCreateAccess, DTOCreateAccess,
DTODeleteAccess, DTODeleteAccess,
DTOUpdateAccess, DTOUpdateAccess,
@@ -142,6 +143,7 @@ export abstract class Driver {
abstract deleteFavoriteItem(itemId: string): Promise<void>; abstract deleteFavoriteItem(itemId: string): Promise<void>;
abstract getItemAccesses(itemId: string): Promise<Access[]>; abstract getItemAccesses(itemId: string): Promise<Access[]>;
abstract createAccess(data: DTOCreateAccess): Promise<void>; abstract createAccess(data: DTOCreateAccess): Promise<void>;
abstract batchShare(payload: DTOBatchShare): Promise<void>;
abstract updateAccess(payload: DTOUpdateAccess): Promise<Access | void>; abstract updateAccess(payload: DTOUpdateAccess): Promise<Access | void>;
abstract updateLinkConfiguration( abstract updateLinkConfiguration(
payload: DTOUpdateLinkConfiguration, payload: DTOUpdateLinkConfiguration,
@@ -12,6 +12,7 @@ import {
DTOUpdateInvitation, DTOUpdateInvitation,
} from "../DTOs/InvitationDTO"; } from "../DTOs/InvitationDTO";
import { import {
DTOBatchShare,
DTOCreateAccess, DTOCreateAccess,
DTOUpdateLinkConfiguration, DTOUpdateLinkConfiguration,
} from "../DTOs/AccessesDTO"; } from "../DTOs/AccessesDTO";
@@ -193,6 +194,15 @@ export class StandardDriver extends Driver {
}); });
} }
async batchShare(payload: DTOBatchShare): Promise<void> {
await fetchAPI(`items/${payload.itemId}/batch-share/`, {
method: "POST",
body: JSON.stringify({
rows: payload.rows,
}),
});
}
async deleteAccess(payload: DTODeleteAccess): Promise<void> { async deleteAccess(payload: DTODeleteAccess): Promise<void> {
await fetchAPI(`items/${payload.itemId}/accesses/${payload.accessId}/`, { await fetchAPI(`items/${payload.itemId}/accesses/${payload.accessId}/`, {
method: "DELETE", method: "DELETE",
@@ -201,6 +201,7 @@ export interface ThemeCustomization {
} }
export type ApiConfig = { export type ApiConfig = {
ALLOW_SHARE_IMPORT_FILE?: boolean;
AWS_S3_UPLOAD_ACL?: string; AWS_S3_UPLOAD_ACL?: string;
DATA_UPLOAD_MAX_MEMORY_SIZE?: number; DATA_UPLOAD_MAX_MEMORY_SIZE?: number;
POSTHOG_KEY?: string; POSTHOG_KEY?: string;
@@ -9,6 +9,7 @@ import {
User, User,
} from "@/features/drivers/types"; } from "@/features/drivers/types";
import { import {
useMutationBatchShare,
useMutationCreateAccess, useMutationCreateAccess,
useMutationCreateInvitation, useMutationCreateInvitation,
useMutationDeleteAccess, useMutationDeleteAccess,
@@ -16,6 +17,7 @@ import {
useMutationUpdateAccess, useMutationUpdateAccess,
useMutationUpdateInvitation, useMutationUpdateInvitation,
} from "@/features/explorer/hooks/useMutationsAccesses"; } from "@/features/explorer/hooks/useMutationsAccesses";
import { useConfig } from "@/features/config/ConfigProvider";
import { useMutationUpdateLinkConfiguration } from "@/features/explorer/hooks/useMutations"; import { useMutationUpdateLinkConfiguration } from "@/features/explorer/hooks/useMutations";
import { import {
useInfiniteItemInvitations, useInfiniteItemInvitations,
@@ -32,7 +34,9 @@ import {
} from "@gouvfr-lasuite/ui-kit"; } from "@gouvfr-lasuite/ui-kit";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useMemo, useRef, useState } from "react"; import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Alert, VariantType } from "@gouvfr-lasuite/cunningham-react";
import { errorToString } from "@/features/api/APIError";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAuth } from "@/features/auth/Auth"; import { useAuth } from "@/features/auth/Auth";
import posthog from "posthog-js"; import posthog from "posthog-js";
@@ -50,6 +54,7 @@ export const ItemShareModal = ({
}: WorkspaceShareModalProps) => { }: WorkspaceShareModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { config } = useConfig();
const { user } = useAuth(); const { user } = useAuth();
const copyToClipboard = useClipboard(); const copyToClipboard = useClipboard();
const itemId = initialItem.originalId ?? initialItem.id; const itemId = initialItem.originalId ?? initialItem.id;
@@ -77,6 +82,8 @@ export const ItemShareModal = ({
const { mutateAsync: deleteAccess } = useMutationDeleteAccess(); const { mutateAsync: deleteAccess } = useMutationDeleteAccess();
const { mutateAsync: deleteInvitation } = useMutationDeleteInvitation(); const { mutateAsync: deleteInvitation } = useMutationDeleteInvitation();
const { mutateAsync: updateInvitation } = useMutationUpdateInvitation(); const { mutateAsync: updateInvitation } = useMutationUpdateInvitation();
const { mutateAsync: batchShare } = useMutationBatchShare();
const [importModalChildren, setImportModalChildren] = useState<ReactNode>();
const rolesOptions = useMemo( const rolesOptions = useMemo(
() => () =>
@@ -498,6 +505,30 @@ export const ItemShareModal = ({
link_role: linkRole, 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,
})),
});
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 />} {!item?.abilities.accesses_manage && <HorizontalSeparator />}
</ShareModal> </ShareModal>
@@ -19,6 +19,23 @@ export const useMutationCreateAccess = () => {
}); });
}; };
export const useMutationBatchShare = () => {
const driver = getDriver();
const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation();
return useMutation({
// Errors are displayed inside the import modal, not by the global toast
meta: { noGlobalError: true },
mutationFn: (...payload: Parameters<typeof driver.batchShare>) => {
return driver.batchShare(...payload);
},
onSuccess: (_, variables) => {
// A batch can create both accesses and invitations
onSuccessAccessOrInvitation(variables.itemId, false);
onSuccessAccessOrInvitation(variables.itemId, true);
},
});
};
export const useMutationCreateInvitation = () => { export const useMutationCreateInvitation = () => {
const driver = getDriver(); const driver = getDriver();
const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation(); const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation();
@@ -0,0 +1,2 @@
user@webkit.test;editor
imported@example.com;reader
1 user@webkit.test editor
2 imported@example.com reader
@@ -0,0 +1,128 @@
import { Page, expect, test } from "@playwright/test";
import path from "path";
import { clearDb, login } from "./utils-common";
import { createFolderInCurrentFolder } from "./utils-item";
import { clickToMyFiles, navigateToFolder } from "./utils-navigate";
import {
expectUserInMembersList,
getShareModal,
openShareModal,
} from "./utils/share-utils";
const CONTACTS_CSV = path.join(__dirname, "assets/share-contacts.csv");
const mockConfig = async (page: Page, allowShareImportFile: boolean) => {
await page.route("**/api/v1.0/config/", async (route) => {
const response = await route.fetch();
const json = await response.json();
json.ALLOW_SHARE_IMPORT_FILE = allowShareImportFile;
await route.fulfill({ response, json });
});
};
const getImportModal = (page: Page) => {
return page
.locator(".c__modal")
.filter({ has: page.locator(".c__share__import__modal") });
};
const openImportModal = async (page: Page) => {
const shareModal = await openShareModal(page);
await shareModal.getByRole("button", { name: "Import contacts" }).click();
await page.getByRole("menuitem", { name: "Import contacts" }).click();
const importModal = getImportModal(page);
await expect(importModal).toBeVisible();
return importModal;
};
const goToNewFolder = async (page: Page, folderName: string) => {
await page.goto("/");
await clickToMyFiles(page);
await createFolderInCurrentFolder(page, folderName);
await navigateToFolder(page, folderName, ["My files", folderName]);
};
test.describe("Share modal contacts import", () => {
test("the import entry is hidden when the feature is disabled", async ({
page,
}) => {
await clearDb();
await mockConfig(page, false);
await login(page, "drive@example.com");
await goToNewFolder(page, "Import disabled");
const shareModal = await openShareModal(page);
await expect(shareModal.getByTestId("members-list")).toBeVisible();
await expect(
shareModal.getByRole("button", { name: "Import contacts" }),
).toBeHidden();
});
test("importing a file shares with users and invites unknown emails", async ({
page,
request,
}) => {
await clearDb();
// Make sure the webkit user exists so its row creates an access
// while the unknown email creates an invitation.
await request.post("http://localhost:8071/api/v1.0/e2e/user-auth/", {
data: { email: "user@webkit.test" },
});
await mockConfig(page, true);
await login(page, "drive@example.com");
await goToNewFolder(page, "Import contacts folder");
const importModal = await openImportModal(page);
await importModal.locator('input[type="file"]').setInputFiles(CONTACTS_CSV);
await expect(
importModal.getByText("2 rows ready to be imported."),
).toBeVisible();
await importModal.getByRole("button", { name: "Import", exact: true }).click();
await expect(importModal).toBeHidden();
await expectUserInMembersList(page, "user@webkit.test", "Editor");
const shareModal = await getShareModal(page);
await expect(shareModal.getByTestId("invitations-list")).toContainText(
"imported@example.com",
);
});
test("a failed import shows the backend error inside the import modal", async ({
page,
}) => {
await clearDb();
await mockConfig(page, true);
await page.route("**/batch-share/", async (route) => {
await route.fulfill({
status: 400,
json: {
type: "validation_error",
errors: [
{
attr: "rows",
code: "invalid",
detail: "This import is not valid.",
},
],
},
});
});
await login(page, "drive@example.com");
await goToNewFolder(page, "Import error folder");
const importModal = await openImportModal(page);
await importModal.locator('input[type="file"]').setInputFiles(CONTACTS_CSV);
await importModal.getByRole("button", { name: "Import", exact: true }).click();
await expect(
importModal.getByText("This import is not valid."),
).toBeVisible();
await expect(importModal).toBeVisible();
// Removing the selected file clears the previous error
await importModal.getByText("Delete", { exact: true }).first().click();
await expect(
importModal.getByText("This import is not valid."),
).toBeHidden();
});
});