Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ db.sqlite3
playwright/.cache/
test-results
e2e/report
storybook-static/
output/playwright/
# Provisioned at build/test time from node_modules/pdfjs-dist
public/pdf.worker.mjs
# Generated by `yarn generate-test-pdfs`
Expand All @@ -67,7 +69,18 @@ storybook-static/
*.iml
.devcontainer
node_modules

# Local AI assistant / skills artifacts
.claude/
CLAUDE.md
.codex/
CODEX.md
.gpt/
GPT.md
.chatgpt/
CHATGPT.md
SKILLS.md
AGENTS.md
.agents/skills/
openspec/
skills-lock.json
21 changes: 21 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mergeConfig } from "vite";
import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
Expand All @@ -24,5 +25,25 @@ const config: StorybookConfig = {
// pdfjs-dist worker so PdfPreview's default workerSrc ("/pdf.worker.mjs") resolves.
{ from: "../node_modules/pdfjs-dist/build", to: "/" },
],

// Pre-bundle addon previews up front so Vite does not discover them lazily and
// trigger a mid-session dep re-optimization + reload, which races with the
// browser and breaks dynamic imports ("Failed to fetch dynamically imported module").
viteFinal: (viteConfig) =>
mergeConfig(viteConfig, {
optimizeDeps: {
include: [
"@storybook/addon-interactions/preview",
"@storybook/addon-a11y/preview",
"@storybook/addon-essentials/actions/preview",
"@storybook/addon-essentials/docs/preview",
"@storybook/addon-essentials/backgrounds/preview",
"@storybook/addon-essentials/viewport/preview",
"@storybook/addon-essentials/measure/preview",
"@storybook/addon-essentials/outline/preview",
"@storybook/addon-essentials/highlight/preview",
],
},
}),
};
export default config;
47 changes: 47 additions & 0 deletions e2e/helpers/mount-share.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useState } from "react";
import { CunninghamProvider } from "../../src/components/Provider/Provider";
import { ShareModal } from "../../src/components/share/modal/ShareModal";
import { UserData } from "../../src/components/share/types";

type SimpleUser = UserData<unknown>;

const USERS: SimpleUser[] = [
{ id: "u1", full_name: "Amandine Salambo", email: "[email protected]" },
{ id: "u2", full_name: "Jakob Philips", email: "[email protected]" },
{ id: "u3", full_name: "Kaylynn George", email: "[email protected]" },
{ id: "u4", full_name: "Beatrice Laurent", email: "[email protected]" },
{ id: "u5", full_name: "Mohamed Benali", email: "[email protected]" },
{ id: "u6", full_name: "Charlotte Dubois", email: "[email protected]" },
{ id: "u7", full_name: "Alejandro Romero", email: "[email protected]" },
{ id: "u8", full_name: "Sophie Moreau", email: "[email protected]" },
{
id: "u9",
full_name: "Christopher Martin",
email: "[email protected]",
},
];

