docs(quick-261006-tnp): add mltextarea field type
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
---
|
||||
phase: quick-261006-tnp
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/cabana/field_ml.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/ml_textarea_test.go
|
||||
- modules/cabana/preset_ml_test.go
|
||||
- admin/src/components/form/fields/MLField.vue
|
||||
- admin/src/components/form/fields/MLTextField.vue
|
||||
- admin/src/components/form/fields/MLMarkdownField.vue
|
||||
- admin/src/components/form/fields/MLTextareaField.vue
|
||||
- admin/src/components/form/mlLocale.ts
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/components/relation/RelationChildModal.vue
|
||||
- admin/tests/form/MLFields.test.ts
|
||||
- admin/tests/form/registry.test.ts
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- modules/boardwalk/dist/
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/admin-controllers.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261006-tnp]
|
||||
|
||||
estimate:
|
||||
tokens: 80000
|
||||
raw_tokens: 80000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A fields.yaml field with `type: mltextarea` (optionally with `size`) compiles and boots; the form schema serves it as type mltextarea with its size; an unknown ml* type such as mltextareas still stops the start-up"
|
||||
- "Saving an mltextarea field with a locale map writes the default locale (multi-line text intact) to the host column, including a nullable *string column, and the other locales through the published TranslationWriter inside the same save transaction; the default locale is not duplicated into the translation store"
|
||||
- "Show and save responses carry an mltextarea field as a map of every enabled locale; a nil host column hydrates to an empty string, never <nil> or a pointer address; a missing non-default locale is an empty string"
|
||||
- "An mltextarea map with an undeclared locale, a non-string value or no default locale is a 422 on that field, exactly as for mltext"
|
||||
- "A preset on an mltextarea field, or a preset naming an mltextarea source, stops the start-up, as for textarea and mlmarkdown"
|
||||
- "In the admin SPA an mltextarea field renders a textarea (rows from size, as on type textarea) with one locale select; it follows ML_LOCALE_CHANGE with every other ML control, edits only the active locale and emits the whole map; create seeds every enabled locale, a GET host string is merged onto the seed, and the save body sends the nested map"
|
||||
- "mltext and mlmarkdown fields behave exactly as before: the existing Go ML tests and the existing MLFields, MarkdownField, registry, formState and FormView Vitest cases pass without edits to their assertions"
|
||||
- "modules/boardwalk/dist matches a fresh build; admin.json and schema.d.ts match a fresh generation; the cabana README and the forms docs list mltextarea; TestDocsTree, go vet ./... and go test ./... are green"
|
||||
artifacts:
|
||||
- path: modules/cabana/field_ml.go
|
||||
provides: "mlTextareaFieldType constant; mlFieldType admits mltext, mltextarea and mlmarkdown, so lift, apply and hydrate cover the new type"
|
||||
- path: modules/cabana/form_schema.go
|
||||
provides: "formFieldTypes admits mltextarea"
|
||||
- path: modules/cabana/crud.go
|
||||
provides: "scalarFormField admits mltextarea (writable binding, required checks, settings and relation forms)"
|
||||
- path: modules/cabana/ml_textarea_test.go
|
||||
provides: "DB-free compile test and Postgres save/hydrate round trip for mltextarea over a nullable *string column"
|
||||
- path: admin/src/components/form/fields/MLField.vue
|
||||
provides: "The one locale-switching wrapper: locale select, ML_LOCALE_CHANGE follow, active-locale edit, inner control passed as a component"
|
||||
- path: admin/src/components/form/fields/MLTextareaField.vue
|
||||
provides: "mltextarea control: MLField around TextareaField"
|
||||
- path: admin/src/components/form/mlLocale.ts
|
||||
provides: "isMLFieldType, the single list of ML field types used by formState, FormView and RelationChildModal"
|
||||
- path: modules/boardwalk/dist
|
||||
provides: "Rebuilt embedded admin SPA"
|
||||
key_links:
|
||||
- from: modules/cabana/field_ml.go mlFieldType
|
||||
to: modules/cabana/field_ml.go liftMLValues, hydrateMLRecord and relation.go child save/show
|
||||
via: "every ML code path selects fields through mlFieldType, so adding the constant there is the whole server wiring"
|
||||
- from: modules/cabana/form_schema.go formFieldTypes
|
||||
to: modules/cabana/crud.go scalarFormField
|
||||
via: "a type must be in both to compile and to be bound as a writable model column"
|
||||
- from: admin/src/components/form/registry.ts renderers
|
||||
to: admin/src/components/form/fields/MLTextareaField.vue
|
||||
via: "['mltextarea', MLTextareaField] plus 'mltextarea' in groupLabelledTypes"
|
||||
- from: admin/src/components/form/mlLocale.ts isMLFieldType
|
||||
to: formState.ts editablePayload/initialValues/presetUpdates, FormView.vue adopt/update, RelationChildModal.vue adopt
|
||||
via: "every place that treats a value as a locale map asks isMLFieldType instead of comparing type names inline"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add the WinterCMS Translate `mltextarea` form field type to cabana and the admin SPA: a multi-line, translatable text field that uses the same translation storage and default-locale host column as `mltext`, and honours `size` like `textarea`. Ported fields.yaml files that say `type: mltextarea` then work unchanged.
|
||||
|
||||
Purpose: Journal plugin UAT found the post excerpt is a textarea in PHP (`mltextarea`) but a single-line `mltext` in Go. The orchestrator chose a new type for parity, not a `size:` switch on mltext.
|
||||
Output: the cabana type registration and tests; the shared SPA ML wrapper plus MLTextareaField, tests and the rebuilt dist; the README and docs updates. sm-journal-plugin is not edited. The SUMMARY records the YAML change it needs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@./CLAUDE.md
|
||||
@modules/cabana/field_ml.go
|
||||
@admin/src/components/form/fields/MLTextField.vue
|
||||
@admin/src/components/form/registry.ts
|
||||
|
||||
Facts gathered during planning (line numbers as of HEAD 673c6ca):
|
||||
- Server type inventory. `mltext` is recognised in exactly these places: form_schema.go formFieldTypes (line ~29), crud.go scalarFormField (line ~1227), and field_ml.go mlFieldType (line 44), which liftMLValues, hydrateMLRecord and the relation-child path in relation.go all use. Preset code (form_schema.go compilePresetKey ~651, checkPresets ~709) names text/mltext explicitly. extension.go FormVirtualFields and list_schema.go are unrelated (virtual fields and list columns) and stay unchanged. No swag enum lists field types. The only OpenAPI text that names ML types is the preset sentence in admin_openapi.go (~line 285), which this plan does not change, so admin.json and schema.d.ts must show no drift.
|
||||
- `size` is accepted on every type (form_schema.go ~509 checks it against formSizes: tiny, small, large, huge, giant) and serialised as FormField.Size, so an mltextarea field keeps its size with no extra server code. The SPA TextareaField maps size to rows (tiny 2, small 4, large 8, huge 12, giant 18, default 5).
|
||||
- hostScalarString (field_ml.go) already dereferences nullable pointer columns (quick 261006-seq). mltextarea gets that behaviour through mlFieldType.
|
||||
- Go test fixtures to reuse (package cabana): DB-free `CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, yaml))` (formFS and formConfig in form_schema_test.go; pattern in ml_test.go TestMLFieldTypes). Postgres via `newListService(t)` (testcontainers, query_test.go). `recordingWriter{defaultLocale: "en", enabled: []string{"en","pl"}}` and `mlFS()` in ml_smoke_test.go (`writer.attrs[locale][field]` holds written translations). `mlNullablePost` (Title string, Description *string), `mlNullableController`, `mlLocaleMap`, `assertNoPointerLeak` and the compileRegistry/formPlugin pattern of `mlNullableCompiled` in ml_nullable_test.go. Preset refusal rows in preset_ml_test.go TestPresetML (compileErr "preset is only valid on type: text or mltext", checkErr "field slug: preset field X must be a text or mltext field").
|
||||
- SPA. MLTextField.vue and MLMarkdownField.vue duplicate the same script and template; they differ only in the inner control (TextField with type text vs MarkdownField with type markdown) and the data-ml-field value. The type check that treats a value as an ML locale map is written inline six times: formState.ts editablePayload (~68), the private isMLType (~114), initialValues (~191), FormView.vue adopt (~180) and update (~252), and RelationChildModal.vue adopt (~220). FormView and RelationChildModal import helpers from formState.ts, which re-exports ML helpers from mlLocale.ts (line 8). presetUpdates (formState.ts ~146) restricts preset targets to text/mltext and that stays as is.
|
||||
- Existing Vitest coverage that must stay green unchanged: tests/form/MLFields.test.ts (mounts MLTextField/MLMarkdownField with FORM_ENABLED_LOCALES provided, asserts `wrapper.emitted('update:modelValue')` on the ML component itself, one `[data-ml-locale]` per field, no copy-from chrome, cross-control locale broadcast, `findComponent(MarkdownField)`), tests/form/MarkdownField.test.ts ("MarkdownField inside mlmarkdown"), tests/form/registry.test.ts (rendererFor('mltext') toBe MLTextField, and the same for mlmarkdown), tests/form/FormView.test.ts (describe 'password and preset on the form' with helpers people(), save(), mlSchema(nameType, slugType), editor(), localeSelect(); the roster fixture has no textarea field, so a test retypes `name`).
|
||||
- Gate scripts. No scripts/check-phase*.sh holds a field-type or component inventory. check-phase14.2.1.sh runs the registry, MLFields and MarkdownField Vitest files and refuses any raw-HTML sink in admin/src other than the one MarkdownField preview line, so the new components must not bind raw HTML. check-phase10/12.1/12.2 have the same raw-HTML allowlist.
|
||||
- Build/check scripts: `npm --prefix admin run build` writes modules/boardwalk/dist; `scripts/check-admin-dist.sh` is the dist drift gate (it runs the typecheck); `scripts/check-admin-openapi.sh --check` is the OpenAPI/TS types drift gate. Never hand-edit admin.json or schema.d.ts.
|
||||
- The translate plugin lives outside this repo (../sm-translate-plugin) and its markdown does not list field types, so no plugin docs change here.
|
||||
- Commit rules: one logical change per commit; planning docs never go in a code commit; NO co-author trailers (the user's global CLAUDE.md overrides any attribution reminder).
|
||||
|
||||
Discretion choices made in this plan (record them in the SUMMARY):
|
||||
- Preset: mltextarea is neither a preset target nor a preset source. Preset is the single-line slug/copy rule: plain `textarea` and `mlmarkdown` are refused today, and ML-ness does not change eligibility. So the minimal, consistent choice is to leave compilePresetKey/checkPresets untouched and pin the refusals with tests. Widening preset to multi-line sources (textarea and mltextarea together) would be its own change.
|
||||
- SPA reuse: one MLField.vue wrapper parameterised by the inner control component, its field type and the data-ml-field value. MLTextField, MLMarkdownField and the new MLTextareaField become thin shells that keep their component identity (the registry and tests reference them) and re-emit update:modelValue. One isMLFieldType helper replaces the inline type-name checks.
|
||||
- Preview screen: ML locale maps keep showing as they do today for mltext. No preview change.
|
||||
|
||||
Plugin follow-up (sm-journal-plugin, NOT edited in this task; copy into the SUMMARY verbatim). models/post/fields.yaml, the excerpt field becomes:
|
||||
|
||||
```yaml
|
||||
excerpt:
|
||||
label: golem15.journal::lang.post.excerpt
|
||||
type: mltextarea
|
||||
size: small
|
||||
tab: golem15.journal::lang.post.tab_manage
|
||||
```
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: cabana accepts mltextarea end to end (compile, nested save, hydrate) with preset refused</name>
|
||||
<files>modules/cabana/ml_textarea_test.go, modules/cabana/preset_ml_test.go, modules/cabana/field_ml.go, modules/cabana/form_schema.go, modules/cabana/crud.go, modules/cabana/relation.go</files>
|
||||
<read_first>modules/cabana/field_ml.go (whole file), modules/cabana/ml_test.go (TestMLFieldTypes, TestMLNestedSave), modules/cabana/ml_nullable_test.go (mlNullablePost, mlNullableController, mlNullableCompiled, mlLocaleMap, TestMLNullablePointerHost), modules/cabana/ml_smoke_test.go (recordingWriter, mlFS), modules/cabana/preset_ml_test.go (case table shape)</read_first>
|
||||
<precondition>A Docker daemon is reachable so the testcontainers Postgres behind newListService can start.</precondition>
|
||||
<behavior>
|
||||
- DB-free: a fields.yaml with `title: {type: mltext}` and `excerpt: {type: mltextarea, size: small}` compiles through CompileForm; the excerpt field has Type "mltextarea" and Size "small"; json.Marshal of it contains "type":"mltextarea" and "size":"small". `type: mltextareas` fails with an error containing "unsupported type mltextareas". mlFieldType("mltextarea") and scalarFormField("mltextarea") are true; mlFieldType("textarea") is false.
|
||||
- Postgres round trip over mlNullablePost with a fields.yaml whose title is mltext (required) and description is `type: mltextarea, size: small`: ShowRecord on a row with nil Description hydrates description to {en: "", pl: ""} with no pointer leak. UpdateRecord with description {en: "Line one\nLine two", pl: "Linia pierwsza\nLinia druga"} stores "Line one\nLine two" in the host *string column (newline intact). writer.attrs["pl"]["description"] is the Polish text and writer.attrs["en"] has no description. The returned Data and a fresh ShowRecord both carry the full two-locale map.
|
||||
- Invalid maps on the mltextarea field: an undeclared locale (de), a non-string value, and a map without the default locale each fail UpdateRecord with a *ValidationError whose Details has a description key.
|
||||
- Preset: `preset: title` on an mltextarea field fails CompileForm with "preset is only valid on type: text or mltext". An mltext slug with `preset: excerpt` naming an mltextarea source compiles, but checkPresets returns "field slug: preset field excerpt must be a text or mltext field".
|
||||
</behavior>
|
||||
<action>
|
||||
RED first. Create modules/cabana/ml_textarea_test.go (package cabana) with TestMLTextareaFieldType (the DB-free rows of the behavior block) and TestMLTextareaRoundTrip (the Postgres rows). For the round trip, reuse mlNullablePost and mlNullableController unchanged. Add a local mlTextareaFields constant and an mlTextareaCompiled(t) helper. The helper copies the mlNullableCompiled pattern, but its fsys is mlFS() with "models/post/fields.yaml" replaced by mlTextareaFields. Drop and auto-migrate mlNullablePost, then use CRUDService{DB: db, writer: &recordingWriter{...}} plus mlLocaleMap and assertNoPointerLeak for the hydration checks. Read the host column back with db.First into a fresh mlNullablePost and dereference Description. Add the two preset refusal rows to the TestPresetML table in preset_ml_test.go ("mltextarea target is refused", "mltextarea source is refused"). Run the new tests and confirm the compile and round-trip rows fail on the current code with "unsupported type mltextarea". The preset rows may also fail for that reason.
|
||||
|
||||
GREEN, per the quick-task decision (a new type, WinterCMS Translate parity, so ported YAML works unchanged):
|
||||
- field_ml.go: add mlTextareaFieldType = "mltextarea" next to the existing constants. mlFieldType returns true for mltext, mltextarea and mlmarkdown (a switch over the three constants). Update the doc comments of liftMLValues and hydrateMLRecord to name all three ML types.
|
||||
- form_schema.go: add "mltextarea" to formFieldTypes next to mltext.
|
||||
- crud.go: add "mltextarea" to the scalarFormField case list. Update the TranslationWriter field comment (~line 33) to name all three ML types. relation.go (~line 190) comment likewise.
|
||||
- Leave compilePresetKey and checkPresets untouched. Their existing text/mltext rule is the preset decision recorded in the context block.
|
||||
- Do not touch README, docs or admin_openapi.go here. Task 3 owns the docs.
|
||||
|
||||
Run gofmt, go vet and the targeted tests, then the existing ML and preset suites to prove mltext/mlmarkdown are unchanged. Commit: feat(cabana): add mltextarea field type (code + tests only; no .planning files, no co-author trailers).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && bash -c 'test -z "$(gofmt -l modules/cabana)"' && go vet ./modules/cabana && go test ./modules/cabana -run 'TestMLTextarea|TestPresetML|TestMLFieldTypes|TestMLNestedSave|TestMLHydration|TestMLNullablePointerHost|TestHostScalarString|TestRelationChildMLNestedSave|TestMLOpenAPIConformance' -count=1</automated>
|
||||
</verify>
|
||||
<done>TestMLTextareaFieldType and TestMLTextareaRoundTrip failed before the change and pass after it. mltextarea compiles with its size, saves the default locale to a nullable host column with newlines intact, writes only non-default locales through the writer and hydrates every enabled locale with no pointer leak. Invalid maps are 422s on the field. Preset on or from mltextarea is refused. The existing ML, nullable, relation-child and preset tests pass unchanged. One code commit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: SPA renders mltextarea through one shared ML wrapper; rebuild dist</name>
|
||||
<files>admin/src/components/form/fields/MLField.vue, admin/src/components/form/fields/MLTextField.vue, admin/src/components/form/fields/MLMarkdownField.vue, admin/src/components/form/fields/MLTextareaField.vue, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts, admin/src/components/form/registry.ts, admin/src/views/FormView.vue, admin/src/components/relation/RelationChildModal.vue, admin/tests/form/MLFields.test.ts, admin/tests/form/registry.test.ts, admin/tests/form/FormView.test.ts, modules/boardwalk/dist/</files>
|
||||
<read_first>admin/src/components/form/fields/MLTextField.vue, admin/src/components/form/fields/MLMarkdownField.vue, admin/src/components/form/fields/TextareaField.vue, admin/src/components/form/control.ts (FieldControlProps), admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts (lines 1-10, editablePayload, isMLType, presetUpdates, initialValues), admin/src/components/form/registry.ts, admin/tests/form/MLFields.test.ts (mlMount helper), admin/tests/form/registry.test.ts (describe 'markdown and multilingual field types'), admin/tests/form/FormView.test.ts lines 441-640 (helpers and the mltext cases)</read_first>
|
||||
<behavior>
|
||||
- registry: rendererFor('mltextarea') is MLTextareaField; isRegistered('mltextarea') true; needsRecord false; ownsLabel false; groupLabelled true. 'MLTEXTAREA' falls back to UnsupportedField and is not registered.
|
||||
- isMLFieldType: true for mltext, mltextarea, mlmarkdown; false for text, textarea, markdown, undefined.
|
||||
- formState: editablePayload sends an mltextarea value as its locale record (non-string entries dropped). initialValues seeds {en: '', pl: ''} for an mltextarea field when enabled locales are en/pl.
|
||||
- MLTextareaField mounted with field {name: 'excerpt', type: 'mltextarea', size: 'small'} and modelValue {en: 'Line 1\nLine 2', pl: 'Linia'}: the root has data-ml-field="mltextarea" and role group. There is exactly one [data-ml-locale] select with options en, pl, and no input element. The textarea has rows 4 and value 'Line 1\nLine 2'. Selecting pl shows 'Linia'. Typing 'Nowa\nlinia' emits [{en: 'Line 1\nLine 2', pl: 'Nowa\nlinia'}] on the MLTextareaField wrapper itself. Without size the textarea has rows 5.
|
||||
- Changing the locale select of a mounted MLTextField switches a mounted MLTextareaField to the same locale (ML_LOCALE_CHANGE).
|
||||
- FormView, update mode, roster schema with en/pl locales and `name` retyped to mltextarea with size small, record name {en: 'Ada\nLovelace', pl: ''}: #field-name-editor is a textarea showing 'Ada\nLovelace'. After the locale select is set to pl and 'Ada po polsku' is typed, the PUT body name equals {en: 'Ada\nLovelace', pl: 'Ada po polsku'}.
|
||||
- Every existing MLFields, MarkdownField, registry, formState and FormView case passes without changing its assertions.
|
||||
</behavior>
|
||||
<action>
|
||||
RED first: add the behavior cases. Registry rows go into the 'markdown and multilingual field types' describe in tests/form/registry.test.ts (extend the unknown-type it.each list with 'MLTEXTAREA'). isMLFieldType, editablePayload, initialValues and the MLTextareaField mount cases go into a new describe in tests/form/MLFields.test.ts, reusing mlMount; widen its component parameter type to accept MLTextareaField. The FormView case goes into the 'password and preset on the form' describe in tests/form/FormView.test.ts: build the schema from mlSchema('mltextarea', 'text') and set size 'small' on the name field, give the record a cloned rosterRecordFixture with the name map, and route PUT to rosterRecordFixture. Run them; they must fail.
|
||||
|
||||
GREEN:
|
||||
- mlLocale.ts: export ML_FIELD_TYPES (a readonly set of 'mltext', 'mltextarea', 'mlmarkdown') and isMLFieldType(type: string | undefined): boolean. Re-export isMLFieldType from formState.ts alongside the existing ML re-exports.
|
||||
- Replace every inline comparison of a field type against the ML type names with isMLFieldType: formState.ts editablePayload and initialValues, the private isMLType (delete it and call isMLFieldType in presetUpdates), FormView.vue adopt and update, and RelationChildModal.vue adopt. Leave the presetUpdates target filter (text or mltext targets only) as it is, per the preset decision. Update the FormView comment that says MLTextField broadcasts the choice to say any ML control does.
|
||||
- Create fields/MLField.vue as the single ML wrapper: props are FieldControlProps plus inner (a Vue Component), innerType (string) and mlType (string). It emits update:modelValue with Record of string to string. Move the script logic from MLTextField.vue into it unchanged: localeRecord values, FORM_ENABLED_LOCALES injection, activeLocale starting on the first enabled locale or 'en', setLocale, onLocaleInput with broadcastMLLocale, onText emitting the whole map, and the window ML_LOCALE_CHANGE listener added on mount and removed on unmount. Add a computed innerField that spreads props.field with type set to innerType, so the inner control gets a stable object and keeps size and attributes. The template is MLTextField's: the role=group root with aria-labelledby, data-ml-field bound to mlType, the same locale select markup and aria-label. In place of the hard-coded inner control it renders a dynamic component whose is binding is inner, given innerField, the active-locale text, the editor id `${controlId}-editor`, invalid and describedBy, and listening to update:modelValue with onText. No raw-HTML binding.
|
||||
- Rewrite MLTextField.vue and MLMarkdownField.vue as thin shells: defineProps FieldControlProps, defineEmits update:modelValue. Each renders MLField with the passed props, inner TextField / innerType 'text' / mlType 'mltext' (respectively MarkdownField / 'markdown' / 'mlmarkdown'), and re-emits MLField's update:modelValue from the shell. The existing tests read emitted events on the shell, so a fallthrough listener is not enough. Create MLTextareaField.vue the same way with TextareaField / 'textarea' / 'mltextarea'.
|
||||
- registry.ts: import MLTextareaField, register ['mltextarea', MLTextareaField] after mltext, add 'mltextarea' to groupLabelledTypes. Mention the ML wrapper in the header comment if the Phase notes there list control additions.
|
||||
|
||||
Run the targeted and full Vitest suites and the typecheck. Then run npm --prefix admin run build and stage modules/boardwalk/dist together with the SPA change. Commit: feat(admin): render mltextarea fields per locale (SPA, tests, dist; no .planning files, no co-author trailers).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && npm --prefix admin test -- tests/form/MLFields.test.ts tests/form/registry.test.ts tests/form/MarkdownField.test.ts tests/form/formState.test.ts tests/form/FormView.test.ts && npm --prefix admin test && npm --prefix admin run typecheck && bash -c '! grep -rn "=== '"'"'mlmarkdown'"'"'" admin/src' && bash -c '! grep -nE "v-html=" admin/src/components/form/fields/MLField.vue admin/src/components/form/fields/MLTextareaField.vue | grep -vE ":[0-9]+:[[:space:]]*(//|<!--|\*)"' && scripts/check-admin-dist.sh</automated>
|
||||
</verify>
|
||||
<done>All new behavior cases pass and every pre-existing ML, markdown, registry, formState and FormView case passes unchanged. MLTextField, MLMarkdownField and MLTextareaField are shells over MLField. No inline ML type-name comparison is left in admin/src. vue-tsc is clean. check-admin-dist reports dist matches a fresh build. One commit holds the SPA change, its tests and modules/boardwalk/dist.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Document mltextarea, confirm no OpenAPI drift, run the full suite</name>
|
||||
<files>modules/cabana/README.md, docs/backend/forms.md, docs/backend/admin-controllers.md</files>
|
||||
<read_first>docs/backend/forms.md (Field types table ~line 87-103, Field options and preset rules ~line 106-127, Markdown and multilingual fields ~line 328-352, the writable-fields paragraph ~line 356), modules/cabana/README.md (lines 15, 27, 28 and the hydrateMLRecord row ~line 223), docs/backend/admin-controllers.md (~line 205)</read_first>
|
||||
<action>
|
||||
docs/backend/forms.md:
|
||||
- Field types table: add an `mltextarea` row between `mltext` and `mlmarkdown`. It renders a multi-line text field with one editor per locale, takes `size` as `textarea` does, and links to Markdown and multilingual fields.
|
||||
- Preset start-up sentence: the refused types read "(`mltextarea` and `mlmarkdown` included)". Say plainly that an `mltextarea` field is neither a preset target nor a preset source, like `textarea`.
|
||||
- Markdown and multilingual fields section: `type: mltext`, `type: mltextarea` and `type: mlmarkdown` reuse the ordinary text, textarea and markdown editors with a locale selector. `mltextarea` takes `size` like `textarea`. Add a `summary` field with `type: mltextarea` and `size: small` to the YAML example (keep acme/blog names; never name a consuming application).
|
||||
- The writable-fields paragraph (~line 356): declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are lifted first.
|
||||
|
||||
docs/backend/admin-controllers.md (~line 205): "An `mltext`, `mltextarea` or `mlmarkdown` field arrives as a nested locale map".
|
||||
|
||||
modules/cabana/README.md: line 15 and line 27 name `mltext`, `mltextarea` and `mlmarkdown`. Line 27 also says `mltextarea` takes `size` like `textarea`. The hydrateMLRecord row (~line 223) names all three. Line 28 (preset) stays text/mltext. Every identifier named must exist (TestDocsTree checks).
|
||||
|
||||
Confirm the OpenAPI is unaffected: scripts/check-admin-openapi.sh --check must pass with no regeneration. If it reports drift, run scripts/check-admin-openapi.sh, inspect the diff, and commit the regenerated admin.json/schema.d.ts with these docs only if the diff is about field types. Confirm no scripts/check-phase*.sh holds a field-type inventory needing mltextarea (grep for mlmarkdown and MLMarkdownField in scripts/; record the result in the SUMMARY).
|
||||
|
||||
Run the docs gate, then go vet ./... and go test ./... from the repo root. Commit: docs(cabana): document mltextarea field type (README, forms.md, admin-controllers.md; no .planning files, no co-author trailers). In the SUMMARY record the sm-journal-plugin YAML snippet from the context block verbatim, the discretion choices (preset refused, MLField wrapper, isMLFieldType), the gate-script finding, and that neither sm-journal-plugin nor sm-translate-plugin was edited.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree -count=1 && grep -q 'mltextarea' modules/cabana/README.md && grep -q '`mltextarea`' docs/backend/forms.md && grep -q 'mltextarea' docs/backend/admin-controllers.md && go vet ./... && go test ./...</automated>
|
||||
</verify>
|
||||
<done>forms.md lists mltextarea in the field-types table, the ML section and the lift paragraph, and states the preset refusal. admin-controllers.md and the cabana README name mltextarea. admin.json and schema.d.ts match a fresh generation. TestDocsTree, go vet ./... and go test ./... are green. One docs commit. The SUMMARY carries the sm-journal-plugin excerpt YAML and the discretion choices.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| plugin fields.yaml -> cabana schema compiler | Plugin-authored YAML compiles at boot; an unknown type or a preset on or from mltextarea must stop the start-up |
|
||||
| admin SPA -> cabana save routes | An mltextarea save body carries a nested locale map that cabana lifts before mass-assignment projection |
|
||||
| record data -> admin SPA textarea | Translated multi-line text from the DB is shown in the admin form |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-quick-261006-tnp-01 | Tampering | modules/cabana/field_ml.go liftMLValues for mltextarea | medium | mitigate | mltextarea goes through the same mlFieldType predicate and liftMLValues validation as mltext (enabled locales only, string values only, default locale required, only fields whose context allows the op). TestMLTextareaRoundTrip pins the 422s for an undeclared locale, a non-string value and a missing default |
|
||||
| T-quick-261006-tnp-02 | Elevation of privilege | translation writes for mltextarea | medium | mitigate | Non-default locales are written only by applyMLTranslations inside the permissioned host save transaction, after the host row has a primary key. No new route is added, and the round-trip test asserts writes go through the published writer only |
|
||||
| T-quick-261006-tnp-03 | Information disclosure / XSS | admin/src/components/form/fields/MLField.vue and MLTextareaField.vue | low | mitigate | Text reaches the DOM only as a textarea value binding. The Task 2 verify fails on any raw-HTML binding in the new components, and the existing check-phase raw-HTML sink grep still allows only the MarkdownField preview line |
|
||||
| T-quick-261006-tnp-04 | Tampering | preset on mltextarea | low | mitigate | compilePresetKey/checkPresets stay text/mltext only. TestPresetML pins the refusal of an mltextarea target and an mltextarea source |
|
||||
| T-quick-261006-tnp-SC | Tampering | npm/go installs | low | accept | No new npm or Go dependency. The work uses the committed lockfile, the existing Vitest setup and the pinned swag v1.16.6 run in check-admin-openapi.sh |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- go test ./modules/cabana -run 'TestMLTextarea|TestPresetML|TestML' -count=1 (testcontainers needs Docker)
|
||||
- npm --prefix admin test and npm --prefix admin run typecheck
|
||||
- scripts/check-admin-dist.sh and scripts/check-admin-openapi.sh --check
|
||||
- go test ./cmd/summer -run TestDocsTree
|
||||
- go vet ./... and go test ./... from the repo root
|
||||
- git log shows three commits (feat(cabana), feat(admin) with dist, docs(cabana)) with no co-author trailers and no .planning files; nothing under ../sm-journal-plugin or ../sm-translate-plugin changed
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- A plugin can declare `type: mltextarea` (with `size`) and the app boots. The field saves and hydrates per locale exactly like mltext, including nullable host columns and multi-line text.
|
||||
- The admin form shows a sized textarea with the shared locale selector that follows every other ML control. Create, update and relation-child forms send the nested map.
|
||||
- mltext and mlmarkdown behave as before, and their logic now lives once in MLField.vue and isMLFieldType.
|
||||
- Embedded dist and OpenAPI/TS types are in sync. README and docs list mltextarea and its preset refusal. go vet ./... and go test ./... are green.
|
||||
- The SUMMARY records the sm-journal-plugin excerpt YAML change (`type: mltextarea`, `size: small`) for application in that repo.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261006-tnp-add-mltextarea-field-type/261006-tnp-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
phase: quick-261006-tnp
|
||||
plan: 01
|
||||
subsystem: cabana, admin SPA
|
||||
tags: [cabana, admin, translate, mltextarea, forms]
|
||||
status: complete
|
||||
requires: [quick-261006-seq nullable ML host columns, quick-261006-t5r mltext preset]
|
||||
provides: [mltextarea field type, MLField.vue shared ML wrapper, isMLFieldType]
|
||||
affects: [sm-journal-plugin models/post/fields.yaml (follow-up, not edited)]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [one ML wrapper component parameterised by inner control, single ML type predicate on server (mlFieldType) and SPA (isMLFieldType)]
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/ml_textarea_test.go
|
||||
- admin/src/components/form/fields/MLField.vue
|
||||
- admin/src/components/form/fields/MLTextareaField.vue
|
||||
modified:
|
||||
- modules/cabana/field_ml.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/preset_ml_test.go
|
||||
- admin/src/components/form/fields/MLTextField.vue
|
||||
- admin/src/components/form/fields/MLMarkdownField.vue
|
||||
- admin/src/components/form/mlLocale.ts
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/components/relation/RelationChildModal.vue
|
||||
- admin/tests/form/MLFields.test.ts
|
||||
- admin/tests/form/registry.test.ts
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- modules/boardwalk/dist/
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/admin-controllers.md
|
||||
decisions:
|
||||
- "mltextarea is neither a preset target nor a preset source (same rule as textarea and mlmarkdown); compilePresetKey/checkPresets unchanged, refusals pinned in TestPresetML"
|
||||
- "SPA ML controls share one MLField.vue wrapper (inner control, innerType, mlType props); MLTextField, MLMarkdownField and MLTextareaField are thin shells that re-emit update:modelValue"
|
||||
- "isMLFieldType in mlLocale.ts is the single SPA list of ML field types; it replaced every inline mltext/mlmarkdown comparison"
|
||||
metrics:
|
||||
duration: 8min
|
||||
completed: 2026-10-06
|
||||
tasks: 3
|
||||
files: 24
|
||||
actuals:
|
||||
tokens: 15900
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 673c6ca82cc2af0acab78bdc771cfe6083d7a184
|
||||
plan_head_after: aa3a7c2e90d6068585a4392601ef8dd991ea7c66
|
||||
---
|
||||
|
||||
# Quick 261006-tnp: Add mltextarea field type Summary
|
||||
|
||||
cabana and the admin SPA now accept the WinterCMS Translate `mltextarea` type. It is a multi-line, per-locale text field stored like `mltext` (the default locale goes to the host column, the other locales go through the TranslationWriter) and sized like `textarea`. In the SPA, all three ML controls are rendered by one shared `MLField.vue` wrapper.
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Commit | Key files |
|
||||
|---|------|--------|-----------|
|
||||
| 1 | cabana accepts mltextarea end to end (tracer, TDD) | 3596df4 | field_ml.go, form_schema.go, crud.go, relation.go, ml_textarea_test.go, preset_ml_test.go |
|
||||
| 2 | SPA renders mltextarea through one shared ML wrapper; dist rebuilt | a913eec | MLField.vue, MLTextareaField.vue, MLTextField.vue, MLMarkdownField.vue, mlLocale.ts, formState.ts, registry.ts, FormView.vue, RelationChildModal.vue, tests, modules/boardwalk/dist |
|
||||
| 3 | Document mltextarea, confirm no OpenAPI drift, full suite | aa3a7c2 | modules/cabana/README.md, docs/backend/forms.md, docs/backend/admin-controllers.md |
|
||||
|
||||
## What was built
|
||||
|
||||
- **Server:** I added the constant `mlTextareaFieldType = "mltextarea"`. `mlFieldType` now switches over mltext, mltextarea and mlmarkdown, so lift, apply, hydrate and the relation-child path all cover the new type with no other code. I also added `mltextarea` to `formFieldTypes` and `scalarFormField`. `size` already worked on every type.
|
||||
- **Tests (Go):** `TestMLTextareaFieldType` is DB-free. It checks that the type compiles with its size and serialises to JSON, that `mltextareas` is refused, and the predicates. `TestMLTextareaRoundTrip` runs on Postgres over a nullable `*string` column. It checks:
|
||||
- a nil host value hydrates to `{en:"", pl:""}`
|
||||
- multi-line text keeps its newlines in the host column
|
||||
- the pl value is written through the writer and en is not duplicated into the translation store
|
||||
- an undeclared locale, a non-string value and a missing default locale each give a 422 on the field
|
||||
|
||||
`TestPresetML` has two new rows: an mltextarea target and an mltextarea source are both refused.
|
||||
- **SPA:**
|
||||
- `MLField.vue` is the single locale-switching wrapper. It uses the inner control as a dynamic component and passes it a computed `innerField`, so size and attributes carry through.
|
||||
- The three ML components are shells over it. `MLTextareaField` wraps `TextareaField`, so rows come from size.
|
||||
- `isMLFieldType` and `ML_FIELD_TYPES` live in `mlLocale.ts` and are re-exported from `formState.ts`. They replace the inline checks in `editablePayload`, `initialValues` and `presetUpdates` (the private `isMLType` is deleted), in FormView `adopt`/`update`, and in RelationChildModal `adopt`.
|
||||
- `mltextarea` is registered and group-labelled.
|
||||
- **Tests (Vitest):** New cases cover the registry, `isMLFieldType`, `editablePayload` and `initialValues`, and mounting MLTextareaField: rows 4 for small and 5 by default, a single locale select, no input element, editing the active locale, and following `ML_LOCALE_CHANGE` from an MLTextField. A FormView update case checks that editing an mltextarea per locale sends the nested map in the PUT body. All 1073 Vitest cases pass. Existing assertions were not changed; only import lines, the `mlMount` parameter type and the unknown-type `it.each` list were widened.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go vet ./...` and `go test ./...` are green at every commit. Testcontainers Postgres was available.
|
||||
- `npm --prefix admin test` passes (73 files, 1073 tests) and `npm --prefix admin run typecheck` is clean.
|
||||
- `scripts/check-admin-dist.sh`: "modules/boardwalk/dist matches a fresh build".
|
||||
- `scripts/check-admin-openapi.sh --check` passes. admin.json and schema.d.ts did not drift and were not regenerated.
|
||||
- `go test ./cmd/summer -run TestDocsTree` passes and `summer docs:build --check` reports "no problems found".
|
||||
- Raw-HTML sink grep over admin/src: the only hit is the allowlisted `MarkdownField.vue` `v-html="sanitizedHtml"` line. MLField and MLTextareaField contain no raw-HTML binding.
|
||||
- Gate-script finding: `grep -rln 'mlmarkdown\|MLMarkdownField' scripts/` returns nothing. No `scripts/check-phase*.sh` holds a field-type or component inventory that needs `mltextarea`.
|
||||
- No inline `=== 'mlmarkdown'` / `'mltext' ||` comparison is left in admin/src. The only remaining `'mltext'` literals are `ML_FIELD_TYPES` and the preset target filter in `presetUpdates`, which the preset decision says to keep.
|
||||
|
||||
## Discretion choices
|
||||
|
||||
- **Preset:** mltextarea is neither a preset target nor a preset source. Preset is the single-line slug/copy rule, and `textarea` and `mlmarkdown` are refused today. `compilePresetKey`/`checkPresets` are untouched and the refusals are pinned by tests. In the SPA, `presetUpdates` keeps its text/mltext target filter. Its source-ML detection now goes through `isMLFieldType`, but the server refuses an mltextarea source at boot, so that path is unreachable.
|
||||
- **SPA reuse:** one `MLField.vue` wrapper takes the props `inner`, `innerType` and `mlType`. The shells keep their component identity, because the registry and tests reference them.
|
||||
- **isMLFieldType** is the single SPA ML type list.
|
||||
- **Preview screen:** unchanged. ML locale maps show as they do for mltext.
|
||||
|
||||
## Plugin follow-up (sm-journal-plugin, NOT edited here)
|
||||
|
||||
models/post/fields.yaml, the excerpt field becomes:
|
||||
|
||||
```yaml
|
||||
excerpt:
|
||||
label: golem15.journal::lang.post.excerpt
|
||||
type: mltextarea
|
||||
size: small
|
||||
tab: golem15.journal::lang.post.tab_manage
|
||||
```
|
||||
|
||||
Neither sm-journal-plugin nor sm-translate-plugin was edited.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan was executed as written.
|
||||
|
||||
Note: during the raw-HTML check, the session's shell `grep` is a ugrep wrapper that prints paths without the `./` prefix, so the allowlist regex looked like it missed. `command grep` (GNU) prints `./components/...` and matches the allowlist. This is environment-only and nothing changed.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new route or trust-boundary surface. mltextarea reuses the existing ML lift and writer path, and the threat-model mitigations T-01 to T-04 are pinned by TestMLTextareaRoundTrip, TestPresetML and the raw-HTML grep.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/cabana/ml_textarea_test.go, admin/src/components/form/fields/MLField.vue, admin/src/components/form/fields/MLTextareaField.vue
|
||||
- FOUND commits: 3596df4, a913eec, aa3a7c2 (no co-author trailers, no .planning files)
|
||||
Reference in New Issue
Block a user