From a65c6705742bb1f73435556b7e1c7d61d51805f9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 00:10:48 +0200 Subject: [PATCH] feat(12.1-02): read-only preview screen with a status hint and record actions - config_form.yaml preview block (optional headerPartial), reported in the form schema as preview - fields with context: preview show only on the preview screen and are never written - form messages preview and edit; recordActions without a preview block stops boot - SPA route {id}/preview, PreviewView and PreviewField, record actions in the footer - mapWinterUrl maps preview/:id; the update form returns to the preview - summer-callout partial style classes for status hints - README, docs, OpenAPI document, TS types and the embedded build updated --- admin/openapi/admin.json | 30 ++ admin/src/api/schema.d.ts | 16 +- admin/src/api/types.ts | 2 + admin/src/app/router.ts | 10 +- admin/src/app/winterUrl.ts | 9 +- admin/src/components/form/PreviewField.vue | 134 +++++++ admin/src/components/form/formState.ts | 3 +- admin/src/components/partial/PartialHost.vue | 9 +- admin/src/styles/main.css | 33 ++ admin/src/views/FormView.vue | 11 +- admin/src/views/PreviewView.vue | 291 ++++++++++++++ admin/tests/app/winterUrl.test.ts | 10 +- .../tests/fixtures/deferred.form-schema.json | 6 + .../tests/fixtures/extension.form-schema.json | 6 + admin/tests/fixtures/roster.form-schema.json | 29 ++ admin/tests/fixtures/roster.list-schema.json | 2 +- admin/tests/fixtures/roster.record.json | 2 +- admin/tests/fixtures/settings.json | 6 + admin/tests/fixtures/typed.ts | 3 + admin/tests/fixtures/widgets.form-schema.json | 6 + admin/tests/smoke/actions.smoke.test.ts | 4 +- admin/tests/smoke/edit.smoke.test.ts | 3 +- admin/tests/smoke/preview.smoke.test.ts | 369 ++++++++++++++++++ docs/backend/admin-controllers.md | 11 +- docs/backend/admin-spa.md | 2 + docs/backend/forms.md | 35 +- docs/backend/partials-and-widgets.md | 20 +- .../boardwalk/dist/assets/index-57SuA8gQ.css | 1 + .../boardwalk/dist/assets/index-8CEYdgqp.js | 9 - .../boardwalk/dist/assets/index-BxJxH4xB.css | 1 - .../boardwalk/dist/assets/index-DEJgWNHv.js | 9 + modules/boardwalk/dist/index.html | 4 +- modules/cabana/README.md | 19 +- modules/cabana/admin_openapi.go | 1 + modules/cabana/extension.go | 14 +- modules/cabana/form_schema.go | 77 ++++ modules/cabana/messages.go | 8 + modules/cabana/openapi_conformance_test.go | 1 + modules/cabana/phase121_actions_test.go | 20 +- modules/cabana/phase121_fixture_test.go | 83 +++- modules/cabana/phase121_form_test.go | 138 +++++++ modules/cabana/schema_types.go | 15 + .../roster/controllers/people/_status.htm | 6 + .../controllers/people/config_form.yaml | 9 +- .../controllers/people/config_list.yaml | 2 +- .../cabana/testdata/roster/lang/en/lang.yaml | 9 + .../cabana/testdata/roster/lang/pl/lang.yaml | 9 + .../testdata/roster/models/person/fields.yaml | 4 + modules/phrasebook/backend/lang/en/lang.yaml | 3 + modules/phrasebook/backend/lang/pl/lang.yaml | 3 + 50 files changed, 1441 insertions(+), 66 deletions(-) create mode 100644 admin/src/components/form/PreviewField.vue create mode 100644 admin/src/views/PreviewView.vue create mode 100644 admin/tests/fixtures/roster.form-schema.json create mode 100644 admin/tests/smoke/preview.smoke.test.ts create mode 100644 modules/boardwalk/dist/assets/index-57SuA8gQ.css delete mode 100644 modules/boardwalk/dist/assets/index-8CEYdgqp.js delete mode 100644 modules/boardwalk/dist/assets/index-BxJxH4xB.css create mode 100644 modules/boardwalk/dist/assets/index-DEJgWNHv.js create mode 100644 modules/cabana/phase121_form_test.go create mode 100644 modules/cabana/testdata/roster/controllers/people/_status.htm diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 9b44f46..60caadc 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -846,6 +846,17 @@ "deleted": { "$ref": "#/components/schemas/cabana.MessageForms" }, + "edit": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "preview": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.MessageForms" + } + ], + "description": "The preview screen's subtitle and its edit button (D-11)." + }, "saved": { "$ref": "#/components/schemas/cabana.MessageForms" }, @@ -857,6 +868,8 @@ "create", "deleteConfirm", "deleted", + "edit", + "preview", "saved", "update" ], @@ -888,6 +901,14 @@ ], "type": "object" }, + "cabana.FormPreview": { + "properties": { + "headerPartial": { + "type": "string" + } + }, + "type": "object" + }, "cabana.FormRedirect": { "properties": { "redirect": { @@ -955,6 +976,14 @@ "name": { "type": "string" }, + "preview": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.FormPreview" + } + ], + "description": "Preview is set when the form has a preview screen (D-11); a form\nwithout one omits the key." + }, "redirects": { "allOf": [ { @@ -3262,6 +3291,7 @@ }, "/{vendor}/{plugin}/{controller}/schema/form": { "get": { + "description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint.", "parameters": [ { "description": "Vendor", diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 3da0977..cfa0d79 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -1214,7 +1214,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Admin form schema */ + /** + * Admin form schema + * @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. + */ get: { parameters: { query?: never; @@ -4469,6 +4472,9 @@ export interface components { create: components["schemas"]["cabana.MessageForms"]; deleteConfirm: components["schemas"]["cabana.MessageForms"]; deleted: components["schemas"]["cabana.MessageForms"]; + edit: components["schemas"]["cabana.MessageForms"]; + /** @description The preview screen's subtitle and its edit button (D-11). */ + preview: components["schemas"]["cabana.MessageForms"]; saved: components["schemas"]["cabana.MessageForms"]; update: components["schemas"]["cabana.MessageForms"]; }; @@ -4479,6 +4485,9 @@ export interface components { label: string; value: components["schemas"]["cabana.jsonScalar"]; }; + "cabana.FormPreview": { + headerPartial?: string; + }; "cabana.FormRedirect": { redirect: string; redirectClose: string; @@ -4500,6 +4509,11 @@ export interface components { meta: components["schemas"]["cabana.FormMeta"]; modelClass?: string; name?: string; + /** + * @description Preview is set when the form has a preview screen (D-11); a form + * without one omits the key. + */ + preview?: components["schemas"]["cabana.FormPreview"]; /** * @description Redirects are the raw Winter config_form.yaml targets; the SPA maps * them onto its routes. diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index 2cfd058..51537e0 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -24,6 +24,8 @@ export type FormField = Schemas['cabana.FormField'] export type FormOption = Schemas['cabana.FormOption'] export type FormMessages = Schemas['cabana.FormMessages'] export type FormRedirects = Schemas['cabana.FormRedirects'] +/** A form's preview screen: present when config_form.yaml declares `preview:`. */ +export type FormPreview = Schemas['cabana.FormPreview'] export type RelationSchema = Schemas['cabana.RelationSchema'] export type RelationMessages = Schemas['cabana.RelationMessages'] export type RelationMutationResult = Schemas['cabana.RelationMutationResult'] diff --git a/admin/src/app/router.ts b/admin/src/app/router.ts index fd42c51..a9c5282 100644 --- a/admin/src/app/router.ts +++ b/admin/src/app/router.ts @@ -7,6 +7,7 @@ import { homePath } from '../state/useNavigation' import LoginView from '../views/LoginView.vue' import ListView from '../views/ListView.vue' import FormView from '../views/FormView.vue' +import PreviewView from '../views/PreviewView.vue' import SettingsFormView from '../views/SettingsFormView.vue' import SettingsIndexView from '../views/SettingsIndexView.vue' import NotFoundView from '../views/NotFoundView.vue' @@ -34,7 +35,7 @@ export function safeRedirect(value: unknown): string | null { return value } -const CONTROLLER_ROUTES = new Set(['list', 'create', 'record']) +const CONTROLLER_ROUTES = new Set(['list', 'create', 'record', 'preview']) /** * The controller a route shows, or '' for every other screen (settings, @@ -69,6 +70,13 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt { path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } }, { path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } }, { path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } }, + // The read-only record screen of a form with a preview block (D-11). + { + path: '/:vendor/:plugin/:controller/:id(\\d+)/preview', + name: 'preview', + component: PreviewView, + meta: { shell: true }, + }, { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } }, ], }) diff --git a/admin/src/app/winterUrl.ts b/admin/src/app/winterUrl.ts index 8f78923..fe2c34a 100644 --- a/admin/src/app/winterUrl.ts +++ b/admin/src/app/winterUrl.ts @@ -1,11 +1,12 @@ // Winter-shaped URLs (config_list recordUrl, config_form redirects) mapped // onto the SPA's D-10 routes. The strings come from plugin YAML, so they are -// never used verbatim: only the current controller's list, create and record -// routes can come out (research Gap 8, T-10-20). +// never used verbatim: only the current controller's list, create, record +// and preview routes can come out (research Gap 8, T-10-20, T-12.1-16). // // // -> list // ///create -> create // ///update/:id -> record (:id substituted) +// ///preview/:id -> preview (:id substituted) // anything else, or another controller -> list import { controllerPath, parseControllerId } from './controllerRoutes' @@ -35,5 +36,9 @@ export function mapWinterUrl(url: string | null | undefined, controllerId: strin const target = rest[1] === ':id' ? String(id ?? '') : (rest[1] ?? '') return DIGITS.test(target) ? `${base}/${target}` : base } + if (rest.length === 2 && rest[0] === 'preview') { + const target = rest[1] === ':id' ? String(id ?? '') : (rest[1] ?? '') + return DIGITS.test(target) ? `${base}/${target}/preview` : base + } return base } diff --git a/admin/src/components/form/PreviewField.vue b/admin/src/components/form/PreviewField.vue new file mode 100644 index 0000000..2c3d8c1 --- /dev/null +++ b/admin/src/components/form/PreviewField.vue @@ -0,0 +1,134 @@ + + + diff --git a/admin/src/components/form/formState.ts b/admin/src/components/form/formState.ts index c92fc1b..34eb538 100644 --- a/admin/src/components/form/formState.ts +++ b/admin/src/components/form/formState.ts @@ -3,7 +3,8 @@ import type { AdminRecord, ErrorBody, FormField } from '../../api/types' import { isRegistered } from './registry' -export type FormMode = 'create' | 'update' +/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */ +export type FormMode = 'create' | 'update' | 'preview' /** One form tab: the YAML tab label, or the default tab for untabbed fields. */ export interface TabItem { diff --git a/admin/src/components/partial/PartialHost.vue b/admin/src/components/partial/PartialHost.vue index 2a668d2..99bb016 100644 --- a/admin/src/components/partial/PartialHost.vue +++ b/admin/src/components/partial/PartialHost.vue @@ -12,7 +12,8 @@ import { renderPartialNodes } from './partialNodes' // The first load shows a skeleton; a reload (reloadKey change) keeps the // current nodes visible and only marks the host busy. Zero nodes render // nothing; a failure shows the extension failure box. No live region: the -// toast of the action that caused a reload is the announcement. +// toast of the action that caused a reload is the announcement. A reload +// that fails keeps nothing: the failure box replaces the content. const props = withDefaults( defineProps<{ source: ControllerParams @@ -22,8 +23,10 @@ const props = withDefaults( variant: 'header' | 'field' /** Bumped by the parent to refetch. */ reloadKey?: number + /** A status hint (preview screen): the first load shows one 68px block. */ + hint?: boolean }>(), - { recordId: null, reloadKey: 0 }, + { recordId: null, reloadKey: 0, hint: false }, ) const nodes = ref(null) @@ -66,7 +69,7 @@ void load()