mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:37:03 +01:00
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 <select> filled from /api/groups -- and a failed listing leaves it empty rather than unhandled. parseSetup returns a result instead of throwing, and readCombos has one return type: a try/catch in the view carried an "and if it was some other error" branch that could not happen, and an untestable branch is an untested one. Same reason the album view splits into roster.tsx and tagger.tsx. Still 100% on all four metrics with no exclude list, and verified against the real album: 185 items, 4 groups in the picker, guard still answering 421. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
This commit is contained in:
parent
a24d0d5905
commit
8500a1add1
@ -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 `<select>` 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<T>(path: string, method: string, body: unknown): Promise<T>
|
||||
export const api = {
|
||||
album: async (): Promise<AlbumState> => get<AlbumState>("/api/album/state"),
|
||||
env: async (): Promise<EnvironmentView> => get<EnvironmentView>("/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<Restart> =>
|
||||
send<Restart>("/api/restart-container", "POST", {}),
|
||||
saveEnv: async (values: Record<string, string>): Promise<unknown> =>
|
||||
send("/api/env", "PUT", { values }),
|
||||
setup: async (groups: string[], roster: Roster): Promise<unknown> =>
|
||||
send("/api/album/setup", "POST", { groups, ...roster }),
|
||||
status: async (): Promise<Status> => get<Status>("/api/status"),
|
||||
tag: async (file: string, keys: string[]): Promise<unknown> =>
|
||||
send("/api/album/tag", "POST", { file, keys }),
|
||||
|
||||
83
web/src/api/setup.test.ts
Normal file
83
web/src/api/setup.test.ts
Normal file
@ -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" });
|
||||
});
|
||||
});
|
||||
122
web/src/api/setup.ts
Normal file
122
web/src/api/setup.ts
Normal file
@ -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<string, string>] {
|
||||
const people: Person[] = [];
|
||||
const byName = new Map<string, string>();
|
||||
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<string, string>): 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 } };
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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(<Album />);
|
||||
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(<Album />);
|
||||
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(<Album />);
|
||||
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(<Album />);
|
||||
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(<Album />);
|
||||
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(<Album />);
|
||||
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(<Album />);
|
||||
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"] });
|
||||
});
|
||||
|
||||
@ -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 | Person[]>(null);
|
||||
const [files, setFiles] = useState<AlbumFile[]>([]);
|
||||
const [at, setAt] = useState(0);
|
||||
const [state, setState] = useState<AlbumState | null>(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 <p>Opening the album…</p>;
|
||||
}
|
||||
// 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 <p data-testid="empty">This album has no items yet.</p>;
|
||||
if (!state.configured) {
|
||||
return <Roster known={state.known_groups} onSaved={load} people={state.people} />;
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<h2>
|
||||
Photos — {at + 1} of {files.length}
|
||||
</h2>
|
||||
<img alt={`album item ${at + 1}`} src={`/api/album/media/${at}`} />
|
||||
<ul>
|
||||
{people.map((person, index) => {
|
||||
return (
|
||||
<li key={person.key}>
|
||||
<label>
|
||||
<input
|
||||
checked={file.keys.includes(person.key)}
|
||||
onChange={() => {
|
||||
toggle(file, person.key);
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
{index + 1}. {person.triggers[0] ?? person.key}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
disabled={at === 0}
|
||||
onClick={() => {
|
||||
setAt((current) => current - 1);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={at >= files.length - 1}
|
||||
onClick={() => {
|
||||
setAt((current) => current + 1);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
return <Tagger onAdded={load} onTag={tag} state={state} />;
|
||||
}
|
||||
|
||||
91
web/src/views/roster.test.tsx
Normal file
91
web/src/views/roster.test.tsx
Normal file
@ -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<typeof vi.fn> } {
|
||||
const onSaved = vi.fn();
|
||||
render(<Roster known={[GROUP]} onSaved={onSaved} people={[]} />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
85
web/src/views/roster.tsx
Normal file
85
web/src/views/roster.tsx
Normal file
@ -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 (
|
||||
<section>
|
||||
<h2>Set up the album</h2>
|
||||
<p>One person per line, aliases separated by commas. A line with an = is a combination.</p>
|
||||
<pre>{HELP}</pre>
|
||||
<label>
|
||||
Group
|
||||
<select
|
||||
onChange={(event) => {
|
||||
setGroup(event.target.value);
|
||||
}}
|
||||
value={group}
|
||||
>
|
||||
<option value="">choose…</option>
|
||||
{props.known.map((entry) => {
|
||||
return (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
People
|
||||
<textarea
|
||||
onChange={(event) => {
|
||||
setText(event.target.value);
|
||||
}}
|
||||
rows={8}
|
||||
value={text}
|
||||
/>
|
||||
</label>
|
||||
<button onClick={save} type="button">
|
||||
Save the roster
|
||||
</button>
|
||||
{problem !== "" && <p data-testid="setup-error">{problem}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -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 { ENV } from "../test/fixtures.ts";
|
||||
import { ENV, GROUP } from "../test/fixtures.ts";
|
||||
import { serve } from "../test/http.ts";
|
||||
import { Settings } from "./settings.tsx";
|
||||
|
||||
@ -11,13 +11,42 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
it("says it is reading before the answer arrives", () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
||||
render(<Settings />);
|
||||
expect(screen.getByText("Reading .env…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers group ids to pick rather than a box to retype one into", async () => {
|
||||
// A group id is 44 characters of base64. Picking is the mitigation that
|
||||
// makes editing GROUP_IDS from a browser reasonable at all.
|
||||
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
||||
render(<Settings />);
|
||||
const picker = await screen.findByLabelText("GROUP_IDS");
|
||||
expect(picker.tagName).toBe("SELECT");
|
||||
expect(screen.getByRole("option", { name: GROUP.name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty picker when the listing could not be read", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: string) => {
|
||||
if (input === "/api/groups") return Promise.reject(new Error("down"));
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve(ENV),
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as Response);
|
||||
}),
|
||||
);
|
||||
render(<Settings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("GROUP_IDS")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole("option", { name: GROUP.name })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("will not let a key the server refuses even be typed into", async () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
||||
render(<Settings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("PHONE_NUMBER")).toBeDisabled();
|
||||
@ -28,7 +57,7 @@ it("will not let a key the server refuses even be typed into", async () => {
|
||||
it("locks the group fields while no listing has been read", async () => {
|
||||
// parse_group_ids only checks syntax. Writing one blind with the container
|
||||
// down turns a scoping mistake into a unit that will not start.
|
||||
serve({ "/api/env": { body: { ...ENV, groups_known: false } } });
|
||||
serve({ "/api/env": { body: { ...ENV, groups_known: false } }, "/api/groups": { body: { groups: [] } } });
|
||||
render(<Settings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("groups-locked")).toBeInTheDocument();
|
||||
@ -37,7 +66,7 @@ it("locks the group fields while no listing has been read", async () => {
|
||||
});
|
||||
|
||||
it("asks for a restart once something has been written", async () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
||||
render(<Settings />);
|
||||
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
@ -47,7 +76,7 @@ it("asks for a restart once something has been written", async () => {
|
||||
});
|
||||
|
||||
it("shows the server's refusal verbatim", async () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
||||
render(<Settings />);
|
||||
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
|
||||
serve({
|
||||
@ -60,7 +89,7 @@ it("shows the server's refusal verbatim", async () => {
|
||||
});
|
||||
|
||||
it("reports a failure that is not the server's own sentence", async () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
||||
render(<Settings />);
|
||||
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
|
||||
vi.stubGlobal(
|
||||
@ -74,9 +103,27 @@ it("reports a failure that is not the server's own sentence", async () => {
|
||||
});
|
||||
|
||||
it("has nothing to save until something is edited", async () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
||||
render(<Settings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
it("writes the group id the picker was set to, not one that was typed", async () => {
|
||||
const fetcher = serve({
|
||||
"/api/env": { body: ENV },
|
||||
"/api/groups": { body: { groups: [GROUP] } },
|
||||
});
|
||||
render(<Settings />);
|
||||
await userEvent.selectOptions(await screen.findByLabelText("GROUP_IDS"), GROUP.id);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("note")).toBeInTheDocument();
|
||||
});
|
||||
// A GET is called with one argument, so the init is genuinely absent for
|
||||
// the two reads this view makes on mount.
|
||||
type Call = [string, undefined | { body: string; method: string }];
|
||||
const calls = fetcher.mock.calls as Call[];
|
||||
const put = calls.find((call) => call[1]?.method === "PUT");
|
||||
expect(JSON.parse(put?.[1]?.body ?? "{}")).toEqual({ values: { GROUP_IDS: GROUP.id } });
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { EnvironmentView as EnvironmentView } from "../api/types.ts";
|
||||
import type { EnvironmentView, Group } from "../api/types.ts";
|
||||
|
||||
import { api, ApiError } from "../api/client.ts";
|
||||
|
||||
@ -9,11 +9,26 @@ import { api, ApiError } from "../api/client.ts";
|
||||
// server would refuse, so a refusal is a bug rather than a normal outcome.
|
||||
export function Settings(): React.JSX.Element {
|
||||
const [view, setView] = useState<EnvironmentView | null>(null);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void api.env().then(setView);
|
||||
// A group id is 44 characters of base64: it is picked, never typed. That
|
||||
// is the property that makes editing GROUP_IDS from a browser reasonable
|
||||
// at all, and it is why the field below is a <select>.
|
||||
void api
|
||||
.groups()
|
||||
.then((listing) => {
|
||||
setGroups(listing.groups);
|
||||
})
|
||||
.catch(() => {
|
||||
// Same answer the server gives when the container is down: no list.
|
||||
// groups_known already turns the field read-only, so an empty picker
|
||||
// is a state, not a failure to report twice.
|
||||
setGroups([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
@ -39,29 +54,46 @@ export function Settings(): React.JSX.Element {
|
||||
<h2>Settings</h2>
|
||||
{!view.groups_known && (
|
||||
<p data-testid="groups-locked">
|
||||
The container is not reachable, so group settings are read-only: a
|
||||
group id can only be checked against a listing that was really read.
|
||||
The container is not reachable, so group settings are read-only: a group id can only
|
||||
be checked against a listing that was really read.
|
||||
</p>
|
||||
)}
|
||||
{view.keys.map((entry) => {
|
||||
const isLocked =
|
||||
!entry.editable ||
|
||||
(view.needs_groups.includes(entry.key) && !view.groups_known);
|
||||
!entry.editable || (view.needs_groups.includes(entry.key) && !view.groups_known);
|
||||
const held = edits[entry.key] ?? entry.value;
|
||||
const change = (value: string): void => {
|
||||
setEdits((current) => ({ ...current, [entry.key]: value }));
|
||||
};
|
||||
return (
|
||||
<label key={entry.key}>
|
||||
{entry.key}
|
||||
{view.needs_groups.includes(entry.key) ? (
|
||||
<select
|
||||
disabled={isLocked}
|
||||
onChange={(event) => {
|
||||
change(event.target.value);
|
||||
}}
|
||||
value={held}
|
||||
>
|
||||
<option value="">every group this account is in</option>
|
||||
{groups.map((group) => {
|
||||
return (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
disabled={isLocked}
|
||||
onChange={(event) => {
|
||||
setEdits((current) => {
|
||||
return {
|
||||
...current,
|
||||
[entry.key]: event.target.value,
|
||||
};
|
||||
});
|
||||
change(event.target.value);
|
||||
}}
|
||||
value={edits[entry.key] ?? entry.value}
|
||||
value={held}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
166
web/src/views/tagger.test.tsx
Normal file
166
web/src/views/tagger.test.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AlbumFile } from "../api/types.ts";
|
||||
|
||||
import { ALBUM } from "../test/fixtures.ts";
|
||||
import { serve } from "../test/http.ts";
|
||||
import { Tagger } from "./tagger.tsx";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function show(state = ALBUM): {
|
||||
onAdded: ReturnType<typeof vi.fn>;
|
||||
onTag: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const onTag = vi.fn();
|
||||
const onAdded = vi.fn();
|
||||
render(<Tagger onAdded={onAdded} onTag={onTag} state={state} />);
|
||||
return { onAdded, onTag };
|
||||
}
|
||||
|
||||
it("says so when there is nothing to tag yet", () => {
|
||||
show({ ...ALBUM, files: [] });
|
||||
expect(screen.getByTestId("empty")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks for media by position, never by name", () => {
|
||||
// The server takes an int for exactly this reason: a filename from a
|
||||
// client is a path, and this directory holds private photographs.
|
||||
show();
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0");
|
||||
});
|
||||
|
||||
it("shows who is already tagged on the current item", () => {
|
||||
show();
|
||||
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", () => {
|
||||
show();
|
||||
expect(screen.getByLabelText("3. p3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves on the click rather than on a done button", async () => {
|
||||
const { onTag } = show();
|
||||
await userEvent.click(screen.getByLabelText("2. !beta"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("2. !beta")).toBeChecked();
|
||||
});
|
||||
expect(onTag).toHaveBeenCalledWith(expect.objectContaining({ name: "one.png" }), ["p1", "p2"]);
|
||||
});
|
||||
|
||||
it("untags somebody who was tagged by mistake", async () => {
|
||||
const { onTag } = show();
|
||||
await userEvent.click(screen.getByLabelText("1. !alfa"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("1. !alfa")).not.toBeChecked();
|
||||
});
|
||||
expect(onTag).toHaveBeenCalledWith(expect.objectContaining({ name: "one.png" }), []);
|
||||
});
|
||||
|
||||
it("walks the album forwards and back", async () => {
|
||||
show();
|
||||
expect(screen.getByRole("button", { name: "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");
|
||||
});
|
||||
});
|
||||
|
||||
it("skips items that already have tags when only untagged ones were asked for", async () => {
|
||||
// one.png carries p1 already, two.png carries nothing. --untagged means
|
||||
// walking past the first without landing on it.
|
||||
const untagged = { ...ALBUM, untagged_only: true };
|
||||
const files: AlbumFile[] = [
|
||||
{ keys: [], name: "zero.png" },
|
||||
{ keys: ["p1"], name: "one.png" },
|
||||
{ keys: [], name: "two.png" },
|
||||
];
|
||||
show({ ...untagged, files });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/2");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an item you have already seen reachable after you tag it", async () => {
|
||||
// Without this, tagging the photo you are looking at removes it from the
|
||||
// list you are walking and the only way back is to restart without the flag.
|
||||
const files: AlbumFile[] = [
|
||||
{ keys: [], name: "zero.png" },
|
||||
{ keys: [], name: "one.png" },
|
||||
];
|
||||
show({ ...ALBUM, files, untagged_only: true });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/1");
|
||||
});
|
||||
await userEvent.click(screen.getByLabelText("1. !alfa"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Previous" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/1");
|
||||
});
|
||||
|
||||
it("stays where it is when there is nothing left to step to", async () => {
|
||||
const files: AlbumFile[] = [
|
||||
{ keys: [], name: "zero.png" },
|
||||
{ keys: ["p1"], name: "one.png" },
|
||||
];
|
||||
show({ ...ALBUM, files, untagged_only: true });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0");
|
||||
});
|
||||
|
||||
it("adds somebody mid-session, because that is when you notice", async () => {
|
||||
const fetcher = serve({ "/api/album/person": { body: { key: "p4", status: "ok" } } });
|
||||
const { onAdded } = show();
|
||||
await userEvent.type(screen.getByLabelText("Add somebody"), "!delta, !d");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
await waitFor(() => {
|
||||
expect(onAdded).toHaveBeenCalled();
|
||||
});
|
||||
const [, init] = fetcher.mock.calls.at(-1) as [string, { body: string }];
|
||||
expect(JSON.parse(init.body)).toEqual({ triggers: ["!delta", "!d"] });
|
||||
});
|
||||
|
||||
it("does nothing when the add box is empty", async () => {
|
||||
const { onAdded } = show();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(onAdded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows why the server refused a new person", async () => {
|
||||
serve({
|
||||
"/api/album/person": { body: { detail: "!alfa is already taken" }, status: 400 },
|
||||
});
|
||||
show();
|
||||
await userEvent.type(screen.getByLabelText("Add somebody"), "alfa");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-error")).toHaveTextContent("already taken");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an add that failed for a reason the server did not give", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.reject(new Error("down"))),
|
||||
);
|
||||
show();
|
||||
await userEvent.type(screen.getByLabelText("Add somebody"), "alfa");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-error")).toHaveTextContent("could not add");
|
||||
});
|
||||
});
|
||||
130
web/src/views/tagger.tsx
Normal file
130
web/src/views/tagger.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AlbumFile, AlbumState } from "../api/types.ts";
|
||||
|
||||
import { api, ApiError } from "../api/client.ts";
|
||||
|
||||
// Anything seen this session stays navigable even once it has tags. Without
|
||||
// it, tagging the item you are looking at under --untagged removes it from
|
||||
// the list you are walking, and the only way back is to restart without it.
|
||||
function isShown(state: AlbumState, files: AlbumFile[], index: number, seen: Set<number>): boolean {
|
||||
return !state.untagged_only || files[index]?.keys.length === 0 || seen.has(index);
|
||||
}
|
||||
|
||||
export function Tagger(props: Readonly<{
|
||||
onAdded: () => void;
|
||||
onTag: (file: AlbumFile, keys: string[]) => void;
|
||||
state: AlbumState;
|
||||
}>): React.JSX.Element {
|
||||
const [files, setFiles] = useState<AlbumFile[]>(props.state.files);
|
||||
const [at, setAt] = useState(0);
|
||||
const [seen, setSeen] = useState<Set<number>>(() => new Set([0]));
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const step = (direction: number): void => {
|
||||
for (let index = at + direction; index >= 0 && index < files.length; index += direction) {
|
||||
if (isShown(props.state, files, index, seen)) {
|
||||
setAt(index);
|
||||
setSeen((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(index);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (file: AlbumFile, personKey: string): void => {
|
||||
const keys = file.keys.includes(personKey)
|
||||
? file.keys.filter((held) => held !== personKey)
|
||||
: [...file.keys, personKey];
|
||||
props.onTag(file, keys);
|
||||
setFiles((current) => current.map((entry) => (entry.name === file.name ? { ...entry, keys } : entry)));
|
||||
};
|
||||
|
||||
const add = (): void => {
|
||||
setError("");
|
||||
const triggers = name
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (triggers.length === 0) return;
|
||||
// Mid-session, not only at setup: the reason to add somebody is usually
|
||||
// a photo of them already on the screen.
|
||||
void api
|
||||
.person(triggers)
|
||||
.then(() => {
|
||||
setName("");
|
||||
props.onAdded();
|
||||
})
|
||||
.catch((error_: unknown) => {
|
||||
setError(error_ instanceof ApiError ? error_.message : "could not add");
|
||||
});
|
||||
};
|
||||
|
||||
const file = files[at];
|
||||
if (file === undefined) {
|
||||
return <p data-testid="empty">This album has no items yet.</p>;
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<h2>
|
||||
Photos — {at + 1} of {files.length}
|
||||
</h2>
|
||||
{/* By position. The server takes an int because a filename from a
|
||||
client is a path, and this directory holds private photographs. */}
|
||||
<img alt={`album item ${at + 1}`} src={`/api/album/media/${at}`} />
|
||||
<ul>
|
||||
{props.state.people.map((person, index) => {
|
||||
return (
|
||||
<li key={person.key}>
|
||||
<label>
|
||||
<input
|
||||
checked={file.keys.includes(person.key)}
|
||||
onChange={() => {
|
||||
toggle(file, person.key);
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
{index + 1}. {person.triggers[0] ?? person.key}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
disabled={at === 0}
|
||||
onClick={() => {
|
||||
step(-1);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={at >= files.length - 1}
|
||||
onClick={() => {
|
||||
step(1);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<label>
|
||||
Add somebody
|
||||
<input
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
}}
|
||||
value={name}
|
||||
/>
|
||||
</label>
|
||||
<button onClick={add} type="button">
|
||||
Add
|
||||
</button>
|
||||
{error !== "" && <p data-testid="add-error">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user