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/src/components/chat/ProjectUpdateSuggestionCard.tsx b/dembrane/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx index 5dbb518b8..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, @@ -46,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 @@ -96,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 @@ -112,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 diff --git a/dembrane/frontend/src/components/common/settingChange.tsx b/dembrane/frontend/src/components/common/settingChange.tsx new file mode 100644 index 000000000..75f116172 --- /dev/null +++ b/dembrane/frontend/src/components/common/settingChange.tsx @@ -0,0 +1,117 @@ +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_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`, +}; + +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/project/ProjectContextInput.tsx b/dembrane/frontend/src/components/project/ProjectContextInput.tsx index dcaa71bd9..ab6c9a930 100644 --- a/dembrane/frontend/src/components/project/ProjectContextInput.tsx +++ b/dembrane/frontend/src/components/project/ProjectContextInput.tsx @@ -21,8 +21,8 @@ export const ProjectContextInput = forwardRef< return (