diff --git a/DOCS-web-ui.md b/DOCS-web-ui.md index 5415c0e..107eabd 100644 --- a/DOCS-web-ui.md +++ b/DOCS-web-ui.md @@ -85,13 +85,37 @@ syntactically valid write can name a group the next startup rejects — turning a scoping mistake into a unit that will not start. The page says so and leaves the other keys editable. -Group ids are **picked, never typed**, for the same reason the tagger picked -them: an id is 44 characters of base64. +Group ids are **picked, never typed** — `GROUP_IDS` and `SELFTEST_GROUPS` +render as a ` { - toggle(file, person.key); - }} - type="checkbox" - /> - {index + 1}. {person.triggers[0] ?? person.key} - - - ); - })} - - - - - ); + 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}
+ +