From bcbf1aa18406377921d780406fa54ad37d403c81 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 26 Oct 2026 22:08:24 +0100 Subject: [PATCH] docs(32-01): complete cross-plugin admin form extensions plan --- .../phases/32-seo-foundation/32-01-SUMMARY.md | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 .planning/phases/32-seo-foundation/32-01-SUMMARY.md diff --git a/.planning/phases/32-seo-foundation/32-01-SUMMARY.md b/.planning/phases/32-seo-foundation/32-01-SUMMARY.md new file mode 100644 index 00000000..412a47db --- /dev/null +++ b/.planning/phases/32-seo-foundation/32-01-SUMMARY.md @@ -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 : controller \"\": : " + - "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 ``. `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*