docs(16.2): map phase patterns

This commit is contained in:
2026-10-09 23:17:15 +02:00
parent 1920ea6462
commit b319b59989

View File

@@ -0,0 +1,261 @@
# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget - Pattern Map
**Mapped:** 2026-10-09
**Files analyzed:** 27 (4 repos)
**Analogs found:** 25 / 27
Binding post-research decisions: the editor image upload goes through a new cabana-owned route `POST {admin}/api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` that calls an optional `FormFieldType.Upload`; Journal implements it by reusing its media storage. The phase includes the 16.1-style release (summercms v0.1.17, journal/host bumps, vue-journal-layer v0.2.0 + app pin, UAT redeploy).
All paths are git-tracked source in their own repos (sm-journal-plugin, vue-journal-layer and vue-grzybyfunkcjonalne-app are sibling repos; there are no `.msd/` mirror paths).
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `modules/pact/capabilities.go` (+FormFieldType, FormFieldValue, FormFieldUpload, FieldError, HasFormFieldTypes) | model/interface | request-response | same file: `AdminAction` / `AdminActionInput` / `HasAdminActions` (lines 205-270) | exact |
| `modules/cabana/field_type.go` (NEW registry, alias/collision, option check, Decode/Load runner, ListValue) | service | transform | `modules/cabana/extension.go` (`compileClientAssets` 253-300, `checkWidgetTag` 320-333, `compileActions` 335+) | exact |
| `modules/cabana/field_type_upload.go` (NEW upload handler) | controller | file-I/O | `modules/cabana/field_file.go` `fileUpload`/`fileUploadOn` (684-760) | exact |
| `modules/cabana/form_schema.go` (formFieldTypes, decodeFields/compileFieldNode take registry) | config/compile | transform | itself (lines 24-30, 466-480) | self |
| `modules/cabana/crud.go` (save: plugin decode/skip in tx; scalarFormField) | service | CRUD | itself: ML path at 600 / 616 / 688 / 709; classifier 1224-1232 | self |
| `modules/cabana/field_ml.go` (mlFieldType registry-aware, hydrate calls Load) | service | CRUD | itself (11-60) | self |
| `modules/cabana/tx_context.go` (+withFieldWrites / FieldWritesFromContext) | utility | request-response | same file `withVirtualFields`/`VirtualFieldsFromContext` (36-60) | exact |
| `modules/cabana/list_schema.go`, `http.go` `projectRow` (plugin column ListValue) | service | transform | `list_schema.go:22-24`, `http.go:1067` | self |
| `modules/cabana/http.go` (Activate threading + route + field-types endpoint) | route | request-response | `http.go:74-121` (Activate), `:335-347` (file routes) | self |
| `modules/cabana/admin_openapi.go` + phase09Routes test list | config | — | existing file-route entries in same file | self |
| `modules/cabana/README.md`, `modules/pact/README.md`, `docs/backend/...` + site nav | docs | — | Phase 10.1 widget sections in same READMEs / `docs/` partials-and-widgets page | role-match |
| `admin/src/components/form/fields/PluginField.vue` (NEW) | component | event-driven | `admin/src/components/form/fields/WidgetField.vue` | exact |
| `admin/src/components/form/fields/MLPluginField.vue` or MLField wiring | component | event-driven | `admin/src/components/form/fields/MLMarkdownField.vue` + `MLField.vue` | exact |
| `admin/src/components/form/registry.ts` | config | — | itself (renderers map, `valueless`, `rendererFor`) | self |
| `admin/src/app/fieldTypes.ts` (NEW global loader) | utility | event-driven | `admin/src/app/pluginAssets.ts` | exact |
| `admin/src/.../mlLocale.ts`, `formState.ts` | utility | transform | itself (`ML_FIELD_TYPES` line 2) | self |
| `admin/src/api/types.ts` generated + `modules/boardwalk/dist` | config | — | `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh` | exact |
| `../sm-journal-plugin/classes/blog_editor.go` (NEW FormFieldType values) | service | transform | `../sm-journal-plugin/classes/format_html.go` (CheckHTML) + `controllers/categories.go:65-82` | role-match |
| `../sm-journal-plugin/classes/media_store.go` (NEW, extracted) | service | file-I/O | `../sm-journal-plugin/controllers/api/media.go` `UploadMedia` (22-91) | exact (extraction) |
| `../sm-journal-plugin/controllers/posts.go` (`preparePostWrite`, FormAfter* companion) | controller hook | CRUD | itself 204-221 | self |
| `../sm-journal-plugin/controllers/categories.go` | controller hook | CRUD | itself 60-82 (keep) | self |
| `../sm-journal-plugin/plugin.go` / `admin.go` (register types, embed dist) | config | — | `admin.go:16-20` `//go:embed` list | exact |
| `../sm-journal-plugin/models/post/fields.yaml:19`, `models/category/fields.yaml:28` | config | — | — (one-line type swaps) | n/a |
| `../sm-journal-plugin/assets/src/blogeditor/*.ts`, `assets/dist/blogeditor.{js,css}` | component (custom element) | event-driven | element contract from `WidgetField.vue` header; no TipTap analog | **no analog** (use RESEARCH Pattern 4/5) |
| `../sm-journal-plugin/package.json`, `vite.config.ts`, `scripts/check-dist.sh` | config | batch | `admin/package.json`, `scripts/check-admin-dist.sh` | exact |
| `../vue-journal-layer/app/utils/embeds.ts` (NEW) + `PostBody.vue:11`, `CategoryHeader.vue:17` | utility | transform | `../vue-journal-layer/app/utils/htmlGate.ts` | exact |
| Release: tag v0.1.17, journal/host go.mod bumps, layer v0.2.0 + app pin, UAT redeploy | config | batch | Phase 16.1 release plan (`.planning/phases/16.1-*/16.1-02-PLAN.md`) | exact |
## Pattern Assignments
### `modules/pact/capabilities.go` (interface)
**Analog:** same file, `AdminAction` lines 217-270. Copy the shape: a struct of plain fields plus func fields tagged `json:"-"`, a separate `...Input` struct, a separate `...Result` struct, and a one-method `Has...` interface. Doc comments say what the framework owns versus what the func owns.
```go
type AdminAction struct {
Name string
Label string
Permissions []string
Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error) `json:"-"`
}
...
// HasAdminActions is implemented by an admin controller that registers named
// actions for its toolbar.buttons list and its `type: widget` form fields.
type HasAdminActions interface {
AdminActions() []AdminAction
}
```
`HasFormFieldTypes` is implemented by the **plugin** (not the controller), like `HasAdminControllers` (line 183-184). `pact.FieldError{Messages []string}` replaces `cabana.ValidationError`, because pact cannot import cabana. Asset fields reuse the `AdminClientAssets` path rule documented in lines 205-210 (paths under `assets/`).
---
### `modules/cabana/field_type.go` (registry, boot validation)
**Analog:** `modules/cabana/extension.go`
**Element tag rule** (lines 49-55, 320-333). Reuse as is for `FormFieldType.Element`:
```go
func widgetTagPrefix(pluginID string) string {
return strings.NewReplacer(".", "-", "_", "-").Replace(strings.ToLower(pluginID)) + "-"
}
func checkWidgetTag(tag, prefix string) error {
if !widgetTagPattern.MatchString(tag) { ... }
if reservedWidgetTags[tag] { ... }
if !strings.HasPrefix(tag, prefix) { ... }
return nil
}
```
**Asset read+hash** (lines 253-300). Extract the `read` closure into a function that takes `(pluginID, ownerLabel, fsys, files, exts...)` so controller assets and plugin field-type assets share it. The key is `vendor + "/" + plugin + "/" + strings.TrimPrefix(name, "assets/")` and the version is `digest[:12]`; errors go through `bootErr(pluginID, id, name, err)`. `checkAssetPath` (302-318) is reused unchanged.
**Name uniqueness pattern**: `compileActions` (335+) builds a map and fails on duplicates or reserved names (`builtinToolbarActions`, line 47). Apply the same check to aliases against the `formFieldTypes` keys, `widget`, `partial` and other plugins' aliases. Do not use a package-level registry: build it in `Activate` (`http.go:74-121`) and pass it into `compileRegistry`.
---
### `modules/cabana/field_type_upload.go` (controller, file-I/O)
**Analog:** `modules/cabana/field_file.go` lines 684-760, routes in `http.go:335-347`.
```go
func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) { s.fileUploadOn(w, r, cc, parentFiles(cc)) })
}
...
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
r.Body = io.NopCloser(body)
mr, err := r.MultipartReader()
if err != nil { writeCRUDError(w, invalidBody()); return }
part, err := mr.NextPart()
...
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
sc, err := fr.scope(ctx, tx, r)
```
Route registration follows `g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))`, so the new route is `g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", requireAjax(s.fieldUpload))`. Use `s.uploadBytes` for the cap, load the record through the `FormExtendQuery` scope when `id != 0`, map `pact.FieldError` to 422 on the field, and answer 201 `{data:{url}}`. Also add an entry to `admin_openapi.go` and `phase09Routes` and rerun `scripts/check-admin-openapi.sh`.
---
### `modules/cabana/tx_context.go` (written-values accessor)
**Analog:** same file, lines 36-60:
```go
type virtualFieldsKey struct{}
func withVirtualFields(ctx context.Context, values map[string]any) context.Context {
if values == nil { values = map[string]any{} }
return context.WithValue(ctx, virtualFieldsKey{}, values)
}
func VirtualFieldsFromContext(ctx context.Context) (map[string]any, bool) {
if ctx == nil { return nil, false }
values, ok := ctx.Value(virtualFieldsKey{}).(map[string]any)
```
Mirror it as `fieldWritesKey{}` / `withFieldWrites` / exported `FieldWritesFromContext(ctx) (map[string]map[string]string, bool)` (field → locale → value), returning a copy and the same "false outside a create or update save" semantics.
---
### `modules/cabana/crud.go` + `field_ml.go` (save and hydrate)
**Analog:** the existing ML path in `save()`:
- `crud.go:600` `translations, err := liftMLValues(ctx, cc, in.Body, op, s.writer, s.DB)` runs before the tx. Add the plugin shape check here; translatable plugin types reuse the `liftMLValues` locale-map rules (`field_ml.go` doc at 52-58).
- `crud.go:616` `loadRecord(...)`. Insert the per-(field, locale) Load → compare → skip/Decode step right after this.
- `crud.go:688` `applyMLTranslations(ctx, tx, s.writer, target, translations)`. Decoded non-default values join `translations`.
- `crud.go:709` `hydrateMLRecord(...)`. Call plugin `Load` per locale here.
- `crud.go:1224-1232`: the `scalarFormField` switch gains a registry lookup (it is used by `BindWritableFields` at 204 and `mergedRules` at 1073).
- `field_ml.go:47-53` `mlFieldType(typ)`: becomes registry-aware (a plugin type with `Translatable: true`).
- Error mapping: follow `lifecycleFailure` (crud.go:815-850). `pact.FieldError` becomes a 422; anything else becomes the opaque 500.
### `modules/cabana/form_schema.go`
The self-pattern at lines 24-30 (closed set) and 466-480 (key check before type resolution). Reorder so the type resolves first and plugin `Options` keys are checked after it, then call `CheckOptions`. `decodeFields` callers: form_schema.go:194, settings.go:74 (refuse plugin types in v1), relation_form.go:145/:246.
---
### `admin/src/components/form/fields/PluginField.vue` (component, event-driven)
**Analog:** `admin/src/components/form/fields/WidgetField.vue`. Copy:
- the imports block (lines 1-9: `inject`, `FORM_ASSETS`, `FORM_LOCALE`, `ExtensionFailure`, `FieldControlProps`);
- the imperative element creation in a `mountPoint` that Vue never renders into;
- the `status: 'loading' | 'ready' | 'failed'` ref, the `defined(tag)` wait with `timeout(ms)` (lines 50-60);
- the header-comment rule: never give the element a token, cookie, function or Vue instance.
Differences: set `el.value` (property, not attribute); listen for `summer-change` → `emit('update:modelValue', detail.value)`; handle `summer-upload` by posting through `api` (the same client as the widget action) to the field upload route, then dispatch `summer-upload-result`. Add event-name constants next to `WIDGET_EVENT`/`WIDGET_RESULT_EVENT` in `formContext.ts:86,93`.
### ML variant
**Analog:** `admin/src/components/form/fields/MLMarkdownField.vue` (whole file):
```vue
<MLField v-bind="props" :inner="MarkdownField" inner-type="markdown" ml-type="mlmarkdown"
@update:model-value="emit('update:modelValue', $event)" />
```
Use `:inner="PluginField"`, `inner-type` = the base alias, and `ml-type` = `field.type`. Let `isMLFieldType` (`mlLocale.ts:2`, `ML_FIELD_TYPES`) accept fields flagged `translatable`.
### `admin/src/components/form/registry.ts`
Self-pattern: `rendererFor(type)` returns `renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField`. Change it to take the field (or check `field.element`) so plugin types fall back to `PluginField`/`MLPluginField` instead of `UnsupportedField`. `valueless` stays unchanged. Add translatable plugin types to `groupLabelledTypes` behaviour.
### `admin/src/app/fieldTypes.ts` (global loader)
**Analog:** `admin/src/app/pluginAssets.ts`. Reuse `assetAllowed()`, the `scripts` once-per-URL map, and the module-script loading. Unlike `activateStyles(controllerId)` (lines 105-110, which disables links whose `owner !== active`), field-type links need a reserved global owner that `activateStyles` skips (Pitfall 8). Call it once after login, from the app shell.
---
### `../sm-journal-plugin/classes/blog_editor.go` (Decode/Load/ListValue/Upload)
**Analog (gate):** `classes/format_html.go`, `CheckHTML(doc string) error`. **Analog (422 shape and nil content):** `controllers/categories.go:65-82`:
```go
func prepareCategoryWrite(cat *models.Category) error {
if cat.Content == nil { return nil }
if strings.TrimSpace(*cat.Content) == "" { cat.Content = nil; return nil }
if err := classes.CheckHTML(*cat.Content); err != nil {
return &cabana.ValidationError{Details: map[string]any{"content": []string{err.Error()}}}
}
return nil
}
```
In Decode, return `pact.FieldError{Messages: []string{err.Error()}}` instead of `cabana.ValidationError`. Load serves `content` if it looks like HTML, otherwise `content_html` (non-default locale through `TranslationWriter.TranslatedExact`, `field_ml.go` interface).
### `../sm-journal-plugin/classes/media_store.go` (extracted storage)
**Analog:** `controllers/api/media.go` `UploadMedia` lines 22-91. Extract the part after auth (lines ~42-84): the extension allowlist `jpg|jpeg|png|gif|webp`, the `mediaMaxBytes` cap, `mediaObjectPath(folder, name)`, `uniqueMediaKey`, `bucket.NewWriter` and `attach.PublicURL(...)`. Use it as a `StoreMedia(ctx, bucket, folder, filename, body) (url, path string, err error)` called by both `UploadMedia` and the `blogeditor` Upload. Validation messages ("The file must be an image." and so on) become `pact.FieldError` on the field route. The API route keeps its response shape (D-09 parity).
### `../sm-journal-plugin/controllers/posts.go`
Self-pattern `preparePostWrite` (204-221). The admin path drops:
```go
html, err := classes.FormatHTML(post.Content)
...
post.ContentHTML = html
```
Replace it with `if writes, ok := cabana.FieldWritesFromContext(ctx); ok { if v, ok := writes["content"][defaultLocale]; ok { post.ContentHTML = v } }`. In `FormAfterCreate`/`FormAfterUpdate`, call `writer.WriteTranslated(ctx, tx, post, "content_html", locale, value)` for each written non-default locale. `controllers/api/posts_helpers.go` keeps `FormatHTML`.
### `../sm-journal-plugin/admin.go` / `plugin.go`
The `//go:embed` list in `admin.go:16-20`, for example `//go:embed assets/images/journal-icon.svg`. Add `assets/dist/blogeditor.js assets/dist/blogeditor.css`. The plugin type implements `pact.HasFormFieldTypes`, with compile-time assertions in the style of `var ( _ pact.AdminController = categoriesAdminController{} )` (categories.go:84+).
### `../sm-journal-plugin/package.json`, `vite.config.ts`, `scripts/check-dist.sh`
**Analog:** `scripts/check-admin-dist.sh` (whole file): `set -euo pipefail`, `npm ci` if needed, typecheck, `vite build --outDir "$TMP/dist" --emptyOutDir`, then `diff -r "$TMP/dist" assets/dist` with a clear stale message. Copy the pins from `admin/package.json` (vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3).
### `../vue-journal-layer/app/utils/embeds.ts` (transform)
**Analog:** `../vue-journal-layer/app/utils/htmlGate.ts`. Same style: an exported regex constant, a header comment that names the Go source of truth, and pure string functions:
```ts
export const UNSAFE_TAG = /<(?:script|iframe|object|embed)\b/i
export function gateJournalHtml(html: string | null | undefined): { safe: boolean; html: string } {
if (!html) return { safe: true, html: '' }
return isSafeJournalHtml(html) ? { safe: true, html } : { safe: false, html: '' }
}
```
`expandEmbeds(html)` runs on `gated.html` after the gate, in `PostBody.vue:11` (`v-html="gated.html"`) and `CategoryHeader.vue:17` (`v-html="content.html"`).
## Shared Patterns
### Fail-loud boot errors
**Source:** `modules/cabana/extension.go` `bootErr(pluginID, id, name, err)` and `compileExtension` (60+). **Apply to:** alias collisions, bad element tags, asset paths, and unknown or invalid plugin options.
### Admin-route guard stack
**Source:** `http.go:335-347` (`requireAjax(...)`) plus `s.protect(w, r, func(cc *CompiledController) {...})` (field_file.go:684). **Apply to:** the field upload route and any field-types boot endpoint.
### Error to HTTP mapping
**Source:** `crud.go:815-850` `lifecycleFailure`, `writeCRUDError`, `WriteError(w, 500, "error", msgServerError)`. **Apply to:** Decode, Load and Upload failures (`pact.FieldError` becomes a 422; anything else becomes an opaque 500).
### Element credential rule and event naming
**Source:** `WidgetField.vue` header comment and `formContext.ts:86,93` (`summer-action`/`summer-result`). **Apply to:** PluginField and the blogeditor element (`summer-change`, `summer-upload`, `summer-upload-result`).
### Committed-dist drift gate
**Source:** `scripts/check-admin-dist.sh`. **Apply to:** the admin SPA (`modules/boardwalk/dist`) and the sm-journal-plugin `assets/dist`.
### Docs and README rule
**Source:** CLAUDE.md "Documentation". **Apply to:** pact and cabana READMEs and `docs/`, using neutral `blog`/`acme` names. Verify with `go test ./cmd/summer -run TestDocsTree`.
### Concurrent-session staging
Stage by explicit path. Check `git status` and `git diff` for in-flight edits to `crud.go`, `form_schema.go` and `http.go` before committing.
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `../sm-journal-plugin/assets/src/blogeditor/*` (TipTap element, schema, serializer, dirty tracking) | component | event-driven | No TipTap/ProseMirror code in any repo. Use RESEARCH Pattern 3/4/5 and Code Example 3 |
| Server-side "served value equals submitted" skip | service | CRUD | New semantics; there is no existing compare-and-skip in `save()`. Use RESEARCH Pattern 3 |
## Metadata
**Analog search scope:** `modules/pact`, `modules/cabana`, `admin/src/{app,components/form}`, `scripts/`, `../sm-journal-plugin/{classes,controllers,admin.go}`, `../vue-journal-layer/app/{utils,components/Journal}`
**Files scanned:** ~25
**Pattern extraction date:** 2026-10-09