mirror of
https://github.com/suitenumerique/drive.git
synced 2026-08-17 20:15:40 +02:00
✨(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:
@@ -14,6 +14,7 @@ and this project adheres to
|
||||
- ✨(frontend) show the messages widget button on the homepage
|
||||
- ✨(frontend) open the messages widget from the help menu
|
||||
- ✨(backend) add an item batch share endpoint gated by ALLOW_SHARE_IMPORT_FILE
|
||||
- ✨(frontend) share an item with contacts imported from a file
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -3,3 +3,5 @@ FRONTEND_HELP_MENU_CONFIG={"documentationUrl": "https://docs.numerique.gouv.fr/d
|
||||
WOPI_CLIENTS="collabora"
|
||||
# Report uploads as safe synchronously so files are ready instantly in e2e.
|
||||
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;
|
||||
};
|
||||
|
||||
export type DTOBatchShare = {
|
||||
itemId: string;
|
||||
rows: {
|
||||
email: string;
|
||||
role: Role;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type DTOUpdateLinkConfiguration = {
|
||||
itemId: string;
|
||||
link_reach: LinkReach;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ExplorerFilterModifiedValue } from "../explorer/components/filters/ExplorerFilterModified";
|
||||
import {
|
||||
DTOBatchShare,
|
||||
DTOCreateAccess,
|
||||
DTODeleteAccess,
|
||||
DTOUpdateAccess,
|
||||
@@ -142,6 +143,7 @@ export abstract class Driver {
|
||||
abstract deleteFavoriteItem(itemId: string): Promise<void>;
|
||||
abstract getItemAccesses(itemId: string): Promise<Access[]>;
|
||||
abstract createAccess(data: DTOCreateAccess): Promise<void>;
|
||||
abstract batchShare(payload: DTOBatchShare): Promise<void>;
|
||||
abstract updateAccess(payload: DTOUpdateAccess): Promise<Access | void>;
|
||||
abstract updateLinkConfiguration(
|
||||
payload: DTOUpdateLinkConfiguration,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DTOUpdateInvitation,
|
||||
} from "../DTOs/InvitationDTO";
|
||||
import {
|
||||
DTOBatchShare,
|
||||
DTOCreateAccess,
|
||||
DTOUpdateLinkConfiguration,
|
||||
} 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> {
|
||||
await fetchAPI(`items/${payload.itemId}/accesses/${payload.accessId}/`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -201,6 +201,7 @@ export interface ThemeCustomization {
|
||||
}
|
||||
|
||||
export type ApiConfig = {
|
||||
ALLOW_SHARE_IMPORT_FILE?: boolean;
|
||||
AWS_S3_UPLOAD_ACL?: string;
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE?: number;
|
||||
POSTHOG_KEY?: string;
|
||||
|
||||
+32
-1
@@ -9,6 +9,7 @@ import {
|
||||
User,
|
||||
} from "@/features/drivers/types";
|
||||
import {
|
||||
useMutationBatchShare,
|
||||
useMutationCreateAccess,
|
||||
useMutationCreateInvitation,
|
||||
useMutationDeleteAccess,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
useMutationUpdateAccess,
|
||||
useMutationUpdateInvitation,
|
||||
} from "@/features/explorer/hooks/useMutationsAccesses";
|
||||
import { useConfig } from "@/features/config/ConfigProvider";
|
||||
import { useMutationUpdateLinkConfiguration } from "@/features/explorer/hooks/useMutations";
|
||||
import {
|
||||
useInfiniteItemInvitations,
|
||||
@@ -32,7 +34,9 @@ import {
|
||||
} from "@gouvfr-lasuite/ui-kit";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
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 { useAuth } from "@/features/auth/Auth";
|
||||
import posthog from "posthog-js";
|
||||
@@ -50,6 +54,7 @@ export const ItemShareModal = ({
|
||||
}: WorkspaceShareModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { config } = useConfig();
|
||||
const { user } = useAuth();
|
||||
const copyToClipboard = useClipboard();
|
||||
const itemId = initialItem.originalId ?? initialItem.id;
|
||||
@@ -77,6 +82,8 @@ export const ItemShareModal = ({
|
||||
const { mutateAsync: deleteAccess } = useMutationDeleteAccess();
|
||||
const { mutateAsync: deleteInvitation } = useMutationDeleteInvitation();
|
||||
const { mutateAsync: updateInvitation } = useMutationUpdateInvitation();
|
||||
const { mutateAsync: batchShare } = useMutationBatchShare();
|
||||
const [importModalChildren, setImportModalChildren] = useState<ReactNode>();
|
||||
|
||||
const rolesOptions = useMemo(
|
||||
() =>
|
||||
@@ -498,6 +505,30 @@ export const ItemShareModal = ({
|
||||
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 />}
|
||||
</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 = () => {
|
||||
const driver = getDriver();
|
||||
const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
user@webkit.test;editor
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user