docs(32-01): complete cross-plugin admin form extensions plan

This commit is contained in:
2026-10-26 22:08:24 +01:00
parent 9ae326908f
commit bcbf1aa184

View File

@@ -0,0 +1,322 @@
---
phase: 32-seo-foundation
plan: 01
subsystem: admin
tags: [cabana, pact, form-extensions, d-91, yaml, admin-spa]
requires:
- phase: 12.x/19.x admin (cabana)
provides: compiled fields.yaml forms, plugin field types, widgets, partials, AdminActionExtension record actions
provides:
- "pact.AdminActionExtension.Form with pact.AdminFormExtension (fields document via Fields path or FieldsYAML bytes, Hide, Permissions, Visible, Dir, Actions, PartialData, Load, Save)"
- "pact.AdminFormLoad, AdminFormSave, AdminFormSaveResult, AdminFormErrors, AdminFormZoneTabs/Outside/Secondary"
- "cabana compileFormExtensions: boot-time placement (field and tab anchors, anchor cursor, Requires() rule, cycles, Hide, D-101 zones) with clear boot errors"
- "cabana runtime: extension values lifted before any owner path, Save in the record's save transaction, Load on show and save, visibility on schema/show/save/widgets/partials"
affects: [32-02, 32-03, 32-04, 32-05, 32-06, sm-seo-plugin]
actuals:
tokens: 34400
tasks: 4
commits: 5
plan_head_before: 9868838fe3d01e07d578f2b2dd557d4296c48e10
plan_head_after: 9ae326908fed1aea0fd5e251e402cc1f01322d9e
tech-stack:
added: []
patterns:
- "Form extension fields document: fields.yaml format plus before:/after:/zone: per field and a top-level tabs: list, compiled through decodeFieldMapping"
- "Extension-owned field registry (CompiledController.extensionFields) keys every owner-aware branch: lift, decode, hydrate, widgets, partials"
- "One visibility helper (visibleExtensions) for schema, show and save"
key-files:
created:
- modules/cabana/form_extensions.go
- modules/cabana/form_extensions_postgres_test.go
- modules/cabana/form_extensions_smoke_test.go
- modules/cabana/example_form_extension_test.go
modified:
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/cabana/contracts.go
- modules/cabana/http.go
- modules/cabana/crud.go
- modules/cabana/actions.go
- modules/cabana/partial_render.go
- modules/cabana/field_type.go
- modules/cabana/field_ml.go
- modules/cabana/README.md
- docs/backend/admin-controllers.md
- docs/setup/coming-from-wintercms.md
- admin/tests/form/FormView.test.ts
key-decisions:
- "D-91 Go shape (Task 1, answered by Jakub before dispatch): yaml-document. An extension carries a fields.yaml-format document whose fields take before:/after:/zone: and whose top-level tabs: list declares new tabs; pact.AdminFormTab and pact.AdminFormField are not published (redundant with the document)"
- "Declaration form: AdminFormExtension.Fields is a path in the contributing plugin's AdminFS resolved with assetPath (like SettingsItem.Form and config_form.yaml form:); AdminFormExtension.FieldsYAML carries the document bytes for fields built at boot (D-104 per-type tabs); setting both stops the boot"
- "A field with an anchor and no tab: joins the anchor's tab; a field's tab: must be an owner tab or one declared under tabs: (by itself or a required plugin)"
- "Hide is static (every administrator); hidden owner fields also leave the owner's bindings, so show and save skip them"
- "Extension field types: text, textarea, number, checkbox, switch, dropdown with listed options, markdown, mltext, mltextarea, mlmarkdown, plugin field types, widget, partial; fileupload refused per D-100; value fields need Save"
- "Save runs after the owner's FormAfterCreate/FormAfterUpdate hooks inside the save transaction, on every create and update of a visible extension; Load runs on show and in the save response"
- "Translatable extension values without a TranslationWriter are keyed by the admin's configured locale"
- "Localized tab-label collisions are checked by checkFormExtensionTabLabels in Activate (where the translator is available), not inside compileFormExtensions"
- "A widget element's script comes from the contributor's field type scripts or its plugin-level pact.AdminClientAssets (added to the target form's assets)"
patterns-established:
- "Form extension boot errors: cabana: plugin <id>: controller \"<ctl>\": <field|tab|Hide> <name>: <reason>"
- "Owner-aware branches check cc.extensionOf(name) before treating a form field as the owner's"
requirements-completed: [SC-1, SC-5, D-91, D-101, D-90, D-98, D-100, D-104]
coverage:
- id: D1
description: "Plugin B adds field C after A on plugin A's form: schema lists a, c, b; Save stores c in the contributor's table in the record's transaction; show serves c; owner model untouched"
requirement: "SC-1"
verification:
- kind: integration
ref: "modules/cabana/form_extensions_postgres_test.go#TestFormExtensionTracer"
status: pass
human_judgment: false
- id: D2
description: "Placement rules: before/after, end of tab, anchor tab inheritance, own-field anchors, new tabs between tabs, tab-less form plus new tab, regrouping, Hide, span kept"
requirement: "D-91"
verification:
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionPlacement"
status: pass
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionTwoPluginsSameAnchor"
status: pass
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionOnePluginTwoControllers"
status: pass
human_judgment: false
- id: D3
description: "Boot errors (22 cases plus localized tab-label collision) name plugin, controller and offending name; D-101 zones and D-100 fileupload refused"
requirement: "D-101"
verification:
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionBootErrors"
status: pass
human_judgment: false
- id: D4
description: "Save transaction semantics: AdminFormErrors 422 with rollback, plain error 500 with rollback, notices, hidden owner field, translatable locale maps, invisible extension inert, create"
requirement: "D-91"
verification:
- kind: integration
ref: "modules/cabana/form_extensions_postgres_test.go#TestFormExtensionSave"
status: pass
human_judgment: false
- id: D5
description: "Extension widgets dispatch to AdminFormExtension.Actions, partials render from the contributor's Dir through PartialData with the GORM view-model guard, invisible extensions offer neither"
requirement: "D-91"
verification:
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionWidgetsAndPartials"
status: pass
human_judgment: false
- id: D6
description: "Strictly additive: a form no extension lays out compiles byte-identically (CanonicalJSON), whole repo vet and tests green, admin OpenAPI unchanged"
requirement: "SC-5"
verification:
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionUnextendedSchemaUnchanged"
status: pass
- kind: other
ref: "go vet ./... && go test -short ./... && go test ./modules/cabana -count=1"
status: pass
- kind: other
ref: "scripts/check-admin-openapi.sh --check"
status: pass
human_judgment: false
- id: D7
description: "SPA renders compiled order with no admin/src change (UI-SPEC C1 backstop)"
verification:
- kind: unit
ref: "admin/tests/form/FormView.test.ts#form extensions in compiled order (UI-SPEC C1, D-91)"
status: pass
human_judgment: false
- id: D8
description: "Docs: admin-controllers section with synced ExtrasPlugin snippet, runnable Example_formExtension, pact and cabana READMEs, WinterCMS extendFormFields mapping; no SEO concept or consuming app named (D-90)"
requirement: "D-90"
verification:
- kind: other
ref: "go run ./cmd/summer docs:build --check && go test ./cmd/summer -run TestDocsTree"
status: pass
- kind: unit
ref: "modules/cabana/example_form_extension_test.go#Example_formExtension"
status: pass
human_judgment: false
- id: D9
description: "The published pact shape (yaml-document form) fits the SEO plugin's needs in plans 32-03/32-04 (SEO tab per type, per-type settings tabs via FieldsYAML)"
verification: []
human_judgment: true
rationale: "One-way framework API; its fitness for sm-seo-plugin is proven only when plan 32-03 dogfoods it"
duration: 32min
completed: 2026-10-26
status: complete
---
# Phase 32 Plan 01: Cross-plugin admin form extensions (D-91) Summary
**A plugin now places fields and tabs inside another plugin's admin form from a fields.yaml-format document with before:/after:/zone: anchors and a tabs: list, compiled once at boot, and loads and saves the values itself inside the record's save transaction without touching the owner's model.**
## Task 1 answer (D-91 Go shape)
Answered by Jakub before dispatch: **yaml-document**. Recorded shape:
- `pact.AdminActionExtension` gains `Form *pact.AdminFormExtension` as its last field.
- `pact.AdminFormExtension{Fields string; FieldsYAML []byte; Hide, Permissions []string; Visible func(ctx) bool; Dir string; Actions []pact.AdminAction; PartialData func(ctx, name, record) (any, error); Load func(ctx, pact.AdminFormLoad) (map[string]any, error); Save func(ctx, pact.AdminFormSave) (pact.AdminFormSaveResult, error)}`.
- `Fields` is a path in the contributing plugin's AdminFS, resolved with `assetPath` exactly like `pact.SettingsItem.Form` and `config_form.yaml` `form:` (so `extensions/posts.yaml`, `$/vendor/plugin/...` and `~/plugins/vendor/plugin/...` work). `FieldsYAML` is the document itself, which is how a plugin generates fields at boot (D-104 per-type tabs). At most one may be set.
- Document format: `fields:` (every fields.yaml key, plus `before:`, `after:`, `zone:` per field) and `tabs:` (a list of `name`, `before`, `after`, `zone`). The placement keys are taken out of each field node before the rest compiles through the same `decodeFieldMapping`/`compileFieldNode` path as an owner's fields.yaml.
- Kept as planned: `AdminFormLoad{DB, Controller, Record, ID}`, `AdminFormSave{DB, Controller, Record, ID, Created, Values}`, `AdminFormSaveResult{Notices}`, `AdminFormErrors` (+`Error()`), `AdminFormZoneTabs/Outside/Secondary` (now the `zone:` values).
- Not published: `pact.AdminFormTab` and `pact.AdminFormField`. In the yaml-document form, tabs and fields live in the document.
- Plan 32-03 should use this shape instead of the go-placement sketch in its `<interfaces>`. `AdminFormField{Config}` becomes a `fields:` entry, and per-type tabs become a `FieldsYAML` document built at boot.
**Plan start SHA (phase base for scripts/check-phase32.sh in plan 32-05): `9868838fe3d01e07d578f2b2dd557d4296c48e10`**
## Performance
- **Duration:** 32 min
- **Started:** 2026-10-26T20:34:49Z
- **Completed:** 2026-10-26T21:07:01Z
- **Tasks:** 4 (Task 1 resolved before dispatch)
- **Files modified:** 17
## Accomplishments
- Additive pact API for form extensions, with every boot error and runtime rule documented on the type
- `compileFormExtensions` covers:
- placement: field and tab anchors, end of tab, an anchored field joining its anchor's tab
- plugin order through an anchor cursor
- the `Requires()` rule for anchors on another plugin's additions, and cycle detection inside one document
- Hide, with checks on fields that reference the hidden one
- D-101 zones and D-100 fileupload refusal
- regrouping only for forms an extension lays out
- Owner-aware branches:
- widget tags use the contributor's prefix and run the extension's own `Actions`
- partials render from the contributor's `Dir` through `PartialData`
- plugin field types run `Decode` before `Save` and their `Load` after the extension's
- translatable values reach `Save`/`Load` as locale maps
- ML and plugin owner paths skip extension fields
- Save path:
- extension keys leave the body before any owner path reads them
- `Save` runs in the record's transaction after the owner row and its after hooks
- `AdminFormErrors` answers 422; any other error is a 500. Both roll everything back
- notices join `meta.notices`
- Permissions/Visible apply to schema, show, save, widget actions and partials through one helper
- Docs (admin-controllers section with synced snippet, cabana/pact READMEs, WinterCMS mapping), runnable example, SPA backstop tests
## Task Commits
1. **Task 1: Confirm the Go shape**: no commit (answered before dispatch: yaml-document)
2. **Task 2: Tracer (c after a through the admin API)**: `0819442` (feat)
3. **Task 3: Full placement rules, boot errors, owner-aware branches (TDD)**:
- RED `4bb22a8` (test)
- GREEN `e7423bd` (feat)
4. **Task 4: Docs, example, SPA backstop**:
- `fa1af69` (docs)
- `9ae3269` (test)
Tracer feedback gate: interactive, end-of-phase mode, automated-only verify. It re-ran green (TestFormExtensionTracer, `go vet ./...`, `go test -short ./...`) before expanding.
## TDD Gate Compliance
- RED `4bb22a8` before GREEN `e7423bd`.
- RED evidence (TAP transcription of `go test -json`, target `TestFormExtensionBootErrors`): `RED_EVIDENCE_OK`. Failing in RED: Placement, TwoPluginsSameAnchor, BootErrors, WidgetsAndPartials.
- `TestFormExtensionOnePluginTwoControllers` and `TestFormExtensionUnextendedSchemaUnchanged` already passed in RED. They guard invariants the tracer already met.
- An initial RED run hit fixture errors (an owner model without the test's columns) and was fixed before the RED commit.
- No REFACTOR commit.
## Files Created/Modified
- `modules/pact/capabilities.go`: `Form` field, `AdminFormExtension` and the Load/Save/result/error types, zone constants
- `modules/pact/README.md`: rows for each new type
- `modules/cabana/form_extensions.go`: document parsing, placement layout, per-kind compile checks, tab-label check, permission validation, runtime (lift, Save, Load, visibility)
- `modules/cabana/contracts.go`: `formExtensions`, `extensionFields`, `extensionPartials` on `CompiledController`
- `modules/cabana/http.go`: wiring in Activate and the form schema filter
- `modules/cabana/crud.go`: save and show wiring; `BindWritableFields` skips extension fields
- `modules/cabana/actions.go`, `partial_render.go`: extension widget and partial dispatch
- `modules/cabana/field_type.go`, `field_ml.go`: owner paths skip extension fields
- Tests:
- `form_extensions_smoke_test.go`, `form_extensions_postgres_test.go`
- `example_form_extension_test.go`
- `admin/tests/form/FormView.test.ts`
- Docs: `docs/backend/admin-controllers.md`, `docs/setup/coming-from-wintercms.md`, `modules/cabana/README.md`
## Decisions Made
See `key-decisions` in the frontmatter. The main one is the Task 1 yaml-document shape and its `Fields`/`FieldsYAML` declaration form, explained above.
## Acceptance-criteria adjustments (yaml-document form)
- Task 2, `go doc ./modules/pact AdminFormExtension`: the plan expected `Tabs` and `Fields` struct fields.
- Now prints `Fields, FieldsYAML, Hide, Permissions, Visible, Dir, Actions, PartialData, Load, Save`.
- Tabs are a `tabs:` list in the document, not a struct field.
- `grep "Form \*AdminFormExtension"`, the `compileFormExtensions` signature and its position before `compileActionExtensions`: unchanged, all pass.
- Task 3 boot-error criterion (14 or more `--- PASS` subtests): 23 subtests pass.
- The zone errors come from the document's `zone:` key, not a struct field.
- Artifact `contains: "Form *AdminFormExtension"`: holds.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] `modules/cabana/contracts.go` modified (not in files_modified)**
- **Found during:** Task 2
- **Issue:** The extension-owned field registry has to live on `CompiledController`.
- **Fix:** Added the unexported fields `formExtensions`, `extensionFields` and `extensionPartials`.
- **Committed in:** `0819442`, `e7423bd`
**2. [Rule 3 - Blocking] The RED commit includes a no-op `checkFormExtensionTabLabels`**
- **Found during:** Task 3 RED
- **Issue:** The tab-label test needs the function to exist, or the build fails (INVALID_RED).
- **Fix:** Declared it returning nil in the RED commit and implemented it in GREEN.
- **Committed in:** `4bb22a8`
**3. [Rule 1 - Test fixture] Smoke owners use their own model, `feWide`; partial templates read `.Data`**
- **Found during:** Task 3 RED and GREEN
- **Fix:** Test-only changes.
**4. [Plan detail] The example lives in package `cabana` as `Example_formExtension`**
- **Issue:** The compile path is unexported, so an external `cabana_test` example could not show the compiled order.
- **Fix:** The docs snippet (`ExtrasPlugin.AdminActionExtensions`) is unaffected.
**5. [Plan detail] Commit split**
- Task 3's compile pass and runtime share `form_extensions.go`, so GREEN is one feat commit instead of two.
- The Task 4 docs commit carries the planned message ("... and pact READMEs"), but the pact README rows had already landed in the Task 2 commit, as the documentation rule requires.
**6. [Rule 2 - Missing critical] Permission codes of extensions and their widget actions are validated at boot**
- **Fix:** Added `validateFormExtensionPermissions` after `compileContributions`, mirroring the controller checks.
- **Committed in:** `e7423bd`
---
**Total deviations:** 6 (2 blocking, 1 test fixture, 2 plan detail, 1 missing critical).
**Impact on plan:** Behaviour is as planned; only the declaration form changed (Task 1). No scope creep.
## Issues Encountered
None.
## Threat model
- T-32-01 (extension values reaching the owner's model): mitigated. Values are lifted first, `BindWritableFields` skips extension fields, and ML/plugin owner paths skip them. Covered by TestFormExtensionTracer and TestFormExtensionSave.
- T-32-02 (posted keys of an invisible extension): mitigated. `visibleExtensions` applies on schema, show, save, widgets and partials. Covered by TestFormExtensionSave and TestFormExtensionWidgetsAndPartials.
- T-32-03 (partial disclosure): mitigated. `refusedViewModel` guards extension partials. Covered by TestFormExtensionWidgetsAndPartials (leak case).
- T-32-SC: no packages installed.
## User Setup Required
None.
## Next Phase Readiness
- Plans 32-02 through 32-06 can build on the seam.
- Plan 32-03 must use the yaml-document shape recorded above: the SEO tab is a `FieldsYAML` or `Fields` document with `tabs: [{name: golem15.seo::lang.tab.seo}]`, and the per-type settings tabs are `FieldsYAML` built at boot.
- `AdminFormSave` has no `Changes` yet; plan 32-02 adds it.
## Self-Check: PASSED
- All key files exist on disk, and commits 0819442, 4bb22a8, e7423bd, fa1af69 and 9ae3269 are present in `git log`.
- Commands run green:
- `go vet ./...` and `go test -short ./...`
- `go test ./modules/cabana -count=1` (Docker), plus TestFormExtensionTracer and TestFormExtensionSave on their own
- `docs:build --check` and `TestDocsTree`
- the FormView tests and `typecheck`
- `scripts/check-admin-openapi.sh --check`
- `git diff --name-only 9868838..HEAD -- admin/src` is empty, and the D-90 grep finds nothing.
---
*Phase: 32-seo-foundation*
*Completed: 2026-10-26*