From 8500a1add1bfabec3402b4e049f64e27f9f329a3 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 31 Aug 2026 15:08:38 +0200 Subject: [PATCH] fix: the absorbed tagger gets its setup form and untagged filter back The first pass shipped the tagging checkboxes and nothing else, which was a regression dressed as an absorption: an album with no people.json has an empty roster, so the checkboxes rendered an empty list and there was no way to add anybody. The old page gated a setup form on `configured` for exactly that reason. So does this one now. Restored, behaviour-for-behaviour: - The roster grammar, in src/api/setup.ts. One person per line, aliases by comma, `=` for a combination with `+` meaning both and `|` meaning either. Keys are reused for anyone already listed and otherwise handed out one past the highest in use -- never the count, or a key deleted by hand gets reissued and inherits somebody else's photos. Mirrors pack_people.next_key. - The add-person box, for the usual case of noticing a new face in the photo already on screen. - `--untagged`, which was a no-op: it is a view filter, and anything seen this session stays navigable, or tagging the photo you are looking at removes it from the list you are walking. DOCS-web-ui.md claimed group ids were "picked, never typed" while settings rendered a plain text box. That was the mitigation cited for accepting an .env editor at all, so the claim is now backed by a ` filled from `/api/groups`, for the same reason the +tagger picked them: an id is 44 characters of base64. > `SELFTEST_GROUPS` is editable and worth understanding before you widen it. > It is what keeps `POST /_selftest` fail-closed; every group named in it is a > group the bot can be made to post arbitrary text into. +## The album, in two halves + +Which half you get is the album's own `configured` flag, exactly as the +tagger decided it. + +- **No `people.json` yet** → the roster form. One person per line, aliases + separated by commas; a line with an `=` is a combination, `+` for "both of + them" and `|` for "either of them". Keys are reused for anyone already on + the roster and otherwise handed out one past the highest in use — never the + count, because a key deleted by hand must not be reissued and inherit + somebody else's photos. That mirrors `pack_people.next_key`, which is the + authority. Names are typed into the page and never passed as arguments: an + argument lands in the shell history. +- **Configured** → the tagger: the item, a checkbox per person, saved on the + click rather than on a "done" button, and an "add somebody" box for the + usual case of noticing a new face in the photo already on screen. + +`--untagged` is a *view filter*, never a shorter list — `/api/album/media` and +the tag route both index the full album, so filtering server-side would show +item *i* of the album while tagging item *i* of the filtered set. Anything +seen this session stays navigable even once it has tags, or tagging the photo +you are looking at would remove it from the list you are walking. + ## Routes Everything under `/api`. The page itself is served from `web/dist` at every diff --git a/tool/web.py b/tool/web.py index 8d1eb36..6e1c7a5 100755 --- a/tool/web.py +++ b/tool/web.py @@ -3,8 +3,9 @@ """Manage the bot, in a browser, on this machine only. Health, configuration and the photo album in one page. It replaces the -tagger, which served the album half of this on the same port; everything it -did is still here, and the routes it did it with now live in +tagger, which served the album half of this on the same port. Everything it +did is still here -- the roster form, the tagging checkboxes, the add-person +box and the ``--untagged`` filter -- and the routes behind them now live in ``signal_bot/manage/`` where the coverage gate can see them. Started by hand and stopped again, deliberately. This page can rewrite diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ebcefbd..29a074e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,3 +1,4 @@ +import type { Roster } from "./setup.ts"; import type { AlbumState, EnvironmentView, Group, Restart, Status } from "./types.ts"; // Every mutating call sends application/json, and it is not decoration: the @@ -47,12 +48,15 @@ async function send(path: string, method: string, body: unknown): Promise export const api = { album: async (): Promise => get("/api/album/state"), env: async (): Promise => get("/api/env"), - groups: async (): Promise<{ groups: Group[] }> => - get<{ groups: Group[] }>("/api/groups"), + groups: async (): Promise<{ groups: Group[] }> => get<{ groups: Group[] }>("/api/groups"), + person: async (triggers: string[]): Promise<{ key: string }> => + send<{ key: string }>("/api/album/person", "POST", { triggers }), restart: async (): Promise => send("/api/restart-container", "POST", {}), saveEnv: async (values: Record): Promise => send("/api/env", "PUT", { values }), + setup: async (groups: string[], roster: Roster): Promise => + send("/api/album/setup", "POST", { groups, ...roster }), status: async (): Promise => get("/api/status"), tag: async (file: string, keys: string[]): Promise => send("/api/album/tag", "POST", { file, keys }), diff --git a/web/src/api/setup.test.ts b/web/src/api/setup.test.ts new file mode 100644 index 0000000..b351bc2 --- /dev/null +++ b/web/src/api/setup.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import type { Parsed, Roster } from "./setup.ts"; + +import { parseSetup } from "./setup.ts"; + +/** +Unwrap a parse the test expects to have succeeded. +*/ +function ok(parsed: Parsed): Roster { + if (!parsed.ok) throw new Error(parsed.reason); + return parsed.roster; +} + +describe("the roster grammar", () => { + it("reads one person per line, with comma-separated aliases", () => { + const { people } = ok(parseSetup("ala, alicja\nola", [])); + expect(people).toEqual([ + { key: "p1", triggers: ["!ala", "!alicja"] }, + { key: "p2", triggers: ["!ola"] }, + ]); + }); + + it("adds the ! for you, and does not double it", () => { + expect(ok(parseSetup("!ala", [])).people[0]?.triggers).toEqual(["!ala"]); + }); + + it("keeps the key somebody already has", () => { + // Handing out p{length+1} on every save renumbers everyone after an + // insertion, and every tags.json entry then points at the wrong person. + const existing = [{ key: "p7", triggers: ["!ala"] }]; + // The newcomer gets p8, not p1: one past the highest in use, so a key + // deleted by hand is never reissued to somebody else's photos. + expect(ok(parseSetup("ola\nala", existing)).people).toEqual([ + { key: "p8", triggers: ["!ola"] }, + { key: "p7", triggers: ["!ala"] }, + ]); + }); + + it("hands out one past the highest key, never the count", () => { + // Deleting p3 by hand must not let a later add reuse it and inherit its + // photos. Mirrors pack_people.next_key, which is the authority. + const existing = [{ key: "p9", triggers: ["!zed"] }]; + expect(ok(parseSetup("nowy", existing)).people[0]?.key).toBe("p10"); + }); + + it("ignores a line that is only punctuation", () => { + expect(ok(parseSetup("ala\n,\n , ", [])).people).toHaveLength(1); + }); + + it("reads = as a combination of everybody named", () => { + const { combos } = ok(parseSetup("ala\nola\nmy=ala+ola", [])); + expect(combos).toEqual([{ all_of: ["p1", "p2"], key: "c1", triggers: ["!my"] }]); + }); + + it("reads | as a combination of either of them", () => { + const { combos } = ok(parseSetup("ala\nola\nktos=ala|ola", [])); + expect(combos).toEqual([{ any_of: ["p1", "p2"], key: "c1", triggers: ["!ktos"] }]); + }); + + it("resolves a combination through any of a person's aliases", () => { + // A combination may name somebody by whichever spelling its author + // remembers, so every alias goes in the lookup, not just the first. + const { combos } = ok(parseSetup("ala, alicja\nola\nmy=alicja+ola", [])); + expect(combos[0]?.all_of).toEqual(["p1", "p2"]); + }); + + it("refuses a combination naming somebody who is not on the roster", () => { + expect(parseSetup("ala\nmy=ala+nikt", [])).toEqual({ + ok: false, + reason: "nie znam: nikt", + }); + }); + + it("numbers combinations in the order they were written", () => { + const { combos } = ok(parseSetup("a\nb\nx=a+b\ny=a|b", [])); + expect(combos.map((combo) => combo.key)).toEqual(["c1", "c2"]); + }); + + it("refuses a roster with nobody in it", () => { + expect(parseSetup("", [])).toEqual({ ok: false, reason: "add at least one person" }); + }); +}); diff --git a/web/src/api/setup.ts b/web/src/api/setup.ts new file mode 100644 index 0000000..6e42c84 --- /dev/null +++ b/web/src/api/setup.ts @@ -0,0 +1,122 @@ +import type { Person } from "./types.ts"; + +// The roster grammar the tagger used, moved across unchanged. One person per +// line, aliases separated by commas; a line with an `=` is a combination: +// +// ala, alicja two names for one person +// my=ala+ola a photo showing BOTH of them +// ktos=ala|ola a photo showing EITHER of them +// +// It is a grammar rather than a form because a roster is a dozen lines typed +// once, and a form with add/remove buttons for that is more UI than content. + +export interface Combo { + all_of?: string[]; + any_of?: string[]; + key: string; + triggers: string[]; +} + +export interface Roster { + combos: Combo[]; + people: Person[]; +} + +/** +Either the roster, or the one sentence explaining why there isn't one. +*/ +export type Parsed = { ok: false; reason: string } | { ok: true; roster: Roster }; + +function triggersOf(text: string): string[] { + return text + .split(",") + .map((part) => `!${part.trim().replace(/^!/u, "")}`) + .filter((trigger) => trigger.length > 1); +} + +function lines(text: string): string[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +// One past the highest number in use, never the count: deleting p3 by hand +// must not let a later add reuse the key and inherit its photos. Mirrors +// pack_people.next_key on the server, which is the authority. +function nextKey(existing: Person[], pending: Person[]): number { + const used = [...existing, ...pending] + .map((person) => Number(person.key.replace(/^p/u, ""))) + .filter((value) => Number.isSafeInteger(value) && value > 0); + return Math.max(0, ...used) + 1; +} + +function readPeople(text: string, existing: Person[]): [Person[], Map] { + const people: Person[] = []; + const byName = new Map(); + for (const raw of lines(text)) { + if (raw.includes("=")) continue; + const triggers = triggersOf(raw); + if (triggers.length === 0) continue; + // Reuse the key this person already has. Handing out p{length+1} on + // every save renumbers everyone after an insertion, and every tags.json + // entry then points at the wrong person, silently. + const held = existing.find((person) => + person.triggers.some((trigger) => triggers.includes(trigger)), + ); + const key = held?.key ?? `p${nextKey(existing, people)}`; + people.push({ key, triggers }); + // Every alias, not just the first: a combination may name a person by + // whichever spelling its author remembers. + for (const trigger of triggers) byName.set(trigger.slice(1), key); + } + return [people, byName]; +} + +type Combos = { combos: Combo[]; ok: true } | { ok: false; reason: string }; + +function readCombos(text: string, byName: Map): Combos { + const combos: Combo[] = []; + let index = 1; + for (const raw of lines(text)) { + if (!raw.includes("=")) continue; + const [left = "", right = ""] = raw.split("=", 2); + const isEvery = right.includes("+"); + const parts = right.split(isEvery ? "+" : "|").map((part) => part.trim().replace(/^!/u, "")); + // Collected in one pass rather than filtered then mapped: the second + // pass would carry a "what if it is missing after all" branch that the + // first has already ruled out, and an untestable branch is an untested one. + const keys: string[] = []; + const unknown: string[] = []; + for (const part of parts) { + const key = byName.get(part); + if (key === undefined) unknown.push(part); + else keys.push(key); + } + if (unknown.length > 0) { + return { ok: false, reason: `nie znam: ${unknown.join(", ")}` }; + } + const triggers = triggersOf(left); + const key = `c${index}`; + combos.push(isEvery ? { all_of: keys, key, triggers } : { any_of: keys, key, triggers }); + index += 1; + } + return { combos, ok: true }; +} + +/** +Turn the typed roster into what POST /api/album/setup expects. +*/ +/** Turn the typed roster into what POST /api/album/setup expects. + * + * Returns a result rather than throwing. The caller is a form, every failure + * here is something somebody typed, and a try/catch around it would carry an + * "and if it was some other error" branch that cannot happen. + */ +export function parseSetup(text: string, existing: Person[]): Parsed { + const [people, byName] = readPeople(text, existing); + const read = readCombos(text, byName); + if (!read.ok) return read; + if (people.length === 0) return { ok: false, reason: "add at least one person" }; + return { ok: true, roster: { combos: read.combos, people } }; +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d40b5e8..6b9a88c 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -46,8 +46,13 @@ export interface AlbumState { combos: Person[]; configured: boolean; files: AlbumFile[]; + groups: string[]; known_groups: Group[]; people: Person[]; + // A view filter, never a shorter list: /media/{index} and the tag route + // both index the full album, so filtering server-side would show item i of + // the album while tagging item i of the filtered set. + untagged_only: boolean; } export interface Restart { diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts index 1a012a9..1b96a55 100644 --- a/web/src/test/fixtures.ts +++ b/web/src/test/fixtures.ts @@ -40,6 +40,7 @@ export const ALBUM: AlbumState = { { keys: ["p1"], name: "one.png" }, { keys: [], name: "two.png" }, ], + groups: [GROUP.id], known_groups: [GROUP], people: [ { key: "p1", triggers: ["!alfa"] }, @@ -48,4 +49,17 @@ export const ALBUM: AlbumState = { // and the page has to show something for it. { key: "p3", triggers: [] }, ], + untagged_only: false, +}; + +// An album the tagger has never been pointed at: no people.json, so there is +// nothing to tag and the setup form is what has to appear. +export const FRESH: AlbumState = { + combos: [], + configured: false, + files: [], + groups: [], + known_groups: [GROUP], + people: [], + untagged_only: false, }; diff --git a/web/src/views/album.test.tsx b/web/src/views/album.test.tsx index 0aafa18..559615f 100644 --- a/web/src/views/album.test.tsx +++ b/web/src/views/album.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; -import { ALBUM } from "../test/fixtures.ts"; +import { ALBUM, FRESH, GROUP } from "../test/fixtures.ts"; import { serve } from "../test/http.ts"; import { Album } from "./album.tsx"; @@ -16,17 +16,19 @@ it("says it is opening before the album arrives", () => { expect(screen.getByText("Opening the album…")).toBeInTheDocument(); }); -it("says so when there is nothing to tag yet", async () => { - serve({ "/api/album/state": { body: { ...ALBUM, files: [] } } }); +it("asks who is in the album before asking you to tag it", async () => { + // An album with no people.json has an empty roster, so the checkboxes + // would be an empty list. The tagger gated its setup form on `configured` + // and so does this. + serve({ "/api/album/state": { body: FRESH } }); render(); await waitFor(() => { - expect(screen.getByTestId("empty")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Set up the album" })).toBeInTheDocument(); }); + expect(screen.getByRole("option", { name: GROUP.name })).toBeInTheDocument(); }); -it("asks for media by position, never by name", async () => { - // The server takes an int for exactly this reason: a filename from a - // client is a path, and this directory holds private photographs. +it("goes straight to tagging once the album is configured", async () => { serve({ "/api/album/state": { body: ALBUM } }); render(); await waitFor(() => { @@ -34,25 +36,7 @@ it("asks for media by position, never by name", async () => { }); }); -it("shows who is already tagged on the current item", async () => { - serve({ "/api/album/state": { body: ALBUM } }); - render(); - await waitFor(() => { - expect(screen.getByLabelText("1. !alfa")).toBeChecked(); - }); - expect(screen.getByLabelText("2. !beta")).not.toBeChecked(); -}); - -it("shows a half-added person by key rather than by nothing", async () => { - serve({ "/api/album/state": { body: ALBUM } }); - render(); - await waitFor(() => { - expect(screen.getByLabelText("3. p3")).toBeInTheDocument(); - }); -}); - -it("saves on the click rather than on a done button", async () => { - // The tab gets closed. A "done" button is a way to lose an evening's work. +it("sends a tag to the server when a person is ticked", async () => { const fetcher = serve({ "/api/album/state": { body: ALBUM }, "/api/album/tag": { body: { status: "ok" } }, @@ -60,44 +44,8 @@ it("saves on the click rather than on a done button", async () => { render(); await userEvent.click(await screen.findByLabelText("2. !beta")); await waitFor(() => { - expect(screen.getByLabelText("2. !beta")).toBeChecked(); - }); - const [path, init] = fetcher.mock.calls.at(-1) as [string, { body: string }]; - expect(path).toBe("/api/album/tag"); - expect(JSON.parse(init.body)).toEqual({ - file: "one.png", - keys: ["p1", "p2"], - }); -}); - -it("untags somebody who was tagged by mistake", async () => { - const fetcher = serve({ - "/api/album/state": { body: ALBUM }, - "/api/album/tag": { body: { status: "ok" } }, - }); - render(); - await userEvent.click(await screen.findByLabelText("1. !alfa")); - await waitFor(() => { - expect(screen.getByLabelText("1. !alfa")).not.toBeChecked(); + expect(fetcher.mock.calls.at(-1)?.[0]).toBe("/api/album/tag"); }); const [, init] = fetcher.mock.calls.at(-1) as [string, { body: string }]; - expect(JSON.parse(init.body)).toEqual({ file: "one.png", keys: [] }); -}); - -it("walks the album forwards and back", async () => { - serve({ "/api/album/state": { body: ALBUM } }); - render(); - const previous = await screen.findByRole("button", { name: "Previous" }); - expect(previous).toBeDisabled(); - - await userEvent.click(screen.getByRole("button", { name: "Next" })); - await waitFor(() => { - expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/1"); - }); - expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); - - await userEvent.click(screen.getByRole("button", { name: "Previous" })); - await waitFor(() => { - expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0"); - }); + expect(JSON.parse(init.body)).toEqual({ file: "one.png", keys: ["p1", "p2"] }); }); diff --git a/web/src/views/album.tsx b/web/src/views/album.tsx index 342b89f..cb580f8 100644 --- a/web/src/views/album.tsx +++ b/web/src/views/album.tsx @@ -1,93 +1,35 @@ import { useCallback, useEffect, useState } from "react"; -import type { AlbumFile, Person } from "../api/types.ts"; +import type { AlbumFile, AlbumState } from "../api/types.ts"; import { api } from "../api/client.ts"; +import { Roster } from "./roster.tsx"; +import { Tagger } from "./tagger.tsx"; -// The file list is held apart from the rest of the album state so that -// recording a tag is a plain list update. Folding it back into a nullable -// object would add a "what if it is null" branch that cannot happen -- this -// only renders once the album has loaded -- and an untestable branch is the -// same thing as an untested one. -function tagged(files: AlbumFile[], name: string, keys: string[]): AlbumFile[] { - return files.map((entry) => (entry.name === name ? { ...entry, keys } : entry)); -} - +// Two halves, and which one you get is the album's own `configured` flag. +// An album with no people.json has nothing to tag and no way to say who is in +// it, so offering the checkboxes first would be offering an empty list -- the +// tagger gated its setup form on exactly this and so does this. export function Album(): React.JSX.Element { - const [people, setPeople] = useState(null); - const [files, setFiles] = useState([]); - const [at, setAt] = useState(0); + const [state, setState] = useState(null); - useEffect(() => { - void api.album().then((state) => { - setPeople(state.people); - setFiles(state.files); - }); + const load = useCallback(() => { + void api.album().then(setState); }, []); - const toggle = useCallback((file: AlbumFile, personKey: string) => { - const keys = file.keys.includes(personKey) - ? file.keys.filter((held) => held !== personKey) - : [...file.keys, personKey]; - // Saved on the click, not on a "done" button: the tab gets closed, and - // a "done" button is a way to lose an evening's work. - void api.tag(file.name, keys).then(() => { - setFiles((current) => tagged(current, file.name, keys)); - }); + useEffect(load, [load]); + + const tag = useCallback((file: AlbumFile, keys: string[]) => { + // Saved on the click, not on a "done" button: the tab gets closed, and a + // "done" button is a way to lose an evening's work. + void api.tag(file.name, keys); }, []); - if (people === null) { + if (state === null) { return

Opening the album…

; } - // One list, one index space. The server serves media by position and tags - // by name against the same ordering, so filtering here would show item i - // of the album while tagging item i of a shorter list. - const file = files[at]; - if (file === undefined) { - return

This album has no items yet.

; + if (!state.configured) { + return ; } - return ( -
-

- Photos — {at + 1} of {files.length} -

- {`album -
    - {people.map((person, index) => { - return ( -
  • - -
  • - ); - })} -
- - -
- ); + return ; } diff --git a/web/src/views/roster.test.tsx b/web/src/views/roster.test.tsx new file mode 100644 index 0000000..621a56f --- /dev/null +++ b/web/src/views/roster.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; + +import { GROUP } from "../test/fixtures.ts"; +import { serve } from "../test/http.ts"; +import { Roster } from "./roster.tsx"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function setup(): { onSaved: ReturnType } { + const onSaved = vi.fn(); + render(); + return { onSaved }; +} + +it("writes the roster the album needs before anything can be tagged", async () => { + const fetcher = serve({ "/api/album/setup": { body: { status: "ok" } } }); + const { onSaved } = setup(); + await userEvent.selectOptions(screen.getByLabelText("Group"), GROUP.id); + await userEvent.type(screen.getByLabelText("People"), "ala, alicja{enter}ola{enter}my=ala+ola"); + await userEvent.click(screen.getByRole("button", { name: "Save the roster" })); + + await waitFor(() => { + expect(onSaved).toHaveBeenCalled(); + }); + const [, init] = fetcher.mock.calls.at(-1) as [string, { body: string }]; + expect(JSON.parse(init.body)).toEqual({ + combos: [{ all_of: ["p1", "p2"], key: "c1", triggers: ["!my"] }], + groups: [GROUP.id], + people: [ + { key: "p1", triggers: ["!ala", "!alicja"] }, + { key: "p2", triggers: ["!ola"] }, + ], + }); +}); + +it("will not write a roster with nobody in it", async () => { + serve({ "/api/album/setup": { body: { status: "ok" } } }); + setup(); + await userEvent.click(screen.getByRole("button", { name: "Save the roster" })); + expect(screen.getByTestId("setup-error")).toHaveTextContent("at least one person"); +}); + +it("will not write a roster with no group to answer in", async () => { + serve({ "/api/album/setup": { body: { status: "ok" } } }); + setup(); + await userEvent.type(screen.getByLabelText("People"), "ala"); + await userEvent.click(screen.getByRole("button", { name: "Save the roster" })); + expect(screen.getByTestId("setup-error")).toHaveTextContent("choose a group"); +}); + +it("says which name a combination did not recognise", async () => { + serve({ "/api/album/setup": { body: { status: "ok" } } }); + setup(); + await userEvent.selectOptions(screen.getByLabelText("Group"), GROUP.id); + await userEvent.type(screen.getByLabelText("People"), "ala{enter}my=ala+nikt"); + await userEvent.click(screen.getByRole("button", { name: "Save the roster" })); + expect(screen.getByTestId("setup-error")).toHaveTextContent("nie znam: nikt"); +}); + +it("shows the server's verdict, which is the bot loading it back", async () => { + // setup() writes people.json and then loads the pack to prove the bot + // could serve it, so a 400 here is the bot's own refusal. + serve({ + "/api/album/setup": { body: { detail: "people.json defines no commands" }, status: 400 }, + }); + setup(); + await userEvent.selectOptions(screen.getByLabelText("Group"), GROUP.id); + await userEvent.type(screen.getByLabelText("People"), "ala"); + await userEvent.click(screen.getByRole("button", { name: "Save the roster" })); + await waitFor(() => { + expect(screen.getByTestId("setup-error")).toHaveTextContent("defines no commands"); + }); +}); + +it("reports a save that failed for a reason the server did not give", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("network is down"))), + ); + setup(); + await userEvent.selectOptions(screen.getByLabelText("Group"), GROUP.id); + await userEvent.type(screen.getByLabelText("People"), "ala"); + await userEvent.click(screen.getByRole("button", { name: "Save the roster" })); + await waitFor(() => { + expect(screen.getByTestId("setup-error")).toHaveTextContent("could not save"); + }); +}); diff --git a/web/src/views/roster.tsx b/web/src/views/roster.tsx new file mode 100644 index 0000000..acee1e3 --- /dev/null +++ b/web/src/views/roster.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; + +import type { Group, Person } from "../api/types.ts"; + +import { api, ApiError } from "../api/client.ts"; +import { parseSetup } from "../api/setup.ts"; + +const HELP = `ala, alicja +ola +my=ala+ola +ktos=ala|ola`; + +// Shown only for an album that has no people.json yet. Names are typed into +// the page and never passed as arguments: an argument lands in the shell +// history, and the whole design of the pack is that the names exist in one +// directory and nowhere else. +export function Roster( + props: Readonly<{ known: Group[]; onSaved: () => void; people: Person[] }>, +): React.JSX.Element { + const [text, setText] = useState(""); + const [group, setGroup] = useState(""); + const [problem, setProblem] = useState(""); + + const save = (): void => { + setProblem(""); + const parsed = parseSetup(text, props.people); + if (!parsed.ok) { + setProblem(parsed.reason); + return; + } + if (group === "") { + setProblem("choose a group"); + return; + } + void api + .setup([group], parsed.roster) + .then(props.onSaved) + .catch((error: unknown) => { + // setup() writes people.json and then loads the pack back to prove + // the bot could serve it, so this is the bot's own verdict rather + // than a second opinion about the same file. + setProblem(error instanceof ApiError ? error.message : "could not save"); + }); + }; + + return ( +
+

Set up the album

+

One person per line, aliases separated by commas. A line with an = is a combination.

+
{HELP}
+ +