/**
* Minimal stateful ShareModal for Playwright CT. Search always resolves to the
* same users so selection is deterministic.
*/
export const TestShareModal = () => {
const [, setSearch] = useState("");
return (
<CunninghamProvider currentLocale="en-US">
<ShareModal
isOpen
onClose={() => undefined}
invitationRoles={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
onSearchUsers={setSearch}
onInviteUser={() => undefined}
searchUsersResult={USERS}
accesses={[]}
invitations={[]}
/>
</CunninghamProvider>
);
};
111 changes: 111 additions & 0 deletions e2e/helpers/mount-upload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useState } from "react";
import { Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react";
import { CunninghamProvider } from "../../src/components/Provider/Provider";
import { FileUploader } from "../../src/components/upload/FileUploader";
import { UploadFile } from "../../src/components/upload/types";

const GB = 1000 * 1000 * 1000;

export type TestUploadFile = Omit<UploadFile, "originalFile"> & {
name: string;
size?: number;
type?: string;
};

const toUploadFile = ({
name,
size = 0,
type = "",
...uploadState
}: TestUploadFile): UploadFile => {
const originalFile = new File([], name, { type });
Object.defineProperty(originalFile, "size", { value: size });
return { ...uploadState, originalFile };
};

type TestUploaderProps = {
name?: string;
multiple?: boolean;
initialFiles?: TestUploadFile[];
cancelUploads?: boolean;
};

/**
* Stateful uploader for Playwright CT: added files are appended as "done" and
* removable. State lives here (browser side) because CT cannot bridge
* callbacks from the test file.
*/
export const TestUploader = ({
name,
multiple = true,
initialFiles = [],
cancelUploads = false,
}: TestUploaderProps) => {
const [files, setFiles] = useState<UploadFile[]>(() =>
initialFiles.map(toUploadFile),
);
return (
<CunninghamProvider currentLocale="en-US">
<FileUploader
name={name}
multiple={multiple}
maxSize={5 * GB}
files={files}
onAddFiles={setFiles}
onRemoveFile={(file) =>
setFiles((prev) => prev.filter((f) => f.id !== file.id))
}
onCancelFile={
cancelUploads
? (file) => setFiles((prev) => prev.filter((f) => f.id !== file.id))
: undefined
}
/>
</CunninghamProvider>
);
};

/** Controlled uploader for rendering static states (no internal state). */
export const TestUploaderStatic = ({
multiple = false,
files = [],
removable = false,
}: {
multiple?: boolean;
files?: TestUploadFile[];
removable?: boolean;
}) => (
<CunninghamProvider currentLocale="en-US">
<FileUploader
multiple={multiple}
maxSize={5 * GB}
files={files.map(toUploadFile)}
onRemoveFile={removable ? () => undefined : undefined}
/>
</CunninghamProvider>
);

/** Populated uploader inside a modal, used to guard against layout shifts. */
export const TestUploaderModal = () => (
<CunninghamProvider currentLocale="en-US">
<Modal
isOpen
onClose={() => undefined}
title="Upload files"
size={ModalSize.MEDIUM}
>
<div style={{ padding: 16 }}>
<FileUploader
multiple
maxSize={5 * GB}
files={[
{ id: "1", name: "first.pdf", size: 10, status: "done" as const },
{ id: "2", name: "second.pdf", size: 20, status: "done" as const },
{ id: "3", name: "third.pdf", size: 30, status: "done" as const },
{ id: "4", name: "fourth.pdf", size: 40, status: "done" as const },
].map(toUploadFile)}
/>
</div>
</Modal>
</CunninghamProvider>
);
163 changes: 163 additions & 0 deletions e2e/share/share-modal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { test, expect } from "@playwright/experimental-ct-react";
import { TestShareModal } from "../helpers/mount-share";

test.describe("ShareModal — unified search field", () => {
test("selected users appear as chips inside the search field (no gray box)", async ({
mount,
page,
}) => {
await mount(<TestShareModal />);

const field = page.getByTestId("share-search-field");
await expect(field).toBeVisible();

const searchIcon = field.locator(".c__share-modal__search-field__icon");
await expect(searchIcon.locator("svg")).toBeVisible();
await expect(searchIcon.locator(".material-icons")).toHaveCount(0);
await expect(searchIcon).toHaveCSS("color", "rgb(93, 93, 112)");

// The legacy separate selected-users frame must be gone.
await expect(page.locator(".c__share-modal__selected-users")).toHaveCount(0);

// Type to trigger the (debounced) search results.
const input = field.locator(".c__share-modal__search-field__input");
await input.fill("am");

const results = page.getByTestId("search-users-list");
await expect(results).toBeVisible();
await results.getByText("Amandine Salambo").click();

// The selected user is now a chip living inside the search field…
const chip = field.getByTestId("selected-user-item");
await expect(chip).toBeVisible();
await expect(chip).toContainText("Amandine Salambo");

// …along with the role selector and the invite button.
await expect(page.getByTestId("share-invite-button")).toBeVisible();
await expect(
field.getByRole("button", { name: "Admin" }),
).toBeVisible();
});

test("a chip can be removed, hiding the invite action", async ({
mount,
page,
}) => {
await mount(<TestShareModal />);

const field = page.getByTestId("share-search-field");
const input = field.locator(".c__share-modal__search-field__input");
await input.fill("am");
await page
.getByTestId("search-users-list")
.getByText("Amandine Salambo")
.click();

const chip = field.getByTestId("selected-user-item");
await expect(chip).toBeVisible();

await chip.getByRole("button").click();

await expect(field.getByTestId("selected-user-item")).toHaveCount(0);
await expect(page.getByTestId("share-invite-button")).toHaveCount(0);
});

test("Backspace selects the last chip before removing it when the input is empty", async ({
mount,
page,
}) => {
await mount(<TestShareModal />);

const field = page.getByTestId("share-search-field");
const input = field.locator(".c__share-modal__search-field__input");

await input.fill("am");
await page
.getByTestId("search-users-list")
.getByText("Amandine Salambo")
.click();

await input.fill("ja");
await page
.getByTestId("search-users-list")
.getByText("Jakob Philips")
.click();

const chips = field.getByTestId("selected-user-item");
const amandineChip = chips.filter({ hasText: "Amandine Salambo" });
const jakobChip = chips.filter({ hasText: "Jakob Philips" });

await expect(input).toHaveValue("");
await expect(chips).toHaveCount(2);

await input.press("Backspace");

await expect(chips).toHaveCount(2);
await expect(jakobChip).toHaveAttribute("data-selected", "true");
await expect(amandineChip).not.toHaveAttribute("data-selected", "true");

await input.press("Backspace");

await expect(jakobChip).toHaveCount(0);
await expect(amandineChip).toBeVisible();

await input.press("Backspace");
await expect(amandineChip).toHaveAttribute("data-selected", "true");

await input.press("Backspace");
await expect(field.getByTestId("selected-user-item")).toHaveCount(0);
await expect(page.getByTestId("share-invite-button")).toHaveCount(0);
});

test("the first row stays fixed while the input follows the current row", async ({
mount,
page,
}) => {
await mount(<TestShareModal />);

const field = page.getByTestId("share-search-field");
const input = field.locator(".c__share-modal__search-field__input");
const icon = field.locator(".c__share-modal__search-field__icon");
const results = page.getByTestId("search-users-list");

await input.fill("am");
await results.getByText("Amandine Salambo").click();

const firstChip = field
.getByTestId("selected-user-item")
.filter({ hasText: "Amandine Salambo" });
const initialChipBox = await firstChip.boundingBox();
const initialIconBox = await icon.boundingBox();
const initialInputBox = await input.boundingBox();

for (const [query, name] of [
["ja", "Jakob Philips"],
["ka", "Kaylynn George"],
["be", "Beatrice Laurent"],
["mo", "Mohamed Benali"],
["ch", "Charlotte Dubois"],
["al", "Alejandro Romero"],
["so", "Sophie Moreau"],
["cr", "Christopher Martin"],
]) {
await input.fill(query);
await results.getByText(name).click();
}

const chips = field.getByTestId("selected-user-item");
const chipRows = await chips.evaluateAll((elements) =>
Array.from(
new Set(elements.map((element) => element.getBoundingClientRect().top)),
),
);
const wrappedChipBox = await firstChip.boundingBox();
const wrappedIconBox = await icon.boundingBox();
const wrappedInputBox = await input.boundingBox();

expect(chipRows.length).toBeGreaterThan(1);
expect(wrappedIconBox?.y).toBe(initialIconBox?.y);
expect(wrappedChipBox?.y).toBe(initialChipBox?.y);
expect(initialInputBox?.y).toBe(initialChipBox?.y);
expect(wrappedInputBox?.y).toBe(Math.max(...chipRows));
});
});
Loading
Loading