Five sequential plans: foundations (deferred_bindings, attach.Store, lagoon.Date/TimeOfDay, purge), cabana datepicker and fileupload, relation child CRUD with deferral, admin SPA, and unit and security tests. Adds D-22..D-24 from the plan-count checkpoint and the pattern map.
43 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12.2-admin-form-fields-date-file-upload-relation-editing-with-def | 04 | execute | 4 |
|
|
false |
|
|
|
Phase Goal
ROADMAP Phase 12.2 goal (verbatim): A plugin's admin forms cover the three gaps a downstream project on SummerCMS v0.1 hit: a date/datetime field, a file upload field, and creating, editing and deleting related records inside the parent form (WinterCMS RelationController parity). Uploads and related-record changes on a record that is not saved yet use Winter-like deferred binding: they are held against a session key and committed with the parent's first save, or discarded with it.
This plan's slice: everything the administrator sees. After it an admin can pick dates, upload, reorder, caption and remove files, and create, edit, delete, link and pivot-edit related records in modals, on new and saved records, in the embedded SPA.
Build the admin SPA (summercms.go `admin/`) side of Phase 12.2 on the routes and generated types of plans 02 and 03, following 12.2-UI-SPEC.md exactly, add the SPA strings to the backend lang catalogs, and rebuild the committed `modules/boardwalk/dist`.Purpose: success criteria 1-4 become usable by an administrator; plan 05 adds the SPA unit tests and backstops. Output: session key helper, file route adapters, DatepickerField, FileuploadField, caption modal, relation child and pivot modals, create-screen deferral, date/time list cells, lang keys, rebuilt dist, admin-spa docs note.
Repo: summercms.go only. Neutral names in fixtures (acme). Code and planning docs in separate commits; no co-author tags. Every task rebuilds modules/boardwalk/dist with npm --prefix admin run build and commits it with its source so scripts/check-admin-dist.sh stays clean at every commit.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Artifacts this phase produces
(This plan's share.)
admin/src/app/sessionKey.ts:newSessionKey(): string,SESSION_HEADER = 'X-Session-Key',CHILD_SESSION_HEADER = 'X-Child-Session-Key'.admin/src/app/dateFormat.ts:parseFieldValue(mode, raw, ignoreTimezone),emitFieldValue(mode, value, ignoreTimezone),formatDisplay(value, mode, displayFormat, locale),weekStart(locale, firstDay).admin/src/api/files.ts:FileRoutesinterface (list, upload, update, remove, reorder, downloadUrl/download, thumb) withparentFileRoutes(...)andchildFileRoutes(...),uploadWithProgress(...)on XMLHttpRequest.admin/src/components/form/formContext.ts:FORM_SESSIONinjection key andFormSessiontype (key, child key, file routes factory, record id with 0 for unsaved,markDirty(),pendingChangescounter).- Components:
DatepickerField.vue,FileuploadField.vue,FileCaptionModal.vue,RelationChildModal.vue,RelationPivotModal.vue; registry entriesdatepicker,fileupload. - Lang keys (en, pl):
backend::lang.datepicker.*,backend::lang.fileupload.*,backend::lang.relation.{child_load_failed, child_gone, next, back, pending_note}with UI-SPEC copy. - Direct npm dependency
@internationalized/date3.12.4 (exact pin, after approval).
Planner assumptions recorded for this plan
- Upload progress needs XMLHttpRequest (fetch has no upload progress); the XHR sends
X-Requested-With,X-Session-Key(and the child key in a child modal), uses same-origin credentials, and on a 401 callsrefreshSession()once and retries. - The caption dialog is a sibling component,
FileCaptionModal.vue(UI-SPEC planner's choice). - Manual checks (keyboard and a11y of the calendar, drag reorder and progress feel) are human-check items harvested at the end of the phase (
workflow.human_verify_modedefault end-of-phase).
(1) sessionKey.ts: newSessionKey() fills 32 bytes with crypto.getRandomValues and encodes them base64url without padding (43 characters, matching the server pattern); header constants as in Artifacts.
(2) api/files.ts: a FileRoutes interface and two factories. parentFileRoutes(source, recordId (0 when unsaved), field, sessionKey) wraps the typed api calls for GET/POST /{vendor}/{plugin}/{controller}/{id}/files/{field}, PUT/DELETE .../{file}, POST .../reorder, GET .../{file}/download and .../thumb, always passing the X-Session-Key header (never a query parameter). childFileRoutes(...) targets the /relations/{name}/records/{child}/files/{field} routes and passes both headers (used in Task 4). uploadWithProgress posts multipart file_data with XMLHttpRequest (credentials same-origin, X-Requested-With, the session headers), reports progress events, supports abort, retries once after refreshSession() on 401, and resolves to the FileItem or a typed error (422 message text, 413, network). Protected thumbnail and download use fetch with the same headers and return a Blob for an object URL.
(3) formContext.ts: FORM_SESSION (FormSession: the key, an optional child key, routes(field) returning FileRoutes, the record id with 0 for unsaved, markDirty() and a reactive pendingChanges count). FormView creates one key on mount, provides FORM_SESSION with parentFileRoutes, adds pendingChanges > 0 to dirty, and sends X-Session-Key on the create POST and update PUT (openapi-fetch headers). A 422 whose details name a fileupload or relation-manager field lands on that field through the existing fieldErrors mapping and counts in the banner.
(4) registry.ts and formState.ts: register fileupload as FileuploadField; it is valueless (never in the save body) and group-labelled (its label is a span the control's role=group points at). Leave the relation-manager rules for Task 4.
(5) FileuploadField.vue exactly per UI-SPEC section 3: dropzone (button, hidden file input with accept from fileTypes/mimeTypes or image defaults, multiple for attachMany, drag-over styling on the zone only), image grid tiles for attachMany image mode, the attachOne image row with Replace and Remove, file-mode rows with Download, Pencil (useCaption) and X, the Unsaved chip on update forms for pending items, per-item states (queued, uploading with ProgressRoot, failed with the server message, too_large, upload_failed plus retry, done), uploads one at a time in selection order, client pre-checks (extension, maxFilesize, remaining maxFiles with the single too_many line), deferred remove (no dialog; the item leaves at once, pending uploads are cancelled), protected thumbnails and downloads from object URLs revoked on unmount (public files use url/thumb_url), the 112px loading bar and the load_failed alert, read-only rendering, reorder by pointer drag and by keyboard (ArrowUp/ArrowLeft earlier, ArrowDown/ArrowRight later, focus stays on the handle, polite aria-live announcing fileupload.moved, one request debounced 400ms, rollback plus reorder_failed toast on failure). FileCaptionModal.vue per the caption modal contract (Reka Dialog, 560px, Title input and Description textarea, Cancel and details_submit, immediate save, details_saved toast, 422 inside the modal). Use only existing tokens and component classes; text interpolation only (no raw-HTML directive). Every string through t/tc/message.
(6) Lang: add every backend::lang.fileupload.* key of the UI-SPEC "All new keys" list to modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml with the UI-SPEC English and Polish texts (CLDR maps for too_many: en one/other, pl one/few/many/other).
(7) Smoke test admin/tests/smoke/deferred.smoke.test.ts with fixture admin/tests/fixtures/deferred.form-schema.json (an acme form with a photos fileupload field, image mode, attachMany): mount FormView in create mode with a mocked XHR and fetch; choose a PNG; assert the upload request targets /files/photos with record id 0 and carries X-Session-Key, the form becomes dirty, and the create POST carries the same key.
(8) docs/backend/admin-spa.md "How the SPA talks to the server": one paragraph on the session-key headers and deferred uploads. Rebuild with npm --prefix admin run build and commit modules/boardwalk/dist with the source.
npm --prefix admin run typecheck && npm --prefix admin test && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1
<fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found" or the summary lacks the deferred smoke file as passed; TestPhase10SPAKeysResolve prints a line ending in "does not resolve"; check-admin-dist.sh prints "modules/boardwalk/dist is stale".</fails_when>
<acceptance_criteria>
- grep -c 'getRandomValues' admin/src/app/sessionKey.ts prints at least 1 and grep -c 'XMLHttpRequest' admin/src/api/files.ts prints at least 1.
- grep -rc 'session_key=' admin/src | awk -F: '{s+=$2} END {print s}' prints 0.
- grep -c 'v-html' admin/src/components/form/fields/FileuploadField.vue prints 0 and grep -c 'v-html' admin/src/components/form/fields/FileCaptionModal.vue prints 0.
- grep -c "\['fileupload', FileuploadField\]" admin/src/components/form/registry.ts prints 1.
- grep -c 'default_prompt_many' modules/phrasebook/backend/lang/pl/lang.yaml prints 1.
- npm --prefix admin test -- tests/smoke/deferred reports the smoke file passed.
</acceptance_criteria>
An administrator can attach files to a record before its first save; the upload, the pending state and the commit at Save all work through the real API contract.
(1) Dependency: with approve, run npm --prefix admin install --save-exact @internationalized/date@3.12.4 and commit package.json and package-lock.json with this task; with transitive, import it without changing package.json and note that in the summary.
(2) dateFormat.ts (pure, unit-testable): parseFieldValue (date: parseDate; datetime: parseAbsolute(value, getLocalTimeZone()), or with ignoreTimezone a CalendarDateTime from the UTC wall clock; time: parseTime; null or empty gives null), emitFieldValue (date YYYY-MM-DD; datetime toAbsoluteString() in UTC; ignoreTimezone YYYY-MM-DDTHH:MM:SSZ from the wall clock; time HH:MM:SS; cleared gives null), formatDisplay (the server's displayFormat tokens DD, D, MM, M, YYYY, YY, HH, H, hh, h, mm, ss, A, a, day and month names in the admin locale; defaults YYYY-MM-DD, YYYY-MM-DD HH:mm, HH:mm), weekStart(locale, firstDay) (firstDay when set, else the locale's first day, Monday for pl). Never construct a global Date for date-only values.
(3) DatepickerField.vue per UI-SPEC section 1: date and datetime modes on DatePickerRoot with DatePickerField/Input segments, the time-zone segment for datetime without ignoreTimezone, the clear button (optional, has value, not read-only) and the calendar trigger (32x32, aria labels datepicker.clear and datepicker.open_calendar), the calendar popover (284px, header with prev/next buttons labelled datepicker.prev_month and next_month, day cell states, closeOnSelect, Esc returns focus to the trigger, picking a day keeps the time or sets 00:00); time mode on TimeFieldRoot with the Clock icon; mapping minDate/maxDate to minValue/maxValue, yearRange clamping without explicit bounds, twelveHour to hourCycle 12, granularity minute for datetime; :focus-within ring, focused segment styling, invalid and data-invalid danger border with aria-invalid; read-only box with formatDisplay or a muted em dash; label and aria-describedby wiring via controlId. Native date inputs are not used. Register datepicker in registry.ts as a value field.
(4) CellValue.vue: kinds date (string as stored) and time (HH:mm, seconds dropped) with the datetime cell classes and the muted em dash when empty, never through the global Date constructor.
(5) Lang: backend::lang.datepicker.* keys (open_calendar, clear, prev_month, next_month) in en and pl with the UI-SPEC texts.
(6) Extend the smoke fixture and test: a datepicker starts_at (datetime) and released_on (date) field; with the process time zone fixed to Europe/Warsaw, a stored 2026-10-02T10:30:00Z shows 12:30 and saving it unchanged sends the same UTC string; the date field sends 2026-10-02. Rebuild and commit modules/boardwalk/dist.
npm --prefix admin run typecheck && npm --prefix admin test && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh
<fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found"; TestPhase10SPAKeysResolve prints "does not resolve"; check-admin-dist.sh prints "is stale".</fails_when>
Run go run ./cmd/summer serve against a development app with an acme form that has date, datetime and time datepicker fields; open the create screen; Tab into each field, type a value by keyboard, open the calendar with Enter, move with arrow keys, PageUp/PageDown, pick a day, press Esc.
Segments follow the admin locale, the focused segment uses the sel tint, the calendar is navigable by keyboard, Esc returns focus to the trigger, a day outside minDate/maxDate cannot be picked, the datetime shows local time with its zone segment, and the visuals match the UI-SPEC and Direction C.
<why_human>Keyboard flow, focus management and visual fit with Direction C cannot be fully asserted by unit tests.</why_human>
<acceptance_criteria>
- grep -c '"@internationalized/date": "3.12.4"' admin/package.json prints 1 when the user chose approve.
- grep -c 'DatePickerRoot' admin/src/components/form/fields/DatepickerField.vue prints at least 1 and grep -c 'TimeFieldRoot' admin/src/components/form/fields/DatepickerField.vue prints at least 1.
- grep -c 'type="date"' admin/src/components/form/fields/DatepickerField.vue prints 0 and grep -c 'new Date(' admin/src/components/form/fields/DatepickerField.vue prints 0.
- grep -c "'date'" admin/src/components/list/CellValue.vue prints at least 1.
- grep -c 'open_calendar' modules/phrasebook/backend/lang/pl/lang.yaml prints 1.
</acceptance_criteria>
Date, datetime and time fields are edited with an accessible, locale-aware picker that converts only datetime values, and lists show stored dates and times without shifting them.
(1) RelationManager.vue: toolbar buttons in declared order, all size sm, exactly one primary (create primary; link primary only without create; delete and unlink danger); row selection checkboxes when delete or unlink is declared; delete selected through ConfirmDialog (messages.relation.delete_selected, delete_confirm, deleted with CLDR :count) calling POST .../relations/{name}/delete; row click order (update: child modal in update mode; else belongsToMany with pivotForm: pivot modal; else viewForm: read-only child modal; else not clickable), the first cell a button, the trailing SlidersHorizontal pivot button when update is declared on a belongsToMany with a pivot form. On the create screen (FORM_SESSION record id 0) it uses id 0 and the X-Session-Key header on every call, shows the relation.pending_note line under the header, and calls markDirty() after each change; on an update form changes are immediate and the form stays clean.
(2) RelationChildModal.vue: Reka Dialog per the UI-SPEC child modal (720px, header with create_title/update_title/preview_title and the close button, body with FormErrorBanner, FormTabs when the child fields declare tabs, FormGrid with idPrefix child-<relation> over manageForm or viewForm fields), footer (danger Delete on the left in update mode when delete is declared, ghost Cancel and the primary create_submit/update_submit on the right, form.saving while busy), skeleton loading, load failure alert, 422 banner and focus on the first invalid field, 404 closes with the child_gone toast and reloads, success closes with created/updated toasts, dirty close asks form.unsaved_confirm, focus returns to the create button or the row button. It generates its own key on open and provides its own FORM_SESSION with childFileRoutes (child id 0 on create) so FileuploadField and DatepickerField work inside it; the save sends X-Session-Key (the parent form's key) and X-Child-Session-Key.
(3) Pivot: RelationPickerModal switches to single select with a Next step when the relation has a pivotForm (step 2 shows the chosen record and a FormGrid of pivotForm, Back keeps search, page and selection, link_submit posts {ids:[id], pivot:{...}}); RelationPivotModal.vue edits pivot values (560px, pivot_title, the related record's name, FormGrid, Cancel and pivot_submit, pivot_saved toast) through GET/PUT .../relations/{name}/pivot/{child}.
(4) FormView and registry: on create, keep relation-manager fields whose deferrable is true (others are dropped as today); everything else of the existing needsRecord rule stays.
(5) Lang: backend::lang.relation.{child_load_failed, child_gone, next, back, pending_note} in en and pl with the UI-SPEC texts. Text interpolation only; existing tokens only.
(6) Extend the smoke test: on the create screen a deferrable hasMany manager posts .../{0}/relations/parts/records with X-Session-Key, the form becomes dirty, and the parent create POST carries the same key. Update existing RelationManager and RelationPickerModal tests only where the new toolbar or single-select behaviour changes their expectations (keep every existing assertion that still describes unchanged behaviour). Rebuild and commit modules/boardwalk/dist.
npm --prefix admin run typecheck && npm --prefix admin test && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && go test ./modules/boardwalk -count=1
<fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found"; TestPhase10SPAKeysResolve prints "does not resolve"; check-admin-dist.sh prints "is stale"; the boardwalk embed test fails.</fails_when>
With the development app, open the create screen of an acme controller that has a deferrable hasMany relation manager (with a fileupload and a datepicker in its manage form) and a belongsToMany manager with a pivot form; create two children (one with an uploaded image), link a record with pivot details, upload several images to an attachMany field and drag them into a new order, then Save; reopen the saved record and edit a child, edit pivot details, delete a child.
Children, links, pivot values and files appear after the first save; upload progress and drag reorder feel smooth; each modal matches the UI-SPEC (single primary button, danger delete on the left, focus handling, toasts); nothing appears for another parent.
<why_human>Pointer drag, progress feel and the end-to-end modal flow across saved and unsaved records need a person in the browser.</why_human>
<acceptance_criteria>
- grep -c 'RelationChildModal' admin/src/components/relation/RelationManager.vue prints at least 1 and grep -c 'RelationPivotModal' admin/src/components/relation/RelationManager.vue prints at least 1.
- grep -c 'CHILD_SESSION_HEADER\|X-Child-Session-Key' admin/src/components/relation/RelationChildModal.vue admin/src/api/files.ts | awk -F: '{s+=$2} END {print s}' prints at least 1.
- grep -c 'v-html' admin/src/components/relation/RelationChildModal.vue prints 0 and grep -c 'v-html' admin/src/components/relation/RelationPivotModal.vue prints 0.
- grep -c 'deferrable' admin/src/views/FormView.vue prints at least 1.
- grep -c 'pending_note' modules/phrasebook/backend/lang/en/lang.yaml prints 1.
</acceptance_criteria>
The relation manager offers Winter's create, update, delete, link, unlink and pivot editing in modals on new and saved records, and the administrator sees the same behaviour the server enforces.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Server data (file names, captions, messages, record values) → DOM | Untrusted strings rendered in the admin's browser |
| SPA → admin API | Session keys and CSRF header on every write; cookie auth |
| npm registry → admin/package.json | A dependency enters the embedded build |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-12.2-29 | Spoofing | session key generation | medium | mitigate | 32 bytes from crypto.getRandomValues per form mount and per child modal; never Math.random (Task 1). |
| T-12.2-30 | Elevation of Privilege | XSS via file names, captions, server messages | high | mitigate | Text interpolation only in every new component; acceptance greps for raw-HTML sinks (Tasks 1, 4). |
| T-12.2-31 | Information Disclosure | protected thumbnail object URLs | low | mitigate | Fetched with cookie and session header, revoked on unmount; never a public URL for protected rows (Task 1). |
| T-12.2-32 | Tampering | CSRF on XHR uploads | high | mitigate | uploadWithProgress always sets X-Requested-With; the server's requireAjax refuses cookie writes without it (Task 1). |
| T-12.2-33 | Information Disclosure | session key in URLs or logs | low | mitigate | Headers only; acceptance grep for a key query parameter (Task 1). |
| T-12.2-SC | Tampering | npm install of @internationalized/date | high | mitigate | blocking-human decision checkpoint before install; exact pin 3.12.4; legitimacy audit OK in RESEARCH; package-lock integrity hash committed (Tasks 2, 3). |
| </threat_model> |
<success_criteria>
- DatepickerField, FileuploadField, the caption modal, the child and pivot modals and create-screen deferral match 12.2-UI-SPEC.md and the D-02, D-03, D-08 to D-12, D-14, D-17, D-18, D-20 and D-21 contracts.
- Every new string resolves in pl and en; the embedded dist matches a fresh build; the only new npm dependency is the approved exact pin. </success_criteria>