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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<snapshots>/: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 <file>` 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
14 changes: 10 additions & 4 deletions apps/payload-example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` 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).
Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions apps/payload-example/app/(payload)/admin/importMap.js
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 2 additions & 1 deletion apps/payload-example/payload.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
Loading
Loading