diff --git a/dembrane-go/FEATURE_PARITY.md b/dembrane-go/FEATURE_PARITY.md index 3a6570052..f530ad2f8 100644 --- a/dembrane-go/FEATURE_PARITY.md +++ b/dembrane-go/FEATURE_PARITY.md @@ -46,6 +46,7 @@ Legend: ✅ built · � gated (needs echo backend, Sameer's OK) · 🖥️ desk ## Projects / workspaces - ✅ List (cross-workspace flat picker, cached) · create · switch · default "Go Recordings" (auto-create, remembered) +- ⏳ Start from a template (web: ECHO-1042): the server applies the preset, and the app only has to send `template_key` (`conversation`, `series`, `event`, `upload` or `blank`) on `POST /api/v2/workspaces/:id/projects`, with the list from `GET /api/v2/project-templates`. Without a key a project starts bare, as the app's create does today. - ✅ First-run "choose a workspace" onboarding - ✅ Settings → open project editor (dashboard portal-editor link) - 🖥️ Project settings/portal-editor (tags, flow, feature toggles, anonymize), access/visibility, clone, usage, webhooks, members/invites — desktop 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/e2e/flows.ts b/dembrane/frontend/e2e/flows.ts index 0743f7053..e310a0494 100644 --- a/dembrane/frontend/e2e/flows.ts +++ b/dembrane/frontend/e2e/flows.ts @@ -312,7 +312,31 @@ export const flows: Record = { params: project, variants: [...EMPTY], }, - "/w/:workspaceId/projects/new": { params: { workspaceId: "$workspace" } }, + "/w/:workspaceId/projects/new": { + params: { workspaceId: "$workspace" }, + // The template picker by purpose: a verb filter, all cards, then the name step with + // "What this sets up" and "More options" open (ECHO-1042, ECHO-1053). + states: [ + { clicks: ["template-filter-verb-decide"], name: "filtered" }, + { clicks: ["template-filter-show-all"], name: "all templates" }, + { + clicks: [ + "template-card-resident_evening", + "create-project-template-continue", + "template-setup-summary-toggle", + ], + name: "name", + }, + { + clicks: [ + "template-card-resident_evening", + "create-project-template-continue", + "create-project-more-options-toggle", + ], + name: "more options", + }, + ], + }, "/w/:workspaceId/settings/*": { params: { "*": ["general", "members", "training", "billing", "danger"], diff --git a/dembrane/frontend/e2e/register-verify-login.spec.ts b/dembrane/frontend/e2e/register-verify-login.spec.ts new file mode 100644 index 000000000..58b942b8c --- /dev/null +++ b/dembrane/frontend/e2e/register-verify-login.spec.ts @@ -0,0 +1,66 @@ +import { createHmac } from "node:crypto"; +import { expect, test } from "@playwright/test"; + +// Register, verify, log in: the whole way in for a new person, through the real UI and API. +// +// The verification email is not readable from here (locally it stays in the API's memory), +// so the spec mints the same link the email carries: Better Auth's verification token is an +// HS256 JWT of { email } signed with the API's AUTH_SECRET. Pass that secret as +// E2E_AUTH_SECRET; without it the spec skips. Needs the dashboard (E2E_BASE_URL, default +// :5173) talking to a running API with a migrated database. Not a PR gate. +const AUTH_SECRET = process.env.E2E_AUTH_SECRET ?? ""; + +const base64url = (value: string | Buffer) => + Buffer.from(value).toString("base64url"); + +function verificationToken(email: string, secret: string): string { + const now = Math.floor(Date.now() / 1000); + const header = base64url(JSON.stringify({ alg: "HS256" })); + const payload = base64url( + JSON.stringify({ email: email.toLowerCase(), exp: now + 3600, iat: now }), + ); + const signature = createHmac("sha256", secret) + .update(`${header}.${payload}`) + .digest("base64url"); + return `${header}.${payload}.${signature}`; +} + +test.skip(!AUTH_SECRET, "set E2E_AUTH_SECRET to the API's AUTH_SECRET"); + +test("a new person registers, verifies their email and logs in", async ({ + page, +}) => { + test.setTimeout(60_000); + const email = `e2e-${Date.now()}@example.com`; + const password = "Abcdefgh1!xyz"; + + await page.goto("/register"); + await page.getByTestId("auth-register-first-name-input").fill("E2E"); + await page.getByTestId("auth-register-email-input").fill(email); + await page.getByTestId("auth-register-terms-checkbox").check(); + await page.getByTestId("auth-register-continue-button").click(); + await page.getByTestId("auth-register-password-input").fill(password); + await page.getByTestId("auth-register-confirm-password-input").fill(password); + await page.getByTestId("auth-register-submit-button").click(); + await expect(page.getByTestId("auth-register-verify-step")).toContainText( + email, + ); + + // Logging in before verifying is refused with the resend offer. + await page.goto("/login"); + await page.getByTestId("auth-login-email-input").fill(email); + await page.getByTestId("auth-login-password-input").fill(password); + await page.getByTestId("auth-login-submit-button").click(); + await expect(page.getByTestId("auth-resend-verification")).toBeVisible(); + + await page.goto( + `/verify-email?token=${verificationToken(email, AUTH_SECRET)}`, + ); + await page.waitForURL(/\/login\?verified=1/, { timeout: 15_000 }); + + await page.getByTestId("auth-login-email-input").fill(email); + await page.getByTestId("auth-login-password-input").fill(password); + await page.getByTestId("auth-login-submit-button").click(); + // A new account lands on onboarding (or the home list once that is done). + await page.waitForURL(/\/(onboarding|o)\b/, { timeout: 20_000 }); +}); diff --git a/dembrane/frontend/e2e/setup-suggestions-harness.spec.ts b/dembrane/frontend/e2e/setup-suggestions-harness.spec.ts new file mode 100644 index 000000000..59763ad33 --- /dev/null +++ b/dembrane/frontend/e2e/setup-suggestions-harness.spec.ts @@ -0,0 +1,146 @@ +import { expect, type Page, test } from "@playwright/test"; +import { SUGGESTIONS } from "../src/components/project/setup-suggestions/setupSuggestions.fixture"; + +// ECHO-1043: "Suggest key terms" under the project context in the create flow's name step +// (the "conversation" template, which the fixture's proposals change), in each state (idle, loading, results, empty, error), at desktop and phone width, in English +// and Dutch. Reuses the templates harness page. 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 AGENDA = + "Bewonersavond Strijp-S met wethouder Marieke de Wolf, Woonbedrijf en Ennatuurlijk. 12 tafels."; + +type Answer = "results" | "empty" | "error" | "pending" | "url_note"; + +async function answerApi(page: Page, answer: () => Answer) { + await page.route("**/v2/workspaces/w1/free-allowance**", (route) => + route.fulfill({ json: { free: false } }), + ); + await page.route("**/v2/workspaces/w1/project-setup/suggestions", (route) => { + const a = answer(); + if (a === "pending") return; // never answered: the loading state stays + if (a === "error") + return route.fulfill({ + json: { code: "project.setup_suggestions_unavailable" }, + status: 503, + }); + if (a === "empty") + return route.fulfill({ + json: { key_terms: [], max_key_terms: 30, notes: [], settings: [] }, + }); + return route.fulfill({ + json: { + ...SUGGESTIONS, + notes: a === "url_note" ? ["url_not_read"] : [], + }, + }); + }); +} + +for (const size of sizes) + for (const lang of ["en-US", "nl-NL"]) + test(`setup suggestions, ${size.name}, ${lang}`, async ({ page }) => { + 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 = "pending"; + await answerApi(page, () => answer); + const block = page.getByTestId("setup-suggestions"); + const shot = async (name: string) => { + if (!shots) return; + await block.scrollIntoViewIfNeeded(); + await page.screenshot({ + fullPage: true, + path: `${shots}/${size.name}-${lang}-${name}.png`, + }); + await block.screenshot({ + path: `${shots}/${size.name}-${lang}-${name}-block.png`, + }); + }; + const noSideScroll = async () => + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + const suggest = () => + page.getByTestId("setup-suggestions-submit").click(); + + await page.goto( + `/e2e/templates-harness.html?path=/${lang}/w/w1/projects/new?template=conversation`, + ); + await expect(block).toBeVisible(); + const context = page.getByRole("textbox").nth(1); + await shot("0-empty"); + await context.fill(AGENDA); + await shot("1-idle"); + await noSideScroll(); + + answer = "pending"; + await suggest(); + await expect( + page.getByTestId("setup-suggestions-status"), + ).not.toBeEmpty(); + // Create is never held up by a running request. + await page.getByRole("textbox").first().fill("Bewonersavond"); + await expect(page.getByTestId("create-project-submit")).toBeEnabled(); + await shot("2-loading"); + + // A fresh page per state: the pending request above never settles. + const fresh = async (a: Answer) => { + answer = a; + await page.goto( + `/e2e/templates-harness.html?path=/${lang}/w/w1/projects/new?template=conversation`, + ); + await page.getByRole("textbox").nth(1).fill(AGENDA); + if (a === "url_note") { + await page.getByTestId("setup-suggestions-link-toggle").click(); + await page + .getByTestId("setup-suggestions-url") + .fill("https://example.org/avond"); + } + await suggest(); + }; + + await fresh("results"); + await expect(page.getByTestId("setup-suggestions-results")).toBeVisible(); + await page.getByTestId("setup-suggestions-term-toggle-1").click(); + await page + .getByTestId("setup-suggestions-setting-is_verify_enabled") + .check(); + // The reason shows on keyboard focus, as on hover. + await page.mouse.move(0, 0); + await page.getByTestId("setup-suggestions-term-toggle-0").focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect( + page.getByRole("tooltip", { name: SUGGESTIONS.key_terms[0]?.reason }), + ).toBeVisible(); + await page.waitForTimeout(300); + await shot("3-results"); + await noSideScroll(); + + await fresh("url_note"); + await expect( + page.getByTestId("setup-suggestions-url-note"), + ).toBeVisible(); + await page.getByTestId("setup-suggestions-accept-all").click(); + await page.mouse.move(0, 0); + await page.waitForTimeout(300); + await shot("4-accepted-all-url-note"); + + await fresh("empty"); + await expect(page.getByTestId("setup-suggestions-empty")).toBeVisible(); + await shot("5-empty"); + + await fresh("error"); + await expect(page.getByTestId("setup-suggestions-error")).toBeVisible(); + await shot("6-error"); + await noSideScroll(); + expect(errors).toEqual([]); + }); diff --git a/dembrane/frontend/e2e/templates-harness.html b/dembrane/frontend/e2e/templates-harness.html new file mode 100644 index 000000000..3e93371b4 --- /dev/null +++ b/dembrane/frontend/e2e/templates-harness.html @@ -0,0 +1,12 @@ + + + + + + Project templates harness + + +
+ + + diff --git a/dembrane/frontend/e2e/templates-harness.spec.ts b/dembrane/frontend/e2e/templates-harness.spec.ts new file mode 100644 index 000000000..da03dd68a --- /dev/null +++ b/dembrane/frontend/e2e/templates-harness.spec.ts @@ -0,0 +1,225 @@ +import { expect, type Page, test } from "@playwright/test"; +import { RESIDENT_EVENING_GUIDE } from "../src/components/project/templates/hostGuide.fixture"; + +// ECHO-1042, ECHO-1053: the template picker by purpose (most used first, each verb filter, +// the setup filter), the name step with "What this sets up" open, the portal editor's name +// box label, a host guide a template filled in, and workspace home's first-project state, +// at desktop and phone width. 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 VERBS = ["gather", "understand", "explore", "align", "decide"]; + +const guide = (nl: boolean) => + nl ? RESIDENT_EVENING_GUIDE.nl : RESIDENT_EVENING_GUIDE.en; + +async function answerApi(page: Page, nl: boolean) { + await page.route("**/v2/workspaces/w1/projects?**", (route) => + route.fulfill({ + json: { + has_more: false, + is_admin: true, + pinned: [], + projects: [ + { + access_count: 1, + access_preview: [], + audio_hours: 0, + conversations_count: 49, + id: "s1", + is_sample: true, + language: "en", + name: "Best practices (sample)", + pin_order: null, + updated_at: "2026-11-10T09:00:00Z", + visibility: "workspace", + }, + ], + total_count: 1, + }, + }), + ); + await page.route("**/v2/workspaces/w1/free-allowance**", (route) => + route.fulfill({ json: { free: false } }), + ); + await page.route("**/v2/projects/p1/bff**", (route) => + route.fulfill({ + json: { + // The portal editor's legal basis section reads the same endpoint. + _legal: { + effective: { + legal_basis: "client-managed", + privacy_policy_url: null, + source: "system", + }, + inherited: { + legal_basis: "client-managed", + privacy_policy_url: null, + source: "system", + }, + organiser_name: null, + }, + default_conversation_ask_for_participant_name: true, + host_guide: guide(nl), + id: "p1", + is_conversation_allowed: true, + language: nl ? "nl" : "en", + legal_basis: null, + name: nl ? "Bewonersavond Lindenwijk" : "Lindenwijk resident evening", + privacy_policy_url: null, + }, + }), + ); + await page.route("**/conversations**", (route) => + route.fulfill({ json: [] }), + ); +} + +for (const size of sizes) + for (const lang of ["en-US", "nl-NL"]) + test(`templates harness, ${size.name}, ${lang}`, async ({ page }) => { + test.setTimeout(90_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); + await answerApi(page, nl); + const shot = async (name: string) => { + if (shots) + await page.screenshot({ + fullPage: true, + 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`, + ); + await expect(page.getByTestId("template-picker")).toBeVisible(); + // The first view: the most used six, and a resident evening among them. + await expect(page.getByRole("radio")).toHaveCount(6); + await expect( + page.getByTestId("template-card-resident_evening"), + ).toContainText(nl ? "Bewonersavond" : "Resident evening"); + // Continue waits for a card, and says so. + await expect( + page.getByTestId("create-project-template-continue"), + ).toBeDisabled(); + await shot("01-first-view"); + await noSideScroll(); + + for (const verb of VERBS) { + await page.getByTestId(`template-filter-verb-${verb}`).click(); + await expect( + page.getByTestId(`template-filter-verb-${verb}`), + ).toHaveAttribute("aria-pressed", "true"); + await shot(`02-verb-${verb}`); + } + // The last verb pressed again clears it. + await page.getByTestId("template-filter-verb-decide").click(); + await page.getByTestId("template-filter-setup-many_groups").click(); + await expect(page.getByRole("radio")).toHaveCount(10); + await shot("03-many-groups"); + await noSideScroll(); + + await page.getByTestId("template-card-resident_evening").click(); + await expect( + page.getByTestId("template-card-resident_evening"), + ).toHaveAttribute("aria-checked", "true"); + // The bug from the earlier screenshot: Continue must be enabled and look it. + const next = page.getByTestId("create-project-template-continue"); + await expect(next).toBeEnabled(); + expect( + await next.evaluate((el) => getComputedStyle(el).backgroundColor), + ).not.toBe("rgba(0, 0, 0, 0)"); + await shot("04-selected"); + await next.click(); + await expect( + page.getByTestId("create-project-chosen-template"), + ).toBeVisible(); + await page.getByTestId("template-setup-summary-toggle").click(); + await expect( + page.getByTestId("template-setup-summary-script"), + ).toHaveText( + nl + ? "Een tafel leiden op de bewonersavond" + : "Hosting a table at a resident evening", + ); + await page.waitForTimeout(300); + await shot("05-name-what-this-sets-up"); + await noSideScroll(); + + await page.goto(`/e2e/templates-harness.html?path=/${lang}/editor`); + await expect( + page.getByTestId("portal-editor-name-label-input"), + ).toHaveValue( + nl ? "Naam of nummer van je tafel" : "Table name or number", + ); + await page + .getByTestId("portal-editor-name-label-input") + .evaluate((el) => el.scrollIntoView({ block: "center" })); + if (shots) + await page.screenshot({ + path: `${shots}/${size.name}-${lang}-06-editor-name-label.png`, + }); + + await page.goto( + `/e2e/templates-harness.html?path=/${lang}/w/w1/projects/p1/host-guide`, + ); + await expect( + page.getByText( + nl + ? "Een tafel leiden op de bewonersavond" + : "Hosting a table at a resident evening", + ), + ).toBeVisible(); + await page.waitForTimeout(300); + await shot("07-host-guide"); + + // The portal's name box: the example follows the label (ECHO-1053). + for (const [name, label, placeholder] of [ + [ + "09-portal-name-default", + "", + nl ? "Groep 1, Tafel 3, enz." : "Group 1, Table 3, etc.", + ], + [ + "09-portal-name-table", + nl ? "Naam of nummer van je tafel" : "Table name or number", + null, + ], + [ + "09-portal-name-interview", + nl + ? "Interviewcode (geen echte namen)" + : "Interview code (no real names)", + null, + ], + ] as const) { + await page.goto( + `/e2e/templates-harness.html?path=/${lang}/p1/start&label=${encodeURIComponent(label)}`, + ); + const input = page.getByTestId("portal-initiate-name-input"); + await expect(input).toBeVisible(); + expect(await input.getAttribute("placeholder")).toBe(placeholder); + await shot(name); + } + + await page.goto(`/e2e/templates-harness.html?path=/${lang}/w/w1/home`); + await expect(page.getByTestId("workspace-home-start")).toBeVisible(); + await expect(page.getByTestId("template-picker")).toBeVisible(); + await shot("08-workspace-home"); + await noSideScroll(); + expect(errors).toEqual([]); + }); diff --git a/dembrane/frontend/e2e/verify-explore-harness.html b/dembrane/frontend/e2e/verify-explore-harness.html new file mode 100644 index 000000000..68dae60c4 --- /dev/null +++ b/dembrane/frontend/e2e/verify-explore-harness.html @@ -0,0 +1,12 @@ + + + + + + Verify and Explore harness + + +
+ + + diff --git a/dembrane/frontend/e2e/verify-explore-harness.spec.ts b/dembrane/frontend/e2e/verify-explore-harness.spec.ts new file mode 100644 index 000000000..aa3f3707a --- /dev/null +++ b/dembrane/frontend/e2e/verify-explore-harness.spec.ts @@ -0,0 +1,151 @@ +import { expect, type Page, test } from "@playwright/test"; + +// ECHO-1052: every portal state of Verify and Explore at phone width, and the portal +// editor's Verify and Explore sections at desktop width, in English and Dutch. +// E2E_SHOTS= also saves screenshots. +const shots = process.env.E2E_SHOTS; + +async function answerApi(page: Page) { + // Anything the seeded queries do not cover answers empty, as a fresh project would. + await page.route("**/api/**", (route) => route.fulfill({ json: {} })); +} + +const open = async (page: Page, view: string, lang: string) => { + await page.addInitScript((l) => { + localStorage.setItem("dembrane-language", l); + }, lang); + await answerApi(page); + await page.goto(`/e2e/verify-explore-harness.html?view=${view}&lang=${lang}`); +}; + +const noSideScroll = (page: Page) => + page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ); + +for (const lang of ["en-US", "nl-NL"]) { + const nl = lang === "nl-NL"; + + test(`portal states at 390px, ${lang}`, async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.setViewportSize({ height: 844, width: 390 }); + const shot = async (name: string) => { + if (shots) + await page.screenshot({ + fullPage: true, + path: `${shots}/portal-${lang}-${name}.png`, + }); + }; + + for (const [view, label] of [ + ["button-verify", nl ? "Verifiëren" : "Verify"], + ["button-explore", nl ? "Verkennen" : "Explore"], + // Both on: the product name, in every language. + ["button-both", "ECHO"], + ] as const) { + await open(page, view, lang); + await expect(page.getByTestId("portal-audio-echo-button")).toHaveText( + label, + ); + await shot(view); + expect(await noSideScroll(page)).toBe(true); + } + + await open(page, "choice-both", lang); + const verify = page.getByTestId("portal-echo-verify-card"); + const explore = page.getByTestId("portal-echo-explore-card"); + await expect(verify).toBeVisible(); + await expect(explore).toBeVisible(); + for (const choice of [verify, explore]) { + const box = await choice.boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + } + await shot("choice-both"); + await page.keyboard.press("Tab"); + await expect(verify).toBeFocused(); + await shot("choice-both-focus"); + expect(await noSideScroll(page)).toBe(true); + + await open(page, "picker", lang); + await expect( + page.getByTestId("portal-verify-topic-agreements"), + ).toBeVisible(); + await page.getByTestId("portal-verify-topic-heard").click(); + await expect(page.getByTestId("portal-verify-topic-heard")).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect( + page.getByTestId("portal-verify-selection-next-button"), + ).toBeEnabled(); + await page.mouse.move(0, 0); + await page.waitForTimeout(300); + await shot("picker"); + expect(await noSideScroll(page)).toBe(true); + + await open(page, "reminder", lang); + await page.getByTestId("portal-audio-stop-finish-button").click(); + await expect(page.getByTestId("portal-audio-verify-button")).toBeVisible(); + await page.waitForTimeout(300); + await shot("reminder"); + + expect(errors).toEqual([]); + }); + + test(`portal editor Verify and Explore at 1280px, ${lang}`, async ({ + page, + }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.setViewportSize({ height: 900, width: 1280 }); + await open(page, "editor", lang); + const cross = page.getByTestId("portal-editor-cross-pollination-switch"); + await expect(cross).toBeAttached(); + await expect( + page.getByTestId("portal-editor-verify-topic-heard"), + ).toBeAttached(); + await expect( + page.getByTestId("portal-editor-ask-email-checkbox"), + ).toHaveCount(0); + const explore = page.getByTestId("portal-editor-go-deeper-switch"); + await explore.scrollIntoViewIfNeeded(); + if (shots) { + const section = page.locator("form").first(); + await section.screenshot({ path: `${shots}/editor-${lang}-full.png` }); + await page + .getByTestId("portal-editor-go-deeper-switch") + .locator( + "xpath=ancestor::div[contains(@class,'mantine-Stack-root')][2]", + ) + .screenshot({ path: `${shots}/editor-${lang}-explore-verify.png` }); + } + expect(errors).toEqual([]); + }); +} + +// The recorder's footer (the ECHO, Verify or Explore button beside Stop) stays on one row +// on a 390px phone in the languages with the longest words. +for (const lang of ["nl-NL", "de-DE"]) { + test(`recorder footer on one row at 390px, ${lang}`, async ({ page }) => { + await page.setViewportSize({ height: 844, width: 390 }); + for (const view of ["button-both", "button-verify", "button-explore"]) { + await open(page, view, lang); + const refine = page.getByTestId("portal-audio-echo-button"); + const stop = page.getByTestId("portal-audio-stop-button"); + await expect(refine).toBeVisible(); + if (view === "button-both") await expect(refine).toHaveText("ECHO"); + const [a, b] = [await refine.boundingBox(), await stop.boundingBox()]; + expect([view, Math.abs((a?.y ?? 0) - (b?.y ?? 1)) < 2]).toEqual([ + view, + true, + ]); + expect(await noSideScroll(page)).toBe(true); + if (shots && lang === "de-DE") + await page.screenshot({ + fullPage: true, + path: `${shots}/portal-${lang}-${view}.png`, + }); + } + }); +} diff --git a/dembrane/frontend/lingui.config.ts b/dembrane/frontend/lingui.config.ts index 48f8eefb4..a59c93942 100644 --- a/dembrane/frontend/lingui.config.ts +++ b/dembrane/frontend/lingui.config.ts @@ -8,6 +8,8 @@ const config: LinguiConfig = { exclude: [ "/src/features/accounts/**", "/src/lib/errors/messages/**", + "/src/components/project/templates/**", + "/src/components/project/digest/**", ], include: ["src"], path: "/src/locales/{locale}", @@ -19,6 +21,18 @@ const config: LinguiConfig = { include: ["/src/features/accounts"], path: "/src/features/accounts/locales/{locale}", }, + { + // The template picker's own catalog (ECHO-1053): sixteen templates' names, lines + // and facts, loaded when the picker opens (src/components/project/templates/i18n.ts). + 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/Router.tsx b/dembrane/frontend/src/Router.tsx index 12238c729..450b53283 100644 --- a/dembrane/frontend/src/Router.tsx +++ b/dembrane/frontend/src/Router.tsx @@ -15,10 +15,8 @@ import { ProjectLibraryLayout } from "./components/layout/ProjectLibraryLayout"; import { ProjectOverviewLayout } from "./components/layout/ProjectOverviewLayout"; import { WorkspaceLayout } from "./components/layout/WorkspaceLayout"; import { ParticipantConversationAudioContent } from "./components/participant/ParticipantConversationAudioContent"; -import { RefineSelection } from "./components/participant/refine/RefineSelection"; import { Verify } from "./components/participant/verify/Verify"; import { VerifyArtefact } from "./components/participant/verify/VerifyArtefact"; -import { VerifySelection } from "./components/participant/verify/VerifySelection"; import { ProjectAccessGuard } from "./components/project/ProjectAccessGuard"; import { ENABLE_CANVAS, ENABLE_MONITOR, ENABLE_PRESENT } from "./config"; import { @@ -45,6 +43,16 @@ import { // Lazy-loaded route components // The conversation page is dashboard only; loaded lazily so the portal's first load stays small. +// The choice between Verify and Explore and the Verify picker open only after a minute of +// recording, so they stay out of the portal's first load (ECHO-1052). +const RefineSelection = createLazyNamedRoute( + () => import("./components/participant/refine/RefineSelection"), + "RefineSelection", +); +const VerifySelection = createLazyNamedRoute( + () => import("./components/participant/verify/VerifySelection"), + "VerifySelection", +); const ProjectConversationRoute = createLazyNamedRoute( () => import("./routes/project/conversation/ProjectConversationRoute"), "ProjectConversationRoute", diff --git a/dembrane/frontend/src/components/auth/LoginBlockedNotice.tsx b/dembrane/frontend/src/components/auth/LoginBlockedNotice.tsx new file mode 100644 index 000000000..9a63ff7ea --- /dev/null +++ b/dembrane/frontend/src/components/auth/LoginBlockedNotice.tsx @@ -0,0 +1,51 @@ +import { Trans } from "@lingui/react/macro"; +import { Alert, Anchor, Button, Stack, Text } from "@mantine/core"; +import { ShieldWarningIcon } from "@phosphor-icons/react"; +import { SUPPORT_EMAIL } from "@/components/error/actions"; +import { testId } from "@/lib/testUtils"; + +/** + * A login refused before it reached dembrane (a 403 that carries no dembrane code). It + * says only what is known, offers the emailed sign-in code when the page has one, and + * names support. It never guesses the cause. + */ +export const LoginBlockedNotice = ({ + onUseCode, +}: { + /** Switches to the emailed sign-in code; the button is left out without it. */ + onUseCode?: () => void; +}) => ( + } + {...testId("auth-login-blocked")} + > + + + + Your login was stopped before it reached dembrane, so we could not + check your details. Try another network or another browser. + + + {onUseCode && ( + + )} + + + Still stuck? Email{" "} + + {SUPPORT_EMAIL} + {" "} + and we will help you log in. + + + + +); diff --git a/dembrane/frontend/src/components/auth/LoginNotices.test.tsx b/dembrane/frontend/src/components/auth/LoginNotices.test.tsx new file mode 100644 index 000000000..c91437493 --- /dev/null +++ b/dembrane/frontend/src/components/auth/LoginNotices.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { MantineProvider } from "@mantine/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { LoginBlockedNotice } from "./LoginBlockedNotice"; +import { formatWait, LoginRateLimitNotice } from "./LoginRateLimitNotice"; +import { SignedInRecoveryNotice } from "./SignedInRecoveryNotice"; + +beforeAll(() => { + i18n.load("en", {}); + i18n.activate("en"); + window.matchMedia = + window.matchMedia || + ((query: string) => ({ + addEventListener: () => {}, + addListener: () => {}, + dispatchEvent: () => false, + matches: false, + media: query, + onchange: null, + removeEventListener: () => {}, + removeListener: () => {}, + })); +}); + +afterEach(cleanup); + +const show = (ui: ReactNode) => + render( + + {ui} + , + ); + +describe("LoginRateLimitNotice", () => { + it("formats the wait as a clock", () => { + expect(formatWait(75)).toBe("1:15"); + expect(formatWait(9)).toBe("0:09"); + expect(formatWait(0)).toBe("0:00"); + expect(formatWait(-3)).toBe("0:00"); + }); + + it("counts down for a password login without mentioning a code", () => { + show(); + const notice = screen.getByTestId("auth-login-rate-limit"); + expect(notice.textContent).toContain("Too many login attempts"); + expect(notice.textContent).not.toMatch(/code/i); + expect(screen.getByTestId("auth-login-rate-limit-wait").textContent).toBe( + "You can try again in 0:42.", + ); + }); + + it("keeps the code wording for the code sign-in", () => { + show(); + expect(screen.getByTestId("auth-login-rate-limit").textContent).toContain( + "request a new code", + ); + }); + + it("says when the wait is over", () => { + show(); + expect(screen.getByTestId("auth-login-rate-limit-wait").textContent).toBe( + "You can try again now.", + ); + }); +}); + +describe("LoginBlockedNotice", () => { + it("offers the emailed code and names support", () => { + const onUseCode = vi.fn(); + show(); + const notice = screen.getByTestId("auth-login-blocked"); + expect(notice.textContent).toContain("stopped before it reached dembrane"); + expect(notice.textContent).toContain("another network or another browser"); + expect( + screen.getByRole("link", { name: "support@dembrane.com" }), + ).toHaveProperty("href", "mailto:support@dembrane.com"); + fireEvent.click(screen.getByTestId("auth-login-blocked-use-code")); + expect(onUseCode).toHaveBeenCalledOnce(); + }); + + it("leaves the code button out when there is no code route", () => { + show(); + expect(screen.queryByTestId("auth-login-blocked-use-code")).toBeNull(); + }); +}); + +describe("SignedInRecoveryNotice", () => { + it("says the person is logged in and lets them carry on", () => { + const onContinue = vi.fn(); + show(); + expect( + screen.getByTestId("auth-login-signed-in-retry").textContent, + ).toContain("You are logged in"); + fireEvent.click(screen.getByTestId("auth-login-signed-in-continue")); + expect(onContinue).toHaveBeenCalledOnce(); + }); +}); diff --git a/dembrane/frontend/src/components/auth/LoginRateLimitNotice.tsx b/dembrane/frontend/src/components/auth/LoginRateLimitNotice.tsx new file mode 100644 index 000000000..e05338bb5 --- /dev/null +++ b/dembrane/frontend/src/components/auth/LoginRateLimitNotice.tsx @@ -0,0 +1,54 @@ +import { Trans } from "@lingui/react/macro"; +import { Alert, Stack, Text } from "@mantine/core"; +import { ClockIcon } from "@phosphor-icons/react"; +import { testId } from "@/lib/testUtils"; + +/** 75 -> "1:15", 9 -> "0:09": a clock reads the same in every language. */ +export function formatWait(seconds: number): string { + const s = Math.max(0, Math.ceil(seconds)); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +} + +/** + * Says that sign-in is paused and counts down to when it opens again. The parent owns the + * countdown (useResendCooldown) and disables its own button while `seconds` is above zero. + * `flow` keeps the wording right: a password login never mentions a code. + */ +export const LoginRateLimitNotice = ({ + seconds, + flow = "password", +}: { + seconds: number; + flow?: "password" | "code"; +}) => { + const clock = formatWait(seconds); + return ( + } {...testId("auth-login-rate-limit")}> + + + {flow === "code" ? ( + + Too many tries. Wait a moment, then request a new code. + + ) : ( + + Too many login attempts. Wait a moment, then try again. + + )} + + + {seconds > 0 ? ( + You can try again in {clock}. + ) : ( + You can try again now. + )} + + + + ); +}; diff --git a/dembrane/frontend/src/components/auth/SignedInRecoveryNotice.tsx b/dembrane/frontend/src/components/auth/SignedInRecoveryNotice.tsx new file mode 100644 index 000000000..cbd5bca0a --- /dev/null +++ b/dembrane/frontend/src/components/auth/SignedInRecoveryNotice.tsx @@ -0,0 +1,38 @@ +import { Trans } from "@lingui/react/macro"; +import { Alert, Button, Stack, Text } from "@mantine/core"; +import { InfoIcon } from "@phosphor-icons/react"; +import { testId } from "@/lib/testUtils"; + +/** + * The sign-in worked, but a step after it (loading the active devices, the landing page) + * did not. The person is logged in; this says so and lets them carry on. + */ +export const SignedInRecoveryNotice = ({ + onContinue, + loading = false, +}: { + onContinue: () => void; + loading?: boolean; +}) => ( + } + {...testId("auth-login-signed-in-retry")} + > + + + + You are logged in, but we could not open dembrane just now. Try again + in a moment. + + + + + +); diff --git a/dembrane/frontend/src/components/auth/hooks/index.ts b/dembrane/frontend/src/components/auth/hooks/index.ts index 4b050c866..f11163ee5 100644 --- a/dembrane/frontend/src/components/auth/hooks/index.ts +++ b/dembrane/frontend/src/components/auth/hooks/index.ts @@ -5,6 +5,7 @@ import { useLocation, useSearchParams } from "react-router"; import { toast } from "@/components/common/Toaster"; import { API_BASE_URL } from "@/config"; import { resetPromptSeen } from "@/features/accounts/help/tasksPromptState"; +import { clearAllDrafts } from "@/features/accounts/signing/signingDraft"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { AuthError, @@ -186,8 +187,10 @@ export const useLoginMutation = () => { try { sessionStorage.removeItem("dembrane_ws_selected"); } catch {} - // A new sign-in may see the tasks popup again. + // A new sign-in may see the tasks banner again, and starts with no + // half-filled signing drafts. resetPromptSeen(); + clearAllDrafts(); } emitAuthCacheBoundary(); await queryClient.invalidateQueries({ queryKey: ["auth", "session"] }); @@ -234,6 +237,7 @@ export const useLogoutMutation = () => { try { sessionStorage.removeItem("dembrane_ws_selected"); } catch {} + clearAllDrafts(); } emitAuthCacheBoundary(); }, diff --git a/dembrane/frontend/src/components/auth/useResendCooldown.ts b/dembrane/frontend/src/components/auth/useResendCooldown.ts index 1b0a7d4f4..00ecac3bb 100644 --- a/dembrane/frontend/src/components/auth/useResendCooldown.ts +++ b/dembrane/frontend/src/components/auth/useResendCooldown.ts @@ -3,7 +3,10 @@ import { useEffect, useState } from "react"; // The API sends one code per email a minute. export const RESEND_COOLDOWN_SECONDS = 60; -/** Seconds left before another code may be sent, and a `start` to begin the wait. */ +/** + * Seconds left before another try, and a `start` to begin the wait: the default length, or + * the seconds a rate-limited answer named. + */ export function useResendCooldown(seconds = RESEND_COOLDOWN_SECONDS) { const [wait, setWait] = useState(0); @@ -13,5 +16,8 @@ export function useResendCooldown(seconds = RESEND_COOLDOWN_SECONDS) { return () => clearTimeout(id); }, [wait]); - return { start: () => setWait(seconds), wait }; + return { + start: (override?: number) => setWait(override ?? seconds), + wait, + }; } diff --git a/dembrane/frontend/src/components/auth/utils/errorUtils.test.ts b/dembrane/frontend/src/components/auth/utils/errorUtils.test.ts new file mode 100644 index 000000000..f29894277 --- /dev/null +++ b/dembrane/frontend/src/components/auth/utils/errorUtils.test.ts @@ -0,0 +1,65 @@ +import { i18n } from "@lingui/core"; +import { beforeAll, describe, expect, it } from "vitest"; +import { AuthError } from "@/lib/auth"; +import { + authErrorStatus, + authRetryAfter, + describeAuthError, + loginFailureCause, +} from "./errorUtils"; + +beforeAll(() => { + i18n.load("en", {}); + i18n.activate("en"); +}); + +const refused = (code: string, status: number, retry?: number) => + new AuthError("x", code, { retryAfterSeconds: retry ?? null, status }); + +describe("loginFailureCause", () => { + it.each([ + [refused("INVALID_EMAIL_OR_PASSWORD", 401), "wrong_credentials"], + [refused("EMAIL_NOT_VERIFIED", 403), "unverified"], + [refused("HTTP_429", 429, 10), "rate_limited"], + [refused("rate_limit.exceeded", 429), "rate_limited"], + [refused("HTTP_403", 403), "blocked"], + [refused("HTTP_500", 500), "server"], + [refused("INTERNAL_SERVER_ERROR", 500), "server"], + [new TypeError("Failed to fetch"), "network"], + [new TypeError("Load failed"), "network"], + [new Error("boom"), "server"], + ])("%s is %s", (error, cause) => { + expect(loginFailureCause(error)).toBe(cause); + }); +}); + +describe("describeAuthError", () => { + it("a password login's rate limit never talks about a code", () => { + const e = refused("HTTP_429", 429); + expect(describeAuthError(e, { flow: "password" })).not.toMatch(/code/i); + expect(describeAuthError(e)).toMatch(/request a new code/); + }); + + it("wrong credentials stay generic: nothing says whether the account exists", () => { + for (const code of ["INVALID_EMAIL_OR_PASSWORD", "INVALID_EMAIL"]) + expect(describeAuthError(refused(code, 401), { flow: "password" })).toBe( + "The email or password is not right. Check them and try again.", + ); + }); + + it("a refusal from in front of dembrane says it never reached dembrane", () => { + expect( + describeAuthError(refused("HTTP_403", 403), { flow: "password" }), + ).toMatch(/stopped before it reached dembrane/); + }); + + it("reads the status and the wait from the error", () => { + const e = refused("HTTP_429", 429, 12); + expect(authErrorStatus(e)).toBe(429); + expect(authRetryAfter(e)).toBe(12); + expect( + authErrorStatus({ errors: [{ extensions: { code: "HTTP_502" } }] }), + ).toBe(502); + expect(authRetryAfter(refused("HTTP_429", 429))).toBeNull(); + }); +}); diff --git a/dembrane/frontend/src/components/auth/utils/errorUtils.ts b/dembrane/frontend/src/components/auth/utils/errorUtils.ts index 7f1e0990a..7948aaccf 100644 --- a/dembrane/frontend/src/components/auth/utils/errorUtils.ts +++ b/dembrane/frontend/src/components/auth/utils/errorUtils.ts @@ -1,4 +1,5 @@ import { t } from "@lingui/core/macro"; +import type { LoginFailureCause } from "@/lib/analytics/events"; /** * Sign-in, reset and verify errors come from Better Auth, not from the platform's error @@ -14,10 +15,83 @@ export function authErrorCode(e: unknown): string | null { return typeof code === "string" ? code : null; } -export function describeAuthError(e: unknown): string { +/** The HTTP status an AuthError carries, from its field or its HTTP_ code. */ +export function authErrorStatus(e: unknown): number | null { + const status = (e as { status?: unknown } | null)?.status; + if (typeof status === "number") return status; + const match = authErrorCode(e)?.match(/^HTTP_(\d{3})$/); + return match ? Number(match[1]) : null; +} + +/** Seconds the server asked to wait, when a rate-limited answer said. */ +export function authRetryAfter(e: unknown): number | null { + const seconds = (e as { retryAfterSeconds?: unknown } | null) + ?.retryAfterSeconds; + return typeof seconds === "number" && seconds > 0 ? Math.ceil(seconds) : null; +} + +/** + * Why a sign-in failed, as analytics counts it (`user_login_failed.cause`) and as the login + * page decides what to show. Never anything the person typed. + */ +export type { LoginFailureCause }; + +const WRONG_CREDENTIALS = new Set([ + "INVALID_EMAIL_OR_PASSWORD", + "INVALID_EMAIL", + "INVALID_CODE", + "INVALID_TWO_FACTOR_CODE", + "INVALID_OTP", + "OTP_EXPIRED", +]); + +const RATE_LIMITED = new Set([ + "TOO_MANY_ATTEMPTS", + "HTTP_429", + "rate_limit.exceeded", +]); + +/** True when the request never got an answer: offline, DNS, a dropped connection. */ +function isNetworkFailure(e: unknown): boolean { + if (authErrorCode(e) === "TIMEOUT") return true; + if (authErrorCode(e)) return false; + if (typeof navigator !== "undefined" && navigator.onLine === false) + return true; + const message = e instanceof Error ? e.message : ""; + // Chrome, Firefox and Safari word the same failed fetch differently. + return ( + message === "Failed to fetch" || + message === "Load failed" || + message.includes("NetworkError") + ); +} + +export function loginFailureCause(e: unknown): LoginFailureCause { + const code = authErrorCode(e); + if (code === "EMAIL_NOT_VERIFIED") return "unverified"; + if (code && WRONG_CREDENTIALS.has(code)) return "wrong_credentials"; + if ((code && RATE_LIMITED.has(code)) || authErrorStatus(e) === 429) + return "rate_limited"; + // A 403 with no code of ours: something in front of dembrane refused it. dembrane's own + // refusals (an unverified email is a 403 too) always name a code. + if (code === "HTTP_403") return "blocked"; + if (isNetworkFailure(e)) return "network"; + return "server"; +} + +/** + * The sentence for an auth error. `flow` says which sign-in the person is in: a password + * login never talks about a code, and the code sign-in keeps its own wording. + */ +export function describeAuthError( + e: unknown, + { flow = "code" }: { flow?: "password" | "code" } = {}, +): string { const code = authErrorCode(e); switch (code) { case "INVALID_EMAIL_OR_PASSWORD": + // Kept generic on purpose: never says whether an account exists. + case "INVALID_EMAIL": return t`The email or password is not right. Check them and try again.`; case "INVALID_CODE": case "INVALID_TWO_FACTOR_CODE": @@ -27,19 +101,25 @@ export function describeAuthError(e: unknown): string { case "INVALID_OTP": case "OTP_EXPIRED": return t`That code is not right or has expired. Request a new one.`; - case "TOO_MANY_ATTEMPTS": - case "HTTP_429": - case "rate_limit.exceeded": - return t`Too many tries. Wait a moment, then request a new code.`; case "INVALID_TOKEN": return t`This link is not valid or has expired. Request a new one.`; - case "TIMEOUT": - return t`This took too long. Try again.`; default: break; } - const message = e instanceof Error ? e.message : ""; - if (message === "Failed to fetch" || message.includes("NetworkError")) - return t`We could not reach dembrane. Check your internet connection and try again.`; - return t`Something went wrong. Try again, and contact support if it keeps happening.`; + switch (loginFailureCause(e)) { + case "rate_limited": + return flow === "password" + ? t`Too many login attempts. Wait a moment, then try again.` + : t`Too many tries. Wait a moment, then request a new code.`; + case "blocked": + return flow === "password" + ? t`Your login was stopped before it reached dembrane, so we could not check your details. Try another network or another browser.` + : t`This request was stopped before it reached dembrane. Try another network or another browser.`; + case "network": + return code === "TIMEOUT" + ? t`This took too long. Try again.` + : t`We could not reach dembrane. Check your internet connection and try again.`; + default: + return t`Something went wrong. Try again, and contact support if it keeps happening.`; + } } diff --git a/dembrane/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx b/dembrane/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx index 53cef43f8..e970a9425 100644 --- a/dembrane/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx +++ b/dembrane/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx @@ -14,6 +14,14 @@ import { import { CaretDownIcon, CaretUpIcon, CheckIcon } from "@phosphor-icons/react"; import { useId, useMemo, useState } from "react"; import { SuggestionCardFrame } from "@/components/common/SuggestionCardFrame"; +import { + ADDED_MARK, + asDisplayString, + fieldLabel, + isEmptyValue, + REMOVED_MARK, + ValueText, +} from "@/components/common/settingChange"; import { toast } from "@/components/common/Toaster"; import { buildWordDiff, @@ -25,6 +33,7 @@ import { useProjectById, useUpdateProjectByIdMutation, } from "@/components/project/hooks"; +import { keyTermCount, track } from "@/lib/analytics/events"; import { presentError } from "@/lib/errors/present"; import { testId } from "@/lib/testUtils"; @@ -45,38 +54,6 @@ export type ProjectUpdateSuggestion = { changes: ProjectUpdateSuggestionChange[]; }; -// Hosts see the portal editor's language, not backend field names. -const FIELD_LABELS: Record string> = { - anonymize_transcripts: () => t`Anonymise transcripts`, - context: () => t`Project context`, - conversation_title_prompt: () => t`Title guidance`, - default_conversation_ask_for_participant_email: () => - t`Ask participants for their email`, - default_conversation_ask_for_participant_name: () => - t`Ask participants for their name`, - default_conversation_description: () => t`Portal description`, - default_conversation_finish_text: () => t`Portal finish message`, - default_conversation_title: () => t`Portal title`, - default_conversation_transcript_prompt: () => t`Key terms`, - default_conversation_tutorial_slug: () => t`Portal tutorial`, - enable_ai_title_and_tags: () => t`Automatic titles and draft tags`, - get_reply_mode: () => t`Reply mode`, - get_reply_prompt: () => t`Reply guidance`, - host_guide: () => t`Host guide`, - image_generation_model: () => t`Image style`, - is_conversation_allowed: () => t`Portal open for new conversations`, - is_get_reply_enabled: () => t`Replies to participants`, - is_project_notification_subscription_allowed: () => - t`Participant updates subscription`, - is_verify_enabled: () => t`Participant verification`, - is_verify_on_finish_enabled: () => t`Verification on the finish screen`, - language: () => t`Portal language`, - legal_basis: () => t`Legal basis`, - name: () => t`Project name`, - selected_verification_key_list: () => t`Verification topics`, - tutorial_slug: () => t`Tutorial`, -}; - /** * What a field actually touches, in one sentence. Only fields whose reach is * wider than the setting screen they live on: the host cannot be expected to @@ -95,10 +72,6 @@ const FIELD_IMPACT: Record string> = { t`This sets the language participants see in the portal, including the questions they are asked.`, }; -const fieldLabel = (field: string) => - FIELD_LABELS[field]?.() ?? - field.replace(/^default_conversation_/, "").replace(/_/g, " "); - const fieldImpact = (field: string) => FIELD_IMPACT[field]?.() ?? null; /** Names the fields in the headline. Past three, the tail becomes a count so @@ -111,85 +84,15 @@ const summarizeFields = (fields: string[]) => { return t`${names} and ${extra} more`; }; -const isEmptyValue = (value: unknown) => - value === null || value === undefined || value === ""; - /** Coarse pointers need a 44px target; Mantine's xs controls are 30px. */ const COARSE_TAP_TARGET = "[@media(pointer:coarse)]:min-h-11"; const VALUE_BOX = "max-h-64 overflow-y-auto border-x-0 border-y border-solid border-[color:var(--app-rule-color)] px-2 py-1.5"; -/** - * The three ways a fragment can read inside a value box, and the only place - * they are defined. The word diff and the short-value line both pull from - * here, so a marking can never drift between the long and short paths. - * - * Each mark carries a shape as well as a colour: the removal is struck - * through, the addition is underlined. A background alone does not survive - * greyscale (the green sits at about 97% luminance) or a colour-blind reader, - * and the addition used to have nothing but that background. - * - * Unchanged text recedes on purpose. Marked and unmarked prose used to sit at - * the same contrast, so a two word edit inside a paragraph read as one flat - * block; dropping the surround to a mid grey makes the edit the first thing - * the eye lands on, without inventing a colour the card does not already use. - */ -const REMOVED_MARK = - "bg-[var(--app-danger-tint)] text-[var(--app-danger-on-tint)] line-through decoration-[var(--app-danger)] decoration-2"; -const ADDED_MARK = - "bg-[var(--app-success-tint)] text-[var(--app-success)] underline decoration-[var(--app-success)] decoration-2"; +// Unchanged text recedes so the marked words (common/settingChange) come forward. const UNCHANGED_TEXT = "text-[var(--app-muted)]"; -const asDisplayString = (value: unknown) => { - if (typeof value === "string") return value; - if (value === null || value === undefined) return ""; - if (typeof value === "object") return JSON.stringify(value); - return String(value); -}; - -const ValueText = ({ - value, - kind, -}: { - value: unknown; - kind: "old" | "new"; -}) => { - if (isEmptyValue(value)) { - // A description of the value, not the value itself. Italics used to carry - // that difference and carried it to nobody; the words say it instead, and - // the dimmed colour keeps them from reading as content. - return ( - - {kind === "old" ? ( - nothing here yet - ) : ( - emptied out - )} - - ); - } - const display = - typeof value === "boolean" ? ( - value ? ( - on - ) : ( - off - ) - ) : ( - asDisplayString(value) - ); - return ( - - {display} - - ); -}; - /** * One side of a word diff. The same chunk list renders both sides: the * "current" side hides additions and strikes removals, the "proposed" side @@ -494,6 +397,14 @@ export const ProjectUpdateSuggestionCard = ({ payload, }); await projectQuery.refetch(); + if ("default_conversation_transcript_prompt" in payload) { + const value = payload.default_conversation_transcript_prompt; + track("key_terms_updated", { + count: keyTermCount(typeof value === "string" ? value : null), + project_id: suggestion.projectId, + surface: "assistant", + }); + } toast.success( t`Changes applied. You can fine-tune them anytime in project settings.`, ); diff --git a/dembrane/frontend/src/components/common/Protected.tsx b/dembrane/frontend/src/components/common/Protected.tsx index ba607cea1..7d7e6b38f 100644 --- a/dembrane/frontend/src/components/common/Protected.tsx +++ b/dembrane/frontend/src/components/common/Protected.tsx @@ -1,6 +1,7 @@ import type { PropsWithChildren } from "react"; import { useAuthenticated } from "@/components/auth/hooks"; import { BeautifulLoading } from "@/components/common/BeautifulLoading"; +import { HostIdentitySync } from "@/lib/analytics/HostIdentitySync"; export const Protected = (props: PropsWithChildren) => { const { loading, isAuthenticated } = useAuthenticated(true); @@ -13,5 +14,10 @@ export const Protected = (props: PropsWithChildren) => { return null; } - return <>{props.children}; + return ( + <> + + {props.children} + + ); }; diff --git a/dembrane/frontend/src/components/common/SelectableCardGrid/SelectableCardGrid.test.tsx b/dembrane/frontend/src/components/common/SelectableCardGrid/SelectableCardGrid.test.tsx new file mode 100644 index 000000000..903a190ef --- /dev/null +++ b/dembrane/frontend/src/components/common/SelectableCardGrid/SelectableCardGrid.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import { MantineProvider } from "@mantine/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { SelectableCardGrid } from "./SelectableCardGrid"; + +afterEach(cleanup); +beforeAll(() => { + window.matchMedia ||= ((query: string) => + ({ + addEventListener: () => {}, + addListener: () => {}, + dispatchEvent: () => false, + matches: false, + media: query, + onchange: null, + removeEventListener: () => {}, + removeListener: () => {}, + }) as unknown as MediaQueryList) as typeof window.matchMedia; +}); + +const options = ["one", "two", "three"].map((value) => ({ + render: (checked: boolean) => ( + + {value} + {checked ? " (chosen)" : ""} + + ), + testId: `card-${value}`, + value, +})); + +const Harness = ({ + initial = null, + onChange = () => {}, +}: { + initial?: string | null; + onChange?: (v: string) => void; +}) => { + const [value, setValue] = useState(initial); + return ( + +

Pick one

+ { + setValue(v); + onChange(v); + }} + labelId="grid-label" + name="harness" + testId="grid" + /> +
+ ); +}; + +const radios = () => screen.getAllByRole("radio"); + +describe("SelectableCardGrid", () => { + it("is a radio group named by its heading, one radio per card", () => { + render(); + const group = screen.getByRole("radiogroup", { name: "Pick one" }); + expect(group).toBeTruthy(); + expect(radios()).toHaveLength(3); + expect( + radios().every((r) => r.getAttribute("aria-checked") === "false"), + ).toBe(true); + }); + + it("has one tab stop: the first card until one is chosen, then the chosen one", () => { + const { unmount } = render(); + expect(radios().map((r) => r.tabIndex)).toEqual([0, -1, -1]); + unmount(); + render(); + expect(radios().map((r) => r.tabIndex)).toEqual([-1, 0, -1]); + }); + + it("chooses a card on click and marks it checked and selected", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByTestId("card-two")); + expect(onChange).toHaveBeenCalledWith("two"); + const two = screen.getByTestId("card-two"); + expect(two.getAttribute("aria-checked")).toBe("true"); + expect(two.hasAttribute("data-selected")).toBe(true); + expect(two.textContent).toContain("(chosen)"); + expect(screen.getByTestId("card-one").hasAttribute("data-selected")).toBe( + false, + ); + }); + + it("moves and chooses with the arrow keys, wrapping at the ends", () => { + const onChange = vi.fn(); + render(); + const [one] = radios(); + one?.focus(); + fireEvent.keyDown(one as HTMLElement, { + code: "ArrowRight", + key: "ArrowRight", + }); + expect(onChange).toHaveBeenLastCalledWith("two"); + expect(document.activeElement).toBe(screen.getByTestId("card-two")); + fireEvent.keyDown(screen.getByTestId("card-two"), { + code: "ArrowDown", + key: "ArrowDown", + }); + expect(onChange).toHaveBeenLastCalledWith("three"); + fireEvent.keyDown(screen.getByTestId("card-three"), { + code: "ArrowDown", + key: "ArrowDown", + }); + expect(onChange).toHaveBeenLastCalledWith("one"); + fireEvent.keyDown(screen.getByTestId("card-one"), { + code: "ArrowUp", + key: "ArrowUp", + }); + expect(onChange).toHaveBeenLastCalledWith("three"); + expect(radios().map((r) => r.tabIndex)).toEqual([-1, -1, 0]); + }); + + it("is a native button, so Space and Enter choose the focused card", () => { + render(); + for (const r of radios()) expect(r.tagName).toBe("BUTTON"); + }); +}); diff --git a/dembrane/frontend/src/components/common/SelectableCardGrid/SelectableCardGrid.tsx b/dembrane/frontend/src/components/common/SelectableCardGrid/SelectableCardGrid.tsx new file mode 100644 index 000000000..94da2ad8e --- /dev/null +++ b/dembrane/frontend/src/components/common/SelectableCardGrid/SelectableCardGrid.tsx @@ -0,0 +1,71 @@ +import { Box, Radio, SimpleGrid, type SimpleGridProps } from "@mantine/core"; +import type { ReactNode } from "react"; + +export interface SelectableCardOption { + value: string; + /** What the card shows; it gets whether it is the chosen one. */ + render: (checked: boolean) => ReactNode; + testId?: string; +} + +interface SelectableCardGridProps { + options: readonly SelectableCardOption[]; + value: string | null; + onChange: (value: string) => void; + /** The id of the visible heading that names the choice. */ + labelId: string; + /** Group name; the arrow keys move within cards that share it. */ + name: string; + cols?: SimpleGridProps["cols"]; + testId?: string; +} + +/** + * A choice of one, drawn as cards: a radio group to a screen reader and the keyboard. + * One tab stop (the chosen card, else the first); the arrow keys move and choose; + * Space and Enter choose the focused card. Cards follow the "do" border grammar and the + * chosen one takes the blue box on the action tint (.app-do[data-selected]). + */ +export const SelectableCardGrid = ({ + options, + value, + onChange, + labelId, + name, + cols = { base: 1, sm: 2 }, + testId, +}: SelectableCardGridProps) => { + const focusable = options.some((o) => o.value === value) + ? value + : (options[0]?.value ?? null); + return ( + + + {options.map((option) => { + const checked = option.value === value; + return ( + + {option.render(checked)} + + ); + })} + + + ); +}; diff --git a/dembrane/frontend/src/components/common/settingChange.tsx b/dembrane/frontend/src/components/common/settingChange.tsx new file mode 100644 index 000000000..fa9bcb108 --- /dev/null +++ b/dembrane/frontend/src/components/common/settingChange.tsx @@ -0,0 +1,120 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Text } from "@mantine/core"; + +/** + * How a proposed settings change reads, shared by the assistant's proposal card + * (chat/ProjectUpdateSuggestionCard) and the setup suggestions in the create flow + * (project/setup-suggestions): the field's name in the portal editor's words, and a + * value marked as the one being replaced or the one proposed. + */ + +// Hosts see the portal editor's language, not backend field names. +export const FIELD_LABELS: Record string> = { + anonymize_transcripts: () => t`Anonymise transcripts`, + context: () => t`Project context`, + conversation_ask_for_participant_name_label: () => + t`What the name box asks for`, + conversation_title_prompt: () => t`Title guidance`, + default_conversation_ask_for_participant_email: () => + t`Ask participants for their email`, + default_conversation_ask_for_participant_name: () => + t`Ask participants for their name`, + default_conversation_description: () => t`Portal description`, + default_conversation_finish_text: () => t`Portal finish message`, + default_conversation_title: () => t`Portal title`, + default_conversation_transcript_prompt: () => t`Key terms`, + default_conversation_tutorial_slug: () => t`Portal tutorial`, + enable_ai_title_and_tags: () => t`Automatic titles and draft tags`, + get_reply_mode: () => t`Reply mode`, + get_reply_prompt: () => t`Reply guidance`, + host_guide: () => t`Host guide`, + image_generation_model: () => t`Image style`, + is_conversation_allowed: () => t`Portal open for new conversations`, + is_get_reply_cross_pollination_enabled: () => t`Cross-pollination`, + is_get_reply_enabled: () => t`Replies to participants`, + is_project_notification_subscription_allowed: () => + t`Participant updates subscription`, + is_verify_enabled: () => t`Participant verification`, + is_verify_on_finish_enabled: () => t`Verification on the finish screen`, + language: () => t`Portal language`, + legal_basis: () => t`Legal basis`, + name: () => t`Project name`, + selected_verification_key_list: () => t`Verification topics`, + tutorial_slug: () => t`Tutorial`, +}; + +export const fieldLabel = (field: string) => + FIELD_LABELS[field]?.() ?? + field.replace(/^default_conversation_/, "").replace(/_/g, " "); + +export const isEmptyValue = (value: unknown) => + value === null || value === undefined || value === ""; + +/** + * The three ways a fragment can read inside a value box, and the only place + * they are defined. The word diff and the short-value line both pull from + * here, so a marking can never drift between the long and short paths. + * + * Each mark carries a shape as well as a colour: the removal is struck + * through, the addition is underlined. A background alone does not survive + * greyscale (the green sits at about 97% luminance) or a colour-blind reader, + * and the addition used to have nothing but that background. + * + * Unchanged text recedes on purpose. Marked and unmarked prose used to sit at + * the same contrast, so a two word edit inside a paragraph read as one flat + * block; dropping the surround to a mid grey makes the edit the first thing + * the eye lands on, without inventing a colour the card does not already use. + */ +export const REMOVED_MARK = + "bg-[var(--app-danger-tint)] text-[var(--app-danger-on-tint)] line-through decoration-[var(--app-danger)] decoration-2"; +export const ADDED_MARK = + "bg-[var(--app-success-tint)] text-[var(--app-success)] underline decoration-[var(--app-success)] decoration-2"; +export const asDisplayString = (value: unknown) => { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +}; + +export const ValueText = ({ + value, + kind, +}: { + value: unknown; + kind: "old" | "new"; +}) => { + if (isEmptyValue(value)) { + // A description of the value, not the value itself. Italics used to carry + // that difference and carried it to nobody; the words say it instead, and + // the dimmed colour keeps them from reading as content. + return ( + + {kind === "old" ? ( + nothing here yet + ) : ( + emptied out + )} + + ); + } + const display = + typeof value === "boolean" ? ( + value ? ( + on + ) : ( + off + ) + ) : ( + asDisplayString(value) + ); + return ( + + {display} + + ); +}; 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/layout/BaseLayout.tsx b/dembrane/frontend/src/components/layout/BaseLayout.tsx index fe9a5cd60..469855588 100644 --- a/dembrane/frontend/src/components/layout/BaseLayout.tsx +++ b/dembrane/frontend/src/components/layout/BaseLayout.tsx @@ -1,7 +1,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Button, Text } from "@mantine/core"; -import type { PropsWithChildren } from "react"; +import { lazy, type PropsWithChildren, Suspense } from "react"; import { Outlet, useSearchParams } from "react-router"; import { roles } from "@/colors"; import { useAuthenticated } from "@/components/auth/hooks"; @@ -13,6 +13,12 @@ import { Toaster } from "../common/Toaster"; import { ErrorBoundary } from "../error/ErrorBoundary"; import { TransitionCurtainProvider } from "./TransitionCurtainProvider"; +// The banner for someone with tasks waiting. Its own chunk: it fetches the account tasks +// summary, which the portal never needs. +const SignPromptBanner = lazy( + () => import("@/features/accounts/help/TasksPrompt"), +); + const SidebarFailure = () => (