Plan checker iteration 1 flagged unresolved research questions and a missing decision note for golang.org/x/net/html. D-18 approves x/net/html for the partial sanitizer; D-19 fixes the Discogs widget fill to [year, format]. STATE marks the phase ready to execute.
556 lines
29 KiB
Markdown
556 lines
29 KiB
Markdown
# Phase 10.1: Runtime admin extension point - Pattern Map
|
|
|
|
**Mapped:** 2026-09-28
|
|
**Files analyzed:** 38 (new + modified, both repos)
|
|
**Analogs found:** 35 / 38
|
|
|
|
All analog paths below are git-tracked (verified with `git ls-files` in each repo). Line numbers are from the tree at commit b2845e0 (summercms.go) and the current fonoteka.go HEAD.
|
|
|
|
## File Classification
|
|
|
|
### Plan 01 — framework Go (`summercms.go`)
|
|
|
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
|
|---|---|---|---|---|
|
|
| `modules/pact/capabilities.go` (mod) | contract/interface | n/a | same file: `AdminAssets` 115-119, `SettingsItem` func field 163-175, `FormExtendQuery` 205-208 | exact |
|
|
| `modules/cabana/form_schema.go` (mod) | config compiler | transform (YAML → schema) | same file `compileFieldNode` 321-389, `formFieldKeys` 33-37 | exact |
|
|
| `modules/cabana/list_schema.go` (mod) | config compiler | transform | same file `toolbarButtons.UnmarshalYAML` 291-320, `compileToolbarButtons` 322-333, `listDocument` 30-42 | exact |
|
|
| `modules/cabana/registry.go` (mod) | boot wiring | batch (boot) | same file `compileRegistry` 53-98, `compileContributions` 173-192 | exact |
|
|
| `modules/cabana/extension.go` (new) | boot validator | batch (boot) | `registry.go` `compileContributions` + `validatePermissions` 212-219; `form_schema.go` `requireDropdownProvider` 186-217 | role-match |
|
|
| `modules/cabana/actions.go` (new) | controller (HTTP handler) | request-response (POST) | `http.go` `relationMutation` 442-471 + `decodeRelationMutation` 473-486; `crud.go` `loadRecord` 435-455 | exact |
|
|
| `modules/cabana/partial_render.go` (new) | service (render + sanitize) | transform | `form_schema.go` `Localize`/`translateKey` 126-160, 244-249 (per-request translation); no HTML sanitizer exists | partial |
|
|
| `modules/cabana/plugin_assets.go` (new) | static file handler | file-I/O (embed.FS) | `modules/boardwalk/boardwalk.go` `serveFile` 135-148, `contentType` 150-159, `setSecurityHeaders` 161-167 | exact |
|
|
| `modules/boardwalk/boardwalk.go` (mod: export `ContentType`, `SetSecurityHeaders`) | utility | n/a | same file 150-167 | exact |
|
|
| `modules/cabana/http.go` (mod: mount 3 API + 1 asset route) | route | request-response | same file `mount` 185-245, `constrainRelation` 253-256 | exact |
|
|
| `modules/cabana/schema_types.go` (mod: `ControllerAssets`, `ToolbarAction`, `HeaderPartial` on ListSchema/FormView, `PartialNode`, `PartialView`, `AdminActionRequest`) | model (DTO) | n/a | existing `ListSchema`/`FormView` types in same file | exact |
|
|
| `modules/cabana/admin_openapi.go` (mod) | config (swag annotations) | n/a | same file `AdminBulkDelete` 370-388 | exact |
|
|
| `modules/cabana/crud.go` (mod, only if a non-locking scoped read helper is added) | service | CRUD | `loadRecord` 435-455 (copy minus `clause.Locking`) | exact |
|
|
| `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (mod: `extension:` group) | config (i18n) | n/a | same file `form:` group (line 54, `unsupported_field` 75) | exact |
|
|
| `modules/cabana/README.md` (mod) | docs | n/a | same file (CLAUDE.md doc rule) | exact |
|
|
| `admin/openapi/admin.json`, `admin/src/api/schema.d.ts` (regen) | generated | n/a | `scripts/check-admin-openapi.sh` output | exact |
|
|
| `go.mod` (promote `golang.org/x/net` to direct) | config | n/a | — | n/a |
|
|
|
|
### Plan 02 — framework SPA (`summercms.go/admin`)
|
|
|
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
|
|---|---|---|---|---|
|
|
| `admin/src/app/pluginAssets.ts` (new) | utility (loader) | event-driven (DOM load/error) | `admin/src/app/runtime.ts` (module-level singleton, `runtime.base`) | partial |
|
|
| `admin/src/components/form/fields/WidgetField.vue` (new) | component | event-driven + request-response | `fields/UnsupportedField.vue` (failure-box geometry), `views/ListView.vue` `onDelete` 184-212 (POST → toast) | role-match |
|
|
| `admin/src/components/form/fields/PartialField.vue` (new) | component | request-response (GET) | `fields/UnsupportedField.vue` + `FieldControlProps` in `control.ts` 7-21 | role-match |
|
|
| `admin/src/components/partial/PartialHost.vue` (new) | component (h() renderer) | transform | `components/form/control.ts` `allowedAttributes`/`controlAttributes` 25-50 (client allowlist idiom) | partial |
|
|
| `admin/src/components/form/formContext.ts` (new) | provider (InjectionKey) | n/a | `components/form/control.ts` (shared-types module split from registry to avoid cycles) | partial |
|
|
| `admin/src/components/form/registry.ts` (mod) | registry | n/a | same file 27-59 | exact |
|
|
| `admin/src/components/form/formState.ts` (mod, only via registry `isRegistered`) | utility | transform | same file `editablePayload` 46-58 | exact |
|
|
| `admin/src/components/form/FormField.vue` (mod: span label + role=group for widget/partial) | component | n/a | same file (`ownsLabel` branch) | exact |
|
|
| `admin/src/components/list/ListToolbar.vue` (mod) | component | event-driven | same file 1-58 (delete button loop 236-247 of template) | exact |
|
|
| `admin/src/views/ListView.vue` (mod) | view | request-response | same file 66-68 (button split), 184-212 (`onDelete`), 216-247 (template) | exact |
|
|
| `admin/src/views/FormView.vue` (mod: provide values/patch, load assets) | view | request-response | same file 131-160 (`dirty`, `load`) | exact |
|
|
| `admin/src/api/types.ts` (mod: `Schemas['cabana.X']` aliases) | model (types) | n/a | existing aliases (gate regex `check-phase10.sh:330`) | exact |
|
|
| `admin/src/styles/main.css` (mod: `.summer-partial`, `.summer-stats` kit in `@layer components`) | config (CSS) | n/a | existing `@layer components` in same file | exact |
|
|
| `admin/vite.config.ts` (mod: `${devPrefix}/assets` proxy) | config | n/a | existing `${devPrefix}/api` proxy entry | exact |
|
|
| `modules/boardwalk/dist/**` (rebuild) | generated | n/a | `scripts/check-admin-dist.sh` | exact |
|
|
|
|
### Plan 03 — application (`fonoteka.go/plugins/golem15/fonoteka`)
|
|
|
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
|
|---|---|---|---|---|
|
|
| `controllers/albums_admin_controller.go` (mod: `AdminJS/AdminCSS`, `AdminActions`, `PartialData`) | controller | CRUD (read counts) + request-response | same file: `DropdownOptions` 42-53, `scopeAlbums` 99-108, `orderedOptions` 149-169 | exact |
|
|
| `controllers/albums/config_list.yaml` (mod) | config | n/a | same file 1-19 | exact |
|
|
| `controllers/albums/_stats.htm` (new) | template | transform | UI-SPEC recommended `<dl class="summer-stats">` markup | no code analog |
|
|
| `models/album/fields.yaml` (mod: `year`, `discogs` widget) | config | n/a | same file 1-28 | exact |
|
|
| `assets/js/discogs-lookup.js` (new) | component (custom element) | event-driven | RESEARCH "Plugin custom element" example | no code analog |
|
|
| `assets/css/albums.css` (new) | config (CSS) | n/a | UI-SPEC S3 visual contract | no code analog |
|
|
| `admin.go` (mod: extend `//go:embed` list) | config | file-I/O | same file line 12 | exact |
|
|
| `lang/{en,pl}/lang.yaml` (mod: `discogs.*`, `stats.*`, `item.year`) | config (i18n) | n/a | same files, `discogs:` group line 197, `album_format:` 161 | exact |
|
|
| `admin_albums_test.go`, `admin_phase10_copy_test.go`, `admin_phase10_controllers_test.go` (mod) | test | n/a | same files (Pitfall 3: lines 132, 357 / 48 / 100) | exact |
|
|
|
|
### Plan 04 — unit tests + gate + evidence
|
|
|
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
|
|---|---|---|---|---|
|
|
| `modules/cabana/testdata/extension/**` (acme fixture tree) | test fixture | n/a | `openapi_conformance_test.go` `conformPlugin`/`conformController`/`conformFS` 365-440 | exact |
|
|
| `modules/cabana/phase101_*_test.go` (schema, toolbar, sanitizer, assets, actions) | test | n/a | `form_schema_test.go` table 215-285; `openapi_conformance_test.go` 110-135 | exact |
|
|
| `modules/cabana/form_schema_test.go`, `list_schema_test.go` (mod) | test | n/a | same files (partial cases 224-238, "bad form fails activation" 273-284) | exact |
|
|
| `modules/cabana/security_coverage_test.go` (mod: `phase09Routes`) | test (inventory) | n/a | same file 33-62, handler table ~266 | exact |
|
|
| `modules/cabana/openapi_conformance_test.go` (mod) | test | request-response | same file 118-130 | exact |
|
|
| `admin/tests/app/pluginAssets.test.ts`, `tests/form/WidgetField.test.ts`, `tests/form/PartialField.test.ts`, `tests/list/PartialHost.test.ts` (new); `tests/list/ListToolbar.test.ts`, `ListView.test.ts`, `tests/form/registry.test.ts`, `formState.test.ts` (mod) | test | n/a | `admin/tests/list/ListToolbar.test.ts` 1-40 | exact |
|
|
| `admin/tests/fixtures/extension.*.json` (new) | fixture | n/a | `admin/tests/fixtures/widgets.list-schema.json`, `widgets.form-schema.json` | exact |
|
|
| `fonoteka.go/.../admin_phase101_albums_test.go` (new) | test (Postgres) | CRUD | `admin_albums_test.go` (`TestAlbumsAdminForm` line 28) | exact |
|
|
| `scripts/check-phase10.1.sh` (new) | gate script | batch | `scripts/check-phase10.sh` (1-60 header/`phase10_detect`, 361-420 hygiene/evidence/dispatch); newest sibling `scripts/check-phase10.2.sh` | exact |
|
|
| `.planning/phases/10.1-.../10.1-SECURITY-REVIEW.md`, `10.1-VALIDATION.md` | docs | n/a | `.planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md` | exact |
|
|
|
|
---
|
|
|
|
## Pattern Assignments
|
|
|
|
### `modules/pact/capabilities.go` (contract)
|
|
|
|
**Analog:** same file. Optional interfaces are one-method, doc-commented, type-asserted by cabana.
|
|
|
|
Lines 115-130:
|
|
```go
|
|
// AdminAssets is the plugin-owned embedded tree of Winter admin YAML.
|
|
// Paths are relative to the plugin root (controllers/..., models/...).
|
|
type AdminAssets interface {
|
|
AdminFS() fs.FS
|
|
}
|
|
|
|
// AdminPermissioned is the D-03 permission list enforced before schema or SQL.
|
|
type AdminPermissioned interface {
|
|
RequiredPermissions() []string
|
|
}
|
|
```
|
|
Func-field-in-struct precedent (lines 163-175): `Form string \`json:"-"\`` / `NewModel func() any \`json:"-"\``. Give `AdminAction.Run` a `json:"-"` tag the same way. Context-taking hook shape (205-208):
|
|
```go
|
|
type FormExtendQuery interface {
|
|
FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB
|
|
}
|
|
```
|
|
Add `AdminClientAssets`, `AdminAction`, `AdminActionInput`, `AdminActionResult`, `HasAdminActions`, `AdminPartialData` (RESEARCH Pattern 1) right after `AdminRecordSource`. Update `modules/pact/README.md` API reference in the same commit.
|
|
|
|
---
|
|
|
|
### `modules/cabana/form_schema.go` (config compiler)
|
|
|
|
**Analog:** same file.
|
|
|
|
Key allowlist to extend (33-37): add `"widget"`, `"action"`, `"fill"`, `"path"` to `formFieldKeys`; add `"widget"`, `"partial"` to `formFieldTypes` (23-26).
|
|
|
|
Rejection to replace (344-349):
|
|
```go
|
|
if typ == "partial" {
|
|
return FormField{}, fmt.Errorf("type partial is not supported")
|
|
}
|
|
if _, ok := formFieldTypes[typ]; !ok {
|
|
return FormField{}, fmt.Errorf("unsupported type %s", typ)
|
|
}
|
|
```
|
|
Per-key decode idiom to copy for `widget`/`action`/`path` (351-356):
|
|
```go
|
|
if node, ok := values["label"]; ok {
|
|
field.Label, err = nodeString(node)
|
|
if err != nil {
|
|
return FormField{}, fmt.Errorf("label: %w", err)
|
|
}
|
|
}
|
|
```
|
|
Key-on-wrong-type rule: after `typ` is known, reject `values["widget"|"action"|"fill"]` unless `typ == "widget"` and `values["path"]` unless `typ == "partial"`. Tag regex / `{vendor}-{plugin}-` prefix, fill ⊆ `cc.Writable` and template existence need the controller and plugin id, so they belong in `extension.go` (post-`BindWritableFields`), not in the decoder. `compileSetting` reuses `decodeFields`: reject `widget`/`partial` there (rule 9).
|
|
|
|
Controller-capability boot check to copy (186-198, `requireDropdownProvider`):
|
|
```go
|
|
if method == "" || dropdownProvider(ctl) != nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("dropdown method %s requires DropdownOptions", method)
|
|
```
|
|
Localize (136-147): add `translateKey` for any new label-bearing fields; widget `label`/`busy-label` come from the action `Label` resolved here.
|
|
|
|
---
|
|
|
|
### `modules/cabana/list_schema.go` (config compiler)
|
|
|
|
**Analog:** same file.
|
|
|
|
Add `HeaderPartial string \`yaml:"headerPartial"\`` to `listDocument` (30-42) — `decodeStrict` rejects unknown keys automatically.
|
|
|
|
Decode-time check that must move (Pitfall 1), lines 288-310:
|
|
```go
|
|
// toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1.
|
|
var toolbarActions = map[string]struct{}{"create": {}, "delete": {}}
|
|
...
|
|
if _, ok := toolbarActions[action]; !ok {
|
|
return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action)
|
|
}
|
|
```
|
|
Keep `nodeString`/identifier + duplicate checks in `UnmarshalYAML`; drop the membership test. Resolve membership in `compileToolbarButtons` (322-333), which gains the controller:
|
|
```go
|
|
func compileToolbarButtons(toolbar *listToolbar, showCheckboxes bool) ([]string, error) {
|
|
...
|
|
for _, action := range out {
|
|
if action == "delete" && !showCheckboxes {
|
|
return nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true")
|
|
}
|
|
}
|
|
```
|
|
Call site (128-131) wraps errors with `bootErr(pluginID, ctl.ID(), cfgPath, err)` — keep that. Note `registry.go` 80-83 strips `create` via `withoutAction`; custom names must survive it.
|
|
|
|
---
|
|
|
|
### `modules/cabana/registry.go` + `modules/cabana/extension.go` (boot)
|
|
|
|
**Analog:** `compileRegistry` 53-98 (per-controller compile then `BindWritableFields`), `compileContributions` 173-192 (permission validation after all plugins' permissions are known).
|
|
|
|
```go
|
|
compiled := &CompiledController{ ... }
|
|
if err := BindWritableFields(compiled); err != nil {
|
|
return nil, err
|
|
}
|
|
byID[id] = compiled
|
|
```
|
|
Insert `compileExtension(item.plugin.ID(), compiled, assets.AdminFS())` after `BindWritableFields` (fill ⊆ `cc.Writable`, widget tag, action registered, partial templates parse, asset files read + hashed). Store results on `CompiledController` (new fields: `Actions map[string]pact.AdminAction`, `Partials map[string]*compiledPartial`, `Assets ControllerAssets`).
|
|
|
|
Action permissions go next to relation permissions (177-181):
|
|
```go
|
|
for name, relation := range controller.Relations {
|
|
if err := reg.validatePermissions("relation "+id+"."+name, relation.RequiredPermissions); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
```
|
|
→ `reg.validatePermissions("action "+id+"."+name, action.Permissions)`. Reserved `assets` segment already exists (line 40), no change.
|
|
|
|
---
|
|
|
|
### `modules/cabana/actions.go` (HTTP handlers, POST)
|
|
|
|
**Analog:** `http.go` `relationMutation` 442-471 and `decodeRelationMutation` 473-486.
|
|
|
|
Handler shape:
|
|
```go
|
|
func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link bool) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
...
|
|
in, err := decodeRelationMutation(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
svc, err := s.relations()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
...
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, result, nil)
|
|
})
|
|
}
|
|
```
|
|
Strict body decode to copy verbatim for `AdminActionRequest`:
|
|
```go
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.UseNumber()
|
|
dec.DisallowUnknownFields()
|
|
var in RelationMutationInput
|
|
if err := dec.Decode(&in); err != nil {
|
|
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
|
|
}
|
|
var trailing any
|
|
if err := dec.Decode(&trailing); err != io.EOF {
|
|
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
|
|
}
|
|
```
|
|
Action-level permission (403) after `protect` — copy from `protect` 713-717:
|
|
```go
|
|
if !Allows(principal, requiredOf(cc.Controller)) {
|
|
s.logAuth(r, "denied", principal.ID)
|
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
|
return
|
|
}
|
|
```
|
|
Scoped record read: copy `crud.go` `loadRecord` 435-455 **without** `.Clauses(clause.Locking{Strength: "UPDATE"})`:
|
|
```go
|
|
q := tx.WithContext(ctx)
|
|
if cc != nil {
|
|
if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil {
|
|
if next := ext.FormExtendQuery(ctx, q); next != nil {
|
|
q = next
|
|
}
|
|
}
|
|
}
|
|
err := q.Where(clause.Eq{Column: clause.Column{Name: col}, Value: pk}).Take(dest).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return recordNotFound{}
|
|
}
|
|
```
|
|
Error mapping: `writeCRUDError` (crud.go 387-404) gives 422/404/500. Name the partial type away from `partialSelection` (crud.go 398, Pitfall 13).
|
|
|
|
---
|
|
|
|
### `modules/cabana/http.go` (route mount)
|
|
|
|
**Analog:** `mount` 198-245.
|
|
```go
|
|
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
|
|
...
|
|
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
|
|
constrainController(g)
|
|
...
|
|
r.GroupRaw(s.adminPrefix(), nil, func(g pact.Router) {
|
|
g.Get("", s.serveSPA)
|
|
g.Get("/{path...}", s.serveSPA)
|
|
```
|
|
Add a `constrainAction(g)` helper modelled on `constrainRelation` (253-256):
|
|
```go
|
|
func constrainRelation(g pact.Router) {
|
|
constrainController(g)
|
|
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
|
|
}
|
|
```
|
|
Mount the asset route **before** `/{path...}` in the prefix group; on allowlist miss call `s.serveSPA(w, r)` (157-163).
|
|
|
|
---
|
|
|
|
### `modules/cabana/plugin_assets.go` (static serving)
|
|
|
|
**Analog:** `modules/boardwalk/boardwalk.go` 135-167.
|
|
```go
|
|
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
|
|
body, err := fs.ReadFile(h.root, name)
|
|
...
|
|
w.Header().Set("Content-Type", contentType(name))
|
|
if strings.HasPrefix(name, "assets/") {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
} else {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
}
|
|
http.ServeContent(w, r, path.Base(name), time.Time{}, bytes.NewReader(body))
|
|
}
|
|
|
|
func setSecurityHeaders(h http.Header) {
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("Referrer-Policy", "same-origin")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Content-Security-Policy", contentSecurityPolicy)
|
|
h.Set("X-Robots-Tag", "noindex, nofollow")
|
|
}
|
|
```
|
|
Export `ContentType` and `SetSecurityHeaders` from boardwalk (update `modules/boardwalk/README.md`). Plugin handler: always `no-cache` + `ETag` (never the `immutable` branch), add `Cross-Origin-Resource-Policy: same-origin`, body from the boot-built map, not `fs.ReadFile` per request.
|
|
|
|
---
|
|
|
|
### `modules/cabana/partial_render.go` (render + sanitize)
|
|
|
|
**Analog (partial):** per-request translation idiom from `form_schema.go` 244-249:
|
|
```go
|
|
func translateKey(ctx context.Context, tr *phrasebook.Translator, key string) string {
|
|
if key == "" || tr == nil {
|
|
return key
|
|
}
|
|
return tr.Get(ctx, key, nil)
|
|
}
|
|
```
|
|
and `s.translator()` (http.go 534-543). The Clone/Funcs/ParseFragment/allowlist core has no codebase analog — use RESEARCH "Partial render + allowlist (sketch)" and Pattern 4 allowlist verbatim. Handler for `GET …/partials/{name}` follows `formSchema` (496-513): `protect` → lookup → `WriteData(w, 200, view, nil)`.
|
|
|
|
---
|
|
|
|
### `modules/cabana/admin_openapi.go` (swag)
|
|
|
|
**Analog:** lines 370-388 (`AdminBulkDelete`). Copy the block per route; POSTs keep `@Accept json`, `@Param body body AdminActionRequest true "..."`, `@Success 200 {object} Envelope[AdminActionResult]`, 401/403/404/422 failures. GET partial: `@Param id query integer false "Record id"`, `@Success 200 {object} Envelope[PartialView]`. Regenerate with `scripts/check-admin-openapi.sh`.
|
|
|
|
---
|
|
|
|
### `admin/src/components/form/registry.ts`
|
|
|
|
**Analog:** same file 27-59.
|
|
```ts
|
|
const recordBound = new Set<string>([RELATION_MANAGER])
|
|
|
|
export function isRegistered(type: string): boolean {
|
|
return renderers.has(type) && !recordBound.has(type)
|
|
}
|
|
export function needsRecord(type: string): boolean {
|
|
return recordBound.has(type)
|
|
}
|
|
```
|
|
Add `['widget', WidgetField]`, `['partial', PartialField]` to `renderers`; add `const valueless = new Set([RELATION_MANAGER, 'widget', 'partial'])` and switch `isRegistered` to it; leave `needsRecord` on `recordBound` (Pitfall 4). `editablePayload` (formState.ts 46-58) then skips them with no change. New field components import `../control`, never `../registry` (cycle note, lines 18-20).
|
|
|
|
---
|
|
|
|
### `admin/src/components/form/fields/WidgetField.vue` / `PartialField.vue`
|
|
|
|
**Analog:** `fields/UnsupportedField.vue` (props + failure box):
|
|
```vue
|
|
const props = defineProps<FieldControlProps>()
|
|
...
|
|
<div
|
|
:id="controlId"
|
|
data-unsupported-field
|
|
:aria-describedby="describedBy || undefined"
|
|
class="flex min-h-input items-center gap-2.5 rounded-control border-[1.5px] border-dashed border-border-strong bg-subtle px-3.5 text-[13px] text-muted"
|
|
>
|
|
<Puzzle :size="16" aria-hidden="true" />
|
|
```
|
|
Use the same class string for the S5 failure box, swap `Puzzle` for `CircleAlert` with `text-danger`, add `role="alert"`. `FieldControlProps` (control.ts 7-21) already carries `recordId` and `source` (controller params for the POST path).
|
|
|
|
POST → toast idiom from `ListView.vue` `onDelete` 197-211:
|
|
```ts
|
|
deleting.value = true
|
|
try {
|
|
const result = await api.POST('/{vendor}/{plugin}/{controller}/bulk-delete', { params: { path }, body: { ids } })
|
|
if (result.data) {
|
|
showToast(message(messages.value?.deleted, result.data.data.deleted))
|
|
...
|
|
return
|
|
}
|
|
showToast(result.error?.error.message || t('backend::lang.list.delete_failed'), 'danger')
|
|
} catch {
|
|
showToast(t('backend::lang.list.delete_failed'), 'danger')
|
|
} finally {
|
|
deleting.value = false
|
|
}
|
|
```
|
|
Replace the fallback key with `backend::lang.extension.action_failed`. `api` from `../../../api/client` (the only allowed `fetch` site).
|
|
|
|
---
|
|
|
|
### `admin/src/components/list/ListToolbar.vue` + `admin/src/views/ListView.vue`
|
|
|
|
**Analog:** same files.
|
|
ListView split (66-68):
|
|
```ts
|
|
const buttons = computed(() => schema.value?.toolbarButtons ?? [])
|
|
const headingButtons = computed(() => buttons.value.filter((button) => button === 'create'))
|
|
const toolbarButtons = computed(() => buttons.value.filter((button) => button === 'delete'))
|
|
```
|
|
→ `toolbarButtons` keeps declared order, excludes `create`, and keeps names that are `delete` or present in `schema.toolbarActions`. ListToolbar template loop (`<template v-for="button in buttons">` with `v-if="button === 'delete'"`) gains a `v-else` outline `Button` with `data-action="{name}"` emitting `action: [name]`. Insert `<PartialHost>` between `</header>` and the card `<div class="overflow-hidden rounded-card …">` (lines 234-235). Refetch it after `loadList()` in `onDelete` and after a custom action.
|
|
|
|
---
|
|
|
|
### `admin/src/app/pluginAssets.ts`
|
|
|
|
**Analog (partial):** `admin/src/app/runtime.ts` — module-level constant state and `runtime.base` for the prefix check:
|
|
```ts
|
|
export const runtime = {
|
|
/** Admin mount path, for example /backend. */
|
|
base,
|
|
/** Admin API root, the mount path plus /api/v1. */
|
|
api: `${base}/api/v1`,
|
|
} as const
|
|
```
|
|
Loader body: RESEARCH "SPA loader (sketch)". Must not contain `fetch(` (hygiene).
|
|
|
|
---
|
|
|
|
### `fonoteka.go/.../controllers/albums_admin_controller.go`
|
|
|
|
**Analog:** same file. Value-receiver methods, db resolved per call, scope through `scopeAlbums`:
|
|
```go
|
|
func (c albumsAdminController) scopeAlbums(ctx context.Context, db *gorm.DB) *gorm.DB {
|
|
if db == nil {
|
|
return db
|
|
}
|
|
binding, err := c.resolveBinding(ctx)
|
|
if err != nil {
|
|
return db.Where("1 = 0")
|
|
}
|
|
return db.Where("golem15_fonoteka_albums.collection_id = ?", binding.CollectionID)
|
|
}
|
|
```
|
|
Nil-db guard + query idiom for the stats view model (149-163):
|
|
```go
|
|
if c.db == nil {
|
|
return nil
|
|
}
|
|
db := c.db()
|
|
if db == nil {
|
|
return nil
|
|
}
|
|
var rows []struct { ... }
|
|
if err := db.Raw(...).Scan(&rows).Error; err != nil {
|
|
```
|
|
For stats use `c.scopeAlbums(ctx, db.WithContext(ctx).Model(&models.Album{}))` then `Group("format")`; return a curated struct of ints (never `*models.Album`). Format order from `models.Album{}.DropdownOptions("format")` (line 45). `AdminActions` stub: RESEARCH "Stub action (fonoteka)", permission `golem15.fonoteka.access_albums` (already registered, `admin_permissions.go:35`).
|
|
|
|
### `fonoteka.go/.../admin.go`
|
|
|
|
Line 12 `//go:embed ...` list: append `controllers/albums/_stats.htm assets/js/discogs-lookup.js assets/css/albums.css`. `AdminFS()` (line 15) unchanged — assets are read from that FS by cabana.
|
|
|
|
### `fonoteka.go/.../controllers/albums/config_list.yaml` and `models/album/fields.yaml`
|
|
|
|
Add `headerPartial: stats` at top level and `buttons: [create, delete, discogsSync]` (line 10). In fields.yaml add after `shelf` (25-28) a `year: {label: golem15.fonoteka::lang.item.year, span: right, type: number}` and last a `discogs` widget per UI-SPEC "Application proof".
|
|
|
|
---
|
|
|
|
### Tests (Plan 04)
|
|
|
|
**Go boot-error table** — copy `form_schema_test.go` 215-284:
|
|
```go
|
|
{
|
|
name: "partial path",
|
|
config: formConfig,
|
|
fields: "fields:\n editors:\n type: partial\n path: $/golem15/acme/controllers/collections/_editors.htm\n",
|
|
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "path"},
|
|
},
|
|
...
|
|
_, err := CompileForm("acme.demo", schemaController{model: model}, formFS(tc.config, tc.fields))
|
|
if err == nil {
|
|
t.Fatal("expected boot error")
|
|
}
|
|
```
|
|
The existing `partial` and `bad form fails activation` cases (224-229, 273-284) must be rewritten: a bare `type: partial` without `path` still fails; the `$/…` case still fails but with the new hint message.
|
|
|
|
**Fixture plugin** — copy `openapi_conformance_test.go` 365-440 (`conformPlugin` with `ID/Requires/Register/Boot/AdminControllers/Permissions/AdminFS`, `conformController`, `conformFS()` built from `fstest.MapFS`). The 10.1 fixture adds `AdminJS/AdminCSS`, `AdminActions`, `PartialData` on the controller and `controllers/gadgets/_stats.htm`, `_summary.htm`, `assets/js/lookup.js`, `assets/css/gadgets.css` in the FS. Vocabulary: acme/gadgets/lookup only.
|
|
|
|
**Inventory** — `security_coverage_test.go` 33-62: append
|
|
```go
|
|
{key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"},
|
|
{key: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}"},
|
|
{key: "GET /{vendor}/{plugin}/{controller}/partials/{name}"},
|
|
{key: "GET /assets/{vendor}/{plugin}/{file...}", public: true, spa: true},
|
|
```
|
|
plus handler-table rows near line 266 (`{"bulk-delete", (*service).bulkDelete}` pattern).
|
|
|
|
**Conformance** — `openapi_conformance_test.go` 127-130 pattern:
|
|
```go
|
|
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
|
...
|
|
return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": ...}, true)
|
|
}, into[cabana.Envelope[cabana.BulkResult]]()},
|
|
```
|
|
|
|
**Vitest** — `admin/tests/list/ListToolbar.test.ts` 1-40: `mount(Component, { props })`, `resetState()` from `../helpers` in `beforeEach`, `data-*` selectors, `wrapper.emitted(...)`. Every new `admin/src` module must be imported by some test (hygiene `check-phase10.sh` ~350-357).
|
|
|
|
**Gate script** — `scripts/check-phase10.sh`: header/`set -euo pipefail`/`ROOT`/`APP` (13-21), `usage()` (23-37), `phase10_detect` python JSON detector (44+), `run_hygiene` (361-364), `run_evidence` python checker (366-403: change required IDs to `T-10.1-01..13` + `T-10.1-SC` and requirement to `ADMIN-07`), `case` dispatch (406+). Call `scripts/check-phase10.sh --hygiene` from the new `--hygiene` stage and add only the extra regex (`setHTML|setHTMLUnsafe|createContextualFragment|DOMParser|srcdoc|document\.write` in `admin/src`; `fetch\(|XMLHttpRequest|document\.cookie` in `$APP/plugins/**/assets/**/*.js`). `scripts/check-phase10.2.sh` is the most recent sibling for structure.
|
|
|
|
---
|
|
|
|
## Shared Patterns
|
|
|
|
### Fail-loud boot errors
|
|
**Source:** `modules/cabana/form_schema.go` 69-104, `list_schema.go` 84-131
|
|
**Apply to:** form/list/extension compilers
|
|
```go
|
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
|
|
```
|
|
Always wrap with `bootErr(pluginID, controllerID, file, err)` so messages name plugin, controller and file (tests assert on all three).
|
|
|
|
### Auth + CSRF on admin routes
|
|
**Source:** `http.go` 198-226 (`requireAjax` wrapper on every unsafe method) + `protect` 701-719
|
|
**Apply to:** widget and toolbar POSTs, partial GET (no `requireAjax` on GET). `TestPhase10CSRF` auto-walks new POSTs.
|
|
|
|
### Response envelope and error mapping
|
|
**Source:** `WriteData(w, status, data, meta)`, `WriteError(w, status, code, msg)`, `writeCRUDError` (crud.go 387-404)
|
|
**Apply to:** all new handlers.
|
|
|
|
### Optional capability type-assertion
|
|
**Source:** `form_schema.go` `dropdownProvider` 200-217; `crud.go` 439-443
|
|
```go
|
|
if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil {
|
|
```
|
|
**Apply to:** `AdminClientAssets`, `HasAdminActions`, `AdminPartialData` lookups.
|
|
|
|
### Phrase keys, not literals
|
|
**Source:** `modules/phrasebook/backend/lang/en/lang.yaml` (`form:` group, line 54); SPA `t('backend::lang.…')` from `app/i18n`
|
|
**Apply to:** all SPA copy and toast fallbacks; action `Label`/`Message` go through `translateKey`.
|
|
|
|
### Framework hygiene
|
|
**Source:** `scripts/check-phase10.sh` ~315-330
|
|
**Apply to:** everything in `summercms.go`: no application names, no raw-HTML sink words (even in comments), no `fetch(` outside `api/client.ts`, `types.ts` aliases only `Schemas['cabana.X']`.
|
|
|
|
## No Analog Found
|
|
|
|
| File | Role | Data Flow | Reason |
|
|
|---|---|---|---|
|
|
| `modules/cabana/partial_render.go` (sanitizer core) | service | transform | No HTML parsing/allowlist code exists; use RESEARCH Pattern 4 + sketch |
|
|
| `fonoteka.go/.../assets/js/discogs-lookup.js` | custom element | event-driven | First plugin JS in the stack; use RESEARCH plugin custom element example + UI-SPEC S3 |
|
|
| `fonoteka.go/.../controllers/albums/_stats.htm` | html/template | transform | First server template; use UI-SPEC `.summer-stats` markup |
|
|
|
|
## Metadata
|
|
|
|
**Analog search scope:** `modules/pact`, `modules/cabana`, `modules/boardwalk`, `admin/src`, `admin/tests`, `scripts/`, `../fonoteka.go/plugins/golem15/fonoteka`
|
|
**Files scanned:** ~30
|
|
**Pattern extraction date:** 2026-09-28
|