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
This commit is contained in:
@@ -846,6 +846,17 @@
|
|||||||
"deleted": {
|
"deleted": {
|
||||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
"$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": {
|
"saved": {
|
||||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||||
},
|
},
|
||||||
@@ -857,6 +868,8 @@
|
|||||||
"create",
|
"create",
|
||||||
"deleteConfirm",
|
"deleteConfirm",
|
||||||
"deleted",
|
"deleted",
|
||||||
|
"edit",
|
||||||
|
"preview",
|
||||||
"saved",
|
"saved",
|
||||||
"update"
|
"update"
|
||||||
],
|
],
|
||||||
@@ -888,6 +901,14 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.FormPreview": {
|
||||||
|
"properties": {
|
||||||
|
"headerPartial": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.FormRedirect": {
|
"cabana.FormRedirect": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"redirect": {
|
"redirect": {
|
||||||
@@ -955,6 +976,14 @@
|
|||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"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": {
|
"redirects": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@@ -3262,6 +3291,7 @@
|
|||||||
},
|
},
|
||||||
"/{vendor}/{plugin}/{controller}/schema/form": {
|
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||||
"get": {
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "Vendor",
|
"description": "Vendor",
|
||||||
|
|||||||
16
admin/src/api/schema.d.ts
vendored
16
admin/src/api/schema.d.ts
vendored
@@ -1214,7 +1214,10 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: 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: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4469,6 +4472,9 @@ export interface components {
|
|||||||
create: components["schemas"]["cabana.MessageForms"];
|
create: components["schemas"]["cabana.MessageForms"];
|
||||||
deleteConfirm: components["schemas"]["cabana.MessageForms"];
|
deleteConfirm: components["schemas"]["cabana.MessageForms"];
|
||||||
deleted: 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"];
|
saved: components["schemas"]["cabana.MessageForms"];
|
||||||
update: components["schemas"]["cabana.MessageForms"];
|
update: components["schemas"]["cabana.MessageForms"];
|
||||||
};
|
};
|
||||||
@@ -4479,6 +4485,9 @@ export interface components {
|
|||||||
label: string;
|
label: string;
|
||||||
value: components["schemas"]["cabana.jsonScalar"];
|
value: components["schemas"]["cabana.jsonScalar"];
|
||||||
};
|
};
|
||||||
|
"cabana.FormPreview": {
|
||||||
|
headerPartial?: string;
|
||||||
|
};
|
||||||
"cabana.FormRedirect": {
|
"cabana.FormRedirect": {
|
||||||
redirect: string;
|
redirect: string;
|
||||||
redirectClose: string;
|
redirectClose: string;
|
||||||
@@ -4500,6 +4509,11 @@ export interface components {
|
|||||||
meta: components["schemas"]["cabana.FormMeta"];
|
meta: components["schemas"]["cabana.FormMeta"];
|
||||||
modelClass?: string;
|
modelClass?: string;
|
||||||
name?: 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
|
* @description Redirects are the raw Winter config_form.yaml targets; the SPA maps
|
||||||
* them onto its routes.
|
* them onto its routes.
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export type FormField = Schemas['cabana.FormField']
|
|||||||
export type FormOption = Schemas['cabana.FormOption']
|
export type FormOption = Schemas['cabana.FormOption']
|
||||||
export type FormMessages = Schemas['cabana.FormMessages']
|
export type FormMessages = Schemas['cabana.FormMessages']
|
||||||
export type FormRedirects = Schemas['cabana.FormRedirects']
|
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 RelationSchema = Schemas['cabana.RelationSchema']
|
||||||
export type RelationMessages = Schemas['cabana.RelationMessages']
|
export type RelationMessages = Schemas['cabana.RelationMessages']
|
||||||
export type RelationMutationResult = Schemas['cabana.RelationMutationResult']
|
export type RelationMutationResult = Schemas['cabana.RelationMutationResult']
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { homePath } from '../state/useNavigation'
|
|||||||
import LoginView from '../views/LoginView.vue'
|
import LoginView from '../views/LoginView.vue'
|
||||||
import ListView from '../views/ListView.vue'
|
import ListView from '../views/ListView.vue'
|
||||||
import FormView from '../views/FormView.vue'
|
import FormView from '../views/FormView.vue'
|
||||||
|
import PreviewView from '../views/PreviewView.vue'
|
||||||
import SettingsFormView from '../views/SettingsFormView.vue'
|
import SettingsFormView from '../views/SettingsFormView.vue'
|
||||||
import SettingsIndexView from '../views/SettingsIndexView.vue'
|
import SettingsIndexView from '../views/SettingsIndexView.vue'
|
||||||
import NotFoundView from '../views/NotFoundView.vue'
|
import NotFoundView from '../views/NotFoundView.vue'
|
||||||
@@ -34,7 +35,7 @@ export function safeRedirect(value: unknown): string | null {
|
|||||||
return value
|
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,
|
* 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', name: 'list', component: ListView, meta: { shell: true } },
|
||||||
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, 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 } },
|
{ 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 } },
|
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// Winter-shaped URLs (config_list recordUrl, config_form redirects) mapped
|
// 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
|
// 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
|
// never used verbatim: only the current controller's list, create, record
|
||||||
// routes can come out (research Gap 8, T-10-20).
|
// and preview routes can come out (research Gap 8, T-10-20, T-12.1-16).
|
||||||
//
|
//
|
||||||
// <vendor>/<plugin>/<controller> -> list
|
// <vendor>/<plugin>/<controller> -> list
|
||||||
// <vendor>/<plugin>/<controller>/create -> create
|
// <vendor>/<plugin>/<controller>/create -> create
|
||||||
// <vendor>/<plugin>/<controller>/update/:id -> record (:id substituted)
|
// <vendor>/<plugin>/<controller>/update/:id -> record (:id substituted)
|
||||||
|
// <vendor>/<plugin>/<controller>/preview/:id -> preview (:id substituted)
|
||||||
// anything else, or another controller -> list
|
// anything else, or another controller -> list
|
||||||
import { controllerPath, parseControllerId } from './controllerRoutes'
|
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] ?? '')
|
const target = rest[1] === ':id' ? String(id ?? '') : (rest[1] ?? '')
|
||||||
return DIGITS.test(target) ? `${base}/${target}` : base
|
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
|
return base
|
||||||
}
|
}
|
||||||
|
|||||||
134
admin/src/components/form/PreviewField.vue
Normal file
134
admin/src/components/form/PreviewField.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
|
||||||
|
import { t } from '../../app/i18n'
|
||||||
|
import { toggleOn } from './control'
|
||||||
|
import { rendererFor } from './registry'
|
||||||
|
|
||||||
|
// One field of the preview screen (UI-SPEC S3, D-11): a label and its value
|
||||||
|
// as a dt/dd pair. Values are text, never disabled inputs, so they keep full
|
||||||
|
// contrast and are read in order. Controls that already have a read-only
|
||||||
|
// mode (datepicker, fileupload, permissioneditor) and partials are rendered
|
||||||
|
// through their own component with the field marked read-only.
|
||||||
|
const props = defineProps<{
|
||||||
|
field: FormField
|
||||||
|
value: unknown
|
||||||
|
labels?: RelationOption[]
|
||||||
|
source?: ControllerParams | null
|
||||||
|
recordId?: number | null
|
||||||
|
idPrefix?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** Types rendered by their own control in its read-only mode. */
|
||||||
|
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor'])
|
||||||
|
|
||||||
|
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
|
||||||
|
const labelId = computed(() => `${controlId.value}-label`)
|
||||||
|
|
||||||
|
const kind = computed(() => {
|
||||||
|
const type = props.field.type
|
||||||
|
if (ownControl.has(type)) {
|
||||||
|
return 'control'
|
||||||
|
}
|
||||||
|
if (type === 'checkbox' || type === 'switch') {
|
||||||
|
return 'toggle'
|
||||||
|
}
|
||||||
|
if (type === 'relation') {
|
||||||
|
return props.field.multiple ? 'chips' : 'relation'
|
||||||
|
}
|
||||||
|
return type === 'textarea' ? 'textarea' : 'text'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The field as its own control sees it on preview: always read-only. */
|
||||||
|
const readOnlyField = computed<FormField>(() => ({ ...props.field, readOnly: true }))
|
||||||
|
const control = computed(() => rendererFor(props.field.type))
|
||||||
|
|
||||||
|
function isEmpty(value: unknown): boolean {
|
||||||
|
return value === null || value === undefined || value === ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = computed(() => {
|
||||||
|
const value = props.value
|
||||||
|
if (isEmpty(value) || typeof value === 'object') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (props.field.type === 'dropdown') {
|
||||||
|
const option = (props.field.options ?? []).find((item) => String(item.value) === String(value))
|
||||||
|
return option ? option.label : String(value)
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const relationLabel = computed(() => props.labels?.[0]?.label ?? '')
|
||||||
|
const chips = computed(() => (Array.isArray(props.value) ? (props.labels ?? []) : []))
|
||||||
|
|
||||||
|
function initials(label: string): string {
|
||||||
|
const parts = label.trim().split(/\s+/).filter(Boolean)
|
||||||
|
return parts
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase())
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = 'flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5 [overflow-wrap:anywhere]'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-w-0 flex-col gap-1.5" :data-preview-field="field.name">
|
||||||
|
<dt :id="labelId" class="font-semibold">{{ field.label || field.name }}</dt>
|
||||||
|
<dd class="m-0 min-w-0">
|
||||||
|
<component
|
||||||
|
:is="control"
|
||||||
|
v-if="kind === 'control'"
|
||||||
|
:field="readOnlyField"
|
||||||
|
:model-value="value"
|
||||||
|
:control-id="controlId"
|
||||||
|
:labels="labels"
|
||||||
|
:source="source"
|
||||||
|
:record-id="recordId"
|
||||||
|
/>
|
||||||
|
<template v-else-if="kind === 'toggle'">
|
||||||
|
<span
|
||||||
|
v-if="toggleOn(value)"
|
||||||
|
data-switch="true"
|
||||||
|
class="inline-flex h-6 items-center rounded-pill bg-ok-bg px-2.5 text-[12px] font-semibold text-ok-text"
|
||||||
|
>{{ t('backend::lang.list.column_switch_true') }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
data-switch="false"
|
||||||
|
class="inline-flex h-6 items-center rounded-pill border border-border-strong px-2.5 text-[12px] font-semibold text-muted"
|
||||||
|
>{{ t('backend::lang.list.column_switch_false') }}</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="kind === 'chips'" data-preview-value :class="box" class="flex-wrap gap-1.5 p-1.5">
|
||||||
|
<span
|
||||||
|
v-for="chip in chips"
|
||||||
|
:key="String(chip.value)"
|
||||||
|
data-chip
|
||||||
|
class="inline-flex min-h-[30px] items-center gap-1.5 rounded-pill bg-surface pr-3 pl-1"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex size-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-on-primary"
|
||||||
|
aria-hidden="true"
|
||||||
|
>{{ initials(chip.label) }}</span
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{{ chip.label }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="chips.length === 0" class="px-2 text-muted" data-empty>{{ t('backend::lang.list.empty_value') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="kind === 'relation'" data-preview-value :class="box">
|
||||||
|
<span v-if="relationLabel !== ''">{{ relationLabel }}</span>
|
||||||
|
<span v-else class="text-muted" data-empty>{{ field.emptyOption || t('backend::lang.list.empty_value') }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
data-preview-value
|
||||||
|
:class="[box, kind === 'textarea' ? 'items-start! py-3 whitespace-pre-wrap' : '']"
|
||||||
|
>
|
||||||
|
<span v-if="text !== ''">{{ text }}</span>
|
||||||
|
<span v-else class="text-muted" data-empty>{{ t('backend::lang.list.empty_value') }}</span>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
|
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
|
||||||
import { isRegistered } from './registry'
|
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. */
|
/** One form tab: the YAML tab label, or the default tab for untabbed fields. */
|
||||||
export interface TabItem {
|
export interface TabItem {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { renderPartialNodes } from './partialNodes'
|
|||||||
// The first load shows a skeleton; a reload (reloadKey change) keeps the
|
// The first load shows a skeleton; a reload (reloadKey change) keeps the
|
||||||
// current nodes visible and only marks the host busy. Zero nodes render
|
// current nodes visible and only marks the host busy. Zero nodes render
|
||||||
// nothing; a failure shows the extension failure box. No live region: the
|
// 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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
source: ControllerParams
|
source: ControllerParams
|
||||||
@@ -22,8 +23,10 @@ const props = withDefaults(
|
|||||||
variant: 'header' | 'field'
|
variant: 'header' | 'field'
|
||||||
/** Bumped by the parent to refetch. */
|
/** Bumped by the parent to refetch. */
|
||||||
reloadKey?: number
|
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<PartialNode[] | null>(null)
|
const nodes = ref<PartialNode[] | null>(null)
|
||||||
@@ -66,7 +69,7 @@ void load()
|
|||||||
<div v-else-if="nodes === null" data-partial-loading aria-busy="true">
|
<div v-else-if="nodes === null" data-partial-loading aria-busy="true">
|
||||||
<div
|
<div
|
||||||
data-partial-skeleton
|
data-partial-skeleton
|
||||||
:class="variant === 'header' ? 'h-[80px] w-full rounded-card' : 'h-[44px] w-full rounded-control'"
|
:class="hint ? 'h-[68px] w-full rounded-inner' : variant === 'header' ? 'h-[80px] w-full rounded-card' : 'h-[44px] w-full rounded-control'"
|
||||||
class="bg-skel"
|
class="bg-skel"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -273,6 +273,39 @@
|
|||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Callout (Phase 12.1, UI-SPEC): a status hint above a preview screen. */
|
||||||
|
.summer-callout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summer-callout--warning {
|
||||||
|
background: var(--c-sel);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summer-callout--danger {
|
||||||
|
background: var(--c-danger-soft);
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summer-callout__title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summer-callout__text {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const path = {
|
|||||||
}
|
}
|
||||||
const controllerId = controllerIdFromParams(path)
|
const controllerId = controllerIdFromParams(path)
|
||||||
const listPath = controllerPath(controllerId) ?? '/'
|
const listPath = controllerPath(controllerId) ?? '/'
|
||||||
const mode: FormMode = route.name === 'create' ? 'create' : 'update'
|
const mode: Exclude<FormMode, 'preview'> = route.name === 'create' ? 'create' : 'update'
|
||||||
const recordId = mode === 'update' ? Number(route.params.id) : null
|
const recordId = mode === 'update' ? Number(route.params.id) : null
|
||||||
|
|
||||||
const schema = ref<FormView | null>(null)
|
const schema = ref<FormView | null>(null)
|
||||||
@@ -152,6 +152,11 @@ const subtitle = computed(() =>
|
|||||||
mode === 'update' ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
|
mode === 'update' ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// An update form of a record that has a preview screen is entered from it,
|
||||||
|
// so back and Cancel return there (UI-SPEC S3); a delete still goes to the list.
|
||||||
|
const backToPreview = computed(() => mode === 'update' && !!schema.value?.preview)
|
||||||
|
const backPath = computed(() => (backToPreview.value ? `${listPath}/${recordId}/preview` : listPath))
|
||||||
|
|
||||||
const dirty = computed(
|
const dirty = computed(
|
||||||
() =>
|
() =>
|
||||||
!loading.value &&
|
!loading.value &&
|
||||||
@@ -366,7 +371,7 @@ async function onLeave(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await go(listPath)
|
await go(backPath.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeRouteLeave(guard)
|
onBeforeRouteLeave(guard)
|
||||||
@@ -395,7 +400,7 @@ void load()
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
data-action="back"
|
data-action="back"
|
||||||
:aria-label="t('backend::lang.form.return_to_list')"
|
:aria-label="t(backToPreview ? 'backend::lang.form.return_to_preview' : 'backend::lang.form.return_to_list')"
|
||||||
class="size-10! px-0!"
|
class="size-10! px-0!"
|
||||||
@click="onLeave"
|
@click="onLeave"
|
||||||
>
|
>
|
||||||
|
|||||||
291
admin/src/views/PreviewView.vue
Normal file
291
admin/src/views/PreviewView.vue
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, provide, readonly, ref, watchEffect } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, Pencil } from '@lucide/vue'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { parentFileRoutes } from '../api/files'
|
||||||
|
import type { AdminRecord, FormField, FormView, RecordAction, RecordMeta } from '../api/types'
|
||||||
|
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||||
|
import { message, t } from '../app/i18n'
|
||||||
|
import { loadControllerAssets } from '../app/pluginAssets'
|
||||||
|
import { newSessionKey } from '../app/sessionKey'
|
||||||
|
import { FORM_SESSION, createUploadGate } from '../components/form/formContext'
|
||||||
|
import FormTabs from '../components/form/FormTabs.vue'
|
||||||
|
import PreviewField from '../components/form/PreviewField.vue'
|
||||||
|
import RecordActions from '../components/form/RecordActions.vue'
|
||||||
|
import { RELATION_MANAGER } from '../components/form/registry'
|
||||||
|
import { DEFAULT_TAB, contextAllows, panelDomId, tabDomId, tabOf, type TabItem } from '../components/form/formState'
|
||||||
|
import PartialHost from '../components/partial/PartialHost.vue'
|
||||||
|
import Button from '../components/ui/Button.vue'
|
||||||
|
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
|
||||||
|
import { showToast } from '../state/useToasts'
|
||||||
|
|
||||||
|
// Read-only record screen (UI-SPEC S3, D-11) of a form whose config declares
|
||||||
|
// `preview:`. It shows the fields whose context allows preview as a dl grid,
|
||||||
|
// the status hint partial above the card, and a footer with the record
|
||||||
|
// actions the record response offers (S2) and the one primary edit button.
|
||||||
|
// Nothing here writes a field: the only requests besides the loads are the
|
||||||
|
// record actions. A form without a preview sends this route to the record
|
||||||
|
// (update) route.
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const ID_PREFIX = 'preview'
|
||||||
|
|
||||||
|
const path = {
|
||||||
|
vendor: String(route.params.vendor ?? ''),
|
||||||
|
plugin: String(route.params.plugin ?? ''),
|
||||||
|
controller: String(route.params.controller ?? ''),
|
||||||
|
}
|
||||||
|
const controllerId = controllerIdFromParams(path)
|
||||||
|
const listPath = controllerPath(controllerId) ?? '/'
|
||||||
|
const recordId = Number(route.params.id)
|
||||||
|
const recordPath = `${listPath}/${recordId}`
|
||||||
|
|
||||||
|
const schema = ref<FormView | null>(null)
|
||||||
|
const values = ref<AdminRecord>({})
|
||||||
|
const labels = ref<RecordMeta['labels']>({})
|
||||||
|
const actions = ref<RecordAction[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const failed = ref(false)
|
||||||
|
// True while a record action's request runs: every footer button is disabled.
|
||||||
|
const acting = ref(false)
|
||||||
|
const activeTab = ref(DEFAULT_TAB)
|
||||||
|
// Bumped after every record action, so the status hint is refetched.
|
||||||
|
const hintKey = ref(0)
|
||||||
|
|
||||||
|
/** Types that are never shown on preview (UI-SPEC S3). */
|
||||||
|
const hidden = new Set(['password', 'widget', RELATION_MANAGER])
|
||||||
|
|
||||||
|
const fields = computed<FormField[]>(() =>
|
||||||
|
(schema.value?.fields ?? []).filter((field) => contextAllows(field, 'preview') && !hidden.has(field.type)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The same tabs as the form; a tab with no preview-visible field never
|
||||||
|
// appears, because tabs are built from the visible fields only.
|
||||||
|
const tabs = computed<TabItem[]>(() => {
|
||||||
|
if (!fields.value.some((field) => field.tab)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const out: TabItem[] = []
|
||||||
|
for (const field of fields.value) {
|
||||||
|
const key = tabOf(field)
|
||||||
|
if (out.some((item) => item.key === key)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const tab = { key, label: field.tab || t('backend::lang.form.tab_default'), errors: 0 }
|
||||||
|
if (key === DEFAULT_TAB) {
|
||||||
|
out.unshift(tab)
|
||||||
|
} else {
|
||||||
|
out.push(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeIndex = computed(() => Math.max(
|
||||||
|
tabs.value.findIndex((tab) => tab.key === activeTab.value),
|
||||||
|
0,
|
||||||
|
))
|
||||||
|
|
||||||
|
const panelFields = computed(() => {
|
||||||
|
if (tabs.value.length === 0) {
|
||||||
|
return fields.value
|
||||||
|
}
|
||||||
|
const key = tabs.value[activeIndex.value]?.key ?? DEFAULT_TAB
|
||||||
|
return fields.value.filter((field) => tabOf(field) === key)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The record's display name: the first text field's value, in schema order. */
|
||||||
|
const recordName = computed(() => {
|
||||||
|
const first = schema.value?.fields.find((field) => field.type === 'text')
|
||||||
|
const value = first ? values.value[first.name] : undefined
|
||||||
|
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const subtitle = computed(() => message(schema.value?.messages.preview, undefined, { name: recordName.value }))
|
||||||
|
const hintPartial = computed(() => schema.value?.preview?.headerPartial ?? '')
|
||||||
|
const ready = computed(() => !loading.value && !failed.value && schema.value !== null)
|
||||||
|
|
||||||
|
function spanClass(field: FormField): string {
|
||||||
|
switch (field.span) {
|
||||||
|
case 'left':
|
||||||
|
return 'min-[600px]:col-start-1'
|
||||||
|
case 'right':
|
||||||
|
return 'min-[600px]:col-start-2'
|
||||||
|
case 'auto':
|
||||||
|
case 'row':
|
||||||
|
return ''
|
||||||
|
default:
|
||||||
|
return 'col-span-full'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches the record; false when it is gone or out of scope. */
|
||||||
|
async function loadRecord(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await api.GET('/{vendor}/{plugin}/{controller}/{id}', {
|
||||||
|
params: { path: { ...path, id: recordId } },
|
||||||
|
})
|
||||||
|
if (!result.data) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
values.value = { ...result.data.data }
|
||||||
|
labels.value = result.data.meta.labels ?? {}
|
||||||
|
actions.value = result.data.meta.actions ?? []
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
failed.value = false
|
||||||
|
const [schemaResult, recordOk] = await Promise.all([
|
||||||
|
api.GET('/{vendor}/{plugin}/{controller}/schema/form', { params: { path } }).catch(() => null),
|
||||||
|
loadRecord(),
|
||||||
|
])
|
||||||
|
const view = schemaResult?.data?.data ?? null
|
||||||
|
if (view && !view.preview) {
|
||||||
|
// The form has no preview screen: the record route is its only screen.
|
||||||
|
await router.replace(recordPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
schema.value = view
|
||||||
|
failed.value = !view || !recordOk
|
||||||
|
if (view) {
|
||||||
|
void loadControllerAssets(controllerId, view.assets)
|
||||||
|
}
|
||||||
|
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reloads the record and the hint in place: the previous values stay on
|
||||||
|
* screen until the new ones arrive (no skeleton flash). A record that is gone
|
||||||
|
* turns the screen into the load failure.
|
||||||
|
*/
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
hintKey.value++
|
||||||
|
if (!(await loadRecord())) {
|
||||||
|
failed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDone(text: string): Promise<void> {
|
||||||
|
showToast(text)
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGone(): void {
|
||||||
|
failed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fileupload field lists its files through the form session. The preview
|
||||||
|
// never uploads or saves, so the session only carries the routes.
|
||||||
|
const { activeUploads, beginUpload } = createUploadGate()
|
||||||
|
const sessionKey = newSessionKey()
|
||||||
|
provide(FORM_SESSION, {
|
||||||
|
key: sessionKey,
|
||||||
|
recordId,
|
||||||
|
routes: (field: string) => parentFileRoutes(path, recordId, field, sessionKey),
|
||||||
|
markDirty: () => undefined,
|
||||||
|
pendingChanges: readonly(ref(0)),
|
||||||
|
revision: readonly(ref(0)),
|
||||||
|
beginUpload,
|
||||||
|
activeUploads: readonly(activeUploads),
|
||||||
|
})
|
||||||
|
|
||||||
|
// The header's last breadcrumb is this record's name (design: Top header).
|
||||||
|
watchEffect(() => setRecordCrumb(loading.value ? '' : recordName.value))
|
||||||
|
onBeforeUnmount(clearRecordCrumb)
|
||||||
|
|
||||||
|
void load()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="flex w-full flex-col gap-5 pb-24" data-preview>
|
||||||
|
<header class="flex flex-wrap items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
data-action="back"
|
||||||
|
:to="listPath"
|
||||||
|
:aria-label="t('backend::lang.form.return_to_list')"
|
||||||
|
class="size-10! px-0!"
|
||||||
|
>
|
||||||
|
<ArrowLeft :size="18" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ loading ? '' : recordName }}</h1>
|
||||||
|
<p v-if="ready && subtitle" class="text-muted">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<FormTabs v-if="ready && tabs.length > 0" v-model="activeTab" :tabs="tabs" :id-prefix="ID_PREFIX" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
|
||||||
|
{{ t('backend::lang.form.load_failed') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else-if="ready">
|
||||||
|
<!-- The status hint: outside every tab, so it is always visible. -->
|
||||||
|
<PartialHost
|
||||||
|
v-if="hintPartial !== ''"
|
||||||
|
variant="header"
|
||||||
|
hint
|
||||||
|
:source="path"
|
||||||
|
:name="hintPartial"
|
||||||
|
:record-id="recordId"
|
||||||
|
:reload-key="hintKey"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
:id="tabs.length > 0 ? panelDomId(ID_PREFIX, activeIndex) : undefined"
|
||||||
|
:role="tabs.length > 0 ? 'tabpanel' : undefined"
|
||||||
|
:aria-labelledby="tabs.length > 0 ? tabDomId(ID_PREFIX, activeIndex) : undefined"
|
||||||
|
class="rounded-card border border-border bg-surface p-7 shadow-card"
|
||||||
|
>
|
||||||
|
<dl class="m-0 grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2">
|
||||||
|
<PreviewField
|
||||||
|
v-for="field in panelFields"
|
||||||
|
:key="field.name"
|
||||||
|
:class="spanClass(field)"
|
||||||
|
:field="field"
|
||||||
|
:value="values[field.name]"
|
||||||
|
:labels="labels?.[field.name]"
|
||||||
|
:source="path"
|
||||||
|
:record-id="recordId"
|
||||||
|
:id-prefix="ID_PREFIX"
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
class="fixed inset-x-0 bottom-0 z-30 flex flex-wrap items-center gap-2.5 border-t border-border bg-surface px-8 py-3.5"
|
||||||
|
>
|
||||||
|
<div class="ml-auto flex flex-wrap items-center gap-2.5">
|
||||||
|
<RecordActions
|
||||||
|
v-if="ready"
|
||||||
|
:source="path"
|
||||||
|
:record-id="recordId"
|
||||||
|
:actions="actions"
|
||||||
|
:disabled="acting"
|
||||||
|
@busy="(running: boolean) => (acting = running)"
|
||||||
|
@done="onDone"
|
||||||
|
@stale="refresh"
|
||||||
|
@gone="onGone"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="!failed"
|
||||||
|
variant="primary"
|
||||||
|
data-action="edit"
|
||||||
|
:icon="Pencil"
|
||||||
|
:disabled="loading || acting"
|
||||||
|
@click="router.push(recordPath)"
|
||||||
|
>
|
||||||
|
{{ message(schema?.messages.edit) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -24,6 +24,14 @@ describe('mapWinterUrl', () => {
|
|||||||
expect(mapWinterUrl('acme/demo/widgets/update/9', ID)).toBe(`${LIST}/9`)
|
expect(mapWinterUrl('acme/demo/widgets/update/9', ID)).toBe(`${LIST}/9`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('substitutes :id in preview/:id and falls back to the list without a usable id (D-11)', () => {
|
||||||
|
expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID, 42)).toBe(`${LIST}/42/preview`)
|
||||||
|
expect(mapWinterUrl('acme/demo/widgets/preview/9', ID)).toBe(`${LIST}/9/preview`)
|
||||||
|
expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID)).toBe(LIST)
|
||||||
|
expect(mapWinterUrl('acme/demo/widgets/preview/abc', ID)).toBe(LIST)
|
||||||
|
expect(mapWinterUrl('acme/demo/gadgets/preview/:id', ID, 1)).toBe(LIST)
|
||||||
|
})
|
||||||
|
|
||||||
it('falls back to the list when update has no usable id', () => {
|
it('falls back to the list when update has no usable id', () => {
|
||||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID)).toBe(LIST)
|
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID)).toBe(LIST)
|
||||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, null)).toBe(LIST)
|
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, null)).toBe(LIST)
|
||||||
@@ -41,7 +49,7 @@ describe('mapWinterUrl', () => {
|
|||||||
it('ignores the query and hash, matches the owner case-insensitively and rejects unknown actions', () => {
|
it('ignores the query and hash, matches the owner case-insensitively and rejects unknown actions', () => {
|
||||||
expect(mapWinterUrl('acme/demo/widgets/create?x=1#top', ID)).toBe(`${LIST}/create`)
|
expect(mapWinterUrl('acme/demo/widgets/create?x=1#top', ID)).toBe(`${LIST}/create`)
|
||||||
expect(mapWinterUrl('Acme/Demo/Widgets/update/:id', ID, 3)).toBe(`${LIST}/3`)
|
expect(mapWinterUrl('Acme/Demo/Widgets/update/:id', ID, 3)).toBe(`${LIST}/3`)
|
||||||
expect(mapWinterUrl('acme/demo/widgets/preview/3', ID)).toBe(LIST)
|
expect(mapWinterUrl('acme/demo/widgets/history/3', ID)).toBe(LIST)
|
||||||
expect(mapWinterUrl('acme/demo/widgets/update/3/extra', ID)).toBe(LIST)
|
expect(mapWinterUrl('acme/demo/widgets/update/3/extra', ID)).toBe(LIST)
|
||||||
expect(mapWinterUrl('acme/demo/widgets/CREATE', ID)).toBe(LIST)
|
expect(mapWinterUrl('acme/demo/widgets/CREATE', ID)).toBe(LIST)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,6 +62,12 @@
|
|||||||
},
|
},
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"other": "Gadget deleted"
|
"other": "Gadget deleted"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"other": "Record preview"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"other": "Edit record"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
|
|||||||
@@ -47,6 +47,12 @@
|
|||||||
},
|
},
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"other": "Widget deleted"
|
"other": "Widget deleted"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"other": "Record preview"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"other": "Edit record"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
|
|||||||
29
admin/tests/fixtures/roster.form-schema.json
vendored
Normal file
29
admin/tests/fixtures/roster.form-schema.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"name": "Person",
|
||||||
|
"modelClass": "Person",
|
||||||
|
"fields": [
|
||||||
|
{ "name": "name", "type": "text", "label": "Name", "span": "left" },
|
||||||
|
{ "name": "email", "type": "text", "label": "Email", "span": "right" },
|
||||||
|
{ "name": "joined_ip", "type": "text", "label": "Joined from IP address", "context": "preview" }
|
||||||
|
],
|
||||||
|
"preview": { "headerPartial": "status" },
|
||||||
|
"messages": {
|
||||||
|
"create": { "other": "Person" },
|
||||||
|
"update": { "other": "Edit record" },
|
||||||
|
"saved": { "other": "Saved" },
|
||||||
|
"deleteConfirm": { "other": "Delete this record?" },
|
||||||
|
"deleted": { "other": "Record deleted" },
|
||||||
|
"preview": { "other": "Person details" },
|
||||||
|
"edit": { "other": "Edit person" }
|
||||||
|
},
|
||||||
|
"redirects": {
|
||||||
|
"default": "acme/roster/people",
|
||||||
|
"create": { "redirect": "acme/roster/people/preview/:id", "redirectClose": "acme/roster/people" },
|
||||||
|
"update": { "redirect": "acme/roster/people", "redirectClose": "acme/roster/people/preview/:id" }
|
||||||
|
},
|
||||||
|
"meta": { "locale": "en" },
|
||||||
|
"assets": { "scripts": [], "styles": [] }
|
||||||
|
},
|
||||||
|
"meta": { "locale": "en" }
|
||||||
|
}
|
||||||
2
admin/tests/fixtures/roster.list-schema.json
vendored
2
admin/tests/fixtures/roster.list-schema.json
vendored
@@ -8,7 +8,7 @@
|
|||||||
"showCheckboxes": true,
|
"showCheckboxes": true,
|
||||||
"showSorting": true,
|
"showSorting": true,
|
||||||
"searchTerm": "search",
|
"searchTerm": "search",
|
||||||
"recordUrl": "acme/roster/people/update/:id",
|
"recordUrl": "acme/roster/people/preview/:id",
|
||||||
"toolbarButtons": ["create", "delete"],
|
"toolbarButtons": ["create", "delete"],
|
||||||
"toolbarActions": [],
|
"toolbarActions": [],
|
||||||
"assets": { "scripts": [], "styles": [] },
|
"assets": { "scripts": [], "styles": [] },
|
||||||
|
|||||||
2
admin/tests/fixtures/roster.record.json
vendored
2
admin/tests/fixtures/roster.record.json
vendored
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test" },
|
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "joined_ip": "203.0.113.7" },
|
||||||
"meta": {
|
"meta": {
|
||||||
"labels": {},
|
"labels": {},
|
||||||
"actions": [
|
"actions": [
|
||||||
|
|||||||
6
admin/tests/fixtures/settings.json
vendored
6
admin/tests/fixtures/settings.json
vendored
@@ -70,6 +70,12 @@
|
|||||||
},
|
},
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"other": "Deleted"
|
"other": "Deleted"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"other": "Record preview"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"other": "Edit record"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
|
|||||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -18,6 +18,7 @@ import listJson from './widgets.list.json'
|
|||||||
import listSchemaJson from './widgets.list-schema.json'
|
import listSchemaJson from './widgets.list-schema.json'
|
||||||
import optionsJson from './widgets.options.json'
|
import optionsJson from './widgets.options.json'
|
||||||
import rosterListJson from './roster.list.json'
|
import rosterListJson from './roster.list.json'
|
||||||
|
import rosterFormSchemaJson from './roster.form-schema.json'
|
||||||
import rosterListSchemaJson from './roster.list-schema.json'
|
import rosterListSchemaJson from './roster.list-schema.json'
|
||||||
import rosterRecordJson from './roster.record.json'
|
import rosterRecordJson from './roster.record.json'
|
||||||
import recordJson from './widgets.record.json'
|
import recordJson from './widgets.record.json'
|
||||||
@@ -59,6 +60,8 @@ export const extensionPartialFixture = extensionPartialJson as S['cabana.Envelop
|
|||||||
export const listFixture: Rows = listJson
|
export const listFixture: Rows = listJson
|
||||||
/** A people list with declared bulk actions (Phase 12.1). */
|
/** A people list with declared bulk actions (Phase 12.1). */
|
||||||
export const rosterListSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = rosterListSchemaJson
|
export const rosterListSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = rosterListSchemaJson
|
||||||
|
/** The people form: a preview block with a status hint and a preview-only field (Phase 12.1). */
|
||||||
|
export const rosterFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = rosterFormSchemaJson
|
||||||
/** One person with two offered record actions (Phase 12.1). */
|
/** One person with two offered record actions (Phase 12.1). */
|
||||||
export const rosterRecordFixture: S['cabana.RecordEnvelope'] = rosterRecordJson
|
export const rosterRecordFixture: S['cabana.RecordEnvelope'] = rosterRecordJson
|
||||||
/** The people of the roster list (Phase 12.1). */
|
/** The people of the roster list (Phase 12.1). */
|
||||||
|
|||||||
@@ -138,6 +138,12 @@
|
|||||||
},
|
},
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"other": "Widget deleted"
|
"other": "Widget deleted"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"other": "Record preview"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"other": "Edit record"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ describe('row state (UI-SPEC S4, D-12)', () => {
|
|||||||
expect(row.find('[data-row-state]').exists()).toBe(false)
|
expect(row.find('[data-row-state]').exists()).toBe(false)
|
||||||
const link = firstCell(wrapper, 0).find('a')
|
const link = firstCell(wrapper, 0).find('a')
|
||||||
expect(link.classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline']))
|
expect(link.classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline']))
|
||||||
expect(link.attributes('href')).toBe('/acme/roster/people/1')
|
expect(link.attributes('href')).toBe('/acme/roster/people/1/preview')
|
||||||
expect(firstCell(wrapper, 0).classes()).not.toContain('text-muted')
|
expect(firstCell(wrapper, 0).classes()).not.toContain('text-muted')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -507,7 +507,7 @@ describe('row state (UI-SPEC S4, D-12)', () => {
|
|||||||
const link = cell.find('a')
|
const link = cell.find('a')
|
||||||
expect(link.classes()).toEqual(expect.arrayContaining(['line-through', 'text-danger', 'min-w-0', 'truncate']))
|
expect(link.classes()).toEqual(expect.arrayContaining(['line-through', 'text-danger', 'min-w-0', 'truncate']))
|
||||||
expect(link.find('[data-row-state]').exists()).toBe(false)
|
expect(link.find('[data-row-state]').exists()).toBe(false)
|
||||||
expect(link.attributes('href')).toBe('/acme/roster/people/2')
|
expect(link.attributes('href')).toBe('/acme/roster/people/2/preview')
|
||||||
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
|
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
|
||||||
expect(row.findAll('td:not([data-select])')[1]!.classes()).toContain('text-muted')
|
expect(row.findAll('td:not([data-select])')[1]!.classes()).toContain('text-muted')
|
||||||
const deleted = cell.find('[data-row-state="deleted"]')
|
const deleted = cell.find('[data-row-state="deleted"]')
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ describe('winter URLs', () => {
|
|||||||
expect(mapWinterUrl('/acme/demo/widgets/create', id)).toBe('/acme/demo/widgets/create')
|
expect(mapWinterUrl('/acme/demo/widgets/create', id)).toBe('/acme/demo/widgets/create')
|
||||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, 42)).toBe('/acme/demo/widgets/42')
|
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, 42)).toBe('/acme/demo/widgets/42')
|
||||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, null)).toBe('/acme/demo/widgets')
|
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, null)).toBe('/acme/demo/widgets')
|
||||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', id, 42)).toBe('/acme/demo/widgets')
|
expect(mapWinterUrl('acme/demo/widgets/preview/:id', id, 42)).toBe('/acme/demo/widgets/42/preview')
|
||||||
|
expect(mapWinterUrl('acme/demo/widgets/history/:id', id, 42)).toBe('/acme/demo/widgets')
|
||||||
expect(mapWinterUrl('https://evil.example/acme/demo/widgets', id, 1)).toBe('/acme/demo/widgets')
|
expect(mapWinterUrl('https://evil.example/acme/demo/widgets', id, 1)).toBe('/acme/demo/widgets')
|
||||||
expect(mapWinterUrl('acme/other/things/update/:id', id, 1)).toBe('/acme/demo/widgets')
|
expect(mapWinterUrl('acme/other/things/update/:id', id, 1)).toBe('/acme/demo/widgets')
|
||||||
expect(mapWinterUrl('', id)).toBe('/acme/demo/widgets')
|
expect(mapWinterUrl('', id)).toBe('/acme/demo/widgets')
|
||||||
|
|||||||
369
admin/tests/smoke/preview.smoke.test.ts
Normal file
369
admin/tests/smoke/preview.smoke.test.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
// Phase 12.1 preview screen (UI-SPEC S3, D-11) with the record actions in
|
||||||
|
// its footer (UI-SPEC S2, D-10): the read-only record route, the status hint,
|
||||||
|
// the dl grid, the preview-only field, the URL mapping and the update form's
|
||||||
|
// way back. Fixtures are neutral acme.roster.* data; no application names
|
||||||
|
// appear in framework tests.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { setBundle } from '../../src/app/i18n'
|
||||||
|
import { routeControllerId } from '../../src/app/router'
|
||||||
|
import { mapWinterUrl } from '../../src/app/winterUrl'
|
||||||
|
import {
|
||||||
|
clone,
|
||||||
|
langFixture,
|
||||||
|
rosterFormSchemaFixture,
|
||||||
|
rosterListFixture,
|
||||||
|
rosterListSchemaFixture,
|
||||||
|
rosterRecordFixture,
|
||||||
|
} from '../fixtures/typed'
|
||||||
|
import { API, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||||
|
|
||||||
|
const LIST = `${API}/acme/roster/people`
|
||||||
|
const RECORD = `${LIST}/1`
|
||||||
|
const PREVIEW = '/acme/roster/people/1/preview'
|
||||||
|
|
||||||
|
// The framework strings this screen uses, as GET /lang serves them in en.
|
||||||
|
const strings = {
|
||||||
|
'backend::lang.form.return_to_list': { other: 'Back to list' },
|
||||||
|
'backend::lang.form.return_to_preview': { other: 'Back to preview' },
|
||||||
|
'backend::lang.form.load_failed': { other: 'The form could not be loaded.' },
|
||||||
|
'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' },
|
||||||
|
'backend::lang.form.action_done': { other: 'Action completed.' },
|
||||||
|
'backend::lang.form.action_stale': {
|
||||||
|
other: 'This action no longer applies to this record. The page has been refreshed.',
|
||||||
|
},
|
||||||
|
'backend::lang.form.cancel': { other: 'Cancel' },
|
||||||
|
'backend::lang.list.empty_value': { other: '—' },
|
||||||
|
'backend::lang.list.column_switch_true': { other: 'Yes' },
|
||||||
|
'backend::lang.list.column_switch_false': { other: 'No' },
|
||||||
|
'backend::lang.extension.partial_failed': { other: 'This section could not be loaded.' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const callout = (title: string, text: string) => ({
|
||||||
|
data: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
tag: 'div',
|
||||||
|
attrs: { class: 'summer-callout summer-callout--warning', role: 'status' },
|
||||||
|
children: [
|
||||||
|
{ tag: 'p', attrs: { class: 'summer-callout__title' }, children: [{ text: title }] },
|
||||||
|
{ tag: 'p', attrs: { class: 'summer-callout__text' }, children: [{ text }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
meta: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
function recordWith(change: (record: typeof rosterRecordFixture) => void): typeof rosterRecordFixture {
|
||||||
|
const record = clone(rosterRecordFixture)
|
||||||
|
change(record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||||
|
return {
|
||||||
|
[`GET ${LIST}/schema/form`]: { body: rosterFormSchemaFixture },
|
||||||
|
[`GET ${LIST}/schema/list`]: { body: rosterListSchemaFixture },
|
||||||
|
[`GET ${LIST}`]: { body: rosterListFixture },
|
||||||
|
[`GET ${RECORD}`]: { body: rosterRecordFixture },
|
||||||
|
[`GET ${LIST}/partials/status`]: { body: callout('This person is not active', 'Activate the person to let them sign in.') },
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||||
|
const footer = (wrapper: VueWrapper) => wrapper.find('[data-preview] footer')
|
||||||
|
const value = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-preview-field="${name}"] dd`)
|
||||||
|
|
||||||
|
async function settle(): Promise<void> {
|
||||||
|
await wait(10)
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetState()
|
||||||
|
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
|
describe('preview screen (UI-SPEC S3, D-11)', () => {
|
||||||
|
it('renders the title, the subtitle and the fields as a dl grid of text', async () => {
|
||||||
|
const { wrapper, router } = await mountApp(PREVIEW, routes())
|
||||||
|
expect(router.currentRoute.value.name).toBe('preview')
|
||||||
|
expect(routeControllerId(router.currentRoute.value)).toBe('acme.roster.people')
|
||||||
|
expect(wrapper.find('h1').text()).toBe('Ada Lovelace')
|
||||||
|
expect(wrapper.find('header p').text()).toBe('Person details')
|
||||||
|
|
||||||
|
const grid = wrapper.find('[data-preview] dl')
|
||||||
|
expect(grid.exists()).toBe(true)
|
||||||
|
expect(grid.findAll('dt').map((item) => item.text())).toEqual(['Name', 'Email', 'Joined from IP address'])
|
||||||
|
expect(value(wrapper, 'name').text()).toBe('Ada Lovelace')
|
||||||
|
expect(value(wrapper, 'email').text()).toBe('ada@example.test')
|
||||||
|
// Values wrap and are never truncated; labels carry no required mark.
|
||||||
|
expect(value(wrapper, 'name').find('[data-preview-value]').classes()).toContain('[overflow-wrap:anywhere]')
|
||||||
|
expect(grid.text()).not.toContain('*')
|
||||||
|
// Nothing on the screen can be edited or saved.
|
||||||
|
expect(wrapper.find('[data-preview] input, [data-preview] textarea, [data-preview] select').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-action="save"], [data-action="delete"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a preview-only field here and not on the update form', async () => {
|
||||||
|
const { wrapper, router } = await mountApp(PREVIEW, routes())
|
||||||
|
expect(value(wrapper, 'joined_ip').text()).toBe('203.0.113.7')
|
||||||
|
expect(wrapper.find('[data-preview-field="joined_ip"] input').exists()).toBe(false)
|
||||||
|
|
||||||
|
await router.push('/acme/roster/people/1')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-field="name"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-field="joined_ip"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the muted dash for an empty value', async () => {
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
PREVIEW,
|
||||||
|
routes({ [`GET ${RECORD}`]: { body: recordWith((record) => (record.data.email = '')) } }),
|
||||||
|
)
|
||||||
|
const empty = value(wrapper, 'email').find('[data-empty]')
|
||||||
|
expect(empty.text()).toBe('—')
|
||||||
|
expect(empty.classes()).toContain('text-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the status hint between the header and the card with the record id', async () => {
|
||||||
|
const { wrapper, calls } = await mountApp(PREVIEW, routes())
|
||||||
|
const hint = wrapper.find('[data-partial="header"]')
|
||||||
|
expect(hint.find('.summer-callout--warning[role="status"]').exists()).toBe(true)
|
||||||
|
expect(hint.text()).toContain('This person is not active')
|
||||||
|
const [request] = requestsTo(calls, 'GET', `${LIST}/partials/status`)
|
||||||
|
expect(new URL(request!.url).searchParams.get('id')).toBe('1')
|
||||||
|
// The hint sits above the card, outside the dl.
|
||||||
|
expect(wrapper.find('[data-preview] dl [data-partial]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing for a hint with zero nodes and the failure box for a failed one', async () => {
|
||||||
|
const none = await mountApp(PREVIEW, routes({ [`GET ${LIST}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } } }))
|
||||||
|
expect(none.wrapper.find('[data-partial], [data-partial-loading], [data-partial-failed]').exists()).toBe(false)
|
||||||
|
expect(none.wrapper.find('[data-preview] dl').exists()).toBe(true)
|
||||||
|
none.wrapper.unmount()
|
||||||
|
|
||||||
|
const broken = await mountApp(
|
||||||
|
PREVIEW,
|
||||||
|
routes({ [`GET ${LIST}/partials/status`]: { status: 500, body: { error: { code: 'error', message: 'x', details: {} } } } }),
|
||||||
|
)
|
||||||
|
expect(broken.wrapper.find('[data-partial-failed]').exists()).toBe(true)
|
||||||
|
expect(broken.wrapper.find('[data-preview] dl').exists()).toBe(true)
|
||||||
|
expect(broken.wrapper.find('[data-action="edit"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows one 68px skeleton while the hint first loads', async () => {
|
||||||
|
let release: ((reply: Reply) => void) | undefined
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
PREVIEW,
|
||||||
|
routes({
|
||||||
|
[`GET ${LIST}/partials/status`]: () =>
|
||||||
|
new Promise<Reply>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const skeleton = wrapper.find('[data-partial-skeleton]')
|
||||||
|
expect(skeleton.classes()).toEqual(expect.arrayContaining(['h-[68px]', 'rounded-inner', 'bg-skel']))
|
||||||
|
release?.({ body: callout('a', 'b') })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-partial-skeleton]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces the route with the record route when the form has no preview', async () => {
|
||||||
|
const plain = clone(rosterFormSchemaFixture)
|
||||||
|
delete plain.data.preview
|
||||||
|
const { router } = await mountApp(PREVIEW, routes({ [`GET ${LIST}/schema/form`]: { body: plain } }))
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.name).toBe('record')
|
||||||
|
expect(router.currentRoute.value.path).toBe('/acme/roster/people/1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the load failure and keeps the back button when the record is missing', async () => {
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
PREVIEW,
|
||||||
|
routes({ [`GET ${RECORD}`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } } }),
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.')
|
||||||
|
expect(wrapper.find('[data-preview] dl').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-action="edit"]').exists()).toBe(false)
|
||||||
|
const back = wrapper.find('[data-action="back"]')
|
||||||
|
expect(back.attributes('href')).toBe('/acme/roster/people')
|
||||||
|
expect(back.attributes('aria-label')).toBe('Back to list')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('preview footer (UI-SPEC S2, D-10)', () => {
|
||||||
|
it('shows the offered record actions in order before the one primary edit button', async () => {
|
||||||
|
const { wrapper, router } = await mountApp(PREVIEW, routes())
|
||||||
|
const buttons = footer(wrapper).findAll('button')
|
||||||
|
expect(buttons.map((item) => item.attributes('data-record-action') ?? item.attributes('data-action'))).toEqual([
|
||||||
|
'activate',
|
||||||
|
'reinstate',
|
||||||
|
'edit',
|
||||||
|
])
|
||||||
|
const edit = wrapper.find('[data-action="edit"]')
|
||||||
|
expect(edit.text()).toBe('Edit person')
|
||||||
|
expect(edit.classes()).toContain('bg-primary')
|
||||||
|
expect(edit.find('svg').exists()).toBe(true)
|
||||||
|
expect(footer(wrapper).findAll('.bg-primary')).toHaveLength(1)
|
||||||
|
// The footer and its right cluster wrap; buttons never shrink or truncate.
|
||||||
|
expect(footer(wrapper).classes()).toContain('flex-wrap')
|
||||||
|
expect(footer(wrapper).find('div').classes()).toEqual(expect.arrayContaining(['ml-auto', 'flex-wrap']))
|
||||||
|
expect(buttons.every((item) => item.classes().includes('whitespace-nowrap') && item.classes().includes('shrink-0'))).toBe(true)
|
||||||
|
|
||||||
|
await edit.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/acme/roster/people/1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows only the edit button when no record action is offered', async () => {
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
PREVIEW,
|
||||||
|
routes({ [`GET ${RECORD}`]: { body: recordWith((record) => delete record.meta.actions) } }),
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-record-action]').exists()).toBe(false)
|
||||||
|
expect(footer(wrapper).findAll('button').map((item) => item.attributes('data-action'))).toEqual(['edit'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('after an action: a success toast, the record and the hint reloaded in place', async () => {
|
||||||
|
let shows = 0
|
||||||
|
const after = recordWith((record) => {
|
||||||
|
record.data.email = 'ada@new.example.test'
|
||||||
|
record.meta.actions = [{ name: 'reinstate', label: 'Reinstate' }]
|
||||||
|
})
|
||||||
|
const { wrapper, calls } = await mountApp(
|
||||||
|
PREVIEW,
|
||||||
|
routes({
|
||||||
|
[`GET ${RECORD}`]: () => ({ body: shows++ === 0 ? rosterRecordFixture : after }),
|
||||||
|
[`POST ${RECORD}/actions/activate`]: { body: { data: { message: 'The person was activated.', fill: {} }, meta: {} } },
|
||||||
|
}),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
// The previous content stays while the record reloads: no skeleton flash.
|
||||||
|
expect(wrapper.find('[data-preview] dl').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-partial-skeleton]').exists()).toBe(false)
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain(
|
||||||
|
'The person was activated.',
|
||||||
|
)
|
||||||
|
expect(value(wrapper, 'email').text()).toBe('ada@new.example.test')
|
||||||
|
expect(wrapper.findAll('[data-record-action]').map((item) => item.attributes('data-record-action'))).toEqual(['reinstate'])
|
||||||
|
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(2)
|
||||||
|
expect(requestsTo(calls, 'GET', `${LIST}/partials/status`)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables every footer button while an action runs', async () => {
|
||||||
|
let release: ((reply: Reply) => void) | undefined
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
PREVIEW,
|
||||||
|
routes({
|
||||||
|
[`POST ${RECORD}/actions/activate`]: () =>
|
||||||
|
new Promise<Reply>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(footer(wrapper).findAll('button').every((item) => item.attributes('disabled') !== undefined)).toBe(true)
|
||||||
|
release?.({ body: { data: { message: '', fill: {} }, meta: {} } })
|
||||||
|
await settle()
|
||||||
|
expect(wrapper.find('[data-action="edit"]').attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads the record and the hint when an action no longer applies', async () => {
|
||||||
|
const { wrapper, calls } = await mountApp(
|
||||||
|
PREVIEW,
|
||||||
|
routes({
|
||||||
|
[`POST ${RECORD}/actions/activate`]: { status: 409, body: { error: { code: 'conflict', message: 'Conflict', details: {} } } },
|
||||||
|
}),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||||
|
await settle()
|
||||||
|
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(2)
|
||||||
|
expect(requestsTo(calls, 'GET', `${LIST}/partials/status`)).toHaveLength(2)
|
||||||
|
expect(document.body.textContent).toContain('This action no longer applies to this record.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns into the load failure when the record is gone', async () => {
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
PREVIEW,
|
||||||
|
routes({
|
||||||
|
[`POST ${RECORD}/actions/activate`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } },
|
||||||
|
}),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||||
|
await settle()
|
||||||
|
expect(wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.')
|
||||||
|
expect(wrapper.find('[data-record-action], [data-action="edit"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-action="back"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('preview URLs (T-12.1-16)', () => {
|
||||||
|
const id = 'acme.roster.people'
|
||||||
|
|
||||||
|
it('maps preview/:id onto the preview route of the current controller only', () => {
|
||||||
|
expect(mapWinterUrl('acme/roster/people/preview/:id', id, 42)).toBe('/acme/roster/people/42/preview')
|
||||||
|
expect(mapWinterUrl('acme/roster/people/preview/7', id)).toBe('/acme/roster/people/7/preview')
|
||||||
|
expect(mapWinterUrl('acme/roster/people/preview/:id', id, null)).toBe('/acme/roster/people')
|
||||||
|
expect(mapWinterUrl('acme/roster/people/preview/1e3', id)).toBe('/acme/roster/people')
|
||||||
|
expect(mapWinterUrl('acme/roster/people/preview/3/extra', id)).toBe('/acme/roster/people')
|
||||||
|
expect(mapWinterUrl('acme/other/people/preview/:id', id, 1)).toBe('/acme/roster/people')
|
||||||
|
expect(mapWinterUrl('https://evil.example/acme/roster/people/preview/1', id)).toBe('/acme/roster/people')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the preview from a list row whose recordUrl points at it', async () => {
|
||||||
|
const { wrapper, router } = await mountApp('/acme/roster/people', routes())
|
||||||
|
const link = wrapper.find('tbody a')
|
||||||
|
expect(link.attributes('href')).toMatch(/^\/acme\/roster\/people\/\d+\/preview$/)
|
||||||
|
await link.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.name).toBe('preview')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leads back to the preview from the update form of a form with a preview', async () => {
|
||||||
|
const { wrapper, router } = await mountApp('/acme/roster/people/1', routes())
|
||||||
|
const back = wrapper.find('[data-action="back"]')
|
||||||
|
expect(back.attributes('aria-label')).toBe('Back to preview')
|
||||||
|
await back.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.path).toBe(PREVIEW)
|
||||||
|
|
||||||
|
await router.push('/acme/roster/people/1')
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('[data-action="cancel"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.path).toBe(PREVIEW)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('follows update.redirectClose to the preview after save and close', async () => {
|
||||||
|
const { wrapper, router } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }))
|
||||||
|
await wrapper.find('[data-action="save-close"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.path).toBe(PREVIEW)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -381,7 +381,7 @@ func (PeopleController) AdminRecordActions() []pact.AdminRecordAction {
|
|||||||
list: ~/plugins/acme/roster/models/person/columns.yaml
|
list: ~/plugins/acme/roster/models/person/columns.yaml
|
||||||
modelClass: Person
|
modelClass: Person
|
||||||
title: acme.roster::lang.people.title
|
title: acme.roster::lang.people.title
|
||||||
recordUrl: acme/roster/people/update/:id
|
recordUrl: acme/roster/people/preview/:id
|
||||||
recordsPerPage: 20
|
recordsPerPage: 20
|
||||||
showCheckboxes: true
|
showCheckboxes: true
|
||||||
toolbar:
|
toolbar:
|
||||||
@@ -414,12 +414,17 @@ form: ~/plugins/acme/roster/models/person/fields.yaml
|
|||||||
modelClass: Person
|
modelClass: Person
|
||||||
defaultRedirect: acme/roster/people
|
defaultRedirect: acme/roster/people
|
||||||
create:
|
create:
|
||||||
redirect: acme/roster/people/update/:id
|
redirect: acme/roster/people/preview/:id
|
||||||
redirectClose: acme/roster/people
|
redirectClose: acme/roster/people
|
||||||
update:
|
update:
|
||||||
redirect: acme/roster/people
|
redirect: acme/roster/people
|
||||||
redirectClose: acme/roster/people
|
redirectClose: acme/roster/people/preview/:id
|
||||||
|
preview:
|
||||||
|
headerPartial: status
|
||||||
recordActions: [activate, reinstate]
|
recordActions: [activate, reinstate]
|
||||||
|
messages:
|
||||||
|
preview: acme.roster::lang.people.preview
|
||||||
|
edit: acme.roster::lang.people.edit
|
||||||
```
|
```
|
||||||
|
|
||||||
Each `pact.AdminRecordAction` has a `Name`, a `Label`, an optional `Confirm` text, its own `Permissions`, an optional `Applies` function and `Run`:
|
Each `pact.AdminRecordAction` has a `Name`, a `Label`, an optional `Confirm` text, its own `Permissions`, an optional `Applies` function and `Run`:
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ A list whose schema carries declared bulk actions shows a "Bulk actions" menu af
|
|||||||
|
|
||||||
A list row that carries a state (deleted, negative or disabled) shows a text badge for each state after its first cell, together with a text style; the row background is never changed. See [Row state](lists-and-filters.md#row-state).
|
A list row that carries a state (deleted, negative or disabled) shows a text badge for each state after its first cell, together with a text style; the row background is never changed. See [Row state](lists-and-filters.md#row-state).
|
||||||
|
|
||||||
|
A form whose schema carries `preview` has a read-only record screen at `<controller>/<id>/preview`, next to the list (`<controller>`), the create form (`<controller>/create`) and the update form (`<controller>/<id>`). It renders the fields whose context allows `preview` as text, the status hint partial above them and, in its footer, the record actions the record response offers before the one edit button. Opening that route for a form without a preview goes to the update form. See [Preview screen](forms.md#preview-screen).
|
||||||
|
|
||||||
## Types from OpenAPI
|
## Types from OpenAPI
|
||||||
|
|
||||||
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. `--check` fails when either committed file is out of date:
|
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. `--check` fails when either committed file is out of date:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ update:
|
|||||||
|
|
||||||
`modelClass` must equal the controller's `pact.AdminController.ModelName`. The `~/plugins/<vendor>/<plugin>/` prefix points into the plugin's own embedded tree.
|
`modelClass` must equal the controller's `pact.AdminController.ModelName`. The `~/plugins/<vendor>/<plugin>/` prefix points into the plugin's own embedded tree.
|
||||||
|
|
||||||
An optional `recordActions` key lists the record actions the form offers, by the names the controller registers through `pact.HasAdminRecordActions`. The show response of a record then carries `meta.actions`: the declared actions the administrator may run and that apply to the record in its current state. See [Record actions](admin-controllers.md#record-actions).
|
An optional `recordActions` key lists the record actions the form offers, by the names the controller registers through `pact.HasAdminRecordActions`. It needs a `preview` block, because record actions are offered on the [preview screen](#preview-screen). The show response of a record then carries `meta.actions`: the declared actions the administrator may run and that apply to the record in its current state. See [Record actions](admin-controllers.md#record-actions).
|
||||||
|
|
||||||
## fields.yaml
|
## fields.yaml
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ The WinterCMS widgets that are not in this list (the rich editor, the markdown e
|
|||||||
|
|
||||||
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields. WinterCMS keys outside this set, such as `readOnly`, `disabled`, `trigger` or `dependsOn`, are refused.
|
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields. WinterCMS keys outside this set, such as `readOnly`, `disabled`, `trigger` or `dependsOn`, are refused.
|
||||||
|
|
||||||
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted. The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
|
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted, and `context: preview` shows a field only on the [preview screen](#preview-screen). The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
|
||||||
|
|
||||||
## Date pickers
|
## Date pickers
|
||||||
|
|
||||||
@@ -185,6 +185,37 @@ Removing a file is deferred the same way: the file disappears from the form at o
|
|||||||
|
|
||||||
The limits are enforced on the server: the upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB (413 `payload_too_large` past it), and a file that is too large, of a type the field does not allow, or not a valid image in image mode is a 422 on the field.
|
The limits are enforced on the server: the upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB (413 `payload_too_large` past it), and a file that is too large, of a type the field does not allow, or not a valid image in image mode is a 422 on the field.
|
||||||
|
|
||||||
|
## Preview screen
|
||||||
|
|
||||||
|
A form may have a read-only screen in front of its update form, as WinterCMS's preview context: the list opens the record there, the administrator reads it, runs a record action or presses the edit button. A `preview` block in `config_form.yaml` turns it on:
|
||||||
|
|
||||||
|
```yaml src=modules/cabana/testdata/roster/controllers/people/config_form.yaml
|
||||||
|
name: acme.roster::lang.people.form
|
||||||
|
form: ~/plugins/acme/roster/models/person/fields.yaml
|
||||||
|
modelClass: Person
|
||||||
|
defaultRedirect: acme/roster/people
|
||||||
|
create:
|
||||||
|
redirect: acme/roster/people/preview/:id
|
||||||
|
redirectClose: acme/roster/people
|
||||||
|
update:
|
||||||
|
redirect: acme/roster/people
|
||||||
|
redirectClose: acme/roster/people/preview/:id
|
||||||
|
preview:
|
||||||
|
headerPartial: status
|
||||||
|
recordActions: [activate, reinstate]
|
||||||
|
messages:
|
||||||
|
preview: acme.roster::lang.people.preview
|
||||||
|
edit: acme.roster::lang.people.edit
|
||||||
|
```
|
||||||
|
|
||||||
|
- `preview` is a mapping. `headerPartial: <name>` names a controller partial rendered above the fields as a status hint (see [Partials and widgets](partials-and-widgets.md#status-hints)); write `preview: {}` for a screen without a hint. An empty `preview:` key, any other key inside it, or a `recordActions` list without a `preview` block stops the start-up.
|
||||||
|
- The form schema reports the block as `preview` (a `cabana.FormPreview`), and the SPA then serves the record at `<controller>/<id>/preview`. A form without the block has no such screen: the route goes to the update form.
|
||||||
|
- The screen shows every field whose `context` allows `preview`. A field without a `context` shows on every screen; `context: preview` shows a field only here. Values are rendered as text; `widget` and `relation-manager` fields are not shown.
|
||||||
|
- A field with `context: preview` is never written. A save asks only for the `create` or the `update` context, so such a field in a request body is dropped like any field hidden on that form.
|
||||||
|
- `messages.preview` is the screen's subtitle and `messages.edit` the label of its edit button; both default to framework texts (`cabana.FormMessages`).
|
||||||
|
- `recordUrl` in `config_list.yaml` and the form's `create.redirect`, `update.redirectClose` and the other redirects may point at the screen as `<vendor>/<plugin>/<controller>/preview/:id`. On the update form of a record with a preview, the back arrow and Cancel return to the preview; after a delete the form goes to the list.
|
||||||
|
- The footer holds the record actions the show response offers in `meta.actions`, then the edit button. After an action the record and the status hint are loaded again in place.
|
||||||
|
|
||||||
## What a save may write
|
## What a save may write
|
||||||
|
|
||||||
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field.
|
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field.
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ WinterCMS controllers extend their screens with partials, `addJs` and `addCss`,
|
|||||||
Two places accept a partial:
|
Two places accept a partial:
|
||||||
|
|
||||||
- `headerPartial: <name>` in `config_list.yaml`, a strip above the list;
|
- `headerPartial: <name>` in `config_list.yaml`, a strip above the list;
|
||||||
- a `type: partial` field with `path: <name>` in `fields.yaml`.
|
- a `type: partial` field with `path: <name>` in `fields.yaml`;
|
||||||
|
- `preview.headerPartial: <name>` in `config_form.yaml`, a status hint above the fields of the [preview screen](forms.md#preview-screen).
|
||||||
|
|
||||||
Both render `{ConfigDir}/_<name>.htm` with Go's `html/template`. WinterCMS `$/` and `~/` partial paths are not supported. The template's data is `.Data`, the value the controller's `pact.AdminPartialData` returns for that partial name; for a form partial on an existing record, cabana passes the record it loaded through the controller's `pact.FormExtendQuery` scope. `trans "<key>"` translates a phrase key in the request locale.
|
All three render `{ConfigDir}/_<name>.htm` with Go's `html/template`. WinterCMS `$/` and `~/` partial paths are not supported. The template's data is `.Data`, the value the controller's `pact.AdminPartialData` returns for that partial name; for a form partial on an existing record, cabana passes the record it loaded through the controller's `pact.FormExtendQuery` scope. `trans "<key>"` translates a phrase key in the request locale.
|
||||||
|
|
||||||
A statistics strip above a list, using the SPA's partial style classes:
|
A statistics strip above a list, using the SPA's partial style classes:
|
||||||
|
|
||||||
@@ -31,6 +32,21 @@ The view model must be a struct built for the template. cabana refuses a view mo
|
|||||||
|
|
||||||
The rendered HTML is parsed and walked through an allowlist before it reaches the SPA: script, style, iframe, form and similar elements are removed with their content, unknown elements are unwrapped, `id`, `style` and event handler attributes are dropped, and links and images must be same-origin paths. Output is capped at 64 KiB, 2000 nodes and a depth of 32. The cabana README lists the allowed elements, attributes and style classes.
|
The rendered HTML is parsed and walked through an allowlist before it reaches the SPA: script, style, iframe, form and similar elements are removed with their content, unknown elements are unwrapped, `id`, `style` and event handler attributes are dropped, and links and images must be same-origin paths. Output is capped at 64 KiB, 2000 nodes and a depth of 32. The cabana README lists the allowed elements, attributes and style classes.
|
||||||
|
|
||||||
|
### Status hints
|
||||||
|
|
||||||
|
The preview screen's header partial always belongs to one record, so cabana renders it like a form partial: with the record it loaded through the controller's `pact.FormExtendQuery` scope. A typical hint tells the administrator why a record needs attention, and the record action that resolves it is a button in the screen's footer. The SPA's callout classes give it the native look:
|
||||||
|
|
||||||
|
```html src=modules/cabana/testdata/roster/controllers/people/_status.htm
|
||||||
|
{{- if .Data.Title -}}
|
||||||
|
<div class="summer-callout summer-callout--{{ .Data.Tone }}" role="status">
|
||||||
|
<p class="summer-callout__title">{{ trans .Data.Title }}</p>
|
||||||
|
<p class="summer-callout__text">{{ trans .Data.Text }}</p>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`summer-callout` is the block, `summer-callout--warning` and `summer-callout--danger` set its tone, and `summer-callout__title` and `summer-callout__text` are its two lines. `role="status"` is on the attribute allowlist and makes a hint that appears after an action polite to screen readers. A callout holds no icon, link or button. A template that renders nothing (here: when the view model has no title) leaves no gap on the screen, and the hint is fetched again after every record action.
|
||||||
|
|
||||||
## Plugin JavaScript and CSS
|
## Plugin JavaScript and CSS
|
||||||
|
|
||||||
A controller that implements `pact.AdminClientAssets` names `.js`, `.mjs` and `.css` files under its plugin's `assets/` directory, the Go form of `addJs` and `addCss`. They are read from the embedded tree at start-up (a missing file stops it) and served from `<prefix>/assets/{vendor}/{plugin}/...` with a content hash in the URL, the admin Content-Security-Policy (`script-src 'self'`) and `nosniff`. Only declared files are reachable; the YAML and templates never are.
|
A controller that implements `pact.AdminClientAssets` names `.js`, `.mjs` and `.css` files under its plugin's `assets/` directory, the Go form of `addJs` and `addCss`. They are read from the embedded tree at start-up (a missing file stops it) and served from `<prefix>/assets/{vendor}/{plugin}/...` with a content hash in the URL, the admin Content-Security-Policy (`script-src 'self'`) and `nosniff`. Only declared files are reachable; the YAML and templates never are.
|
||||||
|
|||||||
1
modules/boardwalk/dist/assets/index-57SuA8gQ.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-57SuA8gQ.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
modules/boardwalk/dist/assets/index-DEJgWNHv.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-DEJgWNHv.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-8CEYdgqp.js"></script>
|
<script type="module" crossorigin src="./assets/index-DEJgWNHv.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BxJxH4xB.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-57SuA8gQ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
|||||||
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
||||||
- Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count.
|
- Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count.
|
||||||
- Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`.
|
- Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`.
|
||||||
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
|
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
|
||||||
|
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
|
||||||
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
||||||
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
||||||
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
|
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
|
||||||
@@ -78,7 +79,7 @@ Every path under the prefix that no API route matches is served by the admin SPA
|
|||||||
|
|
||||||
### Partials
|
### Partials
|
||||||
|
|
||||||
A partial is an `html/template` file next to the controller's YAML: `headerPartial: stats` and `path: stats` both resolve to `{ConfigDir}/_stats.htm`; Winter's `$/` and `~/` paths are not supported. The template's root is `.Data`, the value the controller's `PartialData(ctx, name, record)` returns, and `trans "<key>"` translates a phrase key in the request locale. `record` is nil for a header partial and for a form partial on the create form; with `?id=` it is the record cabana loaded through the controller's `pact.FormExtendQuery` scope, so a plugin never looks a record up by a request id itself.
|
A partial is an `html/template` file next to the controller's YAML: `headerPartial: stats` (in `config_list.yaml`, or under `preview:` in `config_form.yaml`) and `path: stats` all resolve to `{ConfigDir}/_stats.htm`; Winter's `$/` and `~/` paths are not supported. The template's root is `.Data`, the value the controller's `PartialData(ctx, name, record)` returns, and `trans "<key>"` translates a phrase key in the request locale. `record` is nil for a list header partial and for a form partial on the create form; a preview header partial is a form partial and always gets its record; with `?id=` it is the record cabana loaded through the controller's `pact.FormExtendQuery` scope, so a plugin never looks a record up by a request id itself.
|
||||||
|
|
||||||
The view model must be a curated struct built for the template. cabana walks its type through pointers, slices, arrays, maps, struct fields and the results of its exported methods (templates call methods), and the values held in interface-typed members such as `map[string]any`. It refuses the controller's own model type, any other GORM model (a struct with a `TableName` method, a `gorm` struct tag, `gorm.Model` or `gorm.DeletedAt`) and `html/template`'s pre-escaped content types anywhere in that structure, so escaping stays on for every record value. A method that returns an interface is not called, so its run-time result is not checked. The rendered output is parsed with `golang.org/x/net/html` and walked through an allowlist:
|
The view model must be a curated struct built for the template. cabana walks its type through pointers, slices, arrays, maps, struct fields and the results of its exported methods (templates call methods), and the values held in interface-typed members such as `map[string]any`. It refuses the controller's own model type, any other GORM model (a struct with a `TableName` method, a `gorm` struct tag, `gorm.Model` or `gorm.DeletedAt`) and `html/template`'s pre-escaped content types anywhere in that structure, so escaping stays on for every record value. A method that returns an interface is not called, so its run-time result is not checked. The rendered output is parsed with `golang.org/x/net/html` and walked through an allowlist:
|
||||||
|
|
||||||
@@ -105,9 +106,22 @@ The admin SPA ships a small set of stable CSS classes that partial templates may
|
|||||||
| `summer-stat` | One item of the strip: the value is shown above the label while `<dt>` stays first in the DOM. |
|
| `summer-stat` | One item of the strip: the value is shown above the label while `<dt>` stays first in the DOM. |
|
||||||
| `summer-stat__label` | The item label: 13px, muted, wraps. |
|
| `summer-stat__label` | The item label: 13px, muted, wraps. |
|
||||||
| `summer-stat__value` | The item value: 20px, weight 600, tabular numbers. |
|
| `summer-stat__value` | The item value: 20px, weight 600, tabular numbers. |
|
||||||
|
| `summer-callout` | A status hint above a preview screen: a borderless block with a 12px radius and 14px 18px padding; long text wraps. |
|
||||||
|
| `summer-callout--warning`, `summer-callout--danger` | The callout's tone: the selection tint with body text, or the soft danger background with danger text. |
|
||||||
|
| `summer-callout__title` | The callout's first line, weight 600. |
|
||||||
|
| `summer-callout__text` | The callout's second line, weight 400. |
|
||||||
|
|
||||||
Use `<dl class="summer-stats">` with one `<div class="summer-stat">` per item holding a `<dt class="summer-stat__label">` and a `<dd class="summer-stat__value">`, as in the example above.
|
Use `<dl class="summer-stats">` with one `<div class="summer-stat">` per item holding a `<dt class="summer-stat__label">` and a `<dd class="summer-stat__value">`, as in the example above.
|
||||||
|
|
||||||
|
A status hint (`preview.headerPartial`) uses the callout classes. `role="status"` is on the attribute allowlist; a callout holds no icon, link or button:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="summer-callout summer-callout--warning" role="status">
|
||||||
|
<p class="summer-callout__title">{{ trans .Data.Title }}</p>
|
||||||
|
<p class="summer-callout__text">{{ trans .Data.Text }}</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
Plugin CSS (declared through `pact.AdminClientAssets`) and any widget shadow DOM may read only these public variables. They inherit into shadow roots and switch automatically in dark mode: `--c-bg`, `--c-surface`, `--c-subtle`, `--c-border`, `--c-border-strong`, `--c-text`, `--c-muted`, `--c-placeholder`, `--c-primary`, `--c-on-primary`, `--c-danger`, `--c-danger-soft`, `--c-hover`, `--c-sel`, `--c-skel`, `--c-ring`. Plugins must not hardcode hex colours and must not rely on Tailwind utility classes: the SPA build purges every utility it does not use itself. A controller's stylesheets are disabled while another controller's list or form is open.
|
Plugin CSS (declared through `pact.AdminClientAssets`) and any widget shadow DOM may read only these public variables. They inherit into shadow roots and switch automatically in dark mode: `--c-bg`, `--c-surface`, `--c-subtle`, `--c-border`, `--c-border-strong`, `--c-text`, `--c-muted`, `--c-placeholder`, `--c-primary`, `--c-on-primary`, `--c-danger`, `--c-danger-soft`, `--c-hover`, `--c-sel`, `--c-skel`, `--c-ring`. Plugins must not hardcode hex colours and must not rely on Tailwind utility classes: the SPA build purges every utility it does not use itself. A controller's stylesheets are disabled while another controller's list or form is open.
|
||||||
|
|
||||||
### Controller assets
|
### Controller assets
|
||||||
@@ -176,6 +190,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
|||||||
| `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. |
|
| `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. |
|
||||||
| `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. |
|
| `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. |
|
||||||
| `cabana.AdminBulkAction` | Swag annotation of the bulk action route. |
|
| `cabana.AdminBulkAction` | Swag annotation of the bulk action route. |
|
||||||
|
| `cabana.FormPreview` | The `preview` object of a form schema: present when the form has a preview screen; `headerPartial` names its status hint partial. |
|
||||||
| `cabana.RecordAction` | One entry of a record response's `meta.actions`: name, localized label and optional confirm text. |
|
| `cabana.RecordAction` | One entry of a record response's `meta.actions`: name, localized label and optional confirm text. |
|
||||||
| `cabana.CRUDService.RecordAction` | Runs a declared record action on one scoped, locked record. |
|
| `cabana.CRUDService.RecordAction` | Runs a declared record action on one scoped, locked record. |
|
||||||
| `cabana.AdminRecordAction` | Swag annotation of the record action route. |
|
| `cabana.AdminRecordAction` | Swag annotation of the record action route. |
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ func AdminListSchema() {}
|
|||||||
// AdminFormSchema documents the form schema route.
|
// AdminFormSchema documents the form schema route.
|
||||||
//
|
//
|
||||||
// @Summary Admin form schema
|
// @Summary 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.
|
||||||
// @Tags admin
|
// @Tags admin
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security BackendBearer
|
// @Security BackendBearer
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
|
|||||||
if formFile == "" {
|
if formFile == "" {
|
||||||
formFile = "config_form.yaml"
|
formFile = "config_form.yaml"
|
||||||
}
|
}
|
||||||
|
// Record actions are offered on the preview screen only (D-10, D-11).
|
||||||
|
if len(cc.Form.recordActions) > 0 && cc.Form.preview == nil {
|
||||||
|
return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions needs a preview block (record actions are offered on the preview screen)"))
|
||||||
|
}
|
||||||
for _, name := range cc.Form.recordActions {
|
for _, name := range cc.Form.recordActions {
|
||||||
action, ok := recordActions[name]
|
action, ok := recordActions[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -144,8 +148,10 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// compilePartials reads and parses every partial the controller declares:
|
// compilePartials reads and parses every partial the controller declares:
|
||||||
// config_list.yaml headerPartial and each `type: partial` field's path, both
|
// config_list.yaml headerPartial, config_form.yaml preview.headerPartial and
|
||||||
// resolving to {ConfigDir}/_{name}.htm. A missing or unparsable template, or a
|
// each `type: partial` field's path, all resolving to {ConfigDir}/_{name}.htm.
|
||||||
|
// The preview header partial is a form partial: the partial route renders it
|
||||||
|
// with a record id, loaded through the form scope. A missing or unparsable template, or a
|
||||||
// controller without pact.AdminPartialData, fails boot (D-11).
|
// controller without pact.AdminPartialData, fails boot (D-11).
|
||||||
func compilePartials(pluginID string, cc *CompiledController, fsys fs.FS) error {
|
func compilePartials(pluginID string, cc *CompiledController, fsys fs.FS) error {
|
||||||
id := cc.Controller.ID()
|
id := cc.Controller.ID()
|
||||||
@@ -155,6 +161,10 @@ func compilePartials(pluginID string, cc *CompiledController, fsys fs.FS) error
|
|||||||
names = append(names, cc.List.HeaderPartial)
|
names = append(names, cc.List.HeaderPartial)
|
||||||
}
|
}
|
||||||
if cc.Form != nil {
|
if cc.Form != nil {
|
||||||
|
if cc.Form.preview != nil && cc.Form.preview.HeaderPartial != "" {
|
||||||
|
names = append(names, cc.Form.preview.HeaderPartial)
|
||||||
|
formNames[cc.Form.preview.HeaderPartial] = true
|
||||||
|
}
|
||||||
for _, field := range cc.Form.Fields {
|
for _, field := range cc.Form.Fields {
|
||||||
if field.Type == "partial" {
|
if field.Type == "partial" {
|
||||||
names = append(names, field.Path)
|
names = append(names, field.Path)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/towel"
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
"github.com/goccy/go-yaml/ast"
|
"github.com/goccy/go-yaml/ast"
|
||||||
|
"github.com/goccy/go-yaml/parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -55,6 +56,68 @@ type formConfigDocument struct {
|
|||||||
Update *formRedirects `yaml:"update"`
|
Update *formRedirects `yaml:"update"`
|
||||||
Messages *formMessageKeys `yaml:"messages"`
|
Messages *formMessageKeys `yaml:"messages"`
|
||||||
RecordActions recordActionList `yaml:"recordActions"`
|
RecordActions recordActionList `yaml:"recordActions"`
|
||||||
|
Preview previewDocument `yaml:"preview"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewDocument is the config_form.yaml preview block (D-11): a mapping
|
||||||
|
// that enables the read-only preview screen, with the optional headerPartial
|
||||||
|
// naming the status hint partial. The key is never half-set: an empty (null)
|
||||||
|
// value is refused, and `preview: {}` enables the screen without a hint.
|
||||||
|
type previewDocument struct {
|
||||||
|
set bool
|
||||||
|
headerPartial string
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewShapeHint is the boot error of a preview key that is not a mapping.
|
||||||
|
const previewShapeHint = "preview must be a mapping; write preview: {} to enable the preview screen without a header partial"
|
||||||
|
|
||||||
|
// topLevelKey reports whether the YAML document's root mapping has key.
|
||||||
|
func topLevelKey(raw []byte, key string) bool {
|
||||||
|
file, err := parser.ParseBytes(raw, 0)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, doc := range file.Docs {
|
||||||
|
if doc == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var entries []*ast.MappingValueNode
|
||||||
|
switch body := unwrapNode(doc.Body).(type) {
|
||||||
|
case *ast.MappingNode:
|
||||||
|
entries = body.Values
|
||||||
|
case *ast.MappingValueNode:
|
||||||
|
entries = []*ast.MappingValueNode{body}
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if name, err := nodeString(unwrapNode(entry.Key)); err == nil && name == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *previewDocument) UnmarshalYAML(node ast.Node) error {
|
||||||
|
mapping, ok := unwrapNode(node).(*ast.MappingNode)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s", previewShapeHint)
|
||||||
|
}
|
||||||
|
p.set = true
|
||||||
|
for _, entry := range mapping.Values {
|
||||||
|
key, err := nodeString(unwrapNode(entry.Key))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("preview: %w", err)
|
||||||
|
}
|
||||||
|
if key != "headerPartial" {
|
||||||
|
return fmt.Errorf("preview: unknown field %s", key)
|
||||||
|
}
|
||||||
|
name, err := nodeString(unwrapNode(entry.Value))
|
||||||
|
if err != nil || !identifier(name) {
|
||||||
|
return fmt.Errorf("preview: headerPartial %q: %s", nodeText(entry.Value), partialPathHint)
|
||||||
|
}
|
||||||
|
p.headerPartial = name
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordActionList is the declarative recordActions list (D-10): the names of
|
// recordActionList is the declarative recordActions list (D-10): the names of
|
||||||
@@ -106,6 +169,11 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
|
|||||||
if err := decodeStrict(raw, &doc); err != nil {
|
if err := decodeStrict(raw, &doc); err != nil {
|
||||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||||
}
|
}
|
||||||
|
// The decoder does not hand an empty (null) value to previewDocument, so
|
||||||
|
// a half-set `preview:` key is refused here.
|
||||||
|
if !doc.Preview.set && topLevelKey(raw, "preview") {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("%s", previewShapeHint))
|
||||||
|
}
|
||||||
if doc.ModelClass != ctl.ModelName() {
|
if doc.ModelClass != ctl.ModelName() {
|
||||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
|
||||||
}
|
}
|
||||||
@@ -140,6 +208,9 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
|
|||||||
|
|
||||||
recordActions: doc.RecordActions.items,
|
recordActions: doc.RecordActions.items,
|
||||||
}
|
}
|
||||||
|
if doc.Preview.set {
|
||||||
|
schema.preview = &FormPreview{HeaderPartial: doc.Preview.headerPartial}
|
||||||
|
}
|
||||||
if doc.Messages != nil {
|
if doc.Messages != nil {
|
||||||
schema.messageKeys = *doc.Messages
|
schema.messageKeys = *doc.Messages
|
||||||
}
|
}
|
||||||
@@ -186,10 +257,16 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
|
|||||||
if fields == nil {
|
if fields == nil {
|
||||||
fields = []FormField{}
|
fields = []FormField{}
|
||||||
}
|
}
|
||||||
|
var preview *FormPreview
|
||||||
|
if s.preview != nil {
|
||||||
|
copied := *s.preview
|
||||||
|
preview = &copied
|
||||||
|
}
|
||||||
return &FormView{
|
return &FormView{
|
||||||
Name: translateKey(ctx, tr, s.Name),
|
Name: translateKey(ctx, tr, s.Name),
|
||||||
ModelClass: s.ModelClass,
|
ModelClass: s.ModelClass,
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
|
Preview: preview,
|
||||||
Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()),
|
Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()),
|
||||||
Redirects: s.redirects,
|
Redirects: s.redirects,
|
||||||
Meta: FormMeta{Locale: locale},
|
Meta: FormMeta{Locale: locale},
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ type formMessageKeys struct {
|
|||||||
Saved string `yaml:"saved"`
|
Saved string `yaml:"saved"`
|
||||||
DeleteConfirm string `yaml:"deleteConfirm"`
|
DeleteConfirm string `yaml:"deleteConfirm"`
|
||||||
Deleted string `yaml:"deleted"`
|
Deleted string `yaml:"deleted"`
|
||||||
|
// The preview screen's subtitle and its edit button (D-11).
|
||||||
|
Preview string `yaml:"preview"`
|
||||||
|
Edit string `yaml:"edit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormMessages is a form's copy, every key resolved.
|
// FormMessages is a form's copy, every key resolved.
|
||||||
@@ -77,6 +80,9 @@ type FormMessages struct {
|
|||||||
Saved MessageForms `json:"saved"`
|
Saved MessageForms `json:"saved"`
|
||||||
DeleteConfirm MessageForms `json:"deleteConfirm"`
|
DeleteConfirm MessageForms `json:"deleteConfirm"`
|
||||||
Deleted MessageForms `json:"deleted"`
|
Deleted MessageForms `json:"deleted"`
|
||||||
|
// The preview screen's subtitle and its edit button (D-11).
|
||||||
|
Preview MessageForms `json:"preview"`
|
||||||
|
Edit MessageForms `json:"edit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// relationMessageKeys is one config_relation.yaml relation's messages block.
|
// relationMessageKeys is one config_relation.yaml relation's messages block.
|
||||||
@@ -162,6 +168,8 @@ var (
|
|||||||
Saved: "backend::lang.messages.form.saved",
|
Saved: "backend::lang.messages.form.saved",
|
||||||
DeleteConfirm: "backend::lang.messages.form.delete_confirm",
|
DeleteConfirm: "backend::lang.messages.form.delete_confirm",
|
||||||
Deleted: "backend::lang.messages.form.deleted",
|
Deleted: "backend::lang.messages.form.deleted",
|
||||||
|
Preview: "backend::lang.messages.form.preview",
|
||||||
|
Edit: "backend::lang.messages.form.edit",
|
||||||
}
|
}
|
||||||
relationMessageDefaults = relationMessageKeys{
|
relationMessageDefaults = relationMessageKeys{
|
||||||
Link: "backend::lang.messages.relation.link",
|
Link: "backend::lang.messages.relation.link",
|
||||||
|
|||||||
@@ -916,6 +916,7 @@ update:
|
|||||||
redirect: acme/conform/gadgets
|
redirect: acme/conform/gadgets
|
||||||
redirectClose: acme/conform/gadgets
|
redirectClose: acme/conform/gadgets
|
||||||
recordActions: [ping]
|
recordActions: [ping]
|
||||||
|
preview: {}
|
||||||
`),
|
`),
|
||||||
"controllers/gadgets/config_relation.yaml": file(`members:
|
"controllers/gadgets/config_relation.yaml": file(`members:
|
||||||
label: Members
|
label: Members
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ import (
|
|||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
"git.golem15.com/golem15/summercms/modules/compass"
|
|
||||||
"git.golem15.com/golem15/summercms/modules/party"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const rosterPeople = "/acme/roster/people"
|
const rosterPeople = "/acme/roster/people"
|
||||||
@@ -437,20 +434,9 @@ func TestFormSchemaRecordActionsBoot(t *testing.T) {
|
|||||||
// A name the controller does not register is refused when the controller
|
// A name the controller does not register is refused when the controller
|
||||||
// is activated, where the form meets its registered actions.
|
// is activated, where the form meets its registered actions.
|
||||||
t.Run("unregistered", func(t *testing.T) {
|
t.Run("unregistered", func(t *testing.T) {
|
||||||
fsys := fstest.MapFS{}
|
err := rosterBoot(t, rosterTree(t, map[string]string{
|
||||||
for _, name := range []string{"controllers/people/config_list.yaml", "models/person/columns.yaml", "models/person/fields.yaml"} {
|
"controllers/people/config_form.yaml": head + "preview: {}\nrecordActions: [activate, promote]\n",
|
||||||
data, err := os.ReadFile(filepath.Join(rosterDir, name))
|
}))
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
fsys[name] = &fstest.MapFile{Data: data}
|
|
||||||
}
|
|
||||||
fsys["controllers/people/config_form.yaml"] = &fstest.MapFile{Data: []byte(head + "recordActions: [activate, promote]\n")}
|
|
||||||
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}})
|
|
||||||
const want = "recordActions: unsupported action promote (want a record action the controller registers)"
|
const want = "recordActions: unsupported action promote (want a record action the controller registers)"
|
||||||
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "acme.roster.people") || !strings.Contains(err.Error(), "controllers/people/config_form.yaml") {
|
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "acme.roster.people") || !strings.Contains(err.Error(), "controllers/people/config_form.yaml") {
|
||||||
t.Fatalf("error = %v, want %q", err, want)
|
t.Fatalf("error = %v, want %q", err, want)
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ const rosterDir = "testdata/roster"
|
|||||||
// rosterPerson is the fixture model: a person of one tenant who can be
|
// rosterPerson is the fixture model: a person of one tenant who can be
|
||||||
// active, banned and soft-deleted.
|
// active, banned and soft-deleted.
|
||||||
type rosterPerson struct {
|
type rosterPerson struct {
|
||||||
ID uint `gorm:"column:id;primaryKey"`
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
Tenant string `gorm:"column:tenant"`
|
Tenant string `gorm:"column:tenant"`
|
||||||
Name string `gorm:"column:name"`
|
Name string `gorm:"column:name"`
|
||||||
Email string `gorm:"column:email"`
|
Email string `gorm:"column:email"`
|
||||||
Active bool `gorm:"column:active"`
|
Active bool `gorm:"column:active"`
|
||||||
Banned bool `gorm:"column:banned"`
|
Banned bool `gorm:"column:banned"`
|
||||||
|
// JoinedIP is shown on the preview screen only (context: preview).
|
||||||
|
JoinedIP *string `gorm:"column:joined_ip"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +180,35 @@ func (c rosterController) ListRowStates(ctx context.Context, db *gorm.DB, record
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rosterStatus is the curated view model of the preview status hint: the
|
||||||
|
// callout tone and the phrase keys of its title and text. It is empty when
|
||||||
|
// no state applies, and the template then renders nothing.
|
||||||
|
type rosterStatus struct {
|
||||||
|
Tone, Title, Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartialData serves the preview header partial `status`: one callout by
|
||||||
|
// precedence banned, archived, not active.
|
||||||
|
func (rosterController) PartialData(_ context.Context, name string, record any) (any, error) {
|
||||||
|
if name != "status" {
|
||||||
|
return nil, fmt.Errorf("unknown partial %s", name)
|
||||||
|
}
|
||||||
|
person, ok := record.(*rosterPerson)
|
||||||
|
if !ok || person == nil {
|
||||||
|
return rosterStatus{}, nil
|
||||||
|
}
|
||||||
|
const keys = "acme.roster::lang.people."
|
||||||
|
switch {
|
||||||
|
case person.Banned:
|
||||||
|
return rosterStatus{Tone: "danger", Title: keys + "banned_title", Text: keys + "banned_text"}, nil
|
||||||
|
case person.DeletedAt.Valid:
|
||||||
|
return rosterStatus{Tone: "danger", Title: keys + "deleted_title", Text: keys + "deleted_text"}, nil
|
||||||
|
case !person.Active:
|
||||||
|
return rosterStatus{Tone: "warning", Title: keys + "inactive_title", Text: keys + "inactive_text"}, nil
|
||||||
|
}
|
||||||
|
return rosterStatus{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// rosterLocked is the sentinel name of a person the roster's actions refuse.
|
// rosterLocked is the sentinel name of a person the roster's actions refuse.
|
||||||
const rosterLocked = "Locked"
|
const rosterLocked = "Locked"
|
||||||
|
|
||||||
@@ -381,6 +412,46 @@ func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
|
|||||||
return env, gdb
|
return env, gdb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rosterTree is the roster fixture tree as an in-memory file system with the
|
||||||
|
// given files replaced or added (boot-error tests).
|
||||||
|
func rosterTree(t *testing.T, replace map[string]string) fstest.MapFS {
|
||||||
|
t.Helper()
|
||||||
|
out := fstest.MapFS{}
|
||||||
|
err := filepath.WalkDir(rosterDir, func(name string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil || entry.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(rosterDir, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out[filepath.ToSlash(rel)] = &fstest.MapFile{Data: data}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, body := range replace {
|
||||||
|
out[name] = &fstest.MapFile{Data: []byte(body)}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterBoot activates the roster plugin over fsys and returns the boot error.
|
||||||
|
func rosterBoot(t *testing.T, fsys fs.FS) error {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// rosterInsert stores one person and returns its id.
|
// rosterInsert stores one person and returns its id.
|
||||||
func rosterInsert(t *testing.T, gdb *gorm.DB, person rosterPerson) uint {
|
func rosterInsert(t *testing.T, gdb *gorm.DB, person rosterPerson) uint {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
138
modules/cabana/phase121_form_test.go
Normal file
138
modules/cabana/phase121_form_test.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rosterFormHead is the smallest config_form.yaml of the roster fixture.
|
||||||
|
const rosterFormHead = "form: ~/plugins/acme/roster/models/person/fields.yaml\nmodelClass: Person\n"
|
||||||
|
|
||||||
|
// rosterFormSchema fetches the people form schema as auth sees it.
|
||||||
|
func rosterFormSchema(t *testing.T, env *rosterEnv, auth string) (cabana.FormView, string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/form", "", auth)
|
||||||
|
var body cabana.Envelope[cabana.FormView]
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("form schema: %v\n%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
return body.Data, rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterRecord decodes a record response.
|
||||||
|
func rosterRecord(t *testing.T, raw []byte) cabana.RecordEnvelope {
|
||||||
|
t.Helper()
|
||||||
|
var body cabana.RecordEnvelope
|
||||||
|
if err := json.Unmarshal(raw, &body); err != nil {
|
||||||
|
t.Fatalf("record body %s: %v", raw, err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterBootFails asserts that the roster plugin with the replaced files does
|
||||||
|
// not boot and that the error carries every wanted part.
|
||||||
|
func rosterBootFails(t *testing.T, replace map[string]string, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
err := rosterBoot(t, rosterTree(t, replace))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("the plugin booted, want an error naming %q", want)
|
||||||
|
}
|
||||||
|
for _, part := range want {
|
||||||
|
if !strings.Contains(err.Error(), part) {
|
||||||
|
t.Fatalf("error %q does not name %q", err, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreviewSmoke drives the preview context through the assembled router on
|
||||||
|
// PostgreSQL (D-11; T-12.1-14, T-12.1-15): the schema's preview block and
|
||||||
|
// messages, a preview-only field that is shown and never written, the status
|
||||||
|
// hint through the partial route with the form scope, and the boot rules.
|
||||||
|
func TestPreviewSmoke(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
ip := "203.0.113.7"
|
||||||
|
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test", Active: true, JoinedIP: &ip})
|
||||||
|
banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea", Active: true, Banned: true})
|
||||||
|
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", Banned: true})
|
||||||
|
record := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
|
||||||
|
|
||||||
|
t.Run("schema reports the preview, its messages and the preview-only field", func(t *testing.T) {
|
||||||
|
view, raw := rosterFormSchema(t, env, "bearer")
|
||||||
|
if view.Preview == nil || view.Preview.HeaderPartial != "status" {
|
||||||
|
t.Fatalf("preview = %+v", view.Preview)
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, `"preview":{"headerPartial":"status"}`) {
|
||||||
|
t.Fatalf("preview block is not in the schema: %s", raw)
|
||||||
|
}
|
||||||
|
if view.Messages.Preview["other"] != "Person details" || view.Messages.Edit["other"] != "Edit person" {
|
||||||
|
t.Fatalf("messages = %+v", view.Messages)
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, `"name":"joined_ip","type":"text","label":"Joined from IP address","context":"preview"`) {
|
||||||
|
t.Fatalf("preview-only field is not in the schema: %s", raw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, `"redirectClose":"acme/roster/people/preview/:id"`) {
|
||||||
|
t.Fatalf("redirects do not point at the preview: %s", raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a preview-only field is shown and never written", func(t *testing.T) {
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, record(ada), "", "bearer")
|
||||||
|
if got := rosterRecord(t, rec.Body.Bytes()).Data["joined_ip"]; got != ip {
|
||||||
|
t.Fatalf("show joined_ip = %v", got)
|
||||||
|
}
|
||||||
|
rec = env.expect(t, http.StatusOK, http.MethodPut, record(ada), `{"name":"Ada L","joined_ip":"198.51.100.1"}`, "bearer")
|
||||||
|
if got := rosterRecord(t, rec.Body.Bytes()).Data["joined_ip"]; got != ip {
|
||||||
|
t.Fatalf("update answered joined_ip = %v", got)
|
||||||
|
}
|
||||||
|
stored := rosterLoad(t, gdb, ada)
|
||||||
|
if stored.Name != "Ada L" || stored.JoinedIP == nil || *stored.JoinedIP != ip {
|
||||||
|
t.Fatalf("stored = %+v ip=%v", stored, stored.JoinedIP)
|
||||||
|
}
|
||||||
|
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2"}`, "bearer")
|
||||||
|
created := rosterRecord(t, rec.Body.Bytes())
|
||||||
|
id, _ := created.Data["id"].(float64)
|
||||||
|
if got := rosterLoad(t, gdb, uint(id)); got.JoinedIP != nil {
|
||||||
|
t.Fatalf("create wrote joined_ip = %q", *got.JoinedIP)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the status hint renders through the partial route in the form scope", func(t *testing.T) {
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, banned), "", "bearer")
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, part := range []string{`"class":"summer-callout summer-callout--danger"`, `"role":"status"`, `"class":"summer-callout__title"`, "This person is banned", "A banned person cannot sign in until the ban is lifted."} {
|
||||||
|
if !strings.Contains(body, part) {
|
||||||
|
t.Fatalf("hint %s does not carry %s", body, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No state applies: zero nodes, so the screen renders no hint.
|
||||||
|
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, ada), "", "bearer")
|
||||||
|
var view cabana.Envelope[cabana.PartialView]
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil || len(view.Data.Nodes) != 0 {
|
||||||
|
t.Fatalf("hint for an active person = %s err=%v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
// Another tenant's person is outside the form scope.
|
||||||
|
rec = env.expect(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, foreign), "", "bearer")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "not_found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("boot rules", func(t *testing.T) {
|
||||||
|
const form = "controllers/people/config_form.yaml"
|
||||||
|
rosterBootFails(t, map[string]string{form: rosterFormHead + "recordActions: [activate]\n"},
|
||||||
|
"recordActions needs a preview block (record actions are offered on the preview screen)", "acme.roster.people", form)
|
||||||
|
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n"},
|
||||||
|
"preview must be a mapping; write preview: {} to enable the preview screen without a header partial", form)
|
||||||
|
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview: true\n"}, "preview must be a mapping")
|
||||||
|
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n toolbar: x\n"}, "preview: unknown field toolbar")
|
||||||
|
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n headerPartial: $/acme/status.htm\n"}, "headerPartial", "path must be a partial name")
|
||||||
|
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n headerPartial: missing\n"}, "partial missing", "controllers/people/_missing.htm")
|
||||||
|
// preview: {} enables the screen without a hint.
|
||||||
|
if err := rosterBoot(t, rosterTree(t, map[string]string{form: rosterFormHead + "preview: {}\nrecordActions: [activate]\n"})); err != nil {
|
||||||
|
t.Fatalf("preview: {} did not boot: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -140,6 +140,18 @@ type FormSchema struct {
|
|||||||
configPath string
|
configPath string
|
||||||
// recordActions are the declared recordActions names, in declared order.
|
// recordActions are the declared recordActions names, in declared order.
|
||||||
recordActions []string
|
recordActions []string
|
||||||
|
// preview is the config_form.yaml preview block; nil when the form has
|
||||||
|
// no preview screen.
|
||||||
|
preview *FormPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormPreview is a form's preview screen (config_form.yaml `preview:`, D-11):
|
||||||
|
// a read-only record view that shows the fields whose context allows
|
||||||
|
// `preview` and offers the form's record actions. HeaderPartial names the
|
||||||
|
// controller partial rendered above the fields as a status hint; it is empty
|
||||||
|
// when the screen has none.
|
||||||
|
type FormPreview struct {
|
||||||
|
HeaderPartial string `json:"headerPartial,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormView is one request's localized form, including the locale actually used.
|
// FormView is one request's localized form, including the locale actually used.
|
||||||
@@ -147,6 +159,9 @@ type FormView struct {
|
|||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
ModelClass string `json:"modelClass,omitempty"`
|
ModelClass string `json:"modelClass,omitempty"`
|
||||||
Fields []FormField `json:"fields"`
|
Fields []FormField `json:"fields"`
|
||||||
|
// Preview is set when the form has a preview screen (D-11); a form
|
||||||
|
// without one omits the key.
|
||||||
|
Preview *FormPreview `json:"preview,omitempty"`
|
||||||
// Messages is the form's copy resolved in the request locale (D-13).
|
// Messages is the form's copy resolved in the request locale (D-13).
|
||||||
Messages FormMessages `json:"messages"`
|
Messages FormMessages `json:"messages"`
|
||||||
// Redirects are the raw Winter config_form.yaml targets; the SPA maps
|
// Redirects are the raw Winter config_form.yaml targets; the SPA maps
|
||||||
|
|||||||
6
modules/cabana/testdata/roster/controllers/people/_status.htm
vendored
Normal file
6
modules/cabana/testdata/roster/controllers/people/_status.htm
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{{- if .Data.Title -}}
|
||||||
|
<div class="summer-callout summer-callout--{{ .Data.Tone }}" role="status">
|
||||||
|
<p class="summer-callout__title">{{ trans .Data.Title }}</p>
|
||||||
|
<p class="summer-callout__text">{{ trans .Data.Text }}</p>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
@@ -3,9 +3,14 @@ form: ~/plugins/acme/roster/models/person/fields.yaml
|
|||||||
modelClass: Person
|
modelClass: Person
|
||||||
defaultRedirect: acme/roster/people
|
defaultRedirect: acme/roster/people
|
||||||
create:
|
create:
|
||||||
redirect: acme/roster/people/update/:id
|
redirect: acme/roster/people/preview/:id
|
||||||
redirectClose: acme/roster/people
|
redirectClose: acme/roster/people
|
||||||
update:
|
update:
|
||||||
redirect: acme/roster/people
|
redirect: acme/roster/people
|
||||||
redirectClose: acme/roster/people
|
redirectClose: acme/roster/people/preview/:id
|
||||||
|
preview:
|
||||||
|
headerPartial: status
|
||||||
recordActions: [activate, reinstate]
|
recordActions: [activate, reinstate]
|
||||||
|
messages:
|
||||||
|
preview: acme.roster::lang.people.preview
|
||||||
|
edit: acme.roster::lang.people.edit
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
list: ~/plugins/acme/roster/models/person/columns.yaml
|
list: ~/plugins/acme/roster/models/person/columns.yaml
|
||||||
modelClass: Person
|
modelClass: Person
|
||||||
title: acme.roster::lang.people.title
|
title: acme.roster::lang.people.title
|
||||||
recordUrl: acme/roster/people/update/:id
|
recordUrl: acme/roster/people/preview/:id
|
||||||
recordsPerPage: 20
|
recordsPerPage: 20
|
||||||
showCheckboxes: true
|
showCheckboxes: true
|
||||||
toolbar:
|
toolbar:
|
||||||
|
|||||||
@@ -15,3 +15,12 @@ people:
|
|||||||
refused: You may not rename this person.
|
refused: You may not rename this person.
|
||||||
refused_name: This name is reserved.
|
refused_name: This name is reserved.
|
||||||
locked: This person is locked and cannot be changed.
|
locked: This person is locked and cannot be changed.
|
||||||
|
preview: Person details
|
||||||
|
edit: Edit person
|
||||||
|
joined_ip: Joined from IP address
|
||||||
|
banned_title: This person is banned
|
||||||
|
banned_text: A banned person cannot sign in until the ban is lifted.
|
||||||
|
deleted_title: This person is archived
|
||||||
|
deleted_text: An archived person is hidden from the directory.
|
||||||
|
inactive_title: This person is not active
|
||||||
|
inactive_text: Activate the person to let them sign in.
|
||||||
|
|||||||
@@ -15,3 +15,12 @@ people:
|
|||||||
refused: Nie możesz zmienić nazwy tej osoby.
|
refused: Nie możesz zmienić nazwy tej osoby.
|
||||||
refused_name: Ta nazwa jest zastrzeżona.
|
refused_name: Ta nazwa jest zastrzeżona.
|
||||||
locked: Ta osoba jest zablokowana i nie można jej zmienić.
|
locked: Ta osoba jest zablokowana i nie można jej zmienić.
|
||||||
|
preview: Szczegóły osoby
|
||||||
|
edit: Edytuj osobę
|
||||||
|
joined_ip: Adres IP przy dołączeniu
|
||||||
|
banned_title: Ta osoba jest zablokowana
|
||||||
|
banned_text: Zablokowana osoba nie może się zalogować do czasu zdjęcia blokady.
|
||||||
|
deleted_title: Ta osoba jest zarchiwizowana
|
||||||
|
deleted_text: Zarchiwizowana osoba jest ukryta w katalogu.
|
||||||
|
inactive_title: Ta osoba jest nieaktywna
|
||||||
|
inactive_text: Aktywuj osobę, aby mogła się zalogować.
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ fields:
|
|||||||
label: acme.roster::lang.people.email
|
label: acme.roster::lang.people.email
|
||||||
type: text
|
type: text
|
||||||
span: right
|
span: right
|
||||||
|
joined_ip:
|
||||||
|
label: acme.roster::lang.people.joined_ip
|
||||||
|
type: text
|
||||||
|
context: preview
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ form:
|
|||||||
update: Edit
|
update: Edit
|
||||||
create: Create
|
create: Create
|
||||||
return_to_list: Back to list
|
return_to_list: Back to list
|
||||||
|
return_to_preview: Back to preview
|
||||||
close: Close
|
close: Close
|
||||||
confirm: Confirm
|
confirm: Confirm
|
||||||
saving: Saving…
|
saving: Saving…
|
||||||
@@ -183,6 +184,8 @@ messages:
|
|||||||
saved: Saved
|
saved: Saved
|
||||||
delete_confirm: Delete this record?
|
delete_confirm: Delete this record?
|
||||||
deleted: Record deleted
|
deleted: Record deleted
|
||||||
|
preview: Record preview
|
||||||
|
edit: Edit record
|
||||||
relation:
|
relation:
|
||||||
link: Add
|
link: Add
|
||||||
link_hint: Choose the records to link.
|
link_hint: Choose the records to link.
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ form:
|
|||||||
update: Edytuj
|
update: Edytuj
|
||||||
create: Utwórz
|
create: Utwórz
|
||||||
return_to_list: Wróć do listy
|
return_to_list: Wróć do listy
|
||||||
|
return_to_preview: Wróć do podglądu
|
||||||
close: Zamknij
|
close: Zamknij
|
||||||
confirm: Potwierdź
|
confirm: Potwierdź
|
||||||
saving: Zapisywanie…
|
saving: Zapisywanie…
|
||||||
@@ -199,6 +200,8 @@ messages:
|
|||||||
saved: Zapisano
|
saved: Zapisano
|
||||||
delete_confirm: Usunąć ten rekord?
|
delete_confirm: Usunąć ten rekord?
|
||||||
deleted: Usunięto rekord
|
deleted: Usunięto rekord
|
||||||
|
preview: Podgląd rekordu
|
||||||
|
edit: Edytuj rekord
|
||||||
relation:
|
relation:
|
||||||
link: Dodaj
|
link: Dodaj
|
||||||
link_hint: Wybierz rekordy, które chcesz dołączyć.
|
link_hint: Wybierz rekordy, które chcesz dołączyć.
|
||||||
|
|||||||
Reference in New Issue
Block a user