32 KiB
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 |
|
|
true |
|
|
|
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>
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,toListQueryadmin/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)), fieldsTextField,TextareaField,NumberField,DropdownField,SwitchField,CheckboxField,RelationField,UnsupportedField, uiButton,Toast,ConfirmDialog - New
backend::langkeys for every SPA string introduced here (pl and en) - Smoke tests
tests/smoke/{edit,list,form,settings}.smoke.test.tswith neutralacme.demo.widgetsfixtures
(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.
(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.
(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> |
<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>