From 0d3d71c63fc6bd46f3ecb0ca0f7cfd9607a93121 Mon Sep 17 00:00:00 2001 From: Michael Gilardi <11632652+mgilardi@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:08:08 -0700 Subject: [PATCH] Add admin panels to the Payload plugin Part 5 of the plugin. Until now a manager published a snapshot by editing a status field, watched an import as raw JSON, and decided on a proposal through the API. Each of the four collections now opens with a panel on its edit page: what the record holds, its status in words, and the next step, asked for in the page before anything is sent. The panels have no powers of their own. They run in the browser and call the host's site as the signed-in person, so the host's access callbacks decide every answer, and a refusal reads "Nothing was changed." with the site's reason. They are a `ui` field on Payload's own edit page, not a custom view, so Payload's save, permissions and versions stay as they are. One new endpoint, GET /api//:id/summary: counts and capped lists, never names or trees, with the read API's 404. For a snapshot made from a proposal it gives the name the host shows for each sealed person id, to signed-in requesters only, looked up per request and never stored. Before accepting, the panel lists what gets sealed for good and asks the manager to check it for personal details, because the plugin doesn't screen free text. Colours come from Payload's theme variables. A test reads the installed @payloadcms/ui stylesheets and checks contrast in light and dark, and a status is never colour alone. The example site shows the panels to its curator, and turns off Payload's default avatar, which asked gravatar.com with a hash of the signed-in address. The README says so, and that Payload's JSON field loads its editor from cdn.jsdelivr.net. Tested: 94 new tests (74 in jsdom with Payload's hooks and fetch replaced by stand-ins, 11 on the stylesheet, 9 on the summary against a real Payload with an in-memory MongoDB), 8 new checks over HTTP in the example site, and 19 deliberate breaks of which 18 were caught; the audit says which wasn't and why. The snapshot, view and proposal panels were clicked through once in headless Chrome on the example site, in light and dark. Not tested: that browser run is not in CI. The import panel has not run in a real admin. No screen reader, Safari, Firefox, phone or custom Payload theme. Postgres and SQLite, as before. Co-Authored-By: Claude --- CHANGELOG.md | 1 + README.md | 2 +- apps/payload-example/README.md | 14 +- .../app/(payload)/admin/importMap.js | 6 + apps/payload-example/payload.config.ts | 3 +- apps/payload-example/scripts/smoke.mjs | 20 + docs/architecture/adr/0012-payload-plugin.md | 24 +- docs/architecture/backlog.md | 2 +- docs/test-audit.md | 39 ++ packages/payload/README.md | 37 +- packages/payload/package.json | 34 +- packages/payload/src/api.ts | 3 +- .../payload/src/client/ImportPanelView.tsx | 196 ++++++ .../payload/src/client/ProposalPanelView.tsx | 186 +++++ .../payload/src/client/SnapshotPanelView.tsx | 212 ++++++ packages/payload/src/client/ViewPanelView.tsx | 134 ++++ packages/payload/src/client/http.ts | 59 ++ packages/payload/src/client/index.ts | 13 + packages/payload/src/client/panels.css | 411 +++++++++++ packages/payload/src/client/ui.tsx | 278 ++++++++ packages/payload/src/client/wired.tsx | 309 +++++++++ packages/payload/src/collections.ts | 4 +- packages/payload/src/imports.ts | 3 +- packages/payload/src/index.ts | 1 + packages/payload/src/plugin.ts | 3 +- packages/payload/src/proposals.ts | 45 +- packages/payload/src/summary.ts | 110 +++ packages/payload/src/types.ts | 28 +- packages/payload/test/panels-css.test.ts | 123 ++++ packages/payload/test/panels-wired.test.tsx | 426 ++++++++++++ packages/payload/test/panels.test.tsx | 646 ++++++++++++++++++ packages/payload/test/summary.test.ts | 235 +++++++ packages/payload/tsconfig.build.json | 16 + packages/payload/tsconfig.json | 1 + pnpm-lock.yaml | 28 +- 35 files changed, 3612 insertions(+), 40 deletions(-) create mode 100644 packages/payload/src/client/ImportPanelView.tsx create mode 100644 packages/payload/src/client/ProposalPanelView.tsx create mode 100644 packages/payload/src/client/SnapshotPanelView.tsx create mode 100644 packages/payload/src/client/ViewPanelView.tsx create mode 100644 packages/payload/src/client/http.ts create mode 100644 packages/payload/src/client/index.ts create mode 100644 packages/payload/src/client/panels.css create mode 100644 packages/payload/src/client/ui.tsx create mode 100644 packages/payload/src/client/wired.tsx create mode 100644 packages/payload/src/summary.ts create mode 100644 packages/payload/test/panels-css.test.ts create mode 100644 packages/payload/test/panels-wired.test.tsx create mode 100644 packages/payload/test/panels.test.tsx create mode 100644 packages/payload/test/summary.test.ts create mode 100644 packages/payload/tsconfig.build.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1f85a..22e1054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Payload plugin, part 5 of M7: admin panels (still not on npm). Each of the plugin's four collections gets a panel at the top of its edit page, on unless `admin: { panels: false }`. The snapshot panel says the status in words, what the snapshot holds (counts, a line per classification and phylogeny, sources with their licenses) and, for one made by accepting a proposal, which proposal and the two sealed person ids; a manager gets Publish and Withdraw. The view panel draws the saved view (the first 200 nodes) with the same tree as an outline for the keyboard and the lines readers are told, and holds Publish, with the reason, until the form is saved and the snapshot is published. The import panel shows the steps of a run, its warnings and diagnostics, looks again every 3 seconds while a job runs, and offers to run a queued, failed or interrupted job. The proposal panel says what a proposal would change, and a manager accepts it (naming the new version) or declines it. Every button asks first in the page and says what can't be undone; accepting lists what gets sealed for good and asks the manager to check it for personal details. The panels have no powers of their own: they call the host's site from the browser as the signed-in person, so the host's `access` callbacks decide, and a refusal reads "Nothing was changed." with the site's reason. New: the `@captiveorigins/payload-plugin-onetaxonomy/client` entry (found through Payload's import map, so run `payload generate:importmap` after adding the plugin), the `admin.panels` option, and one endpoint, `GET /api//:id/summary` (and `summarizeSnapshot`), which answers counts and capped lists, never names or trees, with the same 404 as the other endpoints. For a snapshot made from a proposal it gives the name the host shows for each sealed id to signed-in requesters only, looked up per request and never stored. `@payloadcms/ui` and `react` are new optional peer dependencies, and the plugin now depends on `@captiveorigins/onetaxonomy-react` for the drawing. Colours come from Payload's theme variables; a test reads the installed `@payloadcms/ui` stylesheets and checks text at 4.5:1 and control edges at 3:1 in light and dark, and a status is never colour alone. The example site shows the panels to its curator and turns Payload's Gravatar avatar off. Tested: 94 new tests (74 render the panels in jsdom with Payload's hooks and `fetch` replaced by stand-ins, 11 on the stylesheet, 9 on the summary against a real Payload with an in-memory MongoDB), and 8 new checks over HTTP in the example site (55 in all). The snapshot, view and proposal panels were also clicked through once in headless Chrome on the example site, in light and dark; that run is not in CI. Not tested: the import panel in a real admin, a screen reader, Safari, Firefox, a phone, or a custom Payload theme. Found on the way and written in the README: Payload's own admin loads its JSON editor from cdn.jsdelivr.net and, unless `admin.avatar` is `'default'`, asks gravatar.com with a hash of the signed-in person's email address. See [ADR 0012](docs/architecture/adr/0012-payload-plugin.md), point 16. - Benchmarks: frame times in a browser. `apps/playground/bench.html` (built with the playground) pans, zooms and scrolls made-up trees of 125 to 10,000 tips as SVG and on the canvas and times every frame; open it in any browser, press Run and save the numbers. `node scripts/bench-browser.mjs` runs it in the Chrome already installed, headless, at pixel density 1 and 2 and with the main thread slowed 4 times, and writes `docs/benchmarks-browser.md`; `--from ` adds a section from numbers saved in another browser. It uses a throwaway profile that it deletes, listens on this machine only and sends nothing anywhere. First numbers (headless Chrome 157, M2 Max, other work running): the canvas held 60 frames a second at every size up to 19,999 nodes, slowed thread included; SVG held it through 1,999 nodes, and through 499 on the slowed thread. A second section is from a headless build of WebKit 27.2 (Playwright's: the engine Safari and the Mac app use, but neither program), driven by hand as the doc describes, at normal speed only: the canvas held 60 frames a second at every size, and SVG held it through 1,999 nodes. When a browser's clock ticks in whole milliseconds, as WebKit's does, the script says so in the section and prints no decimal places. The 1,000-node switch is unchanged. Not measured: Safari itself, the Mac app's web view, Firefox, a window on a real screen, a real slow device, radial layouts. The benchmark page and script have no tests of their own. - Payload plugin docs: moving a departed person's proposals to another account. The README's "When someone leaves" now says that `author` and `decidedBy` can't be changed through Payload, that the lock is a hook and so doesn't cover a host's own database code, and that a host making that move there must change only those two fields and leave `proposal`, `checksum` and the snapshot alone. A new test makes the move on MongoDB and checks that the proposal still matches its checksum, the version made from it still verifies, and the proposal stays locked. No code changed; an option to allow the move through Payload was considered and not built (ADR 0012). - Payload plugin: people are recorded by id, never by name (changes the unreleased proposals, before anyone used them). `proposals.nameOf` is gone. `proposals.personId({ req })` gives an opaque id for the signed-in person (default: their user document's id), and that id is all a proposal stores about its author and all an accepted proposal seals into the new snapshot for the author and the manager. An id is 1 to 64 letters, digits, `.`, `_`, `:` and `-`, starting with a letter or digit, so an email address or a name with a space in it is refused: the action stops, nothing is saved, and the refused value isn't written to the log. A one-word handle would pass; the plugin can't tell it from an id. `proposals.displayName({ req, personId })` is optional and gives the name to show: it is looked up each time a proposal is read (once per person per request), returned as `authorName` on the proposal and in the preview, and never stored or sealed, so a name changed or removed at the site changes what is shown at once and leaves every snapshot's checksum alone. Why: a name inside a sealed snapshot can't be taken out when a person leaves. Core is unchanged apart from its comments and one error text: `acceptIntoSnapshot` still seals the `author` and `acceptedBy` strings it is given. Still sealed as written, and not screened: a proposal's title, each move's note and the manager's note. No endpoint turns an id from a snapshot's record back into a name. Tested on Payload 3.93 with an in-memory MongoDB (142 plugin tests, 526 in all) and through the example site (47 checks); eight deliberate breaks of the new code each failed a test. Not tested: Postgres or SQLite, and a host with more than one auth collection. diff --git a/README.md b/README.md index 4b2f2a0..3aa1e42 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The full rules are in [scientific invariants](docs/architecture/scientific-invar | [`@captiveorigins/onetaxonomy-layout`](packages/layout) | Rectangular, phylogram/time (only when valid), radial and sunburst layouts as plain coordinates | Working | | [`@captiveorigins/onetaxonomy-react`](packages/react) | Server-renderable SVG tree, accessible keyboard outline, explorer (undo/redo, drag, overlays, inspector, clade selector, SVG/PNG/JSON exports), lesson player | Working | | [`@captiveorigins/onetaxonomy-education`](packages/education) | Lesson decks (scenes, cameras, annotations, reveals) and worksheets with answers computed from the data | Working | -| [`@captiveorigins/payload-plugin-onetaxonomy`](packages/payload) | Payload plugin: sealed snapshots checked by checksum, staged and published, views pinned to them, import jobs that turn source files into staged snapshots, people's own proposals that a curator can accept into a new staged version, and a bounded read API (name search, ancestors, descendants, MRCA, coverage, projected views, exports) behind the host's access rules, with optional tenant scope | Early (M7 parts 1 to 4): tested on Payload with an in-memory MongoDB, not yet in a real site. Not on npm | +| [`@captiveorigins/payload-plugin-onetaxonomy`](packages/payload) | Payload plugin: sealed snapshots checked by checksum, staged and published, views pinned to them, import jobs that turn source files into staged snapshots, people's own proposals that a curator can accept into a new staged version, a bounded read API (name search, ancestors, descendants, MRCA, coverage, projected views, exports) behind the host's access rules, with optional tenant scope, and admin panels for publishing, import progress and deciding on proposals | Early (M7 parts 1 to 5): tested on Payload with an in-memory MongoDB and in the example site on this machine, not yet in a deployed site. Not on npm | | [`apps/studio`](apps/studio) | OneTaxonomy for Mac (Tauri 2): open checklists, Darwin Core Archives, Newick, NEXUS and phyloXML trees and snapshots; browse them; collapse weak branches, add a tree to an open checklist and link its tips to taxa (proposed by exact name, confirmed by you), compare two trees, and reroot or midpoint-root as a new tree; save views and sealed snapshots (never over the file that was opened), and save and open `.onetaxonomy` bundles (a documented ZIP with a manifest and checksums) | 0.1.0 on GitHub Releases. Not notarized, and it can't update itself yet | | [`apps/payload-example`](apps/payload-example) | A small Payload and Next.js site that uses the plugin: two roles, public pages that draw published views on the server, and proposals that record people by id, with a public handle shown beside it. Runs on your machine with a throwaway in-memory database | An example. Checked over HTTP by its smoke test; nobody has clicked through its admin yet | diff --git a/apps/payload-example/README.md b/apps/payload-example/README.md index f7f12af..7e20519 100644 --- a/apps/payload-example/README.md +++ b/apps/payload-example/README.md @@ -2,14 +2,15 @@ A small Payload and Next.js site that uses [`@captiveorigins/payload-plugin-onetaxonomy`](../../packages/payload). It shows the plugin set up the way a real site would: accounts with two roles, public pages that draw published views, and proposals that record a person by an id, with a public handle shown beside it and no email address. -> **Status: an example, not a product.** It runs on this machine only, on a throwaway in-memory MongoDB, with made-up accounts and the repository's illustrative fixtures. Tested: 47 checks over HTTP with sign-in cookies (`pnpm smoke`), on `next dev` and on the built site, with Payload 3.93 and Next.js 16; the three public pages were looked at in headless Chrome, in light and dark, at desktop width and at 520 px. Not tested: a person clicking through the admin, a screen reader, a phone, Postgres or SQLite, or a deployed site. +> **Status: an example, not a product.** It runs on this machine only, on a throwaway in-memory MongoDB, with made-up accounts and the repository's illustrative fixtures. Tested: 55 checks over HTTP with sign-in cookies (`pnpm smoke`), on `next dev` and on the built site, with Payload 3.93 and Next.js 16; the three public pages were looked at in headless Chrome, in light and dark, at desktop width and at 520 px; and the admin's panels were clicked through once in headless Chrome as the curator, in light and dark (publishing a snapshot with the keyboard, a view, and accepting a proposal). That last run is not in CI. Not tested: a screen reader, Safari, Firefox, a phone, Postgres or SQLite, or a deployed site. ## What it shows - **Public pages.** `/` lists the published views. `/views/` draws one as an SVG tree with its keyboard outline, what the view leaves out, and its sources. Both are Server Components: the tree is drawn on the server and no tree code is sent to the browser. The pages read as an anonymous visitor, so they only ever show what is published. -- **Two roles.** A curator manages snapshots, views and import jobs, uses the admin, and decides on proposals. A member reads what is published and makes proposals. Only a curator hands out roles: a member can't raise their own, and sees no account but their own. +- **Two roles.** A curator manages snapshots and views, uses the admin, and decides on proposals. A member reads what is published and makes proposals. Only a curator hands out roles: a member can't raise their own, and sees no account but their own. - **Proposals by id, with a handle shown.** A proposal stores its author's account id, and when a curator accepts one, the new snapshot version records the proposer and the curator by those ids, inside its checksum. No name or email address is sealed. `proposals.displayName` looks up each person's `handle` when a proposal is shown, so a changed handle shows at once and a removed one leaves the id alone. - **Staged, then published.** The seed stores one published snapshot with two views, and one staged snapshot that only the curator sees. An accepted proposal makes a new staged version; visitors see nothing until the curator publishes it. +- **Panels in the admin.** Signed in as the curator, a snapshot's, a view's and a proposal's edit page each open with the plugin's panel: what the record holds, its status in words, and the buttons to publish, withdraw, accept or decline, each asking first. The names beside the ids on those panels are the handles, looked up when the page loads. - **Cookies from this site only.** Payload's `csrf` list holds the site's own address, so a sign-in cookie sent from another site's page counts for nothing. The setup is in [`payload.config.ts`](payload.config.ts) (the plugin's options), [`src/users.ts`](src/users.ts) (accounts and roles) and [`src/seed.ts`](src/seed.ts) (the example data). @@ -55,7 +56,10 @@ This builds the site, starts it on a free port with its own database, and sends - a member's proposal is stored under their account's id whatever the request says, shows their handle, has no email address in it, and stays private until shared; - only the curator can accept it, and the new version is staged, sealed, records both people by id, holds no handle and no email address, and isn't public; - a changed handle shows on the accepted proposal at once, and the sealed version doesn't change; -- an accepted proposal can't be deleted. +- an accepted proposal can't be deleted; +- the curator's edit pages in the admin carry the plugin's panels; +- the new version's summary shows the curator both people by id with their handles, is "not found" for a visitor and the member while it is staged, and once published gives a visitor the ids and no names; +- a member can't publish it, and the curator's publish answers without the snapshot's content. `--dev` runs `next dev` instead of a build, and `--skip-build` reuses the last build. CI runs the smoke test on every pull request. @@ -69,7 +73,9 @@ Set `DATABASE_URI` to a MongoDB address and the site uses it instead of the in-m ## What it leaves out -- **No pages for proposals or imports.** They go through the API and Payload's default admin screens, where a proposal is a JSON field. Admin views are a later part of the plugin. +- **No page for making a proposal.** A member makes one through the API. The curator decides on it in the admin, on the proposal's panel. +- **No imports.** The `imports` option is off, so there are no import jobs and the import panel doesn't appear. It is tested in the plugin's own package. +- **Two requests Payload's admin makes to other sites.** The admin's JSON fields load their code editor (Monaco) from cdn.jsdelivr.net. That is Payload's, and this example leaves it. Payload's default avatar would also ask gravatar.com with a hash of the signed-in address; this example turns that off (`admin.avatar: 'default'`). - **No tenants.** The plugin's tenant scope is tested in its own package, not shown here. - **No sign-up.** A curator adds accounts in the admin. - **Nothing for production.** No email, no rate limits, no backups, no deployment setup. diff --git a/apps/payload-example/app/(payload)/admin/importMap.js b/apps/payload-example/app/(payload)/admin/importMap.js index af86423..ba2a071 100644 --- a/apps/payload-example/app/(payload)/admin/importMap.js +++ b/apps/payload-example/app/(payload)/admin/importMap.js @@ -1,6 +1,12 @@ +import { SnapshotPanel as SnapshotPanel_577f3b5b503ff777fe77b01de8cacfa5 } from '@captiveorigins/payload-plugin-onetaxonomy/client' +import { ViewPanel as ViewPanel_577f3b5b503ff777fe77b01de8cacfa5 } from '@captiveorigins/payload-plugin-onetaxonomy/client' +import { ProposalPanel as ProposalPanel_577f3b5b503ff777fe77b01de8cacfa5 } from '@captiveorigins/payload-plugin-onetaxonomy/client' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' /** @type import('payload').ImportMap */ export const importMap = { + "@captiveorigins/payload-plugin-onetaxonomy/client#SnapshotPanel": SnapshotPanel_577f3b5b503ff777fe77b01de8cacfa5, + "@captiveorigins/payload-plugin-onetaxonomy/client#ViewPanel": ViewPanel_577f3b5b503ff777fe77b01de8cacfa5, + "@captiveorigins/payload-plugin-onetaxonomy/client#ProposalPanel": ProposalPanel_577f3b5b503ff777fe77b01de8cacfa5, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/apps/payload-example/payload.config.ts b/apps/payload-example/payload.config.ts index 425f745..b876c08 100644 --- a/apps/payload-example/payload.config.ts +++ b/apps/payload-example/payload.config.ts @@ -17,7 +17,8 @@ export default buildConfig({ // second entry is the same server by its address. csrf: [serverURL.replace('//localhost', '//127.0.0.1')], secret: process.env.PAYLOAD_SECRET || '', - admin: { user: users.slug, importMap: { baseDir: dirname } }, + // Payload's default avatar asks gravatar.com for a picture by a hash of the signed-in address. This example asks nobody. + admin: { user: users.slug, avatar: 'default', importMap: { baseDir: dirname } }, collections: [users], // `ensureIndexes` builds the indexes before the first request, so the seed's first writes don't race them on an empty database. db: mongooseAdapter({ url: process.env.DATABASE_URI || '', ensureIndexes: true }), diff --git a/apps/payload-example/scripts/smoke.mjs b/apps/payload-example/scripts/smoke.mjs index 97f7c5d..b7a1d72 100644 --- a/apps/payload-example/scripts/smoke.mjs +++ b/apps/payload-example/scripts/smoke.mjs @@ -209,6 +209,26 @@ async function checks() { check(`an accepted proposal can't be deleted by ${who}`, (await call('DELETE', `/api/taxonomy-proposals/${proposal?.id}`, { as, origin: site })).status >= 400) } check('the accepted proposal is still there', (await get(`/api/taxonomy-proposals/${proposal?.id}?depth=0`, member)).json?.status === 'accepted') + + // The admin panels: on the curator's edit pages, and the addresses they call. No browser runs here, so this + // checks that each page carries its panel and that the site answers the panel's requests, not a click. + for (const [what, pathname] of [['snapshot', `taxonomy-snapshots/${next?.doc}`], ['view', `taxonomy-views/${viewId}`], ['proposal', `taxonomy-proposals/${proposal?.id}`]]) { + const page = await get(`/admin/collections/${pathname}`, curator) + check(`the curator's ${what} page in the admin carries the plugin's panel`, page.status === 200 && page.text.includes('onetaxonomy-panel'), { status: page.status }) + } + const summary = await get(`/api/taxonomy-snapshots/${next?.doc}/summary`, curator) + const from = summary.json?.derivedFrom?.proposal + check('version 2\'s summary shows the curator both people by id, with their handles looked up beside them', summary.status === 200 && from?.author === String(me?.id) && from?.authorName === 'Morgan Renamed' && from?.acceptedBy === String(curatorId) && from?.acceptedByName === 'Cara Curator', from ?? { status: summary.status }) + check('the staged version\'s summary is "not found" for a visitor and for the member', (await get(`/api/taxonomy-snapshots/${next?.doc}/summary`)).status === 404 && (await get(`/api/taxonomy-snapshots/${next?.doc}/summary`, member)).status === 404) + // Publishing the way the panel does: one PATCH that asks for three fields back. + const asPanel = `/api/taxonomy-snapshots/${next?.doc}?depth=0&select[status]=true&select[publishedBy]=true&select[publishedAt]=true` + check('the member cannot publish version 2', (await call('PATCH', asPanel, { as: member, origin: site, body: { status: 'published' } })).status >= 400 && (await get(`/api/taxonomy-snapshots/${next?.doc}/summary`, curator)).json?.snapshot?.status === 'staged') + const publishing = await call('PATCH', asPanel, { as: curator, origin: site, body: { status: 'published' } }) + const answered = publishing.json?.doc + check('the curator publishes version 2, and the answer holds the fields asked for and not the snapshot\'s content', publishing.status === 200 && answered?.status === 'published' && Boolean(answered?.publishedAt) && !('snapshot' in answered) && publishing.text.length < 2000, { status: publishing.status, keys: answered && Object.keys(answered), length: publishing.text.length }) + const publicSummary = await get(`/api/taxonomy-snapshots/${next?.doc}/summary`) + const publicFrom = publicSummary.json?.derivedFrom?.proposal + check('a visitor now reads version 2\'s summary, with the ids and no names', publicSummary.status === 200 && publicFrom?.author === String(me?.id) && !('authorName' in publicFrom) && !('acceptedByName' in publicFrom) && !publicSummary.text.includes('Morgan') && !publicSummary.text.includes('Cara') && !publicSummary.text.includes('@example.org'), publicFrom ?? { status: publicSummary.status }) } let up = false diff --git a/docs/architecture/adr/0012-payload-plugin.md b/docs/architecture/adr/0012-payload-plugin.md index b032a91..82b205c 100644 --- a/docs/architecture/adr/0012-payload-plugin.md +++ b/docs/architecture/adr/0012-payload-plugin.md @@ -1,6 +1,6 @@ # 0012 — The Payload plugin stores sealed snapshots whole and answers bounded questions about them -**Status.** Accepted by Science (the area lead), 2026-11-11, within the plugin the owner approved by name (N158). Part 2 (2026-11-11) adds points 12 to 14, built and tested; the lead accepted point 14 the same day. Part 3 (2026-11-11) makes claiming an import job one atomic update (point 12) and carries point 14's flag into Payload's own reads of a view. Part 4 (2026-11-11) adds proposals (point 15), built and tested, with core's `acceptIntoSnapshot`. Later parts are listed below. +**Status.** Accepted by Science (the area lead), 2026-11-11, within the plugin the owner approved by name (N158). Part 2 (2026-11-11) adds points 12 to 14, built and tested; the lead accepted point 14 the same day. Part 3 (2026-11-11) makes claiming an import job one atomic update (point 12) and carries point 14's flag into Payload's own reads of a view. Part 4 (2026-11-11) adds proposals (point 15), built and tested, with core's `acceptIntoSnapshot`. Part 5 (2026-11-11) adds the admin panels and a snapshot's summary (point 16), built and tested. Later parts are listed below. **Context.** M7 asks for a Payload plugin with configurable slugs, locale, permissions, an optional tenant scope and source adapters; admin views; a bounded read API with access checks and cross-tenant denial tests; resumable, idempotent import jobs; an example app; and per-user proposals a curator accepts into a new staged version. OneTaxonomy's records are immutable, checksummed snapshots ([data schema](../data-schema.md)), views pinned to them ([ADR 0006](0006-view-actions.md)), and a core that runs anywhere and knows nothing of Payload ([ADR 0002](0002-pure-core.md)). The sister plugins for OnePedigree and OnePhylogeny already set the house pattern: the plugin adds its own collections, takes the host's access rules, refuses what it can't check, and is tested on a real Payload with an in-memory MongoDB. @@ -15,7 +15,7 @@ 7. **Tenant scope is optional and absolute.** With `tenant: { collection, field?, resolve }`, both collections get a required relationship to the host's tenants collection. `resolve({ req })` gives the requester's tenant; null, an unusable value or a throw means no access at all. A new record gets the maker's tenant, naming another tenant is refused, the tenant can't change afterwards (even for trusted code), and a view can only use a snapshot in its own tenant. A person who works for several tenants is the host's to resolve per request. 8. **The read API is bounded and answers 404 for anything unreadable.** `GET /api//:id/search`, `/ancestors`, `/descendants` and `/mrca` run as the requester. A snapshot that doesn't exist, isn't readable to them, belongs to another tenant, or an id that isn't valid all answer the same 404, so a caller can't learn that a hidden snapshot exists; an unknown hierarchy or node is 404 too. Results are clamped to `limits` (defaults: 100 results, depth 100, 100 MRCA nodes) and say when they were `truncated`. The same functions (`searchNames`, `ancestorsOf`, `descendantsOf`, `mrcaOf`) are exported for host pages, taking a `req`. 9. **The science rules carry through.** Search returns every concept that uses a name, so homonyms and splits stay separate (I10); synonyms say they are synonyms. Common names are searched only in the locale asked for (`?locale=`, else the plugin's `locale.default`; without either, only scientific names). Ancestors and descendants of a node in an unrooted phylogeny answer `indeterminate` (`unrooted`), not storage order (I5). MRCA is core's `findMRCA`, naming the hypothesis; a classification is refused with a reason (I2), and a forest gives `none` (I8). -10. **Core stays free of Payload.** Only `packages/payload` depends on `payload` (a peer dependency, `^3.80.0`). Core, import, layout, react and education neither depend on nor import it, and a test in the plugin checks that. The plugin depends on core and, since part 2, on import for its adapters. It holds no taxonomic data and fetches nothing by itself: an import reads a URL only through a `fetch` the host passes, which decides what is allowed ([ADR 0005](0005-source-ownership.md)). +10. **Core stays free of Payload.** Only `packages/payload` depends on `payload` (a peer dependency, `^3.80.0`). Core, import, layout, react and education neither depend on nor import it, and a test in the plugin checks that. The plugin depends on core, since part 2 on import for its adapters, and since part 5 on react for the drawing in the view panel. It holds no taxonomic data and fetches nothing by itself: an import reads a URL only through a `fetch` the host passes, which decides what is allowed ([ADR 0005](0005-source-ownership.md)). 11. **License and release.** PolyForm Noncommercial 1.0.0 with the `Required Notice`, like every OneTaxonomy package ([ADR 0009](0009-license.md)). The version moves in lockstep with the others ([ADR 0007](0007-releases.md)), but the Release workflow doesn't stage it: the owner publishes its first version by hand, as with each package's first version, and it is added to the workflow once trusted publishing is set up for it. 12. **Import jobs are a collection of their own, not Payload's jobs queue.** With the `imports` option, a `taxonomy-imports` collection holds one document per job: the format (`csv`, `dwca`, `onetaxonomy-json`, `newick`, `nexus`, `phyloxml`), one input (a document of the host's upload collection, or a URL), plain JSON settings, and what the run did: status (`queued`, `running`, `done`, `already-imported`, `failed`), phase, progress lines, the adapter's warnings, diagnostics, the input's SHA-256, the checksum and the snapshot made or found. Payload's jobs queue was not used: registering a task adds a `payload-jobs` collection to every host, its tasks run with access skipped, and its retries would repeat a run the job record already describes. A host that wants a queue calls `runImport` from its own task. @@ -37,13 +37,25 @@ - **Once, and together.** The new snapshot and the proposal's new status are written in one transaction where the database has them. The proposal is claimed with one conditional update (still shared, same checksum), so two managers accepting at once store one version and the other gets a 409, and an author who takes the proposal back in that moment wins: nothing is accepted. Without transactions the new snapshot is deleted again when the claim fails. Declining is the same conditional update without a snapshot. - **People are sealed by id, never by name (changed 2026-11-11, before any release).** The first version of this point sealed a name the host chose (`nameOf`, a public handle). That was wrong for a site whose people can ask to be forgotten: a name inside a checksum can't be taken out without making a new version, and a published version may already be pinned and cited. Now `proposals.personId({ req })` gives an opaque id (default: the signed-in user's document id), and only that id is stored on a proposal and, on acceptance, sealed into the new snapshot's source record and `derivedFrom`. An id is 1 to 64 letters, digits, `.`, `_`, `:` and `-`, starting with a letter or digit, so an email address or a name with a space in it is refused; a throw or any other value also stops the action, nothing is saved, and the log line never repeats the value. The pattern can't tell a one-word handle from an id, so the docs say not to give one. A name is shown through `proposals.displayName({ req, personId })`, called when a proposal is read (once per person per request) and returned as `authorName`: a field that is computed on read, dropped on write and never in the database. When a name is removed at the host, nothing shown or sealed names the person. The title, each move's note and the manager's note are free text and are sealed as written; the plugin doesn't screen them. - **Kept and deleted.** An author can delete their own private and declined proposals. A shared one must be taken back first. An accepted one can't be changed or deleted, even by trusted code, because a snapshot version names it. If the staged version it made is deleted before publishing, the proposal goes back to shared with a note saying so. A host's own code can take back and delete a departed person's open proposals; their accepted ones stay, under their id. The document's `author` and `decidedBy` (the user document's id, as text) can't be changed through Payload. A way to allow it for a host that moves a departed person's records to another account (a flag only server code can set, plus a host check) was considered on 2026-11-11 and not built: the one host that asked makes that move in its own database transaction, where no hook runs, so the option would have had no user. What the plugin owes such a host is that the move is safe, and a test shows it: with only those two fields changed underneath it, the proposal still matches its checksum and the version made from it still verifies, because neither field is inside either checksum. Build the option when a host needs to make the move through the Local API. - - **Limits.** Each acceptance is made from the published base alone: two proposals accepted on one snapshot give two versions, not one with both. To combine them, publish the first and propose again on it. Without transactions, a crash between storing the snapshot and claiming the proposal can leave a staged version no proposal points to; a manager deletes it. The note from an earlier decision stays on a proposal that is shared again, until the next decision. The conditional update is one statement on MongoDB; it hasn't been checked on Postgres or SQLite. One person keeps at most `maxPerUser` proposals (default 200, not counting accepted ones) of at most `maxMoves` moves (default 500). In the admin a proposal is a JSON field. No endpoint turns an id read from a snapshot's record back into a name; a host that wants that on a snapshot page looks it up itself. + - **Limits.** Each acceptance is made from the published base alone: two proposals accepted on one snapshot give two versions, not one with both. To combine them, publish the first and propose again on it. Without transactions, a crash between storing the snapshot and claiming the proposal can leave a staged version no proposal points to; a manager deletes it. The note from an earlier decision stays on a proposal that is shared again, until the next decision. The conditional update is one statement on MongoDB; it hasn't been checked on Postgres or SQLite. One person keeps at most `maxPerUser` proposals (default 200, not counting accepted ones) of at most `maxMoves` moves (default 500). In the admin a proposal's content is still a JSON field, under its panel (point 16). Only a snapshot's summary (point 16) turns a sealed id back into the name the host shows for it now, and only for a signed-in requester; a visitor gets the ids alone. + +16. **The admin panels are a page over the same API, with no powers of their own (part 5).** Each of the plugin's four collections gets a panel at the top of its edit page, on unless `admin: { panels: false }`. + - **A field on Payload's own edit page, not a custom view.** The panel is a `ui` field. Payload's save button, permissions, versions and the fields under the panel stay as they are, and there is no second edit page to keep in step with Payload. The cost is that a snapshot's edit page still loads the whole snapshot into Payload's JSON field below the panel. + - **Browser code is a separate entry.** The panels are client components in `@captiveorigins/payload-plugin-onetaxonomy/client`, which Payload finds through its import map. `@payloadcms/ui` and `react` are optional peer dependencies, so a host that turns the panels off, or uses the plugin without an admin, needs neither. A test checks that the server entry imports no browser code and the client entry no server code. + - **Every answer is the site's.** The panels call the host's own site from the browser, as the signed-in person, with their cookie. Publishing and withdrawing are Payload's standard `PATCH` of `status`, asking back only the fields the panel shows, so the answer never carries a snapshot's content. Running an import, accepting and declining are the endpoints of points 12 and 15. Which buttons appear follows Payload's own word on whether this person may save the document; that is a courtesy, and a button shown by mistake still gets the site's 403 or 404. A refusal is shown as "Nothing was changed." with the site's reason. + - **One new endpoint.** `GET /api//:id/summary` (and `summarizeSnapshot`) answers what a snapshot holds, small enough for a page: its status, counts, sources with their licenses, a line per classification and phylogeny, and what it was made from. Lists are capped (20 by default, never past `limits.maxResults`) and say when they were cut. It never returns names, concepts or trees. Same access and the same 404 as point 8, after the checksum check of point 3. For a snapshot made from an accepted proposal it gives the two sealed person ids, and beside each the name `proposals.displayName` gives now: for a signed-in requester only, looked up per request, never stored. A visitor who may read the snapshot gets the ids and no names. + - **Ask first, and say what can't be undone.** Every button opens a confirm in the page, and nothing is sent before it. Publishing and withdrawing say that nothing moves back. Accepting lists what is sealed into the new version for good (the title, the notes and move notes, the manager's note, the date and the two ids) and asks the manager to check that none of it holds personal details, because point 15 doesn't screen free text. Declining says the author will see the note. Accepting sends the checksum the panel showed, so a proposal that changed meanwhile is refused (point 15). + - **A view's drawing is bounded and has an outline.** The view panel draws the saved view from its projection, cut at 200 nodes and saying so, with the same tree as an outline for the keyboard and the lines readers are told about it. Publish waits, with the reason beside it, until the form is saved and the snapshot is published. + - **An import's progress is asked for, not pushed.** While a job is running the panel reads its few run fields again every 3 seconds, and stops when it isn't or when the page is left. A job running past `staleAfterMinutes` is called probably interrupted and can be run again; a running one can't be started twice from the panel, and point 12's claim refuses it anyway. + - **Readable without colour, in Payload's themes.** A status is a word, with a coloured edge beside it; the state colours are used for edges only, never for text. The panels take their colours from Payload's theme variables, and a test reads the installed `@payloadcms/ui` stylesheets and checks text and muted text at 4.5:1 and control edges at 3:1 in the light and dark themes. The drawing's colours are copies of the React package's, kept equal by a test, and switch on Payload's own theme marker, because the admin's theme is the person's choice in Payload and not the system's. + - **Limits.** Tested in jsdom with Payload's hooks and `fetch` replaced by stand-ins, over HTTP in the example site, and clicked through once in headless Chrome on that site (snapshot, view and proposal panels, light and dark); that last run isn't in CI. The import panel hasn't run in a real admin. No screen reader, Safari, Firefox or phone was tried. Payload's admin makes two requests of its own to other sites on these pages: its JSON field loads its code editor from cdn.jsdelivr.net, and its default avatar asks gravatar.com with a hash of the signed-in person's email address unless the host sets `admin.avatar: 'default'`. The panels themselves ask only the host's site. **Later parts of M7, not built yet.** -- Admin views: import progress, conflicts, curation preview, tree authoring, profiles, publishing, lessons. Parts 1 to 4 use Payload's default edit views, with read-only fields for what the plugin sets; an import's progress, warnings and diagnostics show as JSON. -- An interface for proposals. Part 4 stores, shares and decides them through Payload's APIs and three endpoints; drawing one, and a manager's side-by-side look before accepting, belong to the admin views. -- `apps/payload-example`. +- The rest of the admin views: list views, conflicts, tree authoring, profiles, lessons. Part 5 covers import progress, publishing and a manager's look at a proposal before deciding. +- A page for making a proposal in the admin, and a drawing of one. A proposal is made through the API or in the React explorer; the proposal panel says what it would change in words and tables. +- A snapshot edit page that doesn't carry the whole snapshot. Payload's JSON field loads all of it, up to 15 MB, under the panel. +- The panels in a screen reader, Safari, Firefox and on a phone, and a browser run in CI. - The atomic claims (import jobs, proposals) on Postgres and SQLite, which need those adapters in the tests. - Very large snapshots. A MongoDB document holds at most 16 MB, so a snapshot past that can't be stored this way (imports refuse past `maxBytes`); the likely answer is the host's upload collection for the JSON, with the checksum kept here. Reading the collection over REST also returns the whole JSON, which is slow for big snapshots. diff --git a/docs/architecture/backlog.md b/docs/architecture/backlog.md index cac2043..305f465 100644 --- a/docs/architecture/backlog.md +++ b/docs/architecture/backlog.md @@ -72,7 +72,7 @@ Task-level plan per milestone ([`phase-plan.md`](phase-plan.md)). `[x]` done wit ## M7 — Payload plugin - [ ] `@captiveorigins/payload-plugin-onetaxonomy` with configurable slugs, locale, permissions, optional tenant scope, source adapters (part 1, 2026-11-11, [ADR 0012](adr/0012-payload-plugin.md): slugs, locale for common-name search, read and manage callbacks that fail closed, tenant scope, sealed snapshots and pinned views with staged/published/withdrawn states; part 2, 2026-11-11: the six import adapters run in import jobs, configured per job; part 4, 2026-11-11: proposals, with a third callback, `access.propose`. Not ticked: admin views remain; the example app is done, below) -- [ ] Admin views: import progress, conflicts, curation preview, tree authoring, profiles, publishing, lessons (not started; part 1 uses Payload's default edit views) +- [ ] Admin views: import progress, conflicts, curation preview, tree authoring, profiles, publishing, lessons (part 5, 2026-11-11, [ADR 0012](adr/0012-payload-plugin.md), point 16: panels on the four collections' edit pages for import progress with re-runs, publishing and withdrawing snapshots and views with a bounded drawing of a view, and a manager's look at a proposal before accepting or declining. Tested in jsdom and over HTTP in the example site, and clicked through once in headless Chrome; no screen reader, Safari or Firefox. Not built: list views, conflicts, tree authoring, profiles, lessons, and a page for making a proposal) - [x] Bounded API: search, ancestors/descendants, MRCA, coverage, projected views, exports; access checks; cross-tenant denial tests (part 1, 2026-11-11: search, ancestors, descendants and MRCA with access checks, limits, 404 for anything unreadable and cross-tenant denial tests; part 2, 2026-11-11: coverage, projected views with their disclosure, and snapshot and view exports, under the same rules and tests; 2026-11-11: every endpoint also answers through Payload's own REST router with a signed-in token, in-process (`test/http.test.ts`). 2026-11-11: run in a real site behind Next.js, with cookies, by `apps/payload-example`'s smoke test, below) - [x] Resumable, idempotent import jobs (part 2, 2026-11-11: a `taxonomy-imports` collection, not Payload's jobs queue (ADR 0012, point 12); staged sealed snapshots, "already imported" on a re-run, failures store nothing, interrupted jobs restart from the file; part 3, 2026-11-11: a job is claimed by one conditional update, so runs started together can't both proceed; tested on an in-memory MongoDB, not in a real site or on Postgres or SQLite) - [x] Withdrawn snapshots' published views stay readable with a "withdrawn source" flag (ADR 0012, point 14, accepted by the area lead 2026-11-11): on the projection and export endpoints (part 2) and as a `source` field on Payload's own reads of a view (part 3) diff --git a/docs/test-audit.md b/docs/test-audit.md index 268566e..53c8f1a 100644 --- a/docs/test-audit.md +++ b/docs/test-audit.md @@ -2,6 +2,45 @@ Each audit lists what this repo promises, the tests that prove it, and the gaps: those closed in the same pass and those left, with why. Audits run after each feature or fix, and at least every 14 days while the repo has commits. Newest first. +## 2026-11-11: Payload plugin part 5, admin panels + +Scope: `packages/payload`: the panels (`src/client/`), a snapshot's summary (`src/summary.ts`), the `admin.panels` option and the panel field on each collection; and `apps/payload-example`, which now shows the panels. ADR 0012 point 16. Plugin tests went from 143 to 237, all unit tests from 527 to 621, the example's smoke checks from 47 to 55. + +### Covered + +- **The summary keeps the read API's access rules** (`summary.test.ts`, 9 tests on a real Payload with an in-memory MongoDB): a manager gets counts, capped lists and sources and never the content; lists are cut to the limit and say so; a visitor, another tenant, a reader of a staged snapshot, a throwing access check, a missing id and an id that isn't one all get the same 404; a snapshot changed in the database is refused. +- **Names for sealed ids go to signed-in people only, and are never stored** (`summary.test.ts`): a signed-in reader gets `authorName` and `acceptedByName` from the host's lookup; a visitor allowed to read the snapshot gets the two ids and no names; the stored document holds neither name. +- **The panels have no powers of their own** (`panels-wired.test.tsx`, 25 tests in jsdom with `fetch` and Payload's hooks replaced by stand-ins): each request goes to the site's own API with the person's cookie; publishing is one `PATCH` that asks back only the fields shown; a run, an accept and a decline are one `POST` each; accept carries the checksum the panel showed; no buttons for someone Payload says may not save; a manager-only field is left out of the form for someone the site didn't send it to. +- **Nothing is sent before the confirm, and a refusal is never reported as done** (`panels.test.tsx`, `panels-wired.test.tsx`): each of publish, withdraw, run, accept and decline sends nothing until confirmed; a refusal reads "Nothing was changed." with the site's reason and leaves the form alone; a confirm stays open when the action fails or throws. +- **What gets sealed is said before accepting** (`panels.test.tsx`): the warning lists the title, notes, the manager's note, the date and the two ids, and asks to check for personal details; Accept needs a version name; a declining manager is told the author sees the note. +- **Each role sees only its next steps** (`panels.test.tsx`, 49 tests): a manager gets only the step each status allows; someone who may not manage gets none; Publish on a view is held with its reason while the form is unsaved or the snapshot isn't published; a running import can't be started twice, and one running past the limit is called probably interrupted. +- **An import's progress** (`panels-wired.test.tsx`): the panel looks again every 3 seconds while the job runs, stops when it doesn't, and stops when the page is left. +- **Keyboard and screen-reader wiring, as far as jsdom shows it** (`panels.test.tsx`): Escape and Cancel close a confirm and put the focus back on its button; Escape is ignored while it works; a held button's reason is tied to it; a problem is announced at once; a running job is in a live region; the view's drawing has the same tree as an outline. +- **Contrast, and status without colour** (`panels-css.test.ts`, 11 tests): colours are read from the installed `@payloadcms/ui` stylesheets. Text and muted text are at least 4.5:1 on the panel and on raised boxes, and control edges at least 3:1, in Payload's light and dark themes. The drawing colours equal the React package's. State colours are used only for borders. +- **Browser code stays out of the server entry, and server code out of the browser** (`panels-wired.test.tsx`, "what runs where"). +- **Over HTTP, in the example site** (`scripts/smoke.mjs`, 8 new checks): the curator's built edit pages carry the panel; the staged version's summary is "not found" for a visitor and for the member; the member can't publish it; the curator's publish answers without the snapshot's content; once published a visitor reads the summary with the ids and no names. + +### Checked by breaking it + +Nineteen deliberate breaks, one at a time. Eighteen failed at least one test. In `src/client/`: no cookie sent; a publish not put into the form; the import poll never stopping; accept sending no checksum, or an empty one; a manager-only field always written to the form; the preview not bounded; a refused accept reported as a success; Escape closing a confirm while it works; focus not returned; Publish allowed with unsaved changes; decide buttons shown to non-managers; the seal warning shortened; a state colour put on text. In `src/summary.ts`: the head read with access skipped; the author's name given to visitors; the manager's name given to visitors; lists not clamped. + +The one that no test caught: taking out the `catch` around the summary's first read. Payload answers nothing for a missing or malformed id, and the plugin's access wrapper turns a throw into a no before that line, so no test reaches it. It stays, as the same guard the other endpoints have, and it is not counted as covered. + +### Gaps left + +- **No browser run in CI.** The snapshot, view and proposal panels were clicked through once in headless Chrome on the example site, signed in as the made-up curator, in light and dark: 18 checks, from a scratch script that isn't in the repo. A Payload upgrade that changes its hooks is caught by TypeScript and by the smoke test's "page carries the panel" checks, not by a click. +- **The import panel hasn't run in a real admin.** The example site has no imports. Its logic is covered in jsdom only. +- **No screen reader, Safari, Firefox, phone or custom Payload theme.** The jsdom tests check roles, labels and focus, which is not the same as hearing it. +- **Payload's hooks are stand-ins in the jsdom tests.** `useDocumentInfo`, `useForm`, `useFormModified` and `useConfig` are replaced, so what Payload really gives them is seen only in the example site. +- **Postgres and SQLite:** as before, not run. + +### Left out as overkill + +- Every status with every role: each transition and each role's refusal has a test, and the server's own rules are tested in `snapshots.test.ts`, `views.test.ts` and `proposals.test.ts`. +- Payload's `PATCH` route and its access: the plugin's hooks behind it are tested already, over HTTP in `http.test.ts`. +- How the tree is drawn: the React package's tests cover `TreeView`; the panel test checks that it is drawn, bounded and has its outline. +- Pixel positions and screenshots of the panels. + ## 2026-11-11: browser benchmark, and moving a departed person's proposals Scope: `apps/playground/bench.tsx` and `bench.html`, `scripts/bench-browser.mjs`, `docs/benchmarks-browser.md`, and the "When someone leaves" paragraph of `packages/payload/README.md`. Plugin tests went from 142 to 143, all unit tests from 526 to 527. diff --git a/packages/payload/README.md b/packages/payload/README.md index 82ac3ab..3530a87 100644 --- a/packages/payload/README.md +++ b/packages/payload/README.md @@ -2,7 +2,7 @@ OneTaxonomy for Payload: sealed taxonomy snapshots, views pinned to them, import jobs that turn source files into staged snapshots, people's own proposals that a manager can accept into a new staged version, and a small read API for search, ancestors, descendants, MRCA, coverage, projected views and exports, all behind your own access rules. -> **Status: early, parts 1 to 4 of M7.** Not on npm yet. The collections, publishing, tenant scope, import jobs, proposals and the read endpoints work, and are tested on a real Payload 3.93 with an in-memory MongoDB (142 tests): bad checksums refused, sealed snapshots locked, an import of each format, re-runs that store nothing new, malformed files refused with their line, interrupted jobs resumed, runs started together claimed by one, every endpoint's results, a view's `source` through the Local API and GraphQL, proposals kept private, shared, accepted into a staged version once and declined, access refusals and cross-tenant denial. Twelve of those tests send real requests through Payload's own REST router, signed in with a token from the login route; the rest call the endpoint handlers and the Local API directly. It also runs in a small real site, [`apps/payload-example`](../../apps/payload-example) (Next.js 16, on this machine only), where 46 more checks go over HTTP with sign-in cookies. It hasn't run on a deployed site, or on Postgres or SQLite, and no URL import has fetched over a real network. No admin views yet: a proposal is a JSON field in the admin. See [ADR 0012](../../docs/architecture/adr/0012-payload-plugin.md). +> **Status: early, parts 1 to 5 of M7.** Not on npm yet. The collections, publishing, tenant scope, import jobs, proposals, the read endpoints and the admin panels work. The server side is tested on a real Payload 3.93 with an in-memory MongoDB (152 tests): bad checksums refused, sealed snapshots locked, an import of each format, re-runs that store nothing new, malformed files refused with their line, interrupted jobs resumed, runs started together claimed by one, every endpoint's results, a view's `source` through the Local API and GraphQL, proposals kept private, shared, accepted into a staged version once and declined, access refusals and cross-tenant denial. Twelve of those tests send real requests through Payload's own REST router, signed in with a token from the login route; the rest call the endpoint handlers and the Local API directly. It also runs in a small real site, [`apps/payload-example`](../../apps/payload-example) (Next.js 16, on this machine only), where 55 more checks go over HTTP with sign-in cookies. The [admin panels](#admin-panels) have 74 tests in jsdom and were clicked through once in headless Chrome; no screen reader, Safari or Firefox has been tried on them. It hasn't run on a deployed site, or on Postgres or SQLite, and no URL import has fetched over a real network. See [ADR 0012](../../docs/architecture/adr/0012-payload-plugin.md). ## What it does @@ -147,7 +147,36 @@ A proposal's title and notes, each move's note and the manager's note are people **When someone leaves.** Your server code can take back their shared proposals (`status: 'private'`) and delete their private and declined ones. Their accepted proposals stay, and so does their id in the snapshot. Stop giving a name for that id from `displayName`, and nothing shown names them. The accepted proposal's document still holds their user document's id, as text (`author`, and `decidedBy` for a manager). Nothing written through Payload can change those two, your own server code through the Local API included, and there is no option to allow it. The lock is a hook, so it doesn't cover a write made straight to the database. If your rules move a departed person's records to another account, change `author` and `decidedBy` there, in your own code, and change nothing else: leave `proposal` (it holds the sealed person id), `checksum` and the snapshot exactly as they are, or the proposal stops matching the version made from it. A test does this on MongoDB and checks that the proposal and the snapshot still verify. Otherwise keep or delete the user document as your own rules say; the two fields are plain text, so the database doesn't mind an id whose document is gone. -Options: `slugs` (`snapshots`, `views`, `imports`, `proposals`), `access` (`read`, `manage`, `propose`), `tenant` (`collection`, `field`, `resolve`), `locale`, `limits`, `imports` (`uploads`, `fetch`, `maxBytes`, `staleAfterMinutes`, `runOnCreate`), `proposals` (`personId`, `displayName`, `maxMoves` default 500, `maxPerUser` default 200, not counting accepted ones), `admin.group`. +Options: `slugs` (`snapshots`, `views`, `imports`, `proposals`), `access` (`read`, `manage`, `propose`), `tenant` (`collection`, `field`, `resolve`), `locale`, `limits`, `imports` (`uploads`, `fetch`, `maxBytes`, `staleAfterMinutes`, `runOnCreate`), `proposals` (`personId`, `displayName`, `maxMoves` default 500, `maxPerUser` default 200, not counting accepted ones), `admin` (`group`, `panels`). + +## Admin panels + +Each of the plugin's collections gets a panel at the top of its edit page. It is on unless you set `admin: { panels: false }`. + +- **Snapshot.** Status in words, the dataset, version, checksum and date, how many names, concepts, classifications, phylogenies and sources it holds, a table of its classifications and phylogenies, and its sources with their licenses. A snapshot made by accepting a proposal says which proposal, who proposed it and who accepted it. A manager gets **Publish this snapshot** on a staged one and **Withdraw this snapshot** on a published one. +- **View.** Status, the snapshot it is drawn over and that snapshot's status, a drawing of the saved view (the first 200 nodes) with the same tree as an outline for the keyboard, and the lines readers are told about it. **Publish this view** waits until the form is saved and the snapshot is published, and says which it is waiting for. +- **Import job.** The steps of the run with the one it is on, the result, the adapter's warnings and the diagnostics with their line or position. While a job is running the panel asks again every 3 seconds. A manager gets **Run this import** on a queued job and **Run it again** on a failed one, and on a running one only after `imports.staleAfterMinutes`. +- **Proposal.** Whose it is, what it is drawn over, and what it would change: taxa with a new parent, groups that gain or lose members, groups only one side has. A manager gets **Accept this proposal**, which asks for the new version's name and an optional note, and **Decline this proposal**, which asks for a note the author will see. + +Every button asks first, in the page, and says what can't be undone. Nothing is sent until you confirm. If the site refuses, the panel says "Nothing was changed." and gives the site's reason. Before a proposal is accepted the panel lists what gets sealed into the new version for good (the title, the notes, your note, the date and the two person ids) and asks you to check none of it holds personal details. + +The panels are a page over the same API as everything else. They run in the browser and call your own site as the signed-in person, so your `access` callbacks decide every answer, and a button the panel shows by mistake still gets a 403 or 404. They add one endpoint, `GET /api/taxonomy-snapshots/:id/summary` (and `summarizeSnapshot(req, id)`): the counts, tables and sources above, with each list capped by `limit` (default 20), never the names or trees themselves. It answers the same 404 as the other endpoints. + +A person's name appears beside their id only if you give `proposals.displayName`, only to a signed-in requester, and it is looked up each time the page loads. It is never stored. + +**Setting up.** The panels need `@payloadcms/ui` and `react`, which a Payload admin already has. Payload finds them through its import map, so regenerate it after adding the plugin or changing `admin.panels`: + +```sh +npx payload generate:importmap +``` + +Without that the edit pages load with nothing where the panel should be, and Payload logs `PayloadComponent not found in importMap`. + +**Colours.** The panels take their colours from Payload's theme variables, so they follow the admin's light and dark themes and a host's own theme. A status is always a word with a coloured edge, never colour alone. A test reads the installed `@payloadcms/ui` stylesheets and checks that text and muted text reach 4.5:1 and control edges 3:1 in both themes. + +**What was checked.** 74 tests render the panels in jsdom with Payload's hooks and `fetch` replaced by stand-ins: which buttons each person gets, that nothing is sent before the confirm, what a refusal says, and where Escape and the focus go. The example site's smoke test checks over HTTP that the built admin pages carry the panels and that the routes they call refuse a member. The panels were also clicked through once in headless Chrome on the example site, in light and dark: publishing a snapshot with the keyboard, the wait on an unsaved view, and accepting a proposal into a new version. That run is not in CI. The import panel has not run in a real admin, because the example site has no imports. Not checked: a screen reader, Safari, Firefox, a phone, or a custom Payload theme. + +**What Payload's own admin asks other sites for.** The panels ask only your site. Payload's JSON field loads its code editor (Monaco) from cdn.jsdelivr.net, and its default avatar asks gravatar.com with a hash of the signed-in person's email address. Both are Payload's, and both happen on these edit pages because they hold JSON fields. Set `admin.avatar: 'default'` in your Payload config to stop the second. ## Limits @@ -161,7 +190,9 @@ Options: `slugs` (`snapshots`, `views`, `imports`, `proposals`), `access` (`read - A new snapshot and its proposal's status are written in one transaction where the database has them (a MongoDB replica set). Without transactions the snapshot is removed again if the proposal couldn't be marked, but a crash between the two writes can leave a staged version that no proposal points to. A manager can delete it. - The claim that lets one manager's decision win is one statement on MongoDB. It hasn't been checked on Postgres or SQLite. - A proposal that is shared again after a decision keeps that decision's note until the next one. -- Payload's default edit views only: an import's progress, warnings and diagnostics show as JSON, and a proposal is a JSON field. Admin views are a later part. [`apps/payload-example`](../../apps/payload-example) is a small site that uses the plugin. +- The panels sit on Payload's default edit pages; there are no list views, no tree authoring and no page for making a proposal in the admin. A proposal is still made through the API, or in the React explorer. +- A snapshot's edit page still carries the whole snapshot in Payload's JSON field under the panel, so a snapshot of several MB makes a heavy page. The panel itself asks only for the summary. +- The panels haven't been run with a screen reader, in Safari or Firefox, or on a phone. See [Admin panels](#admin-panels) for what was checked. ## Commands diff --git a/packages/payload/package.json b/packages/payload/package.json index 68d1239..d2a20a7 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -25,11 +25,17 @@ "directory": "packages/payload" }, "type": "module", - "sideEffects": false, + "sideEffects": [ + "*.css" + ], "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./client": { + "types": "./dist/client/index.d.ts", + "import": "./dist/client/index.js" } }, "main": "./dist/index.js", @@ -43,24 +49,42 @@ "node": ">=22" }, "scripts": { - "build": "tsup src/index.ts --format esm --dts --clean --target es2022 --external payload", + "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/client/panels.css dist/client/panels.css", "typecheck": "tsc --noEmit", "test": "vitest run", "prepack": "pnpm run build" }, "dependencies": { "@captiveorigins/onetaxonomy-core": "workspace:^", - "@captiveorigins/onetaxonomy-import": "workspace:^" + "@captiveorigins/onetaxonomy-import": "workspace:^", + "@captiveorigins/onetaxonomy-react": "workspace:^" }, "peerDependencies": { - "payload": "^3.80.0" + "@payloadcms/ui": "^3.80.0", + "payload": "^3.80.0", + "react": ">=19" + }, + "peerDependenciesMeta": { + "@payloadcms/ui": { + "optional": true + }, + "react": { + "optional": true + } }, "devDependencies": { "@payloadcms/db-mongodb": "3.93.0", "@payloadcms/graphql": "3.93.0", + "@payloadcms/ui": "3.93.0", + "@testing-library/react": "^16.3.3", "@types/node": "^24.19.2", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "graphql": "^16.15.0", + "jsdom": "^30.1.3", "mongodb-memory-server": "^11.3.0", - "payload": "^3.93.0" + "payload": "^3.93.0", + "react": "^19.3.1", + "react-dom": "^19.3.1" } } diff --git a/packages/payload/src/api.ts b/packages/payload/src/api.ts index d6d408a..d494688 100644 --- a/packages/payload/src/api.ts +++ b/packages/payload/src/api.ts @@ -193,7 +193,8 @@ function hierarchyOf(index: DatasetIndex, q: HierarchyQuery): ApiResult = { + queued: { word: 'Queued', means: 'It hasn’t run yet.' }, + running: { word: 'Running', means: 'This page checks on it every few seconds.' }, + done: { word: 'Done', means: 'It stored a staged snapshot. Check that snapshot, then publish it.' }, + 'already-imported': { word: 'Already imported', means: 'This file with these settings was imported before, so the job points at that snapshot.' }, + failed: { word: 'Failed', means: 'It stopped before a snapshot was stored. The reason is below.' }, +} + +const STEPS: { phase: ImportPhase; label: string }[] = [ + { phase: 'reading', label: 'Read the file' }, + { phase: 'parsing', label: 'Parse it' }, + { phase: 'sealing', label: 'Seal the snapshot' }, + { phase: 'storing', label: 'Store it' }, +] + +export type StepState = 'done' | 'current' | 'stopped' | 'todo' +const STEP_WORD: Record = { done: 'done', current: 'in progress', stopped: 'stopped here', todo: 'not yet' } + +/** Where each step stands, from the job's status and the phase it last reached. */ +export function stepStates(job: Pick): StepState[] { + if (job.status === 'done' || job.status === 'already-imported') return STEPS.map(() => 'done') + const at = STEPS.findIndex((s) => s.phase === job.phase) + if (job.status === 'queued' || at < 0) return STEPS.map(() => (job.phase === 'finished' && job.status !== 'queued' ? 'done' : 'todo')) + return STEPS.map((_, i) => (i < at ? 'done' : i === at ? (job.status === 'failed' ? 'stopped' : 'current') : 'todo')) +} + +/** True when a job has been marked running for longer than the site allows, so it was probably interrupted. */ +export const isStale = (job: Pick, now: number, staleAfterMinutes: number): boolean => + job.status === 'running' && Boolean(job.startedAt) && now - Date.parse(job.startedAt!) >= staleAfterMinutes * 60_000 + +const where = (d: { line?: number; position?: number; path?: string; row?: number; externalId?: string }) => + [d.path ? d.path : '', d.row !== undefined ? `row ${d.row}` : '', d.line !== undefined ? `line ${d.line}` : '', d.position !== undefined ? `position ${d.position}` : '', d.externalId ? `id ${d.externalId}` : ''].filter(Boolean).join(', ') + +export type ImportPanelViewProps = { + job: Loadable + /** Whether this person may run the job. The site checks again when they do. */ + canRun: boolean + /** True while a run started from this page hasn't answered yet. */ + starting: boolean + staleAfterMinutes: number + /** The time now, in milliseconds, to tell an interrupted job from a working one. */ + now: number + /** Where the job's snapshot is in the admin, when it has one. */ + snapshotHref: string | null + outcome: Outcome + onRun: Act + onRetry?: () => void +} + +/** How an import job is going: its steps, what it said, what it found wrong, the snapshot it made, and a button to run it. */ +export function ImportPanelView({ job, canRun, starting, staleAfterMinutes, now, snapshotHref, outcome, onRun, onRetry }: ImportPanelViewProps) { + if (job.state !== 'ready') + return ( + + + + ) + const j = job.data + const status = IMPORT_STATUS[j.status] + const stale = isStale(j, now, staleAfterMinutes) + const states = stepStates(j) + const errors = j.diagnostics.filter((d) => d.severity === 'error') + const others = j.diagnostics.filter((d) => d.severity !== 'error') + const runLabel = j.status === 'queued' ? 'Run this import' : 'Run it again' + const mayRun = canRun && (j.status === 'queued' || j.status === 'failed' || stale) + return ( + + {/* Polite live region: a screen reader hears the status and message when they change, not on every check. */} +
+ + {status.means} + + {j.message ?

{j.message}

: null} +
+ +
    + {STEPS.map((s, i) => ( +
  1. + {s.label} {STEP_WORD[states[i]!]} +
  2. + ))} +
+ + {stale ? ( + + This job has been marked as running for more than {count(staleAfterMinutes, 'minute')}, so the run was probably interrupted. You can run it again: it starts from the file, and the same file and settings give the same snapshot. + + ) : null} + + {snapshotHref ? ( +

+ Open the staged snapshot this job {j.status === 'already-imported' ? 'found' : 'stored'} +

+ ) : null} + + {errors.length ? ( +
+

What stopped it

+
    + {errors.map((d, i) => ( +
  • + {d.message} + {where(d) ? ({where(d)}) : null} +
  • + ))} +
+
+ ) : null} + + {others.length ? ( +
+ {count(others.length, 'validation warning')} +
    + {others.map((d, i) => ( +
  • + {d.message} + {where(d) ? ({where(d)}) : null} +
  • + ))} +
+
+ ) : null} + + {j.warningCount > 0 ? ( +
+ {count(j.warningCount, 'thing')} the importer noticed and handled with care + {j.warnings.length < j.warningCount ?

The first {j.warnings.length.toLocaleString('en')} are kept.

: null} +
    + {j.warnings.map((w, i) => ( +
  • + {w.message} + {where(w) ? ({where(w)}) : null} +
  • + ))} +
+
+ ) : null} + + {j.progress.length ? ( +
+ Progress log +
    + {j.progress.map((p, i) => ( +
  1. + {p.message} +
  2. + ))} +
+
+ ) : null} + + : null], + ['Finished', j.finishedAt ? : null], + ['File SHA-256', j.inputSha256 ? {j.inputSha256} : null], + ['Snapshot checksum', j.checksum ? {j.checksum} : null], + ]} + /> + + + {mayRun ? ( +

+ + {starting ? Keep this page open. A large file can take a while. : null} +

+ ) : null} +
+ ) +} diff --git a/packages/payload/src/client/ProposalPanelView.tsx b/packages/payload/src/client/ProposalPanelView.tsx new file mode 100644 index 0000000..ce20a1d --- /dev/null +++ b/packages/payload/src/client/ProposalPanelView.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react' + +import type { ProposalPreview, ProposalStatus } from '../proposals.js' +import { ConfirmAction, count, Exact, Facts, Loading, Notice, OutcomeNotice, Panel, StatusLine, TextBox, When, type Act, type Loadable, type Outcome } from './ui.js' + +export const PROPOSAL_STATUS: Record = { + private: { word: 'Private', means: 'Only its author sees it.' }, + shared: { word: 'Shared', means: 'Readers see it, and a manager can accept or decline it.' }, + accepted: { word: 'Accepted', means: 'It became a new staged snapshot version.' }, + declined: { word: 'Declined', means: 'Its author can revise it and share it again.' }, +} + +/** What a manager decided, as far as this person may see it. */ +export type ProposalDecisionInfo = { note?: string | null; at?: string | null; snapshotHref?: string | null } + +export type ProposalPanelViewProps = { + preview: Loadable + decision: ProposalDecisionInfo + /** Where the proposal's snapshot is in the admin. */ + snapshotHref: string | null + /** Longest version name and note the site takes. */ + limits: { version: number; note: number } + outcome: Outcome + onAccept: Act<{ version: string; note: string; checksum: string }> + onDecline: Act<{ note: string }> + onRetry?: () => void +} + +/** What a proposal would change, in names, and for a manager the buttons to accept or decline it. */ +export function ProposalPanelView({ preview, decision, snapshotHref, limits, outcome, onAccept, onDecline, onRetry }: ProposalPanelViewProps) { + const [version, setVersion] = useState('') + const [acceptNote, setAcceptNote] = useState('') + const [declineNote, setDeclineNote] = useState('') + if (preview.state !== 'ready') + return ( + + + + + ) + const p = preview.data + const status = PROPOSAL_STATUS[p.proposal.status] + const name = (id: string | null) => + id === null ? ( + 'the top level' + ) : p.labels[id] ? ( + <> + {p.labels[id]} {id} + + ) : ( + {id} + ) + const names = (ids: string[]) => + ids.map((id, i) => ( + + {i ? ', ' : ''} + {name(id)} + + )) + const snapshotName = ( + <> + {p.snapshot.id} version {p.snapshot.version} + + ) + const open = p.proposal.status === 'shared' + return ( + + + {status.means} + + + {p.proposal.authorName} ({p.proposal.author}) + + ) : ( + {p.proposal.author} + ), + ], + ['Made', ], + ['Drawn over', snapshotHref ? {snapshotName} : snapshotName], + [ + p.proposal.base.kind === 'classification' ? 'Classification' : 'Phylogeny', + + {p.proposal.base.id} version {p.proposal.base.version} + , + ], + ['Checksum', {p.proposal.checksum}], + ['Decided', decision.at ? : null], + ['Manager’s note', decision.note || null], + ['Accepted into', decision.snapshotHref ? the snapshot version it made : null], + ]} + /> + +
+

