Files
summercms/.planning/phases/10-admin-vue-spa/10-03-PLAN.md
2026-09-27 14:11:07 +02:00

32 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
10-admin-vue-spa 03 execute 3
10-02
admin/src/main.ts
admin/src/app/router.ts
admin/src/app/i18n.ts
admin/src/app/winterUrl.ts
admin/src/app/listQuery.ts
admin/src/api/types.ts
admin/src/state/useToasts.ts
admin/src/state/useSettings.ts
admin/src/views/ListView.vue
admin/src/views/FormView.vue
admin/src/views/SettingsIndexView.vue
admin/src/views/SettingsFormView.vue
admin/src/components/shell/AppShell.vue
admin/src/components/shell/PluginRail.vue
admin/src/components/list/DataTable.vue
admin/src/components/list/ListToolbar.vue
admin/src/components/list/FilterBar.vue
admin/src/components/list/Pagination.vue
admin/src/components/list/CellValue.vue
admin/src/components/form/FormTabs.vue
admin/src/components/form/FormGrid.vue
admin/src/components/form/FormField.vue
admin/src/components/form/FieldRenderer.vue
admin/src/components/form/registry.ts
admin/src/components/form/fields/TextField.vue
admin/src/components/form/fields/TextareaField.vue
admin/src/components/form/fields/NumberField.vue
admin/src/components/form/fields/DropdownField.vue
admin/src/components/form/fields/SwitchField.vue
admin/src/components/form/fields/CheckboxField.vue
admin/src/components/form/fields/RelationField.vue
admin/src/components/form/fields/UnsupportedField.vue
admin/src/components/ui/Button.vue
admin/src/components/ui/Toast.vue
admin/src/components/ui/ConfirmDialog.vue
admin/tests/fixtures/lang.json
admin/tests/fixtures/widgets.form-schema.json
admin/tests/fixtures/widgets.record.json
admin/tests/fixtures/widgets.list-schema.json
admin/tests/fixtures/widgets.list.json
admin/tests/fixtures/widgets.options.json
admin/tests/fixtures/settings.json
admin/tests/smoke/edit.smoke.test.ts
admin/tests/smoke/list.smoke.test.ts
admin/tests/smoke/form.smoke.test.ts
admin/tests/smoke/settings.smoke.test.ts
phrasebook/backend/lang/en/lang.yaml
phrasebook/backend/lang/pl/lang.yaml
boardwalk/dist/**
true
ADMIN-06
tokens raw_tokens tasks confidence
130000 130000 3 low
truths artifacts key_links prohibitions
Per D-20/D-24, the SPA loads the backend::lang bundle before auth at startup, sets the document language from meta.locale, and its interpolation replaces :name, :Name and :NAME exactly like phrasebook.interpolate; plural messages pick the CLDR form with Intl.PluralRules(locale) and fall back to other.
Per D-10/D-18, clicking a list row opens {backend.uri}/{vendor}/{plugin}/{controller}/{id}; the form sends scalar values and relation ids keyed by field name, and a successful save shows the resolved saved message as a toast (role=status, auto-dismiss about 4 s).
Per D-09, a 422 response's error.details field-to-messages map puts each message under its field (aria-invalid, aria-describedby), shows the banner with the plural invalid-field count, focuses the first invalid field, marks tabs holding invalid fields with a count badge, and clears a field's error when it changes.
Per D-05, the field renderer registry maps text, textarea, number, checkbox, switch, dropdown and relation to components, and any unregistered type renders the UnsupportedField box with the type name in DM Mono instead of breaking the form.
Per D-14/D-13, the list renders toolbarButtons in declared order (create in the heading row, delete in the toolbar), delete is disabled without a selection and confirms with the plural deleteConfirm message before POST bulk-delete, and the heading subtitle is the plural recordCount message for the list total.
Per D-22/D-27, the filter bar renders switch, daterange and model-backed scope filters from schema.filters, loads scope choices from the filters options endpoint, and drives filter[name] in the URL; the list's search (300 ms debounce), sort (asc, desc, none with aria-sort), page, per_page and filters live in the URL query and a change of any of them resets the selection.
Per D-17/D-18/D-26, a single relation field searches the fields options endpoint and shows emptyOption first; a multiple relation field shows removable chips in order; a readOnly relation field shows only its label from meta.labels.
Per D-21, the Ustawienia rail item appears when the settings list is non-empty, lists every permitted settings page, and renders each one through the same FormGrid and field registry against the settings schema and GET/PUT endpoints.
Per D-08/D-12, the SPA renders exactly the columns and fields the server schema declares; a text column renders its value as plain text (no per-value icon pills); empty values render a muted dash.
An empty list shows the empty message; an empty search shows the search-x state with the term and a clear-search button; loading shows eight skeleton rows while the toolbar and footer stay mounted.
path provides
admin/src/views/FormView.vue Schema-driven create/update form with tabs, 422 mapping, footer actions, dirty guard and redirect mapping
path provides
admin/src/components/form/registry.ts D-05 field renderer registry with UnsupportedField fallback
path provides
admin/src/components/list/DataTable.vue Schema columns, selection, sort, states
path provides
admin/src/components/list/FilterBar.vue Switch, daterange and scope filters driving filter[name]
path provides
admin/src/app/i18n.ts Bundle loading, t, plural selection and phrasebook-compatible interpolation
path provides
admin/src/views/SettingsFormView.vue Settings screen through the shared form renderer
from to via pattern
admin/src/components/form/fields/RelationField.vue GET /{vendor}/{plugin}/{controller}/fields/{field}/options typed openapi-fetch call with search and page fields/{field}/options
from to via pattern
admin/src/components/list/FilterBar.vue GET /{vendor}/{plugin}/{controller}/filters/{scope}/options typed openapi-fetch call filters/{scope}/options
from to via pattern
admin/src/main.ts GET /lang loadStrings before /auth/me /lang
from to via pattern
admin/src/views/FormView.vue admin/src/app/winterUrl.ts redirects and recordUrl mapped onto D-10 routes mapWinterUrl
[flagged-unverified] Plugin-supplied labels, messages and record values must be rendered as text only, never as raw HTML.
[flagged-unverified] The SPA must not declare its own TypeScript shapes for API payloads; every payload type is an alias of a generated schema type or a string-keyed record read through its schema.
[flagged-unverified] The SPA must not hide or add navigation, toolbar actions or fields on its own; it renders only what the server schema and navigation return.
[flagged-unverified] Winter recordUrl and redirect strings must not be used verbatim as SPA routes.

Phase Goal

As a backend administrator, I want to open my project's own admin URL, log in and manage Albums, Artists, Collections, Genres and Styles through schema-driven lists, forms and the relation manager, so that I can administer the catalogue from one Go binary without the WinterCMS backend.

Turn the tracer's read-only list into the full list and form experience for any controller: open a record, edit every built-in field type, save with 422 feedback and toasts, create and delete, search, sort, filter, page and bulk-delete, and manage settings pages through the same renderer. All five fonoteka controllers become usable because they only use built-in field and column types.

Purpose: Success criterion 2 (each of the five controllers renders a working list and form generated from its JSON schema) is met by generic components driven by the Plan 10-02 contract. Decisions implemented: D-05, D-06, D-08, D-09, D-10, D-12, D-13, D-14, D-17, D-18, D-20, D-21, D-22, D-24, D-26, D-27; D-28 fixes this plan's scope. Output: list, form, filter and settings components and views, i18n bundle loading, smoke tests with neutral fixtures, rebuilt boardwalk/dist.

Repo: summercms.go only (admin/, phrasebook backend lang for new SPA keys, boardwalk/dist). Commit code separately from planning docs; never add co-author tags.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/10-admin-vue-spa/10-CONTEXT.md @.planning/phases/10-admin-vue-spa/10-RESEARCH.md @.planning/phases/10-admin-vue-spa/design/README.md @.planning/phases/10-admin-vue-spa/10-01-SUMMARY.md @.planning/phases/10-admin-vue-spa/10-02-SUMMARY.md @admin/src/api/schema.d.ts @admin/src/api/client.ts @admin/src/views/ListView.vue @phrasebook/translator.go From Plans 10-01/10-02 (read the generated `admin/src/api/schema.d.ts` for exact shapes; never re-declare them): - Client: `api.GET/POST/PUT/DELETE(path, {params: {path, query}, body})` typed by `paths`; base URL from runtime meta; CSRF header and refresh are handled in `src/api/client.ts`. - Routes (prefix-relative): `/lang`, `/auth/me`, `/navigation`, `/settings`, `/settings/{code}/schema`, `/settings/{code}`, `/{vendor}/{plugin}/{controller}/schema/list|form`, `/{vendor}/{plugin}/{controller}`, `/{vendor}/{plugin}/{controller}/{id}`, `/{vendor}/{plugin}/{controller}/bulk-delete`, `/{vendor}/{plugin}/{controller}/fields/{field}/options`, `/{vendor}/{plugin}/{controller}/filters/{scope}/options`. - Schemas: ListSchema (columns, filters, toolbarButtons, perPageOptions, recordsPerPage, recordUrl, messages), FormView (fields with type, span, tab, context, required, comment, options, emptyOption, nameFrom, multiple, readOnly; messages; redirects), record envelope data plus meta.labels, D-10 error envelope with details field to messages, list meta page/per_page/total/last_page. - `messages` values are CLDR form maps (key to text); plain strings arrive as {other: text}. - Filter wire values (Phase 9 query.go): switch sends the raw JSON of the option value (JSON.stringify of the scalar), daterange sends YYYY-MM-DD..YYYY-MM-DD, scope sends the option value.

Artifacts this phase produces

  • admin/src/app/i18n.ts: loadStrings(), t(key, params), tc(key, count, params), message(forms, count, params), interpolate(text, params)
  • admin/src/app/winterUrl.ts: mapWinterUrl(url, controllerId, id); admin/src/app/listQuery.ts: parseListQuery, toListQuery
  • admin/src/state/useToasts.ts (queue), admin/src/state/useSettings.ts
  • Views: FormView.vue, SettingsIndexView.vue, SettingsFormView.vue; routes /:vendor/:plugin/:controller/create, /:vendor/:plugin/:controller/:id(\\d+), /settings, /settings/:code
  • Components: DataTable, ListToolbar, FilterBar, Pagination, CellValue, FormTabs, FormGrid, FormField, FieldRenderer, registry.ts (rendererFor(type)), fields TextField, TextareaField, NumberField, DropdownField, SwitchField, CheckboxField, RelationField, UnsupportedField, ui Button, Toast, ConfirmDialog
  • New backend::lang keys for every SPA string introduced here (pl and en)
  • Smoke tests tests/smoke/{edit,list,form,settings}.smoke.test.ts with neutral acme.demo.widgets fixtures
Task 1: An admin opens a record from a list, edits it and saves it with toast and 422 feedback admin/src/main.ts, admin/src/app/router.ts, admin/src/app/i18n.ts, admin/src/app/winterUrl.ts, admin/src/api/types.ts, admin/src/state/useToasts.ts, admin/src/views/ListView.vue, admin/src/views/FormView.vue, admin/src/components/shell/AppShell.vue, admin/src/components/list/DataTable.vue, admin/src/components/form/FormGrid.vue, admin/src/components/form/FormField.vue, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/registry.ts, admin/src/components/form/fields/TextField.vue, admin/src/components/form/fields/TextareaField.vue, admin/src/components/form/fields/NumberField.vue, admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/fields/UnsupportedField.vue, admin/src/components/ui/Button.vue, admin/src/components/ui/Toast.vue, admin/tests/fixtures/lang.json, admin/tests/fixtures/widgets.form-schema.json, admin/tests/fixtures/widgets.record.json, admin/tests/smoke/edit.smoke.test.ts, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, boardwalk/dist/** admin/src/api/schema.d.ts, admin/src/api/client.ts, admin/src/api/types.ts, admin/src/app/router.ts, admin/src/app/i18n.ts, admin/src/views/ListView.vue, admin/src/components/list/DataTable.vue, admin/src/main.ts, phrasebook/translator.go (interpolate), phrasebook/backend/lang/pl/lang.yaml, .planning/phases/10-admin-vue-spa/design/README.md (screen 4, Interactions, State Management), .planning/phases/10-admin-vue-spa/10-RESEARCH.md (Patterns 7 and 8) Build the thinnest edit path through every SPA layer, using only generated API types (aliases in `src/api/types.ts`) and text interpolation.

(1) Strings, per D-20/D-24: src/app/i18n.ts gains loadStrings() (GET /lang, store the key to forms map and meta.locale, set document.documentElement.lang), t(key, params) (forms.other, else the key), tc(key, count, params) and message(forms, count, params) (select with new Intl.PluralRules(locale).select(count), fall back to other, add count to params), and interpolate(text, params) that mirrors phrasebook.interpolate: for each param build :Name (first rune upper-cased value), :NAME (upper-cased value) and :name (value), and replace longest placeholders first. src/main.ts boots loadStrings() then /auth/me then /navigation, then mounts.

(2) Routes and URL mapping, per D-10: add /:vendor/:plugin/:controller/create and /:vendor/:plugin/:controller/:id(\\d+) to the router. src/app/winterUrl.ts exports mapWinterUrl(url, controllerId, id): strip the leading vendor/plugin/controller path of the controller, then an empty remainder maps to the list route, create to the create route, update/:id to the record route with the id substituted, and anything else to the list route (research Gap 8). DataTable rows link to the record route when the schema has a recordUrl; the first column is weight 600.

(3) Form, per D-05/D-09/D-18: FormView.vue loads schema/form and, in update mode, the record (data plus meta.labels); it shows fields whose context allows the mode. FormGrid.vue maps span left to column 1, right to column 2, full to the full row, auto and row to the next free slot, with one column below 600px, and the 22px by 24px gaps from the design. FormField.vue renders the label (600 weight, an aria-hidden red asterisk plus aria-required on the control when required), the comment, the control and the error line (13px, danger colour, circle-alert 14px, linked by aria-describedby). registry.ts exports rendererFor(type) over a Map of the built-ins registered so far (text, textarea, number, dropdown); every other type returns UnsupportedField.vue, the design's dashed box with the puzzle icon and t('backend::lang.form.unsupported_field', {type}) where the type is rendered in DM Mono. Text, textarea (rows from size), number and dropdown (schema options, emptyOption first and muted) are v-model controls at 44px height. Saving POSTs (create) or PUTs (update) the values keyed by field name; relation fields send ids (wired by Task 3). A 200/201 shows a toast with the form's saved message (:name is the value of the first text field in schema order) and, after a create, navigates to mapWinterUrl(redirects.create.redirect, ...) for the new id. A 422 reads error.details (field to messages): each message renders under its field with aria-invalid="true", a top banner shows the plural invalid-field count, the first invalid field receives focus, and editing a field clears its error. Other errors show a danger toast with the envelope message.

(4) Toasts: src/state/useToasts.ts is a module-level queue; Toast.vue (mounted by AppShell.vue) is bottom-centre 88px from the bottom, navy background, 26px yellow check circle, close button aria-label from t, role="status", auto-dismiss after about 4 seconds.

(5) Add every new SPA string key to phrasebook/backend/lang/{pl,en}/lang.yaml (design copy in Polish), keep TestPhase10SPAKeysResolve green, add neutral fixtures and tests/smoke/edit.smoke.test.ts (list row opens the record route; editing and saving PUTs the typed body and shows the saved toast; a 422 fixture maps messages to fields, focuses the first invalid one and clears on input; an unknown colorpicker field renders the unsupported box; interpolate matches phrasebook for :name, :Name, :NAME), then rebuild boardwalk/dist. npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke/edit.smoke.test.ts && go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh <fails_when>Any command exits non-zero; vitest prints "No test files found" or any failed test; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve" or shows "no tests to run"; check-admin-dist.sh prints a diff.</fails_when> <acceptance_criteria> - The edit smoke test passes and covers row navigation, typed PUT, saved toast, 422 mapping with focus and clearing, and the unsupported box. - grep -rn 'v-html' admin/src prints nothing. - grep -rln 'fetch(' admin/src | grep -v 'admin/src/api/client.ts' prints nothing. - scripts/check-admin-dist.sh exits 0. </acceptance_criteria> From any controller list an admin can open a record, change text, textarea, number and dropdown fields, save, and see either the saved toast or per-field validation messages.

Task 2: An admin searches, sorts, filters, pages and bulk-deletes any list admin/src/app/listQuery.ts, admin/src/views/ListView.vue, admin/src/components/list/DataTable.vue, admin/src/components/list/ListToolbar.vue, admin/src/components/list/FilterBar.vue, admin/src/components/list/Pagination.vue, admin/src/components/list/CellValue.vue, admin/src/components/ui/ConfirmDialog.vue, admin/tests/fixtures/widgets.list-schema.json, admin/tests/fixtures/widgets.list.json, admin/tests/fixtures/widgets.options.json, admin/tests/smoke/list.smoke.test.ts, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, boardwalk/dist/** admin/src/views/ListView.vue, admin/src/components/list/DataTable.vue, admin/src/app/i18n.ts, admin/src/api/types.ts, admin/src/api/schema.d.ts, cabana/query.go (normalizePage, pageSizeAllowed, switchArgument, parseDateRange, scopeArgument), .planning/phases/10-admin-vue-spa/design/README.md (screen 3, Interactions) (1) URL state: `src/app/listQuery.ts` parses and serializes `search`, `sort`, `dir`, `page`, `per_page` and `filter[]` between the route query and a typed list input; ListView reads the list from the URL and every change is a router replace. Search is debounced 300 ms and resets page to 1; changing search, sort, page, per_page or a filter clears the selection.

(2) DataTable, per the design screen 3 and D-12: a 52px checkbox column when showCheckboxes (header checkbox tri-state: empty, mixed with the minus icon, checked; it selects the current page), sortable headers cycle asc, desc, none with arrow-up/arrow-down 14px, aria-sort and the text colour on the sorted header, 54px rows with the sel background when selected, a sticky subtle header, horizontal scroll inside the card. CellValue.vue renders by column type: default text (a muted dash for null or empty; arrays comma-joined), datetime as YYYY-MM-DD HH:mm at 13px muted with tabular numbers, switch as the green Tak pill with arrow-up-right or the muted outline Nie pill (strings from backend::lang.list.column_switch_true/false); no per-value icon pills. Loading shows eight skeleton rows of the same height with the toolbar and footer mounted. An empty list shows the list's empty message; an empty search shows the 56px search-x circle, the emptySearch title, the emptySearchHint text with :term, and an outline clear-search button.

(3) Actions, per D-13/D-14: the heading row shows the localized title and the plural recordCount message for meta.total; toolbarButtons render in declared order, with create as the primary button (plus) in the heading row linking to the create route and delete in the toolbar. With no selection delete is a disabled outline button (native disabled); with a selection the selected pill (check icon, sel background) appears and delete is a danger outline button (trash-2). Delete opens ConfirmDialog.vue (Reka Dialog, alert dialog role) with the plural deleteConfirm message, then POSTs bulk-delete with the ids, shows the plural deleted toast and reloads; a 409 shows a danger toast.

(4) FilterBar, per D-22/D-27: rendered only when schema.filters is non-empty. A switch filter offers an "all" choice plus its options (or true/false values) and sends JSON.stringify(option.value); a daterange filter has two date inputs and sends from..to; a scope filter loads its choices from /filters/{scope}/options and sends the chosen value. Each writes filter[name] to the URL and resets the page.

(5) Pagination: range text :from–:to z :total (en dash) from t, the plural results message for zero rows with no pager, the per-page select over schema.perPageOptions (falling back to [recordsPerPage], hidden when there is one choice; research Gap 7), page buttons with ellipsis, the current page with sel background, weight 700 and aria-current, and previous/next buttons disabled at the ends (34px, radius 8).

(6) Add the new keys to both backend lang files, a neutral filters fixture (one switch, one daterange, one scope) and tests/smoke/list.smoke.test.ts (sort cycle and aria-sort, debounced search resets page, tri-state header selection, disabled and enabled delete with plural confirm and bulk-delete body, each filter shape writes the right filter[...] value, per-page hidden for one option, empty and empty-search states, skeleton rows), then rebuild boardwalk/dist. npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke/list.smoke.test.ts && go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh <fails_when>Any command exits non-zero; vitest prints "No test files found" or any failed test; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve" or shows "no tests to run"; check-admin-dist.sh prints a diff.</fails_when> <acceptance_criteria> - The list smoke test passes and covers every behaviour named in item (6). - grep -c 'JSON.stringify' admin/src/components/list/FilterBar.vue prints at least 1 and grep -c 'filters/{scope}/options' admin/src/components/list/FilterBar.vue prints at least 1. - grep -rn 'v-html' admin/src prints nothing. </acceptance_criteria> Every controller list supports search, sort, filters, paging, selection and confirmed bulk delete with the controller's own copy, and its state survives a reload through the URL.

Task 3: An admin works through tabs, toggles and relation fields, creates and deletes records, and edits settings pages admin/src/app/router.ts, admin/src/state/useSettings.ts, admin/src/views/FormView.vue, admin/src/views/SettingsIndexView.vue, admin/src/views/SettingsFormView.vue, admin/src/components/shell/PluginRail.vue, admin/src/components/form/FormTabs.vue, admin/src/components/form/registry.ts, admin/src/components/form/fields/SwitchField.vue, admin/src/components/form/fields/CheckboxField.vue, admin/src/components/form/fields/RelationField.vue, admin/src/components/ui/ConfirmDialog.vue, admin/tests/fixtures/widgets.form-schema.json, admin/tests/fixtures/widgets.options.json, admin/tests/fixtures/settings.json, admin/tests/smoke/form.smoke.test.ts, admin/tests/smoke/settings.smoke.test.ts, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, boardwalk/dist/** admin/src/views/FormView.vue, admin/src/components/form/registry.ts, admin/src/components/form/FormGrid.vue, admin/src/components/shell/PluginRail.vue, admin/src/api/schema.d.ts, cabana/settings.go, cabana/form_schema.go (context handling), .planning/phases/10-admin-vue-spa/design/README.md (screen 4 tabs, toggle cards, sticky footer, Interactions) (1) Tabs: `FormTabs.vue` groups fields by `tab`; fields without a tab go to a first tab labelled `t('backend::lang.form.tab_default')`; tabs render only when at least one field has a tab, as the design's segmented control (`role="tablist"`/`"tab"`, `aria-selected`, 34px pills) with a count badge on any tab holding invalid fields after a 422.

(2) Toggles and relations, per D-05/D-17/D-18/D-26: register switch, checkbox and relation in registry.ts. SwitchField.vue and CheckboxField.vue are the design's toggle cards (label as title, comment as helper text; a 44 by 26 role="switch" control or a 20px checkbox). RelationField.vue has three modes from the schema: readOnly shows only the label from meta.labels (no control); single shows a searchable select that queries /fields/{field}/options (300 ms debounce, per_page 20, more on scroll or a next-page action) with emptyOption first and muted, sending the id or null; multiple shows chips (30px pill, 22px initials avatar in the primary colour, name at 600 weight, a remove button whose aria-label is t('backend::lang.form.remove_item', {name})) in order, plus an inline input that opens the same searchable listbox and appends the chosen id. Initial labels come from meta.labels. relation-manager stays unregistered until Plan 10-04 and so renders the unsupported box.

(3) Footer and lifecycle: a sticky footer with, on the left, a danger outline Usuń (trash-2, update mode only) that confirms with the form's deleteConfirm message then DELETEs, toasts deleted and returns to the list; on the right Anuluj (ghost, returns to the list), Zapisz i zamknij (outline, saves then goes to mapWinterUrl(redirects.<mode>.redirectClose, ...) and shows the toast there) and Zapisz (primary, saves and stays; after create it goes to the mapped create.redirect). The header has a 40px back button (arrow-left, labelled from t), the record title (first text field value, or the form's create message in create mode) and the update message as subtitle. A dirty form asks for confirmation (ConfirmDialog) before Anuluj, the back button or any route leave, and registers a beforeunload guard while dirty.

(4) Settings, per D-21: src/state/useSettings.ts loads /settings; PluginRail.vue shows the Ustawienia item (settings icon, pinned to the bottom) only when that list is non-empty; /settings (SettingsIndexView.vue) lists entries grouped by category with icon, label and description; /settings/:code (SettingsFormView.vue) loads /settings/{code}/schema and /settings/{code}, renders the same FormGrid and registry, PUTs the values, toasts the default saved message and maps a 422 like the record form.

(5) Add the new keys to both backend lang files, extend the neutral form fixture with tabs, a switch, a checkbox, single, multiple and read-only relations and an unknown type, add tests/smoke/form.smoke.test.ts (tab badges after 422, toggle cards, each relation mode including option search and ordered ids in the body, context filtering between create and update, delete confirm, dirty guard, create redirect mapping) and tests/smoke/settings.smoke.test.ts (rail item visibility, index listing, settings save and 422), then rebuild boardwalk/dist. npm --prefix admin run typecheck && npm --prefix admin test -- tests/smoke && go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestPhase10Controllers$' -count=1 -v) <fails_when>Any command exits non-zero; vitest prints "No test files found" or any failed test; a go test output lacks its "--- PASS" line or shows "no tests to run" or SKIP; check-admin-dist.sh prints a diff.</fails_when> Run the fonoteka binary (summer serve in ../fonoteka.go with a local database and SUMMER_ADMIN__JWT__SECRET set), open http://localhost:8080/plytadmin, log in as a developer-role admin and open Albumy, Artyści, Kolekcje, Gatunki, Style and Ustawienia. Each list shows its YAML columns with Polish copy; each form opens, the album genre dropdown and artist chips work, Kolekcje shows the owner as read-only text, saving shows the toast, and an empty required name shows the field error and banner. <why_human>No browser e2e in Phase 10 (D-23); real rendering of the five controllers against the Go backend can only be judged in a browser.</why_human> <acceptance_criteria> - Form and settings smoke tests pass; every smoke test under tests/smoke passes. - grep -c "'switch'\|'checkbox'\|'relation'" admin/src/components/form/registry.ts prints at least 3 and grep -c "'relation-manager'" admin/src/components/form/registry.ts prints 0. - TestPhase10Controllers passes, so every field type the five controllers serve is registered except relation-manager (Plan 10-04). </acceptance_criteria> All five controllers' forms work with tabs, toggles and relation fields, records can be created, updated and deleted safely, and settings pages are editable through the same renderer.

<threat_model>

Trust Boundaries

Boundary Description
Admin API responses → DOM Plugin-supplied labels, messages and record values are rendered in the admin origin
URL query → list requests User-editable query parameters become list filters and sorting
Winter redirect strings → router Plugin YAML strings choose navigation targets

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-10-16 Tampering SPA rendering of plugin labels, messages and record values (XSS) high mitigate Text interpolation only; no raw-HTML directive anywhere in admin/src (acceptance grep); interpolate replaces placeholders in plain strings, never markup.
T-10-18 Elevation of Privilege client-side hiding of actions and fields low accept The server enforces permissions, toolbar actions, writable fields and relation scope (Plans 10-01/10-02); the SPA renders only what it receives and never adds entries, so client manipulation gains nothing.
T-10-19 Information Disclosure list state (search terms, filters) in the URL low accept Admin-only, same-origin, Referrer-Policy same-origin and noindex from Plan 10-01; search terms are not secrets.
T-10-20 Tampering Winter redirect and recordUrl strings used for navigation low mitigate mapWinterUrl only produces routes under the current controller (list, create, record); unknown shapes fall back to the list; smoke test covers mapping.
T-10-SC Tampering npm dependencies high mitigate No new package; installs use npm ci against the lockfile approved in Plan 10-01.
</threat_model>
Run `npm --prefix admin run typecheck && npm --prefix admin test`, `go test ./phrasebook -count=1`, `scripts/check-admin-dist.sh`, `scripts/check-admin-openapi.sh --check`, and `go vet ./... && go test ./...` in summercms.go. A non-zero exit, a failed or missing smoke test, or a printed diff fails the plan.

<success_criteria>

  • Lists and forms for any controller are generated from the server schema with every built-in field and column type, 422 mapping, toasts, toolbar actions, filters, pagination and URL state (SC-2).
  • Settings pages render through the same form renderer (D-21).
  • No raw-HTML rendering, no hand-written API types, no network calls outside the typed client. </success_criteria>
Create `.planning/phases/10-admin-vue-spa/10-03-SUMMARY.md` when done.