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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions dembrane/frontend/e2e/setup-suggestions-harness.spec.ts
Original file line number Diff line number Diff line change
@@ -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=<dir> 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([]);
});
116 changes: 9 additions & 107 deletions dembrane/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, () => 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
Expand All @@ -96,10 +72,6 @@ const FIELD_IMPACT: Record<string, () => 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
Expand All @@ -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 (
<Text size="sm" component="span" c="dimmed">
{kind === "old" ? (
<Trans>nothing here yet</Trans>
) : (
<Trans>emptied out</Trans>
)}
</Text>
);
}
const display =
typeof value === "boolean" ? (
value ? (
<Trans>on</Trans>
) : (
<Trans>off</Trans>
)
) : (
asDisplayString(value)
);
return (
<Text
size="sm"
component="span"
className={`break-words ${kind === "old" ? REMOVED_MARK : ADDED_MARK}`}
>
{display}
</Text>
);
};

/**
* One side of a word diff. The same chunk list renders both sides: the
* "current" side hides additions and strikes removals, the "proposed" side
Expand Down
Loading
Loading