diff --git a/dembrane/frontend/e2e/digest-harness.spec.ts b/dembrane/frontend/e2e/digest-harness.spec.ts new file mode 100644 index 000000000..cf4752b0a --- /dev/null +++ b/dembrane/frontend/e2e/digest-harness.spec.ts @@ -0,0 +1,280 @@ +import { expect, type Page, test } from "@playwright/test"; +import { + DIGEST_RESULT_EN, + DIGEST_RESULT_NL, +} from "../src/components/project/digest/digest.fixture"; +import { + makeDocx, + makePdf, + makePptx, +} from "../src/components/project/digest/extract/fixtures"; + +// ECHO-1055: "Start from your documents" on the template picker. Real generated PDF, DOCX +// and PPTX files go into the drop zone; the browser reads them, the digest endpoint is +// answered here. The entry row, the reading states, the review with every section, a +// section's provenance, an edited section, the failure fallback and the scanned-PDF +// refusal, at desktop and phone width, in English and Dutch. A network capture checks +// that only text leaves the browser. E2E_SHOTS= also saves screenshots. +const shots = process.env.E2E_SHOTS; +const sizes = [ + { height: 900, name: "desktop", width: 1280 }, + { height: 844, name: "phone", width: 390 }, +]; + +const INVITATION = makePdf([ + [ + "Uitnodiging bewonersavond Kanaalzone-Oost", + "Gemeente Veldhoven-Noord en woningcorporatie Thuisbasis nodigen je uit.", + "Donderdag 4 december, 19.30 tot 21.30 uur", + "Locatie: Wijkcentrum De Schakel, Sluisweg 12", + ], + ["Aanmelden bij Ilse Verbeek, i.verbeek@thuisbasis.example of 06-41234567."], +]); +const AGENDA = makeDocx([ + "Agenda bewonersavond Kanaalzone-Oost", + "19.30 Welkom door wethouder Ramon Dijkhuis", + "19.45 Ronde 1: Wat gaat goed in de Kanaalzone-Oost?", + "20.35 Ronde 2: Wat moet anders aan de groenstrook langs de Sluisweg?", +]); +const SLIDES = makePptx([ + { + lines: [ + "Bewonersavond Kanaalzone-Oost", + "Bewoners praten aan tafels in twee rondes", + ], + }, + { + lines: ["Ronde 1", "Wat gaat goed?"], + notes: ["Laat iedereen kort aan het woord."], + }, +]); +const SCAN = makePdf([null, null]); + +const files = { + agenda: { + buffer: Buffer.from(AGENDA), + mimeType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + name: "agenda.docx", + }, + invitation: { + buffer: Buffer.from(INVITATION), + mimeType: "application/pdf", + name: "uitnodiging-bewonersavond.pdf", + }, + scan: { + buffer: Buffer.from(SCAN), + mimeType: "application/pdf", + name: "scan.pdf", + }, + slides: { + buffer: Buffer.from(SLIDES), + mimeType: + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + name: "programma.pptx", + }, +}; + +type Answer = "result" | "fail" | "hold"; + +async function answerApi(page: Page, nl: boolean, answer: () => Answer) { + const sent: { url: string; body: string; type: string }[] = []; + page.on("request", (r) => { + if (r.method() !== "GET") + sent.push({ + body: r.postData() ?? "", + type: r.headers()["content-type"] ?? "", + url: r.url(), + }); + }); + let release: (() => void) | null = null; + await page.route("**/v2/workspaces/w1/free-allowance**", (route) => + route.fulfill({ json: { free: false } }), + ); + await page.route( + "**/v2/workspaces/w1/project-setup/digest", + async (route) => { + const a = answer(); + if (a === "hold") + await new Promise((r) => { + release = r; + }); + if (a === "fail") + return route.fulfill({ + json: { code: "project.setup_suggestions_unavailable" }, + status: 503, + }); + return route.fulfill({ json: nl ? DIGEST_RESULT_NL : DIGEST_RESULT_EN }); + }, + ); + await page.route("**/v2/workspaces/w1/projects", (route) => + route.fulfill({ json: { id: "p9", name: "x", workspace_id: "w1" } }), + ); + return { release: () => release?.(), sent }; +} + +for (const size of sizes) + for (const lang of ["en-US", "nl-NL"]) + test(`digest harness, ${size.name}, ${lang}`, async ({ page }) => { + test.setTimeout(120_000); + const nl = lang === "nl-NL"; + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.setViewportSize({ height: size.height, width: size.width }); + await page.addInitScript((l) => { + localStorage.setItem("dembrane-language", l); + }, lang); + let answer: Answer = "hold"; + const api = await answerApi(page, nl, () => answer); + const shot = async (name: string, full = true) => { + if (shots) + await page.screenshot({ + fullPage: full, + path: `${shots}/${size.name}-${lang}-${name}.png`, + }); + }; + const noSideScroll = async () => + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + + await page.goto( + `/e2e/templates-harness.html?path=/${lang}/w/w1/projects/new`, + ); + const entry = page.getByTestId("project-digest-entry"); + await expect(entry).toBeVisible(); + await expect(page.getByTestId("template-picker")).toBeVisible(); + await expect(page.getByTestId("project-digest-privacy")).toBeVisible(); + await shot("01-entry", false); + await noSideScroll(); + + // The scanned PDF is refused on this device; nothing is sent. + const input = page.locator( + "[data-testid=project-digest-dropzone] input[type=file]", + ); + await input.setInputFiles([files.scan]); + await expect( + page.getByTestId("project-digest-file-0-reason"), + ).toContainText(nl ? "scan" : "scan"); + await expect(page.getByTestId("template-picker")).toBeVisible(); + await shot("02-scanned-refused", false); + expect(api.sent.filter((s) => s.url.includes("digest"))).toHaveLength(0); + + // Three real files: read here, then "Reading your material" while held. + await input.setInputFiles([files.invitation, files.agenda, files.slides]); + await expect( + page.getByTestId("project-digest-progress-status"), + ).toContainText( + nl ? "Je materiaal wordt gelezen" : "Reading your material", + ); + await expect(page.getByTestId("template-picker")).toBeHidden(); + await shot("03-reading", false); + await noSideScroll(); + answer = "result"; + api.release(); + + const review = page.getByTestId("project-digest-review"); + await expect(review).toBeVisible(); + for (const id of [ + "name", + "goal", + "context", + "key-terms", + "template", + "tags", + "verify", + "name-label", + "explore", + "host-steps", + ]) + await expect( + page.getByTestId(`project-digest-section-${id}`), + ).toBeVisible(); + await page.waitForTimeout(300); + await shot("04-review"); + await noSideScroll(); + + // Provenance: the source on the button, the words on focus. + const from = page.getByTestId("project-digest-provenance-goal"); + await from.scrollIntoViewIfNeeded(); + await from.focus(); + await expect(page.getByRole("tooltip")).toContainText("Samen met jou"); + await page.waitForTimeout(200); + await shot("05-provenance", false); + + // Edited: the goal rewritten, the Explore question switched off, another template. + await page + .getByTestId("project-digest-goal") + .fill( + nl + ? "Weten wat bewoners willen aan de Sluisweg." + : "Learn what residents want at the Sluisweg.", + ); + await page + .getByTestId("project-digest-section-explore-switch") + .evaluate((el) => (el as HTMLInputElement).click()); + await page.getByTestId("project-digest-runner-up-world_cafe").click(); + await page + .getByTestId("project-digest-section-goal") + .scrollIntoViewIfNeeded(); + await page.waitForTimeout(300); + await shot("06-edited"); + + // The network: only text and names went out, never a file's bytes. + const digestCalls = api.sent.filter((s) => + s.url.includes("project-setup/digest"), + ); + expect(digestCalls).toHaveLength(1); + for (const s of api.sent) { + expect(s.type).not.toContain("multipart"); + expect(s.body).not.toContain("%PDF"); + expect(s.body).not.toContain("PK\u0003\u0004"); + } + const body = JSON.parse(digestCalls[0]?.body ?? "{}"); + expect(body.sources.map((x: { name: string }) => x.name)).toEqual([ + "uitnodiging-bewonersavond.pdf", + "agenda.docx", + "programma.pptx", + ]); + expect(Object.keys(body.sources[0]).sort()).toEqual([ + "kind", + "name", + "text", + ]); + expect(body.sources[0].text).toContain("Wijkcentrum De Schakel"); + expect(body.sources[2].text).toContain("Speaker notes: Laat iedereen"); + + // Create sends the template and what is on, in one request. + await page.getByTestId("project-digest-create").click(); + await expect(page.getByTestId("harness-landed")).toBeAttached(); + const create = api.sent.find((s) => + s.url.endsWith("/v2/workspaces/w1/projects"), + ); + const created = JSON.parse(create?.body ?? "{}"); + expect(created.template_key).toBe("world_cafe"); + expect(created.digest.goal).toContain("Sluisweg"); + expect(created.digest.explore_prompt).toBeUndefined(); + expect(created.digest.key_terms).toHaveLength(5); + + // The failure fallback: the picker with the text in the context. + answer = "fail"; + await page.goto( + `/e2e/templates-harness.html?path=/${lang}/w/w1/projects/new`, + ); + await page + .locator("[data-testid=project-digest-dropzone] input[type=file]") + .setInputFiles([files.agenda]); + await expect(page.getByTestId("project-digest-fallback")).toBeVisible(); + await expect(page.getByTestId("template-picker")).toBeVisible(); + await shot("07-fallback", false); + await page.getByTestId("template-card-resident_evening").click(); + await page.getByTestId("create-project-template-continue").click(); + await expect( + page.getByLabel(nl ? "Projectcontext" : "Project context"), + ).toHaveValue(/Ronde 1: Wat gaat goed/); + await shot("08-fallback-context"); + + expect(errors).toEqual([]); + }); diff --git a/dembrane/frontend/lingui.config.ts b/dembrane/frontend/lingui.config.ts index cc657081d..a59c93942 100644 --- a/dembrane/frontend/lingui.config.ts +++ b/dembrane/frontend/lingui.config.ts @@ -9,6 +9,7 @@ const config: LinguiConfig = { "/src/features/accounts/**", "/src/lib/errors/messages/**", "/src/components/project/templates/**", + "/src/components/project/digest/**", ], include: ["src"], path: "/src/locales/{locale}", @@ -26,6 +27,12 @@ const config: LinguiConfig = { include: ["/src/components/project/templates"], path: "/src/components/project/templates/locales/{locale}", }, + { + // The context digest's own catalog (ECHO-1055): the drop zone, the review and its + // messages, loaded when the drop zone first shows (src/components/project/digest/i18n.ts). + include: ["/src/components/project/digest"], + path: "/src/components/project/digest/locales/{locale}", + }, { // The friendly message for every error code, loaded with the first error a // screen shows (src/lib/errors/present.ts), never in the first load. diff --git a/dembrane/frontend/package.json b/dembrane/frontend/package.json index 03a3861b8..b5fde0d6b 100644 --- a/dembrane/frontend/package.json +++ b/dembrane/frontend/package.json @@ -69,6 +69,7 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.20", "diff": "^8.0.2", + "fflate": "0.4.8", "js-cookie": "^3.0.8", "lodash": "^4.18.1", "lucide-react": "^0.468.0", diff --git a/dembrane/frontend/pnpm-lock.yaml b/dembrane/frontend/pnpm-lock.yaml index ea275b211..643f8434c 100644 --- a/dembrane/frontend/pnpm-lock.yaml +++ b/dembrane/frontend/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: diff: specifier: ^8.0.2 version: 8.0.2 + fflate: + specifier: 0.4.8 + version: 0.4.8 js-cookie: specifier: ^3.0.8 version: 3.0.8 diff --git a/dembrane/frontend/src/components/dropzone/Dropzone.test.tsx b/dembrane/frontend/src/components/dropzone/Dropzone.test.tsx new file mode 100644 index 000000000..07f33e33c --- /dev/null +++ b/dembrane/frontend/src/components/dropzone/Dropzone.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeAll, expect, it, vi } from "vitest"; +import { + Providers, + setUpDom, +} from "@/components/project/setup-suggestions/testing"; +import { CommonDropzone } from "./Dropzone"; +import { AUDIO_ACCEPT } from "./UploadConversationDropzone"; + +beforeAll(setUpDom); +afterEach(cleanup); + +const input = (container: HTMLElement) => + container.querySelector("input[type=file]") as HTMLInputElement; + +const renderZone = (onDrop = vi.fn(), onReject = vi.fn()) => { + const view = render( + + drop it} + > + pick audio + + , + ); + return { ...view, onDrop, onReject }; +}; + +it("filters with the accept setting and never renders it as text", () => { + const { container } = renderZone(); + expect(container.textContent).not.toContain("audio/mpeg"); + const accept = input(container).getAttribute("accept") ?? ""; + for (const part of ["audio/mpeg", "video/mp4", ".mp3", ".qta", ".m4a"]) + expect(accept).toContain(part); + expect(screen.getByText("pick audio")).toBeTruthy(); +}); + +it("takes an audio file by its type or its extension", async () => { + const { container, onDrop, onReject } = renderZone(); + const byType = new File(["x"], "recording", { type: "audio/mpeg" }); + const byExtension = new File(["x"], "memo.qta", { type: "" }); + fireEvent.change(input(container), { + target: { files: [byType, byExtension] }, + }); + await waitFor(() => expect(onDrop).toHaveBeenCalled()); + expect(onDrop.mock.calls[0][0].map((f: File) => f.name)).toEqual([ + "recording", + "memo.qta", + ]); + expect(onReject).not.toHaveBeenCalled(); +}); + +it("rejects anything else itself, as an invalid type", async () => { + const { container, onDrop, onReject } = renderZone(); + const pdf = new File(["%PDF"], "agenda.pdf", { type: "application/pdf" }); + fireEvent.change(input(container), { target: { files: [pdf] } }); + await waitFor(() => expect(onReject).toHaveBeenCalled()); + const [rejection] = onReject.mock.calls[0][0]; + expect(rejection.file.name).toBe("agenda.pdf"); + expect(rejection.errors[0].code).toBe("file-invalid-type"); + expect(onDrop.mock.calls.every((c) => c[0].length === 0)).toBe(true); +}); diff --git a/dembrane/frontend/src/components/dropzone/Dropzone.tsx b/dembrane/frontend/src/components/dropzone/Dropzone.tsx index b1c0d3c72..544791a96 100644 --- a/dembrane/frontend/src/components/dropzone/Dropzone.tsx +++ b/dembrane/frontend/src/components/dropzone/Dropzone.tsx @@ -1,5 +1,6 @@ import { Group } from "@mantine/core"; import { + type DropzoneProps, type FileRejection, Dropzone as MantineDropzone, } from "@mantine/dropzone"; @@ -9,7 +10,15 @@ import type { PropsWithChildren, ReactNode } from "react"; interface CommonDropzoneProps { idle?: ReactNode; reject?: ReactNode; - accept?: ReactNode; + /** Shown while a drag holds only accepted files. */ + acceptContent?: ReactNode; + /** + * Which files the drop zone takes: MIME types, each with the extensions that count as + * it (`{ "audio/mpeg": [".mp3"] }`). A file is taken when its type or its extension + * is listed; anything else goes to onReject with "file-invalid-type". The file dialog + * offers only these. + */ + accept?: DropzoneProps["accept"]; maxFiles?: number; maxSize?: number; loading?: boolean; @@ -20,7 +29,7 @@ interface CommonDropzoneProps { export const CommonDropzone = ({ idle, reject, - accept, + acceptContent, children, ...props }: PropsWithChildren) => { @@ -28,7 +37,9 @@ export const CommonDropzone = ({ - {accept || } + {acceptContent || ( + + )} {reject || } diff --git a/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.test.tsx b/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.test.tsx index 8d02aed33..cf9efcbc4 100644 --- a/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.test.tsx +++ b/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.test.tsx @@ -43,25 +43,35 @@ const recording = vi.hoisted(() => ({ })); const dropped = vi.hoisted(() => ({ + accept: undefined as unknown, files: [] as { name: string; type: string }[], })); vi.mock("./hooks", () => ({ useConversationUploader: () => uploader })); vi.mock("./Dropzone", () => ({ - CommonDropzone: ({ onDrop }: { onDrop: (files: File[]) => void }) => ( - - ), + CommonDropzone: ({ + onDrop, + accept, + }: { + onDrop: (files: File[]) => void; + accept?: unknown; + }) => { + dropped.accept = accept; + return ( + + ); + }, })); vi.mock("@/components/project/hooks", () => ({ useProjectById: () => ({ isLoading: false }), @@ -182,6 +192,17 @@ it("keeps Upload files enabled when some files can still upload", async () => { expect((button as HTMLButtonElement).disabled).toBe(false); }); +it("hands the drop zone the audio accept setting, not a list to print", async () => { + renderDropzone(); + fireEvent.click(screen.getByTestId("conversation-upload-button")); + await screen.findByText("drop"); + const accept = dropped.accept as Record; + expect(Object.keys(accept)).toContain("audio/mpeg"); + expect(Object.values(accept).flat()).toEqual( + expect.arrayContaining([".mp3", ".m4a", ".qta", ".opus"]), + ); +}); + const renderFailed = (error: Error) => { Object.assign(uploader, { error, isError: true, isUploading: true }); renderDropzone(); diff --git a/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.tsx b/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.tsx index 38a8adf9d..200e8a90b 100644 --- a/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.tsx +++ b/dembrane/frontend/src/components/dropzone/UploadConversationDropzone.tsx @@ -14,7 +14,6 @@ import { Tooltip, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { usePostHog } from "@posthog/react"; import { ArrowRightIcon, CheckCircleIcon, @@ -26,6 +25,7 @@ import { WarningCircleIcon, XIcon, } from "@phosphor-icons/react"; +import { usePostHog } from "@posthog/react"; import { type PropsWithChildren, useCallback, @@ -86,44 +86,58 @@ const getFileExtension = (fileName: string): string => { return fileName.split(".").pop()?.toLowerCase() || ""; }; +// The audio types and extensions an upload takes: the drop zone's accept setting and the +// check on every selected file read the same two lists. +const AUDIO_MIME_TYPES = [ + "audio/mp3", + "audio/wav", + "audio/mpeg", + "audio/ogg", + "audio/webm", + "audio/m4a", + "audio/aac", + "audio/flac", + "audio/opus", + "video/mp4", // MP4 can contain audio +]; + +const AUDIO_EXTENSIONS = [ + "mp3", + "wav", + "mpeg", + "ogg", + "webm", + "m4a", + "mp4", + "aac", + "flac", + "opus", + // Apple Voice Memos; renamed to m4a on selection (see asUploadable). + "qta", +]; + +/** + * The drop zone's accept setting: a file passes when its type is one of the audio types + * or its extension one of the audio extensions, exactly what isAudioFile checks. The + * extensions sit under the first type; react-dropzone reads the whole object as one list. + */ +export const AUDIO_ACCEPT: Record = Object.fromEntries( + AUDIO_MIME_TYPES.map((type, i) => [ + type, + i === 0 ? AUDIO_EXTENSIONS.map((ext) => `.${ext}`) : [], + ]), +); + // Check if file is a valid audio file based on its content type or extension const isAudioFile = (file: File): boolean => { - const validAudioTypes = [ - "audio/mp3", - "audio/wav", - "audio/mpeg", - "audio/ogg", - "audio/webm", - "audio/m4a", - "audio/aac", - "audio/flac", - "audio/opus", - "video/mp4", // MP4 can contain audio - ]; - - const validExtensions = [ - "mp3", - "wav", - "mpeg", - "ogg", - "webm", - "m4a", - "mp4", - "aac", - "flac", - "opus", - // Apple Voice Memos; renamed to m4a on selection (see asUploadable). - "qta", - ]; - // Check MIME type first - if (validAudioTypes.includes(file.type)) { + if (AUDIO_MIME_TYPES.includes(file.type)) { return true; } // Fallback to extension check const extension = getFileExtension(file.name); - return validExtensions.includes(extension); + return AUDIO_EXTENSIONS.includes(extension); }; // Create a new file with updated name but same content @@ -257,20 +271,6 @@ const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB in bytes // One rule between upload rows (the read grammar, a single container). const RULE = "var(--app-stroke) solid var(--app-rule-color)"; -// Valid audio MIME types -const VALID_AUDIO_TYPES = [ - "audio/mp3", - "audio/wav", - "audio/mpeg", - "audio/ogg", - "audio/webm", - "audio/m4a", - "video/mp4", // MP4 can contain audio - "audio/aac", - "audio/flac", - "audio/opus", -]; - export const UploadConversationDropzone = ( props: PropsWithChildren<{ projectId: string; @@ -678,7 +678,7 @@ export const UploadConversationDropzone = ( } }} loading={uploader.isPending} - accept={VALID_AUDIO_TYPES} + accept={AUDIO_ACCEPT} {...testId("conversation-upload-dropzone")} > diff --git a/dembrane/frontend/src/components/project/ProjectContextInput.tsx b/dembrane/frontend/src/components/project/ProjectContextInput.tsx index 7c93bfcd1..d140c2dd8 100644 --- a/dembrane/frontend/src/components/project/ProjectContextInput.tsx +++ b/dembrane/frontend/src/components/project/ProjectContextInput.tsx @@ -7,14 +7,19 @@ import { useFocusOnHash } from "@/hooks/useFocusOnHash"; export const PROJECT_CONTEXT_HASH = "project-context"; -/** Past this many characters the field says to keep the essentials (ECHO-1053). */ -export const LONG_CONTEXT_CHARS = 2000; +/** + * Past this many characters the field says only the start is read (ECHO-1053). The same + * number as the platform's prompt budget (PROJECT_CONTEXT_BUDGET in @dembrane/core, + * ECHO-1055): every prompt reads at most this much of the context. + */ +export const LONG_CONTEXT_CHARS = 4000; /** * The project context field, with the one explanation of what it does. * The create wizard and project settings both render this, so the copy * lives with the control instead of being repeated per screen. A long - * context gets a quiet note under it; nothing is cut. + * context gets a quiet note under it; the stored text is never cut, the + * prompts read its first LONG_CONTEXT_CHARS characters. */ export const ProjectContextInput = forwardRef< HTMLTextAreaElement, @@ -42,8 +47,9 @@ export const ProjectContextInput = forwardRef< data-testid="project-context-long-note" > - Long context is read on every answer, so keep the essentials: the - people, the places and the question. + dembrane reads the first 4,000 characters on every answer, so keep + the essentials near the top: the people, the places and the + question. )} diff --git a/dembrane/frontend/src/components/project/digest/DigestConnected.test.tsx b/dembrane/frontend/src/components/project/digest/DigestConnected.test.tsx new file mode 100644 index 000000000..e129995eb --- /dev/null +++ b/dembrane/frontend/src/components/project/digest/DigestConnected.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import { + Providers, + setUpDom, +} from "@/components/project/setup-suggestions/testing"; +import { TEMPLATE_SUMMARIES } from "@/components/project/templates/projectTemplates.fixture"; +import { DigestConnected } from "./DigestConnected"; +import { DIGEST_RESULT_EN } from "./digest.fixture"; + +const events = vi.hoisted(() => [] as [string, Record][]); +vi.mock("@/lib/analytics/events", () => ({ + track: (name: string, props: Record) => + events.push([name, props]), +})); +vi.mock("@/config", () => ({ API_BASE_URL: "/api" })); + +beforeAll(() => { + setUpDom(); + globalThis.ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} + } as never; + window.scrollTo = () => {}; +}); +beforeEach(() => { + events.length = 0; +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const AGENDA = "Agenda bewonersavond Kanaalzone-Oost\nRonde 1: Wat gaat goed?"; + +const renderConnected = () => { + const props = { + creating: false, + onActiveChange: vi.fn(), + onCreate: vi.fn(async () => true), + onFallback: vi.fn(), + }; + const view = render( + + + , + ); + return { ...view, ...props }; +}; + +const drop = async (container: HTMLElement) => { + const input = (await waitFor(() => { + const el = container.querySelector("input[type=file]"); + if (!el) throw new Error("not yet"); + return el; + })) as HTMLInputElement; + fireEvent.change(input, { + target: { + files: [new File([AGENDA], "agenda.txt", { type: "text/plain" })], + }, + }); +}; + +it("reads the file here, sends only its text, and shows the review", async () => { + const fetch = vi.fn( + async () => new Response(JSON.stringify(DIGEST_RESULT_EN)), + ); + vi.stubGlobal("fetch", fetch); + const { container, onActiveChange, onCreate } = renderConnected(); + await drop(container); + await screen.findByTestId("project-digest-review"); + expect(onActiveChange).toHaveBeenLastCalledWith(true); + const body = JSON.parse( + String((fetch.mock.calls[0] as unknown as [string, RequestInit])[1].body), + ); + expect(body.sources).toEqual([ + { kind: "file", name: "agenda.txt", text: AGENDA }, + ]); + fireEvent.click(screen.getByTestId("project-digest-create")); + await waitFor(() => expect(onCreate).toHaveBeenCalled()); + await waitFor(() => + expect(events.map((e) => e[0])).toContain("project_digest_applied"), + ); + expect(events.map((e) => e[0])).toEqual([ + "project_digest_started", + "project_digest_ready", + "project_digest_applied", + ]); + // Never names, text or terms in an event. + expect(JSON.stringify(events)).not.toMatch(/agenda|Kanaalzone|Thuisbasis/i); +}); + +it("falls back to the picker with the text for the context when the digest fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}", { status: 503 })), + ); + const { container, onFallback, onActiveChange } = renderConnected(); + await drop(container); + await screen.findByTestId("project-digest-fallback"); + expect(onFallback).toHaveBeenCalledWith({ + cause: "unavailable", + context: AGENDA, + cut: false, + }); + expect(onActiveChange).toHaveBeenLastCalledWith(false); + expect(events.at(-1)).toEqual([ + "project_digest_failed", + { cause: "unavailable", surface: "picker" }, + ]); +}); diff --git a/dembrane/frontend/src/components/project/digest/DigestConnected.tsx b/dembrane/frontend/src/components/project/digest/DigestConnected.tsx new file mode 100644 index 000000000..35304ea5b --- /dev/null +++ b/dembrane/frontend/src/components/project/digest/DigestConnected.tsx @@ -0,0 +1,218 @@ +import { Trans } from "@lingui/react/macro"; +import { Stack, Text } from "@mantine/core"; +import { Dropzone } from "@mantine/dropzone"; +import { FileArrowUpIcon } from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import type { TemplateSummary } from "@/components/project/templates/projectTemplates"; +import { track } from "@/lib/analytics/events"; +import { testId } from "@/lib/testUtils"; +import { DigestEntry, type DigestMaterial } from "./DigestEntry/DigestEntry"; +import { DigestProgress, FileList } from "./DigestProgress/DigestProgress"; +import { DigestReview } from "./DigestReview/DigestReview"; +import type { DigestFailure, DigestResult } from "./digest"; +import { useDigestCatalog } from "./i18n"; +import { CONTEXT_BUDGET, cutToBudget, DIGEST_ACCEPT } from "./limits"; +import { + appliedCounts, + type DigestCreateBody, + initialReview, + type ReviewState, + sectionsShown, + toCreateBody, +} from "./review"; +import { useDigestRun } from "./useDigestRun"; + +export interface DigestFallback { + /** The material's text, cut to the context budget; "" when there was none. */ + context: string; + /** The material was longer than the context holds. */ + cut: boolean; + cause: DigestFailure; +} + +interface DigestConnectedProps { + workspaceId: string | null; + /** The new project's language: what the digest writes in. */ + language: string; + templates: readonly TemplateSummary[] | undefined; + /** True while files are read, the digest runs or its review shows: the picker steps aside. */ + onActiveChange: (active: boolean) => void; + /** The digest failed or found nothing: the picker takes over with the text in the context. */ + onFallback: (fallback: DigestFallback) => void; + /** Creates the project; resolves true once it exists. */ + onCreate: (body: DigestCreateBody) => Promise; + creating: boolean; + /** Dropping a file anywhere on the page starts it too. */ + pageDrop?: boolean; +} + +/** + * The digest on the template picker (ECHO-1055): the entry row, the reading states, the + * review, and the fallback to the picker. Fetching, extraction and analytics live here; + * the blocks only show. + */ +export const DigestConnected = ({ + workspaceId, + language, + templates, + onActiveChange, + onFallback, + onCreate, + creating, + pageDrop = true, +}: DigestConnectedProps) => { + const ready = useDigestCatalog(); + const digest = useDigestRun({ language, surface: "picker", workspaceId }); + const [result, setResult] = useState(null); + const [review, setReview] = useState(null); + const [fallback, setFallback] = useState(null); + const active = + digest.phase === "files" || digest.phase === "material" || result !== null; + const onActive = useRef(onActiveChange); + onActive.current = onActiveChange; + useEffect(() => onActive.current(active), [active]); + + const start = async (m: DigestMaterial) => { + setFallback(null); + setResult(null); + const outcome = await digest.run(m); + if (!outcome) return; + if (outcome.result) { + setResult(outcome.result); + setReview(initialReview(outcome.result, templates ?? [])); + window.scrollTo?.({ behavior: "smooth", top: 0 }); + return; + } + const cause = outcome.cause ?? "error"; + // Nothing readable at all: the file list says why, the picker is untouched. + if (cause === "no_readable_files") return; + const next = { + cause, + context: cutToBudget(outcome.text, CONTEXT_BUDGET), + cut: outcome.text.length > CONTEXT_BUDGET, + }; + setFallback(next); + onFallback(next); + }; + + const startOver = () => { + setResult(null); + setReview(null); + digest.reset(); + }; + + const create = async () => { + if (!result || !review) return; + const body = toCreateBody(review); + if (!body) return; + const created = await onCreate(body); + if (created) + track( + "project_digest_applied", + appliedCounts( + review, + sectionsShown(result), + result.template?.key ?? null, + ), + ); + }; + + if (!ready) return null; + + if (result && review) + return ( + void create()} + onStartOver={startOver} + /> + ); + + if (digest.phase === "files" || digest.phase === "material") + return ( + + ); + + const refused = digest.files.length > 0 && digest.phase === "failed"; + return ( + <> + void start(m)} + notice={ + + {refused && !fallback ? : null} + {fallback ? : null} + + } + /> + {pageDrop ? ( + {}} + onDropAny={(files, rejections) => + void start({ + files: [...files, ...rejections.map((r) => r.file)], + text: "", + url: "", + }) + } + {...testId("project-digest-page-drop")} + > + + + + Drop it to set up the project from it + + + + ) : null} + + ); +}; + +const FallbackNotice = ({ fallback }: { fallback: DigestFallback }) => ( + + + {fallback.cause === "budget" ? ( + + Your workspace's AI budget for this month is used up, so dembrane + can't read this now. + + ) : fallback.cause === "rate_limited" ? ( + + You've set up several projects from material in the last hour. Try + again later. + + ) : ( + dembrane couldn't set the project up from this. + )}{" "} + {fallback.context ? ( + + Your text is in the project context: choose a template below to + continue. + + ) : ( + Choose a template below to continue. + )} + + {fallback.cut ? ( + + + Only its start fits: dembrane reads the first 4,000 characters of the + context. + + + ) : null} + +); + +export default DigestConnected; diff --git a/dembrane/frontend/src/components/project/digest/DigestEntry/DigestEntry.test.tsx b/dembrane/frontend/src/components/project/digest/DigestEntry/DigestEntry.test.tsx new file mode 100644 index 000000000..6c7fabd7c --- /dev/null +++ b/dembrane/frontend/src/components/project/digest/DigestEntry/DigestEntry.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeAll, expect, it, vi } from "vitest"; +import { + Providers, + setUpDom, +} from "@/components/project/setup-suggestions/testing"; +import { DigestEntry } from "./DigestEntry"; + +beforeAll(setUpDom); +afterEach(cleanup); + +const renderEntry = () => { + const onSubmit = vi.fn(); + const view = render( + + + , + ); + return { ...view, onSubmit }; +}; + +it("says what is sent where, always", () => { + renderEntry(); + expect(screen.getByTestId("project-digest-privacy").textContent).toContain( + "Files stay on your device", + ); + expect( + screen + .getByLabelText("Drop files or choose them") + .getAttribute("aria-describedby"), + ).toBe(screen.getByTestId("project-digest-privacy").id); +}); + +it("hands over chosen files, a refused type included, so it can say why", async () => { + const { container, onSubmit } = renderEntry(); + const input = container.querySelector("input[type=file]") as HTMLInputElement; + expect(input.getAttribute("accept")).toContain(".pptx"); + const pdf = new File(["%PDF"], "agenda.pdf", { type: "application/pdf" }); + const doc = new File(["x"], "oud.doc", { type: "application/msword" }); + fireEvent.change(input, { target: { files: [pdf, doc] } }); + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0].files.map((f: File) => f.name)).toEqual([ + "agenda.pdf", + "oud.doc", + ]); +}); + +it("pasted text and a link go in with Set up from this; a bad link does not", () => { + const { onSubmit } = renderEntry(); + fireEvent.click(screen.getByTestId("project-digest-paste-toggle")); + fireEvent.click(screen.getByTestId("project-digest-link-toggle")); + fireEvent.change(screen.getByTestId("project-digest-url"), { + target: { value: "not a link" }, + }); + expect( + (screen.getByTestId("project-digest-submit") as HTMLButtonElement).disabled, + ).toBe(true); + fireEvent.change(screen.getByTestId("project-digest-text"), { + target: { value: " Agenda " }, + }); + fireEvent.click(screen.getByTestId("project-digest-submit")); + expect(onSubmit).toHaveBeenCalledWith({ files: [], text: "Agenda", url: "" }); +}); + +it("a link pasted into the zone becomes the link", () => { + renderEntry(); + fireEvent.paste(screen.getByTestId("project-digest-dropzone"), { + clipboardData: { files: [], getData: () => "https://example.org/brief" }, + }); + expect( + (screen.getByTestId("project-digest-url") as HTMLInputElement).value, + ).toBe("https://example.org/brief"); +}); diff --git a/dembrane/frontend/src/components/project/digest/DigestEntry/DigestEntry.tsx b/dembrane/frontend/src/components/project/digest/DigestEntry/DigestEntry.tsx new file mode 100644 index 000000000..5f6d32b83 --- /dev/null +++ b/dembrane/frontend/src/components/project/digest/DigestEntry/DigestEntry.tsx @@ -0,0 +1,235 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + Button, + Collapse, + Group, + Stack, + Text, + Textarea, + TextInput, +} from "@mantine/core"; +import { Dropzone } from "@mantine/dropzone"; +import { + CaretDownIcon, + CaretRightIcon, + FileArrowUpIcon, +} from "@phosphor-icons/react"; +import { type ClipboardEvent, type ReactNode, useId, useState } from "react"; +import { testId } from "@/lib/testUtils"; +import { DIGEST_ACCEPT, MAX_FILES } from "../limits"; + +export interface DigestMaterial { + files: File[]; + text: string; + url: string; +} + +interface DigestEntryProps { + /** Files dropped, picked or pasted, with any text and link the host added first. */ + onSubmit: (material: DigestMaterial) => void; + /** The small variant beside "Suggest key terms" on the name step. */ + compact?: boolean; + disabled?: boolean; + /** A quiet line under the zone: what happened last time. */ + notice?: ReactNode; +} + +const looksLikeUrl = (s: string) => /^https?:\/\/\S+$/i.test(s.trim()); + +/** + * "Have an agenda or invitation? Drop it here": one quiet row with a drop zone that also + * opens the file dialog, takes pasted text (into the zone or the "Paste text" box) and a + * link. Files are read in this browser; the line under it always says what is sent where. + * Props in, callbacks out. + */ +export const DigestEntry = ({ + onSubmit, + compact = false, + disabled = false, + notice, +}: DigestEntryProps) => { + const pasteId = useId(); + const linkId = useId(); + const privacyId = useId(); + const [pasteOpen, setPasteOpen] = useState(false); + const [linkOpen, setLinkOpen] = useState(false); + const [text, setText] = useState(""); + const [url, setUrl] = useState(""); + + const submit = (files: File[]) => { + const cleanUrl = looksLikeUrl(url) ? url.trim() : ""; + if (!files.length && !text.trim() && !cleanUrl) return; + onSubmit({ files, text: text.trim(), url: cleanUrl }); + }; + + // Pasting into the focused zone: a link goes to the link, text to the text box, files + // (a screenshot, a copied file) go in as files. + const onPaste = (e: ClipboardEvent) => { + const files = [...e.clipboardData.files]; + const pasted = e.clipboardData.getData("text/plain"); + if (files.length) { + e.preventDefault(); + submit(files); + } else if (looksLikeUrl(pasted)) { + e.preventDefault(); + setUrl(pasted.trim()); + setLinkOpen(true); + } else if (pasted.trim()) { + e.preventDefault(); + setText((prev) => (prev ? `${prev}\n\n${pasted}` : pasted)); + setPasteOpen(true); + } + }; + + const ready = Boolean(text.trim()) || looksLikeUrl(url); + + return ( + + {!compact && ( + + + Have an agenda or invitation? Drop it here and dembrane sets the + project up. + + + )} + + submit([...files, ...rejections.map((r) => r.file)]) + } + onDrop={() => {}} + accept={DIGEST_ACCEPT} + disabled={disabled} + p={compact ? "xs" : "sm"} + onPaste={onPaste} + aria-label={t`Drop files or choose them`} + aria-describedby={privacyId} + {...testId("project-digest-dropzone")} + > + + + + + {compact ? ( + Add from a file + ) : ( + Drop files here, or choose them + )} + + + + PDF, Word, PowerPoint or text. Up to {MAX_FILES} files of 10 MB. + + + + + + + + + + + + +