{p.proposal.status === 'accepted' ? 'What it changed' : 'What it would change'}

+

+ {count(p.counts.moves, 'taxon moved', 'taxa moved')}, {count(p.counts.changedGroups, 'group changed', 'groups changed')}, {count(p.counts.broken, 'group gone', 'groups gone')},{' '} + {count(p.counts.formed, 'new group')}. +

+ {p.moved.length ? ( +
+ Taxa with a new parent +
    + {p.moved.map((m) => ( +
  • + {name(m.node)} moves from {name(m.from)} to {name(m.to)}.{m.note ?
    Author’s note: {m.note}
    : null} +
  • + ))} +
+
+ ) : null} + {p.changedGroups.length ? ( +
+ Groups that gain or lose members +
    + {p.changedGroups.map((g) => ( +
  • + {name(g.node)} + {g.gained.length ?
    Gains: {names(g.gained)}
    : null} + {g.lost.length ?
    Loses: {names(g.lost)}
    : null} +
  • + ))} +
+
+ ) : null} + {(['broken', 'formed'] as const).map((kind) => + p[kind].length ? ( +
+ {kind === 'broken' ? 'Groups the snapshot has and the proposal doesn’t' : 'Groups the proposal has and the snapshot doesn’t'} +
    + {p[kind].map((g) => ( +
  • + {name(g.node)} + {g.tips.length ?
    Holds: {names(g.tips)}
    : null} +
  • + ))} +
+
+ ) : null, + )} + {p.truncated ?

Long lists are cut short here. The counts are complete.

: null} +
+ + {open && !p.acceptable && p.notAcceptable ? It can’t be accepted as it stands. {p.notAcceptable} : null} + + {p.mayDecide && open ? ( +
+ + Accept this proposal? It makes a new staged snapshot version with these changes. Nothing is published, and the snapshot it was drawn over stays as it is. + + } + args={() => ({ version: version.trim(), note: acceptNote.trim(), checksum: p.proposal.checksum })} + onConfirm={onAccept} + > + + These are sealed into the new version and can’t be taken out later: the proposal’s title, its notes and move notes, your note, the date, and the ids of its author and of you. Check that none of it holds personal details. + + + + + ({ note: declineNote.trim() })} + onConfirm={onDecline} + > + + +
+ ) : null} +
+ ) +} diff --git a/packages/payload/src/client/SnapshotPanelView.tsx b/packages/payload/src/client/SnapshotPanelView.tsx new file mode 100644 index 0000000..b04c64c --- /dev/null +++ b/packages/payload/src/client/SnapshotPanelView.tsx @@ -0,0 +1,212 @@ +import type { SnapshotSummary } from '../summary.js' +import { ConfirmAction, count, Exact, Facts, Loading, Notice, OutcomeNotice, Panel, StatusLine, When, type Act, type Loadable, type Outcome } from './ui.js' + +export type SnapshotStatus = SnapshotSummary['snapshot']['status'] + +export const SNAPSHOT_STATUS: Record = { + staged: { word: 'Staged', means: 'Only managers see it. Check it, then publish it.' }, + published: { word: 'Published', means: 'Readers see it.' }, + withdrawn: { word: 'Withdrawn', means: 'Hidden from readers. It is kept for the views that pin it.' }, +} + +export type SnapshotPanelViewProps = { + summary: Loadable + /** Whether this person may publish and withdraw. The site checks again when they do. */ + canManage: boolean + outcome: Outcome + onSetStatus: Act<'published' | 'withdrawn'> + onRetry?: () => void +} + +/** A person as a snapshot records them: the name the site shows for them now when it gave one, then the sealed id. */ +function Person({ id, name }: { id: string; name?: string }) { + return name ? ( + <> + {name} ({id}) + + ) : ( + {id} + ) +} + +/** What a stored snapshot holds and where it came from, with the buttons to publish or withdraw it. */ +export function SnapshotPanelView({ summary, canManage, outcome, onSetStatus, onRetry }: SnapshotPanelViewProps) { + if (summary.state !== 'ready') + return ( + + + + ) + const s = summary.data + const status = SNAPSHOT_STATUS[s.snapshot.status] + const d = s.derivedFrom + return ( + + + {status.means} + + {s.snapshot.id}], + ['Version', {s.snapshot.version}], + ['Checksum', {s.snapshot.checksum}], + ['Sealed', ], + ['Holds', [count(s.counts.names, 'name'), count(s.counts.concepts, 'concept'), count(s.counts.classifications, 'classification'), count(s.counts.phylogenies, 'phylogeny', 'phylogenies'), count(s.counts.sources, 'source')].join(', ')], + ]} + /> + + {d ? ( +
+

Where it came from

+

+ Made from {d.snapshot} version {d.version} + {d.selection ? ( + <> + {' '} + by selecting part of it ({d.selection.id} version {d.selection.version}) + + ) : null} + {d.proposal ? ' by accepting a proposal' : ''}. +

+ {d.proposal ? ( + ], + ['Accepted by', ], + ]} + /> + ) : null} + {d.proposal ?

The ids are sealed in this snapshot. Names are looked up when this page loads and aren’t stored in it.

: null} +
+ ) : null} + + {s.classifications.length ? ( +
+

Classifications

+
+ + + + + + + + + + + {s.classifications.map((c) => ( + + + + + + + ))} + +
TitleVersion + Placements + + Top-level taxa +
{c.title} + {c.version} + {c.placements.toLocaleString('en')}{c.roots.toLocaleString('en')}
+
+
+ ) : null} + + {s.phylogenies.length ? ( +
+

Phylogenies

+
+ + + + + + + + + + + + {s.phylogenies.map((y) => ( + + + + + + + + ))} + +
TitleVersionRooted + Nodes + + Tips +
{y.title} + {y.version} + {y.rooted ? 'Rooted' : 'Unrooted'}{y.nodes.toLocaleString('en')}{y.tips.toLocaleString('en')}
+
+
+ ) : null} + + {s.sources.length ? ( +
+

Sources

+
    + {s.sources.map((x) => ( +
  • + {x.title} + {x.illustrative ? Made-up example data : null} + {x.citation ?
    {x.citation}
    : null} +
    + {[x.license ? `License: ${x.license}` : 'No license recorded', x.externalVersion ? `version ${x.externalVersion}` : ''].filter(Boolean).join(', ')} + {x.retrievedAt ? ( + <> + {', retrieved '} + + + ) : null} + {x.url && /^https?:\/\//i.test(x.url) ? ( + <> + {', '} + + {x.url} + + + ) : null} +
    +
  • + ))} +
+
+ ) : null} + + {s.truncated ?

The lists here are cut short. The counts above are complete.

: null} + + + {canManage && s.snapshot.status === 'staged' ? ( + 'published' as const} + onConfirm={onSetStatus} + /> + ) : null} + {canManage && s.snapshot.status === 'published' ? ( + 'withdrawn' as const} + onConfirm={onSetStatus} + /> + ) : null} + {s.snapshot.status === 'withdrawn' ? This snapshot was withdrawn. That can’t be undone: store a new version instead. : null} +
+ ) +} diff --git a/packages/payload/src/client/ViewPanelView.tsx b/packages/payload/src/client/ViewPanelView.tsx new file mode 100644 index 0000000..5695a08 --- /dev/null +++ b/packages/payload/src/client/ViewPanelView.tsx @@ -0,0 +1,134 @@ +import type { Highlight, LayoutKind } from '@captiveorigins/onetaxonomy-core' +import { layoutFor, TreeOutline, TreeView, treeViewBox } from '@captiveorigins/onetaxonomy-react' + +import type { ProjectionBody, ViewSource } from '../views-api.js' +import { SNAPSHOT_STATUS } from './SnapshotPanelView.js' +import { ConfirmAction, Exact, Loading, Notice, OutcomeNotice, Panel, StatusLine, type Act, type Loadable, type Outcome } from './ui.js' + +export type ViewStatus = 'draft' | 'published' | 'withdrawn' + +/** The saved view, as the panel needs it. `layout` and `highlights` are null when the stored view has none it can read. */ +export type SavedView = { title: string; status: ViewStatus; source: ViewSource | null; layout: LayoutKind | null; highlights: Highlight[] } + +export const VIEW_STATUS: Record = { + draft: { word: 'Draft', means: 'Only managers see it. Check it, then publish it.' }, + published: { word: 'Published', means: 'Readers see it.' }, + withdrawn: { word: 'Withdrawn', means: 'Hidden from readers.' }, +} + +export type ViewPanelViewProps = { + view: Loadable + /** The saved view drawn over its snapshot, cut to a preview's size. */ + preview: Loadable + canManage: boolean + /** True while the form has changes that aren't saved: publishing would publish the saved view, not what is on the page. */ + unsaved: boolean + /** Where this view's snapshot is in the admin, when it has one. */ + snapshotHref: string | null + outcome: Outcome + onSetStatus: Act<'published' | 'withdrawn'> + onRetry?: () => void +} + +/** Why a draft can't be published now, or null when it can. The site checks the same things when it is asked to. */ +export function whyNotPublishable(view: SavedView, unsaved: boolean): string | null { + if (unsaved) return 'Save your changes first.' + if (!view.source) return 'Its snapshot no longer exists.' + if (view.source.status === 'withdrawn') return 'Its snapshot was withdrawn, so this view can’t be published.' + if (view.source.status !== 'published') return 'Publish its snapshot first.' + return null +} + +/** A saved view's status, the snapshot it is shown over, a small drawing of it, and the buttons to publish or withdraw it. */ +export function ViewPanelView({ view, preview, canManage, unsaved, snapshotHref, outcome, onSetStatus, onRetry }: ViewPanelViewProps) { + if (view.state !== 'ready') + return ( + + + + ) + const v = view.data + const status = VIEW_STATUS[v.status] + const source = v.source + const sourceName = source ? ( + <> + {source.id} version {source.version} + + ) : null + return ( + + + {status.means} + + {source ? ( +

+ Shown over the snapshot {snapshotHref ? {sourceName} : sourceName}, which is {SNAPSHOT_STATUS[source.status].word.toLowerCase()}. +

+ ) : ( + This view’s snapshot no longer exists, or you may not see it. The view can’t be drawn or published. + )} + {source?.withdrawn && v.status === 'published' ? Its snapshot was withdrawn after this view was published. Readers still see the view, with a notice saying so. : null} + + {source ? ( +
+

Preview of the saved view

+ {preview.state !== 'ready' ? : } +
+ ) : null} + + + {canManage && v.status === 'draft' ? ( + 'published' as const} + onConfirm={onSetStatus} + /> + ) : null} + {canManage && v.status === 'published' ? ( + 'withdrawn' as const} + onConfirm={onSetStatus} + /> + ) : null} +
+ ) +} + +function Preview({ body, view }: { body: ProjectionBody; view: SavedView }) { + const { projection, disclosure } = body + if (!projection.nodes.length) return

This view draws nothing: every node is hidden or filtered out.

+ const layout = layoutFor(projection, view.layout ?? 'rectangular') + return ( + <> + {/* The drawing can be wider than the page, so its box scrolls and takes the keyboard focus to do it. A small tree + is drawn at its own size, one layout unit to a pixel, and not blown up to the width of the box. */} +
+ +
+ {projection.truncated ? ( +

This preview stops at {projection.nodes.length.toLocaleString('en')} nodes. The view itself has more.

+ ) : null} +
+ The same tree as an outline + +
+ {disclosure.lines.length ? ( +
+ What readers are told about it +
    + {disclosure.lines.map((line, i) => ( +
  • {line}
  • + ))} +
+
+ ) : null} + + ) +} diff --git a/packages/payload/src/client/http.ts b/packages/payload/src/client/http.ts new file mode 100644 index 0000000..674d915 --- /dev/null +++ b/packages/payload/src/client/http.ts @@ -0,0 +1,59 @@ +/** A request that didn't succeed, with words a person can read. `status` is 0 when the site wasn't reached. */ +export class RequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message) + this.name = 'RequestError' + } +} + +const FALLBACK: Record = { + 401: 'Sign in again to do this.', + 403: 'You aren\'t allowed to do this.', + 404: 'This wasn\'t found, or you may not see it.', +} + +/** The reason in an error answer: the plugin's `{ error }`, or Payload's `{ errors: [{ message }] }`. */ +export function reasonIn(body: unknown): string | null { + if (!body || typeof body !== 'object') return null + const b = body as { error?: unknown; errors?: unknown; message?: unknown } + if (typeof b.error === 'string' && b.error) return b.error + if (Array.isArray(b.errors)) { + const all = b.errors.map((e) => (e && typeof e === 'object' && typeof (e as { message?: unknown }).message === 'string' ? (e as { message: string }).message : '')).filter(Boolean) + if (all.length) return all.join(' ') + } + return typeof b.message === 'string' && b.message ? b.message : null +} + +/** + * One JSON request to the site's own API, with the signed-in person's cookie. Anything but a 2xx answer throws a + * `RequestError` carrying the site's own reason when it gave one. + */ +export async function request(url: string, init: { method?: 'GET' | 'PATCH' | 'POST'; body?: unknown; signal?: AbortSignal } = {}): Promise { + let res: Response + try { + res = await fetch(url, { + method: init.method ?? 'GET', + credentials: 'include', + headers: { Accept: 'application/json', ...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}) }, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), + ...(init.signal ? { signal: init.signal } : {}), + }) + } catch (error) { + if ((error as { name?: string }).name === 'AbortError') throw error + throw new RequestError('The site couldn\'t be reached. Check your connection and try again.', 0) + } + let body: unknown = null + try { + body = await res.json() + } catch { + body = null + } + if (!res.ok) throw new RequestError(reasonIn(body) ?? FALLBACK[res.status] ?? `The request failed (${res.status}).`, res.status) + return body as T +} + +/** Words for a thrown value. */ +export const reasonOf = (error: unknown): string => (error instanceof Error && error.message ? error.message : 'Something went wrong, and nothing more is known.') diff --git a/packages/payload/src/client/index.ts b/packages/payload/src/client/index.ts new file mode 100644 index 0000000..4a380be --- /dev/null +++ b/packages/payload/src/client/index.ts @@ -0,0 +1,13 @@ +'use client' + +/** + * The plugin's admin panels. Payload loads them through its import map; a host doesn't import them itself. + * Everything here runs in the browser and talks to the site's own API as the signed-in person. + */ +export { ImportPanel, ProposalPanel, SnapshotPanel, ViewPanel, type PanelProps } from './wired.js' +export { ImportPanelView, IMPORT_STATUS, isStale, stepStates, type ImportJob, type ImportPanelViewProps } from './ImportPanelView.js' +export { ProposalPanelView, PROPOSAL_STATUS, type ProposalDecisionInfo, type ProposalPanelViewProps } from './ProposalPanelView.js' +export { SnapshotPanelView, SNAPSHOT_STATUS, type SnapshotPanelViewProps } from './SnapshotPanelView.js' +export { ViewPanelView, VIEW_STATUS, whyNotPublishable, type SavedView, type ViewPanelViewProps } from './ViewPanelView.js' +export { RequestError } from './http.js' +export type { Act, Loadable, Outcome } from './ui.js' diff --git a/packages/payload/src/client/panels.css b/packages/payload/src/client/panels.css new file mode 100644 index 0000000..14192c6 --- /dev/null +++ b/packages/payload/src/client/panels.css @@ -0,0 +1,411 @@ +/* + * The OneTaxonomy admin panels. They take their colours, spacing and type from Payload's own theme variables, so + * they follow the admin's light and dark themes. Each variable has a fallback for a page that doesn't set it. + * Status is always said in words; colour only repeats it. + */ +.onetaxonomy-panel { + --otp-text: var(--theme-text, CanvasText); + --otp-muted: var(--theme-elevation-650, GrayText); + --otp-bg: var(--theme-elevation-50, Canvas); + --otp-raised: var(--theme-elevation-0, Canvas); + --otp-border: var(--theme-elevation-150, GrayText); + /* Edges of things a person presses or types in: 3:1 against the page (WCAG 1.4.11). */ + --otp-control-border: var(--theme-elevation-500, GrayText); + --otp-success: var(--theme-success-500, #1a7f4b); + --otp-warning: var(--theme-warning-500, #b45309); + --otp-error: var(--theme-error-500, #b91c1c); + --otp-gap: var(--base, 1.25rem); + --otp-radius: var(--style-radius-m, 4px); + --otp-radius-s: var(--style-radius-s, 3px); + --otp-focus: var(--accessibility-outline, 2px solid var(--otp-text)); + + box-sizing: border-box; + margin: 0 0 var(--otp-gap); + padding: var(--otp-gap); + border: 1px solid var(--otp-border); + border-radius: var(--otp-radius); + background: var(--otp-bg); + color: var(--otp-text); + font-family: var(--font-body, system-ui, sans-serif); + line-height: 1.5; + overflow-wrap: anywhere; +} +.onetaxonomy-panel *, +.onetaxonomy-panel *::before { + box-sizing: border-box; +} +.onetaxonomy-panel p, +.onetaxonomy-panel h3, +.onetaxonomy-panel h4 { + margin: 0; +} +.onetaxonomy-panel > * + * { + margin-top: calc(var(--otp-gap) * 0.6); +} +.onetaxonomy-panel__title { + font-size: 1.15em; + font-weight: 600; +} +.onetaxonomy-panel h4 { + font-size: 1em; + font-weight: 600; + margin-bottom: 0.35em; +} +.onetaxonomy-panel a { + color: inherit; + text-decoration: underline; + text-underline-offset: 0.15em; +} +.onetaxonomy-panel a:focus-visible, +.onetaxonomy-panel summary:focus-visible, +.onetaxonomy-panel [tabindex]:focus-visible, +.onetaxonomy-btn:focus-visible, +.onetaxonomy-input:focus-visible { + outline: var(--otp-focus); + outline-offset: 2px; +} +.onetaxonomy-panel__block { + padding-top: calc(var(--otp-gap) * 0.6); + border-top: 1px solid var(--otp-border); +} +.onetaxonomy-panel__block > * + * { + margin-top: 0.5em; +} +.onetaxonomy-panel__hint, +.onetaxonomy-panel__loading { + color: var(--otp-muted); + font-size: 0.925em; +} +.onetaxonomy-panel__message { + white-space: pre-wrap; +} + +/* A status word. The bar beside it takes the status colour; the word stays in the text colour. */ +.onetaxonomy-status { + display: inline-block; + padding: 0.1em 0.6em; + border: 1px solid var(--otp-control-border); + border-inline-start-width: 0.4em; + border-radius: var(--otp-radius-s); + background: var(--otp-raised); + font-weight: 600; +} +.onetaxonomy-status[data-status='published'], +.onetaxonomy-status[data-status='done'], +.onetaxonomy-status[data-status='already-imported'], +.onetaxonomy-status[data-status='accepted'] { + border-inline-start-color: var(--otp-success); +} +.onetaxonomy-status[data-status='staged'], +.onetaxonomy-status[data-status='draft'], +.onetaxonomy-status[data-status='shared'], +.onetaxonomy-status[data-status='running'], +.onetaxonomy-status[data-status='queued'] { + border-inline-start-color: var(--otp-warning); +} +.onetaxonomy-status[data-status='failed'], +.onetaxonomy-status[data-status='declined'] { + border-inline-start-color: var(--otp-error); +} + +.onetaxonomy-notice { + padding: 0.6em 0.8em; + border: 1px solid var(--otp-border); + border-inline-start: 0.4em solid var(--otp-control-border); + border-radius: var(--otp-radius-s); + background: var(--otp-raised); +} +.onetaxonomy-notice--error { + border-inline-start-color: var(--otp-error); +} +.onetaxonomy-notice--warning { + border-inline-start-color: var(--otp-warning); +} +.onetaxonomy-notice--success { + border-inline-start-color: var(--otp-success); +} +.onetaxonomy-outcome:focus { + outline: var(--otp-focus); + outline-offset: 2px; +} + +.onetaxonomy-facts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: 0.6em var(--otp-gap); + margin: 0; +} +.onetaxonomy-facts dt { + color: var(--otp-muted); + font-size: 0.925em; +} +.onetaxonomy-facts dd { + margin: 0; +} +.onetaxonomy-exact { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.925em; + overflow-wrap: anywhere; +} + +/* Buttons are tall enough to press (WCAG 2.5.8), and a disabled one keeps a visible edge. */ +.onetaxonomy-btn { + min-height: 2.25rem; + padding: 0.35em 0.9em; + border: 1px solid var(--otp-control-border); + border-radius: var(--otp-radius-s); + background: var(--otp-raised); + color: var(--otp-text); + font: inherit; + cursor: pointer; +} +.onetaxonomy-btn:hover:not(:disabled) { + border-color: var(--otp-text); +} +.onetaxonomy-btn--primary { + border-color: var(--theme-elevation-800, ButtonText); + background: var(--theme-elevation-800, ButtonText); + color: var(--theme-elevation-0, ButtonFace); +} +.onetaxonomy-btn--danger { + border-color: var(--otp-error); + border-width: 2px; +} +.onetaxonomy-btn:disabled { + cursor: not-allowed; + border-style: dashed; + border-color: var(--otp-control-border); + background: transparent; + color: var(--otp-muted); +} +.onetaxonomy-action { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6em; +} +.onetaxonomy-action__reason { + color: var(--otp-muted); + font-size: 0.925em; +} +.onetaxonomy-actions { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.75em; +} +.onetaxonomy-confirm { + flex: 1 1 100%; + padding: 0.8em; + border: 1px solid var(--otp-control-border); + border-radius: var(--otp-radius-s); + background: var(--otp-raised); +} +.onetaxonomy-confirm > * + * { + margin-top: 0.7em; +} +.onetaxonomy-confirm:focus { + outline: none; +} +.onetaxonomy-confirm:focus-visible { + outline: var(--otp-focus); + outline-offset: 2px; +} +.onetaxonomy-confirm__question { + font-weight: 600; +} +.onetaxonomy-confirm__buttons { + display: flex; + flex-wrap: wrap; + gap: 0.6em; +} + +.onetaxonomy-field label { + display: block; + margin-bottom: 0.25em; + font-weight: 600; +} +.onetaxonomy-field__required, +.onetaxonomy-field__optional { + color: var(--otp-muted); + font-weight: 400; +} +.onetaxonomy-field__hint { + margin-top: 0.25em; + color: var(--otp-muted); + font-size: 0.925em; +} +.onetaxonomy-input { + display: block; + width: 100%; + max-width: 40rem; + min-height: 2.25rem; + padding: 0.35em 0.6em; + border: 1px solid var(--otp-control-border); + border-radius: var(--otp-radius-s); + background: var(--theme-input-bg, Field); + color: var(--otp-text); + font: inherit; +} + +/* A table wider than the page scrolls in its own box, which takes the keyboard focus to do it. */ +.onetaxonomy-scroll { + overflow-x: auto; +} +.onetaxonomy-table { + width: 100%; + border-collapse: collapse; +} +.onetaxonomy-table th, +.onetaxonomy-table td { + padding: 0.35em 0.7em 0.35em 0; + border-bottom: 1px solid var(--otp-border); + text-align: start; + vertical-align: top; +} +.onetaxonomy-table th { + color: var(--otp-muted); + font-size: 0.925em; + font-weight: 600; +} +.onetaxonomy-table .onetaxonomy-num { + text-align: end; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.onetaxonomy-list { + margin: 0; + padding-inline-start: 1.2em; +} +.onetaxonomy-list > li + li { + margin-top: 0.25em; +} +.onetaxonomy-log { + max-height: 16rem; + overflow-y: auto; + font-size: 0.925em; +} +.onetaxonomy-tag { + display: inline-block; + padding: 0 0.45em; + border: 1px solid var(--otp-control-border); + border-radius: var(--otp-radius-s); + font-size: 0.85em; +} +.onetaxonomy-details > summary { + cursor: pointer; + font-weight: 600; + min-height: 1.5rem; +} +.onetaxonomy-details > summary + * { + margin-top: 0.5em; +} + +/* An import's steps, in order. Each says its state in words; the bar above it repeats it. */ +.onetaxonomy-steps { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + margin: 0; + padding: 0; + list-style: none; +} +.onetaxonomy-steps > li { + flex: 1 1 9rem; + padding: 0.45em 0.7em; + border: 1px solid var(--otp-border); + border-top: 0.3em solid var(--otp-control-border); + border-radius: var(--otp-radius-s); + background: var(--otp-raised); +} +.onetaxonomy-steps > li[data-state='done'] { + border-top-color: var(--otp-success); +} +.onetaxonomy-steps > li[data-state='current'] { + border-top-color: var(--otp-warning); +} +.onetaxonomy-steps > li[data-state='stopped'] { + border-top-color: var(--otp-error); +} +.onetaxonomy-steps > li[data-state='todo'] { + border-top-style: dashed; +} +.onetaxonomy-steps__label { + display: block; + font-weight: 600; +} +.onetaxonomy-steps__state { + display: block; + color: var(--otp-muted); + font-size: 0.925em; +} + +/* + * The drawing of a view. It keeps the drawing's own colours (the --ot-* variables of + * @captiveorigins/onetaxonomy-react) on the drawing's own surface, so its contrast is the one that package was + * checked for. That package picks light or dark from the device's setting; here the choice follows Payload's theme + * instead, which a person can set apart from their device. The values are copies: test/panels-css.test.ts fails if + * they drift from the package's stylesheet. + */ +/* drawing:light */ +.onetaxonomy-panel :is(.onetaxonomy-figure, .ot-root, .ot-outline) { + --ot-ink: #4c312c; + --ot-text: #1d2330; + --ot-muted: #5b6577; + --ot-edge: #a08a83; + --ot-node: var(--ot-ink); + --ot-selected: #cf8137; + --ot-selected-text: #a65f20; + --ot-warning: #b45309; + --ot-error: #b91c1c; + --ot-field-border: #a08a83; + --ot-surface: #ffffff; + --ot-panel: #fbf7f4; + --ot-border: #eaddd6; + --ot-focus: #a65f20; +} +/* drawing:dark */ +html[data-theme='dark'] .onetaxonomy-panel :is(.onetaxonomy-figure, .ot-root, .ot-outline) { + --ot-ink: #eda9ac; + --ot-text: #eef1f6; + --ot-muted: #a7b0c0; + --ot-edge: #8a7470; + --ot-node: var(--ot-ink); + --ot-selected: #e7a066; + --ot-selected-text: #e7a066; + --ot-warning: #fbbf24; + --ot-error: #f87171; + --ot-field-border: #8a7470; + --ot-surface: #1c1311; + --ot-panel: #241916; + --ot-border: #3a2a26; + --ot-focus: #e7a066; +} +.onetaxonomy-figure { + max-height: 32rem; + overflow: auto; + padding: 0.5em; + border: 1px solid var(--ot-border); + border-radius: var(--otp-radius-s); + background: var(--ot-surface); +} +/* As wide as the box but no wider than the tree's own size, as tall as its shape asks, and never squeezed so far + that its names can't be read. */ +.onetaxonomy-figure .ot-root { + display: block; + width: 100%; + max-width: var(--otp-widest, 100%); + min-width: min(36rem, var(--otp-widest, 36rem)); + height: auto; +} +.onetaxonomy-panel .ot-outline { + color: var(--ot-text); +} + +@media (forced-colors: active) { + .onetaxonomy-btn--primary { + border-color: ButtonText; + background: ButtonFace; + color: ButtonText; + } +} diff --git a/packages/payload/src/client/ui.tsx b/packages/payload/src/client/ui.tsx new file mode 100644 index 0000000..78606b1 --- /dev/null +++ b/packages/payload/src/client/ui.tsx @@ -0,0 +1,278 @@ +import { useEffect, useId, useRef, useState, type ComponentProps, type ReactNode } from 'react' + +/** Data a panel fetches: still loading, failed with a reason, or here. */ +export type Loadable = { state: 'loading' } | { state: 'error'; message: string } | { state: 'ready'; data: T } + +/** What the last action did. `link` goes to what it made. */ +export type Outcome = { kind: 'success' | 'error'; message: string; link?: { href: string; label: string } } | null + +/** An action a panel offers. It answers true when it worked; when it didn't, the panel is given the reason as an `Outcome`. */ +export type Act = (args: A) => Promise + +const cx = (...names: (string | false | null | undefined)[]) => names.filter(Boolean).join(' ') + +export function Panel({ title, children }: { title: string; children: ReactNode }) { + const id = useId() + return ( +
+

+ {title} +

+ {children} +
+ ) +} + +/** A status as a word in a pill, then what it means. The word carries it; the colour only repeats it. */ +export function StatusLine({ status, word, children }: { status: string; word: string; children?: ReactNode }) { + return ( +

+ + {word} + {' '} + {children} +

+ ) +} + +const LEAD = { error: 'Problem:', warning: 'Warning:', success: 'Done:', info: '' } as const + +/** A boxed note. It starts with a word saying what kind it is, so it doesn't rely on colour. */ +export function Notice({ kind = 'info', children }: { kind?: keyof typeof LEAD; children: ReactNode }) { + return ( +
+ {LEAD[kind] ? {LEAD[kind]} : null} + {children} +
+ ) +} + +/** + * What the last action did. A problem is announced at once (`role="alert"`). A success takes the keyboard focus, + * because the button that was pressed is usually gone by then, and a screen reader reads what has focus. + */ +export function OutcomeNotice({ outcome }: { outcome: Outcome }) { + const ref = useRef(null) + useEffect(() => { + if (outcome?.kind === 'success') ref.current?.focus() + }, [outcome]) + if (!outcome) return null + return ( +
+ ) +} + +/** Label and value pairs. Empty values are left out. */ +export function Facts({ items }: { items: [label: string, value: ReactNode][] }) { + const shown = items.filter(([, v]) => v !== null && v !== undefined && v !== '' && v !== false) + if (!shown.length) return null + return ( +
+ {shown.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ) +} + +/** + * A date and time in the reader's own format, with the exact value for machines. Only an ISO moment + * (`2026-11-12T00:52:05Z`) is read as one. Anything else is shown as written: a browser reads "spring 2026" as the + * first of January, and a day alone as midnight in Greenwich, which is the day before for a reader west of it. + */ +export function When({ at }: { at: string | null | undefined }) { + if (!at) return null + const d = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(at) ? new Date(at) : new Date(Number.NaN) + if (Number.isNaN(d.getTime())) return <>{at} + return +} + +/** An id, checksum or other exact value, shown so it can be told from prose and wraps on a narrow page. */ +export const Exact = ({ children }: { children: ReactNode }) => {children} + +export const count = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString('en')} ${n === 1 ? one : many}` + +export function Button({ danger, primary, ...props }: { danger?: boolean; primary?: boolean } & ComponentProps<'button'>) { + return + {disabledReason ? ( + + {disabledReason} + + ) : null} + + ) + return ( +
{ + if (e.key === 'Escape' && !working) { + e.stopPropagation() + close() + } + }} + > +

+ {question} +

+ {children} +
+ + +
+
+ ) +} + +/** A labelled text box with a hint under it. */ +export function TextBox({ + label, + hint, + value, + onChange, + required = false, + maxLength, + multiline = false, +}: { + label: string + hint?: string + value: string + onChange: (value: string) => void + required?: boolean + maxLength?: number + multiline?: boolean +}) { + const id = useId() + const hintId = useId() + const shared = { id, value, required, maxLength, 'aria-describedby': hint ? hintId : undefined, className: 'onetaxonomy-input' } + return ( +
+ + {multiline ?