docs+test: document the apiActions / server-injected-table primitive
Capture the mechanism the tokens + profile consolidation now rests on: AGENTS.md gains a "Server-injected collections (apiActions)" section under the Tables system (pre-assembled #table-context + the create/deleteRow/ rowNav layer, with server-side per-role gating), and the ARCHITECTURE ADR marks step 2 done (/.tokens + /.profile render via the engine) and flags that the remaining folds (archive/landing/transmittal) are feature-rich PLUGIN migrations — not quick tables-fications. Adds TestBuildTokensTableContext locking the contract: only the caller's own tokens become rows, each row carries its id for the delete action, and apiActions wires create (one-time secret) + per-row delete to /.api/tokens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d9256050d2
commit
0c6396d246
3 changed files with 62 additions and 4 deletions
16
AGENTS.md
16
AGENTS.md
|
|
@ -406,6 +406,22 @@ Read/aggregate counterpart to the form system. Renders a directory of YAML row f
|
||||||
|
|
||||||
**Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule.
|
**Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule.
|
||||||
|
|
||||||
|
### Server-injected collections (`apiActions`) — dynamic/virtual tables
|
||||||
|
|
||||||
|
The tables renderer also accepts a **fully pre-assembled, server-injected** `#table-context` (`{title, description, columns[], rows[]}` — used as-is, no directory walk; see `tables/js/context.js` and `handler.injectTableContextObj`). This lets a server handler render a *dynamic or virtual* record collection through the same engine + header chrome as an on-disk table, instead of a bespoke page. When the injected context also carries an **`apiActions`** block, the generic `tables/js/api-actions.js` layer turns the read-only table into a managed collection backed by a REST endpoint — **without touching the file-save/row-ops machinery** (which is bound to `<dir>/*.yaml` row files):
|
||||||
|
|
||||||
|
```
|
||||||
|
apiActions: {
|
||||||
|
create: { url, title?, fixed?{k:v}, fields:[{name,label,placeholder?,type?,required?}], secretField?, secretLabel? },
|
||||||
|
deleteRow: { urlTemplate (with {id} ← row data-url), label?, confirm? },
|
||||||
|
rowNav: true // clicking a row navigates to its data-url (capture-phase)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`create` → modal form → `POST` (date fields → RFC3339; `fixed` adds constants; a `secretField` in the response is shown once); `deleteRow` → per-row button → `DELETE`; both reload on success. It also hides the file-model toolbar buttons (`+ Add row`, `Save`).
|
||||||
|
|
||||||
|
**Consumers:** `/.tokens` (`handler.buildTokensTableContext` → `/.api/tokens`) and `/.profile` (`handler.buildProfileTableContext` → effective access + `POST /.profile/projects` + super-admin diagnostic rows). Per-role correctness is enforced **server-side** — a row/action only appears when the caller is authorized (e.g. profile diagnostics gated on elevated super-admin), so a non-admin's bytes never name a capability they lack. This is the "any dynamic collection is a declarative table, not a bespoke page" primitive from ARCHITECTURE.md's browse-as-shell ADR.
|
||||||
|
|
||||||
**Default-MDL columns mirror the tracking-number components** documented at `zddc.varasys.io/reference.html#tracking-numbers`. The default ships the five required components + an optional per-deliverable `suffix`: `originator`, `project`, `discipline`, `type`, `sequence`, `suffix` — each a slot of the deliverable's permanent identifier — plus `title`, `plannedRevision`, `plannedDate`, `status`, `owner`. The project-wide `phase` / `area` components are shipped only as commented-out templates in the default form/table YAML (a project that uses them must enable them on *every* deliverable to keep filenames lexically consistent, so the simplest default omits them). `originator` is **folder-bound**: the cascade's `folder_fields` pins it to the party-folder name, so the form renders it read-only and the server sets it from the path. The form schema accepts free-text on every other component by default; projects narrow the vocabulary via the cascade's `field_codes:` (see below). Operator overrides at `archive/<party>/mdl/{table,form}.yaml` still win atomically over the embedded defaults. Source: `zddc/internal/handler/default-mdl.{table,form}.yaml`.
|
**Default-MDL columns mirror the tracking-number components** documented at `zddc.varasys.io/reference.html#tracking-numbers`. The default ships the five required components + an optional per-deliverable `suffix`: `originator`, `project`, `discipline`, `type`, `sequence`, `suffix` — each a slot of the deliverable's permanent identifier — plus `title`, `plannedRevision`, `plannedDate`, `status`, `owner`. The project-wide `phase` / `area` components are shipped only as commented-out templates in the default form/table YAML (a project that uses them must enable them on *every* deliverable to keep filenames lexically consistent, so the simplest default omits them). `originator` is **folder-bound**: the cascade's `folder_fields` pins it to the party-folder name, so the form renders it read-only and the server sets it from the path. The form schema accepts free-text on every other component by default; projects narrow the vocabulary via the cascade's `field_codes:` (see below). Operator overrides at `archive/<party>/mdl/{table,form}.yaml` still win atomically over the embedded defaults. Source: `zddc/internal/handler/default-mdl.{table,form}.yaml`.
|
||||||
|
|
||||||
**Adding a new table**: create a directory `<dir>/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `<dir>/table.html`.
|
**Adding a new table**: create a directory `<dir>/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `<dir>/table.html`.
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,11 @@ The md / yaml / `.zddc`-form editors already follow this shape (`handles` / `ren
|
||||||
|
|
||||||
**Migration (incremental; standalone tools keep working throughout).**
|
**Migration (incremental; standalone tools keep working throughout).**
|
||||||
1. ✓ Editors are in-pane modules; classifier / tables / forms embed in the pane; the shell header carries the profile menu + progressive-enhancement elevation.
|
1. ✓ Editors are in-pane modules; classifier / tables / forms embed in the pane; the shell header carries the profile menu + progressive-enhancement elevation.
|
||||||
2. Fold `archive` into the tree + a listing plugin.
|
2. ✓ The two bespoke, chrome-less server pages — `/.tokens` and `/.profile` — now render through the tables engine via server-injected `#table-context` + the generic `apiActions` layer (see AGENTS.md "Server-injected collections"). That's the "dynamic collection → declarative table, not a bespoke page" half proven.
|
||||||
3. Make `landing` the shell's root ("no project selected") view.
|
3. Fold `archive` into the tree + a listing plugin.
|
||||||
4. Move `transmittal` into a workflow plugin.
|
4. Make `landing` the shell's root ("no project selected") view — note `landing` is feature-rich (saved groups, multi-select, filters), so this is a *plugin* migration that preserves those, NOT a tables-fication.
|
||||||
5. Flip `default_tool` routing to "browse + plugin X"; retire each standalone `<app>.html` only once its plugin lands.
|
5. Move `transmittal` into a workflow plugin.
|
||||||
|
6. Flip `default_tool` routing to "browse + plugin X"; retire each standalone `<app>.html` only once its plugin lands. (`archive`/`landing`/`transmittal` are all feature-rich — each fold is a deliberate, scoped effort, not a quick tables swap.)
|
||||||
|
|
||||||
**Consequences / tradeoffs.**
|
**Consequences / tradeoffs.**
|
||||||
- Preserves the single-file + offline value: the shell still builds to one `browse.html` that runs from `file://`. Heavy plugins should lazy-load in server mode to keep the bundle reasonable.
|
- Preserves the single-file + offline value: the shell still builds to one `browse.html` that runs from `file://`. Heavy plugins should lazy-load in server mode to keep the bundle reasonable.
|
||||||
|
|
|
||||||
|
|
@ -456,3 +456,44 @@ func TestWithEmail(t *testing.T) {
|
||||||
t.Errorf("EmailFromContext = %q", got)
|
t.Errorf("EmailFromContext = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuildTokensTableContext locks the server-injected token table contract:
|
||||||
|
// only the caller's own tokens become rows, each row carries its id (for the
|
||||||
|
// delete action), and apiActions wires create (with the one-time secret) +
|
||||||
|
// per-row delete to /.api/tokens.
|
||||||
|
func TestBuildTokensTableContext(t *testing.T) {
|
||||||
|
store := newTestTokenStore(t)
|
||||||
|
if _, _, err := store.Generate("alice@example.com", "Field laptop", time.Time{}); err != nil {
|
||||||
|
t.Fatalf("Generate alice: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := store.Generate("mallory@example.com", "other", time.Time{}); err != nil {
|
||||||
|
t.Fatalf("Generate mallory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := buildTokensTableContext(store, "alice@example.com")
|
||||||
|
if ctx["title"] != "API tokens" {
|
||||||
|
t.Errorf("title = %v", ctx["title"])
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, ok := ctx["rows"].([]map[string]interface{})
|
||||||
|
if !ok || len(rows) != 1 {
|
||||||
|
t.Fatalf("rows = %#v, want exactly alice's one token", ctx["rows"])
|
||||||
|
}
|
||||||
|
data, _ := rows[0]["data"].(map[string]interface{})
|
||||||
|
if data["description"] != "Field laptop" {
|
||||||
|
t.Errorf("row description = %v", data["description"])
|
||||||
|
}
|
||||||
|
if id, _ := rows[0]["url"].(string); id == "" {
|
||||||
|
t.Errorf("row missing url (token id needed for the delete action)")
|
||||||
|
}
|
||||||
|
|
||||||
|
api, _ := ctx["apiActions"].(map[string]interface{})
|
||||||
|
create, _ := api["create"].(map[string]interface{})
|
||||||
|
if create["url"] != TokensAPIPathPrefix || create["secretField"] != "token" {
|
||||||
|
t.Errorf("apiActions.create = %#v, want url=%s secretField=token", create, TokensAPIPathPrefix)
|
||||||
|
}
|
||||||
|
del, _ := api["deleteRow"].(map[string]interface{})
|
||||||
|
if del["urlTemplate"] != TokensAPIPathPrefix+"/{id}" {
|
||||||
|
t.Errorf("apiActions.deleteRow.urlTemplate = %v", del["urlTemplate"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue