diff --git a/dembrane/frontend/e2e/flows.ts b/dembrane/frontend/e2e/flows.ts index 3776a1bba..e310a0494 100644 --- a/dembrane/frontend/e2e/flows.ts +++ b/dembrane/frontend/e2e/flows.ts @@ -314,15 +314,22 @@ export const flows: Record = { }, "/w/:workspaceId/projects/new": { params: { workspaceId: "$workspace" }, - // The template picker, then the name step with "More options" open (ECHO-1042). + // 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-event", "create-project-template-continue"], + clicks: [ + "template-card-resident_evening", + "create-project-template-continue", + "template-setup-summary-toggle", + ], name: "name", }, { clicks: [ - "template-card-event", + "template-card-resident_evening", "create-project-template-continue", "create-project-more-options-toggle", ], diff --git a/dembrane/frontend/e2e/templates-harness.spec.ts b/dembrane/frontend/e2e/templates-harness.spec.ts index 2a649afc6..da03dd68a 100644 --- a/dembrane/frontend/e2e/templates-harness.spec.ts +++ b/dembrane/frontend/e2e/templates-harness.spec.ts @@ -1,14 +1,21 @@ import { expect, type Page, test } from "@playwright/test"; +import { RESIDENT_EVENING_GUIDE } from "../src/components/project/templates/hostGuide.fixture"; -// ECHO-1042: the template picker, the name step with "More options", and workspace home's -// first-project state, at desktop and phone width. E2E_SHOTS= also saves screenshots. +// 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"]; -async function answerApi(page: Page) { +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: { @@ -37,18 +44,51 @@ async function answerApi(page: Page) { 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); + await answerApi(page, nl); const shot = async (name: string) => { if (shots) await page.screenshot({ @@ -56,37 +96,130 @@ for (const size of sizes) 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(); - await expect(page.getByRole("radio")).toHaveCount(4); - await page.getByTestId("template-card-event").click(); - await expect(page.getByTestId("template-card-event")).toHaveAttribute( - "aria-checked", - "true", - ); - await shot("1-picker"); - // No sideways scroll at phone width. - expect( - await page.evaluate( - () => document.documentElement.scrollWidth <= window.innerWidth, - ), - ).toBe(true); + // 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("create-project-template-continue").click(); + 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 shot("2-name"); - await page.getByTestId("create-project-more-options-toggle").click(); - await expect(page.getByTestId("create-project-review")).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("3-more-options"); + 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 shot("4-workspace-home"); + await expect(page.getByTestId("template-picker")).toBeVisible(); + await shot("08-workspace-home"); + await noSideScroll(); expect(errors).toEqual([]); }); diff --git a/dembrane/frontend/lingui.config.ts b/dembrane/frontend/lingui.config.ts index 48f8eefb4..cc657081d 100644 --- a/dembrane/frontend/lingui.config.ts +++ b/dembrane/frontend/lingui.config.ts @@ -8,6 +8,7 @@ const config: LinguiConfig = { exclude: [ "/src/features/accounts/**", "/src/lib/errors/messages/**", + "/src/components/project/templates/**", ], include: ["src"], path: "/src/locales/{locale}", @@ -19,6 +20,12 @@ 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 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/src/components/common/settingChange.tsx b/dembrane/frontend/src/components/common/settingChange.tsx index ecb64fc5f..fa9bcb108 100644 --- a/dembrane/frontend/src/components/common/settingChange.tsx +++ b/dembrane/frontend/src/components/common/settingChange.tsx @@ -13,6 +13,8 @@ import { Text } from "@mantine/core"; 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`, diff --git a/dembrane/frontend/src/components/participant/ParticipantInitiateForm.test.tsx b/dembrane/frontend/src/components/participant/ParticipantInitiateForm.test.tsx index e549e008c..a6bc55832 100644 --- a/dembrane/frontend/src/components/participant/ParticipantInitiateForm.test.tsx +++ b/dembrane/frontend/src/components/participant/ParticipantInitiateForm.test.tsx @@ -148,6 +148,31 @@ it("leaves the Continue button alone when the project asks for a name", () => { expect(screen.getByTestId("portal-initiate-next-button")).toBeTruthy(); }); +it("gives the default name label a neutral example, with no person's name", () => { + wrap(project({ default_conversation_ask_for_participant_name: true })); + const input = screen.getByTestId("portal-initiate-name-input"); + expect(input.getAttribute("placeholder")).toBe("Group 1, Table 3, etc."); +}); + +it("leaves the example out when the host set the label", () => { + for (const label of [ + "Table name or number", + "Meeting name or date", + "Interview code (no real names)", + ]) { + wrap( + project({ + conversation_ask_for_participant_name_label: label, + default_conversation_ask_for_participant_name: true, + }), + ); + expect(screen.getByText(label)).toBeTruthy(); + const input = screen.getByTestId("portal-initiate-name-input"); + expect(input.getAttribute("placeholder")).toBeNull(); + cleanup(); + } +}); + const refusal = { response: { data: { diff --git a/dembrane/frontend/src/components/participant/ParticipantInitiateForm.tsx b/dembrane/frontend/src/components/participant/ParticipantInitiateForm.tsx index c78aaba3f..698cd5898 100644 --- a/dembrane/frontend/src/components/participant/ParticipantInitiateForm.tsx +++ b/dembrane/frontend/src/components/participant/ParticipantInitiateForm.tsx @@ -262,6 +262,9 @@ export const ParticipantInitiateForm = ({ onRefusedChange?.(Boolean(refused)); }, [refused, onRefusedChange]); + const customNameLabel = + project.conversation_ask_for_participant_name_label?.trim() || ""; + return (
((props, forwardedRef) => { const hashRef = useFocusOnHash(PROJECT_CONTEXT_HASH); const ref = useMergedRef(hashRef, forwardedRef); + const length = typeof props.value === "string" ? props.value.length : 0; return ( -