feat(10.1-02): render header and form partials through an allowlisted node renderer

- partialNodes rebuilds the server node tree with h() under the server's tag, attribute and URL lists
- PartialHost owns the skeleton, empty and failure states and keeps nodes visible on refetch
- type: partial is a valueless group-labelled field rendered on create and update
- ListView shows headerPartial above the list card and refetches it after bulk delete
- summer-partial and summer-stats style kit in main.css, documented in the cabana README; dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-29 02:10:18 +02:00
parent 107d820109
commit 9df9fae930
20 changed files with 870 additions and 22 deletions

View File

@@ -40,6 +40,10 @@ export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
export type AdminActionResult = Schemas['cabana.AdminActionResult'] export type AdminActionResult = Schemas['cabana.AdminActionResult']
/** A controller's plugin script and stylesheet URLs under {base}/assets/. */ /** A controller's plugin script and stylesheet URLs under {base}/assets/. */
export type ControllerAssets = Schemas['cabana.ControllerAssets'] export type ControllerAssets = Schemas['cabana.ControllerAssets']
/** One node of a rendered partial: an allowlisted element or a text node. */
export type PartialNode = Schemas['cabana.PartialNode']
/** A rendered header or form partial. */
export type PartialView = Schemas['cabana.PartialView']
/** One message as CLDR plural forms; "other" is always present (D-13). */ /** One message as CLDR plural forms; "other" is always present (D-13). */
export type MessageForms = Schemas['cabana.MessageForms'] export type MessageForms = Schemas['cabana.MessageForms']
/** The public backend::lang bundle: full key to CLDR forms (D-20). */ /** The public backend::lang bundle: full key to CLDR forms (D-20). */

View File

@@ -39,10 +39,10 @@ const describedBy = computed(() =>
<template> <template>
<div class="flex min-w-0 flex-col gap-1.5" :data-field="field.name"> <div class="flex min-w-0 flex-col gap-1.5" :data-field="field.name">
<span v-if="labelsGroup" :id="`${controlId}-label`" class="font-semibold"> <span v-if="labelsGroup && field.label" :id="`${controlId}-label`" class="font-semibold">
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span> {{ field.label }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</span> </span>
<label v-else-if="!selfLabelled" :for="controlId" class="font-semibold"> <label v-else-if="!selfLabelled && !labelsGroup" :for="controlId" class="font-semibold">
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span> {{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</label> </label>
<FieldRenderer <FieldRenderer

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { t } from '../../../app/i18n'
import PartialHost from '../../partial/PartialHost.vue'
import ExtensionFailure from '../../ui/ExtensionFailure.vue'
import type { FieldControlProps } from '../control'
// A `type: partial` form row (D-09; UI-SPEC S2): the controller partial
// named by `path`, rendered on create (no record) and update (?id=). It holds
// no value. The row's visible label, when declared, names the group. A form
// without a controller (a settings page) has no partial route to call.
const props = defineProps<FieldControlProps>()
</script>
<template>
<div
:id="controlId"
role="group"
data-partial-field
:aria-labelledby="props.field.label ? `${controlId}-label` : undefined"
:aria-describedby="describedBy || undefined"
>
<PartialHost
v-if="source"
variant="field"
:source="source"
:name="props.field.path || props.field.name"
:record-id="recordId ?? null"
/>
<ExtensionFailure v-else data-partial-failed :text="t('backend::lang.extension.partial_failed')" />
</div>
</template>

View File

@@ -162,7 +162,7 @@ onBeforeUnmount(() => {
:id="controlId" :id="controlId"
role="group" role="group"
data-widget-host data-widget-host
:aria-labelledby="`${controlId}-label`" :aria-labelledby="field.label ? `${controlId}-label` : undefined"
:aria-describedby="describedBy || undefined" :aria-describedby="describedBy || undefined"
:aria-busy="status === 'loading' ? 'true' : undefined" :aria-busy="status === 'loading' ? 'true' : undefined"
class="flex min-h-input items-center" class="flex min-h-input items-center"

View File

@@ -4,12 +4,13 @@
// is registered like any control but holds no form value: it edits its // is registered like any control but holds no form value: it edits its
// relation through its own endpoints and renders only on an existing record. // relation through its own endpoints and renders only on an existing record.
// Phase 10.1 adds the plugin extension types: a widget mounts a plugin custom // Phase 10.1 adds the plugin extension types: a widget mounts a plugin custom
// element (D-04, D-09). It holds no form value either, but it renders on // element (D-04, D-09) and a partial renders a server template (D-17). They
// create and update. // hold no form value either, but they render on create and update.
import type { Component } from 'vue' import type { Component } from 'vue'
import CheckboxField from './fields/CheckboxField.vue' import CheckboxField from './fields/CheckboxField.vue'
import DropdownField from './fields/DropdownField.vue' import DropdownField from './fields/DropdownField.vue'
import NumberField from './fields/NumberField.vue' import NumberField from './fields/NumberField.vue'
import PartialField from './fields/PartialField.vue'
import RelationManager from '../relation/RelationManager.vue' import RelationManager from '../relation/RelationManager.vue'
import RelationField from './fields/RelationField.vue' import RelationField from './fields/RelationField.vue'
import SwitchField from './fields/SwitchField.vue' import SwitchField from './fields/SwitchField.vue'
@@ -37,6 +38,7 @@ const renderers = new Map<string, Component>([
['relation', RelationField], ['relation', RelationField],
[RELATION_MANAGER, RelationManager], [RELATION_MANAGER, RelationManager],
['widget', WidgetField], ['widget', WidgetField],
['partial', PartialField],
]) ])
/** Types whose control shows the label itself (toggle cards, relation manager). */ /** Types whose control shows the label itself (toggle cards, relation manager). */
@@ -49,13 +51,13 @@ const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
const recordBound = new Set<string>([RELATION_MANAGER]) const recordBound = new Set<string>([RELATION_MANAGER])
/** Types that hold no form value: never part of the save body (D-09). */ /** Types that hold no form value: never part of the save body (D-09). */
const valueless = new Set<string>([RELATION_MANAGER, 'widget']) const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial'])
/** /**
* Types whose control is a group rather than one focusable element: the * Types whose control is a group rather than one focusable element: the
* visible label is a span the group points at, not a label for an input. * visible label is a span the group points at, not a label for an input.
*/ */
const groupLabelledTypes = new Set<string>(['widget']) const groupLabelledTypes = new Set<string>(['widget', 'partial'])
export function rendererFor(type: string): Component { export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField return renderers.get(type) ?? UnsupportedField
@@ -75,7 +77,10 @@ export function ownsLabel(type: string): boolean {
return selfLabelled.has(type) return selfLabelled.has(type)
} }
/** Whether a field type's label labels a group (widget) instead of a control. */ /**
* Whether a field type's label names a group (widget, partial) instead of a
* control. Such a row shows no label row when the field declares no label.
*/
export function groupLabelled(type: string): boolean { export function groupLabelled(type: string): boolean {
return groupLabelledTypes.has(type) return groupLabelledTypes.has(type)
} }

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref, watch, type FunctionalComponent } from 'vue'
import { api } from '../../api/client'
import type { ControllerParams, PartialNode } from '../../api/types'
import { t } from '../../app/i18n'
import ExtensionFailure from '../ui/ExtensionFailure.vue'
import { renderPartialNodes } from './partialNodes'
// A server-rendered controller partial (D-17; UI-SPEC S1, S2): a header
// strip above a list or a `type: partial` form row. The node tree from
// GET .../partials/{name} is built with h() under the client allowlist.
// The first load shows a skeleton; a reload (reloadKey change) keeps the
// current nodes visible and only marks the host busy. Zero nodes render
// nothing; a failure shows the extension failure box. No live region: the
// toast of the action that caused a reload is the announcement.
const props = withDefaults(
defineProps<{
source: ControllerParams
name: string
/** Form partials on an existing record pass it as ?id=. */
recordId?: number | null
variant: 'header' | 'field'
/** Bumped by the parent to refetch. */
reloadKey?: number
}>(),
{ recordId: null, reloadKey: 0 },
)
const nodes = ref<PartialNode[] | null>(null)
const failed = ref(false)
const busy = ref(false)
let generation = 0
const PartialNodes: FunctionalComponent<{ nodes: PartialNode[] }> = (content) => renderPartialNodes(content.nodes)
PartialNodes.props = ['nodes']
async function load(): Promise<void> {
const current = ++generation
busy.value = true
let next: PartialNode[] | null = null
try {
const result = await api.GET('/{vendor}/{plugin}/{controller}/partials/{name}', {
params: {
path: { ...props.source, name: props.name },
query: props.recordId != null ? { id: props.recordId } : undefined,
},
})
next = result.data?.data.nodes ?? null
} catch {
next = null
}
if (current !== generation) {
return
}
busy.value = false
failed.value = next === null
nodes.value = next
}
watch(() => props.reloadKey, () => void load())
void load()
</script>
<template>
<ExtensionFailure v-if="failed" data-partial-failed :text="t('backend::lang.extension.partial_failed')" />
<div v-else-if="nodes === null" data-partial-loading aria-busy="true">
<div
data-partial-skeleton
:class="variant === 'header' ? 'h-[80px] w-full rounded-card' : 'h-[44px] w-full rounded-control'"
class="bg-skel"
aria-hidden="true"
/>
</div>
<div
v-else-if="nodes.length > 0"
class="summer-partial"
:data-partial="variant"
:aria-busy="busy ? 'true' : undefined"
>
<PartialNodes :nodes="nodes" />
</div>
</template>

View File

@@ -0,0 +1,131 @@
// Client half of the partial allowlist (D-17, T-10.1-14). cabana renders a
// plugin partial on the server and sends it as a tree of element and text
// nodes, already walked through its allowlist (modules/cabana
// partial_render.go). The SPA checks every node again against the same
// lists and builds it with Vue's h(): an allowed element keeps only its
// allowed attributes, a removed element goes with its subtree, any other
// element is unwrapped (its children stay) and text is always text. No
// string is ever parsed as markup.
import { h, type VNodeArrayChildren } from 'vue'
import type { PartialNode } from '../../api/types'
/** Elements a partial may render; the same list as the server. */
export const PARTIAL_TAGS: ReadonlySet<string> = new Set([
'div', 'span', 'p', 'strong', 'em', 'b', 'i', 'u', 's', 'small', 'mark',
'code', 'pre', 'br', 'hr', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'section', 'header', 'footer',
'figure', 'figcaption', 'blockquote', 'q', 'abbr', 'time', 'data', 'meter', 'progress', 'sup',
'sub', 'a', 'img',
])
/** Elements removed together with everything inside them. */
export const PARTIAL_DROPPED_TAGS: ReadonlySet<string> = new Set([
'script', 'style', 'template', 'iframe', 'object', 'embed', 'noscript', 'textarea', 'title',
'xmp', 'svg', 'math', 'form', 'input', 'button', 'select', 'link', 'meta', 'base',
])
/** Deepest element nesting rendered, and the most nodes of one partial. */
export const PARTIAL_MAX_DEPTH = 32
export const PARTIAL_MAX_NODES = 2000
const globalAttributes = new Set(['class', 'title', 'lang', 'dir', 'role'])
const tagAttributes: Record<string, ReadonlySet<string>> = {
a: new Set(['href']),
img: new Set(['src', 'alt', 'width', 'height']),
td: new Set(['colspan', 'rowspan', 'scope']),
th: new Set(['colspan', 'rowspan', 'scope']),
time: new Set(['datetime']),
data: new Set(['value']),
meter: new Set(['value', 'min', 'max', 'low', 'high', 'optimum']),
progress: new Set(['value', 'max']),
}
const prefixedAttribute = /^(aria|data)-[a-z0-9_-]+$/
/**
* A same-origin path starting with exactly one slash (never "//" or "/\",
* which resolve to another host), or for links a #fragment. Whitespace and
* control characters, which browsers strip before resolving, are refused.
*/
function safeUrl(raw: string, allowFragment: boolean): boolean {
if (raw === '') {
return false
}
for (const char of raw) {
const code = char.codePointAt(0) ?? 0
if (code <= 0x20 || code === 0x7f) {
return false
}
}
if (raw.startsWith('#')) {
return allowFragment
}
if (!raw.startsWith('/')) {
return false
}
return raw.length === 1 || (raw[1] !== '/' && raw[1] !== '\\')
}
/** Whether an attribute may stay on an allowed element. Names are lower case. */
export function partialAttrAllowed(tag: string, name: string, value: string): boolean {
const allowed = globalAttributes.has(name) || tagAttributes[tag]?.has(name) === true || prefixedAttribute.test(name)
if (!allowed) {
return false
}
if ((tag === 'a' && name === 'href') || (tag === 'img' && name === 'src')) {
return safeUrl(value, tag === 'a')
}
return true
}
function allowedAttributes(tag: string, attrs: Record<string, string> | undefined): Record<string, string> {
const out: Record<string, string> = {}
for (const [rawName, value] of Object.entries(attrs ?? {})) {
const name = rawName.toLowerCase()
if (typeof value === 'string' && partialAttrAllowed(tag, name, value)) {
out[name] = value
}
}
return out
}
interface Budget {
left: number
}
function build(nodes: readonly PartialNode[] | undefined, depth: number, budget: Budget): VNodeArrayChildren {
const out: VNodeArrayChildren = []
for (const node of nodes ?? []) {
if (budget.left <= 0) {
break
}
if (node.tag === undefined || node.tag === '') {
if (typeof node.text === 'string' && node.text !== '') {
budget.left -= 1
out.push(node.text)
}
continue
}
const tag = node.tag.toLowerCase()
if (PARTIAL_DROPPED_TAGS.has(tag)) {
continue
}
if (!PARTIAL_TAGS.has(tag)) {
out.push(...build(node.children, depth, budget))
continue
}
if (depth + 1 > PARTIAL_MAX_DEPTH) {
continue
}
budget.left -= 1
const children = build(node.children, depth + 1, budget)
out.push(h(tag, allowedAttributes(tag, node.attrs), children.length > 0 ? children : undefined))
}
return out
}
/** Vue nodes for a partial's node tree, allowlisted again on the client. */
export function renderPartialNodes(nodes: readonly PartialNode[] | undefined): VNodeArrayChildren {
return build(nodes, 0, { left: PARTIAL_MAX_NODES })
}

View File

@@ -207,6 +207,72 @@
opacity: 0; opacity: 0;
scale: 0.98; scale: 0.98;
} }
/* Partial style kit (Phase 10.1, UI-SPEC): stable class names a plugin's
partial templates may use, so server-rendered content looks native
without plugin CSS. They read only the public --c-* variables. */
.summer-partial {
font-size: 14px;
line-height: 1.5;
color: var(--c-text);
overflow-wrap: anywhere;
}
.summer-partial :where(p, ul, ol) {
margin: 0 0 8px;
}
.summer-partial :where(p, ul, ol):last-child {
margin-bottom: 0;
}
.summer-partial a {
color: var(--c-text);
text-decoration: underline;
}
.summer-partial a:focus-visible {
outline: 3px solid var(--c-ring);
outline-offset: 2px;
}
.summer-stats {
display: flex;
flex-wrap: wrap;
column-gap: 32px;
row-gap: 8px;
margin: 0;
padding: 16px 20px;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 16px;
box-shadow: var(--c-shadow-card);
}
.summer-stat {
display: flex;
flex-direction: column-reverse;
gap: 4px;
min-width: 0;
}
.summer-stat__label {
margin: 0;
font-size: 13px;
line-height: 1.5;
font-weight: 400;
color: var(--c-muted);
overflow-wrap: anywhere;
}
.summer-stat__value {
margin: 0;
font-size: 20px;
line-height: 1.2;
font-weight: 600;
color: var(--c-text);
font-variant-numeric: tabular-nums;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@@ -12,6 +12,7 @@ import DataTable from '../components/list/DataTable.vue'
import FilterBar from '../components/list/FilterBar.vue' import FilterBar from '../components/list/FilterBar.vue'
import ListToolbar from '../components/list/ListToolbar.vue' import ListToolbar from '../components/list/ListToolbar.vue'
import Pagination from '../components/list/Pagination.vue' import Pagination from '../components/list/Pagination.vue'
import PartialHost from '../components/partial/PartialHost.vue'
import Button from '../components/ui/Button.vue' import Button from '../components/ui/Button.vue'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue' import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm' import { useConfirm } from '../components/ui/confirm'
@@ -46,6 +47,10 @@ const listFailed = ref(false)
const failed = computed(() => schemaFailed.value || listFailed.value) const failed = computed(() => schemaFailed.value || listFailed.value)
const selected = ref<RowId[]>([]) const selected = ref<RowId[]>([])
const deleting = ref(false) const deleting = ref(false)
// Bumped after an action that changes the collection (bulk delete), so the
// header partial refetches. Search, filters, sorting and paging do not: the
// header describes the whole scoped collection, not the filtered page (D-11).
const partialReload = ref(0)
const confirm = useConfirm() const confirm = useConfirm()
const query = computed(() => parseListQuery(route.query)) const query = computed(() => parseListQuery(route.query))
@@ -201,6 +206,7 @@ async function onDelete(): Promise<void> {
showToast(message(messages.value?.deleted, result.data.data.deleted)) showToast(message(messages.value?.deleted, result.data.data.deleted))
selected.value = [] selected.value = []
await loadList() await loadList()
partialReload.value += 1
return return
} }
showToast(result.error?.error.message || t('backend::lang.list.delete_failed'), 'danger') showToast(result.error?.error.message || t('backend::lang.list.delete_failed'), 'danger')
@@ -232,6 +238,13 @@ async function onDelete(): Promise<void> {
</Button> </Button>
</div> </div>
</header> </header>
<PartialHost
v-if="schema?.headerPartial"
variant="header"
:source="path"
:name="schema.headerPartial"
:reload-key="partialReload"
/>
<div class="overflow-hidden rounded-card border border-border bg-surface shadow-card"> <div class="overflow-hidden rounded-card border border-border bg-surface shadow-card">
<ListToolbar <ListToolbar
v-if="schema" v-if="schema"

View File

@@ -24,6 +24,12 @@
"action": "lookup", "action": "lookup",
"actionLabel": "Look up", "actionLabel": "Look up",
"fill": ["name"] "fill": ["name"]
},
{
"name": "summary",
"label": "Summary",
"type": "partial",
"path": "summary"
} }
], ],
"messages": { "messages": {

View File

@@ -0,0 +1,106 @@
{
"data": {
"title": "Widgets",
"recordsPerPage": 20,
"perPageOptions": [],
"showSearch": true,
"showSetup": false,
"showCheckboxes": true,
"showSorting": true,
"searchTerm": "",
"recordUrl": "acme/demo/widgets/update/:id",
"toolbarButtons": [
"create",
"delete"
],
"toolbarActions": [],
"assets": {
"scripts": [],
"styles": [
"/admin-test/assets/acme/demo/css/demo.css?v=abc"
]
},
"columns": [
{
"key": "name",
"label": "Name",
"searchable": true,
"sortable": true
},
{
"key": "code",
"label": "Code",
"searchable": true,
"sortable": true
},
{
"key": "tags",
"label": "Tags",
"searchable": false,
"sortable": false,
"type": "relation",
"relation": "tags",
"select": "name"
},
{
"key": "created_at",
"label": "Created",
"searchable": false,
"sortable": true,
"type": "datetime"
},
{
"key": "active",
"label": "Active",
"searchable": false,
"sortable": false,
"type": "switch"
}
],
"filters": [],
"rowActions": [],
"bulkActions": [],
"messages": {
"create": {
"other": "New widget"
},
"deleteConfirm": {
"one": "Delete :count widget?",
"other": "Delete :count widgets?"
},
"deleteSelected": {
"other": "Delete selected"
},
"deleted": {
"one": "Deleted :count widget",
"other": "Deleted :count widgets"
},
"empty": {
"other": "No widgets yet."
},
"emptySearch": {
"other": "Nothing found"
},
"emptySearchHint": {
"other": "No widget matches “:term”."
},
"recordCount": {
"one": ":count widget",
"other": ":count widgets"
},
"searchPrompt": {
"other": "Search widgets…"
},
"selected": {
"other": "Selected :count"
}
},
"meta": {
"locale": "en"
},
"headerPartial": "stats"
},
"meta": {
"locale": "en"
}
}

View File

@@ -0,0 +1,151 @@
{
"data": {
"nodes": [
{
"tag": "dl",
"attrs": {
"class": "summer-stats"
},
"children": [
{
"tag": "div",
"attrs": {
"class": "summer-stat"
},
"children": [
{
"tag": "dt",
"attrs": {
"class": "summer-stat__label"
},
"children": [
{
"text": "All widgets"
}
]
},
{
"tag": "dd",
"attrs": {
"class": "summer-stat__value"
},
"children": [
{
"text": "12"
}
]
}
]
},
{
"tag": "div",
"attrs": {
"class": "summer-stat"
},
"children": [
{
"tag": "dt",
"attrs": {
"class": "summer-stat__label"
},
"children": [
{
"text": "Small"
}
]
},
{
"tag": "dd",
"attrs": {
"class": "summer-stat__value"
},
"children": [
{
"text": "5"
}
]
}
]
}
]
},
{
"tag": "script",
"children": [
{
"text": "window.hijacked = true"
}
]
},
{
"tag": "p",
"attrs": {
"id": "hijack",
"style": "color:red",
"data-note": "kept"
},
"children": [
{
"tag": "a",
"attrs": {
"href": "javascript:alert(1)",
"onclick": "alert(1)",
"class": "danger-link"
},
"children": [
{
"text": "Unsafe link"
}
]
},
{
"text": " "
},
{
"tag": "a",
"attrs": {
"href": "/admin-test/acme/demo/widgets",
"title": "All widgets"
},
"children": [
{
"text": "Safe link"
}
]
},
{
"text": " "
},
{
"tag": "a",
"attrs": {
"href": "//evil.example.test/x"
},
"children": [
{
"text": "Protocol-relative link"
}
]
}
]
},
{
"tag": "custom-box",
"children": [
{
"tag": "p",
"attrs": {
"class": "unwrapped"
},
"children": [
{
"text": "Use <b>bold</b> & <i>italic</i>"
}
]
}
]
}
]
},
"meta": {}
}

View File

@@ -8,6 +8,8 @@ import langJson from './lang.json'
import navigationJson from './navigation.json' import navigationJson from './navigation.json'
import settingsJson from './settings.json' import settingsJson from './settings.json'
import extensionFormSchemaJson from './extension.form-schema.json' import extensionFormSchemaJson from './extension.form-schema.json'
import extensionListSchemaJson from './extension.list-schema.json'
import extensionPartialJson from './extension.partial.json'
import formSchemaJson from './widgets.form-schema.json' import formSchemaJson from './widgets.form-schema.json'
import listJson from './widgets.list.json' import listJson from './widgets.list.json'
import listSchemaJson from './widgets.list-schema.json' import listSchemaJson from './widgets.list-schema.json'
@@ -30,6 +32,15 @@ export const settingsFixture: {
export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson
/** A form with a plugin widget and its controller script (Phase 10.1). */ /** A form with a plugin widget and its controller script (Phase 10.1). */
export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson
/** A list with a header partial and a controller stylesheet (Phase 10.1). */
export const extensionListSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = extensionListSchemaJson
/**
* A rendered partial with a stats strip and hostile nodes the client drops.
* An assertion, not an annotation: TypeScript widens sibling attrs objects of
* a JSON array with `key?: undefined`, which no string index signature
* accepts. The assertion still refuses a fixture that does not overlap.
*/
export const extensionPartialFixture = extensionPartialJson as S['cabana.Envelope-cabana_PartialView']
export const listFixture: Rows = listJson export const listFixture: Rows = listJson
export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
export const optionsFixture: { export const optionsFixture: {

View File

@@ -1,6 +1,7 @@
// Phase 10.1 extension point, SPA half (ADMIN-07): plugin assets, a widget // Phase 10.1 extension point, SPA half (ADMIN-07): plugin assets, a widget
// custom element bridged to its action. Fixtures are neutral acme.demo.* // custom element bridged to its action, header and form partials rendered
// data; no application names appear in framework tests. // through the client allowlist. Fixtures are neutral acme.demo.* data; no
// application names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils' import { flushPromises, type VueWrapper } from '@vue/test-utils'
import { import {
@@ -20,15 +21,33 @@ import {
WIDGET_TIMEOUT, WIDGET_TIMEOUT,
} from '../../src/components/form/formContext' } from '../../src/components/form/formContext'
import WidgetField from '../../src/components/form/fields/WidgetField.vue' import WidgetField from '../../src/components/form/fields/WidgetField.vue'
import PartialField from '../../src/components/form/fields/PartialField.vue'
import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue' import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue'
import { isRegistered, needsRecord, rendererFor } from '../../src/components/form/registry' import PartialHost from '../../src/components/partial/PartialHost.vue'
import { clone, extensionFormSchemaFixture } from '../fixtures/typed' import {
import { API, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers' PARTIAL_DROPPED_TAGS,
PARTIAL_TAGS,
partialAttrAllowed,
renderPartialNodes,
} from '../../src/components/partial/partialNodes'
import { groupLabelled, isRegistered, needsRecord, rendererFor } from '../../src/components/form/registry'
import listRows from '../fixtures/widgets.list.json'
import {
clone,
extensionFormSchemaFixture,
extensionListSchemaFixture,
extensionPartialFixture,
} from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets` const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1` const RECORD = `${BASE}/1`
const WIDGET_ACTION = `${BASE}/widgets/lookup` const WIDGET_ACTION = `${BASE}/widgets/lookup`
const SCRIPT = '/admin-test/assets/acme/demo/js/lookup.js?v=abc' const SCRIPT = '/admin-test/assets/acme/demo/js/lookup.js?v=abc'
const STATS = `${BASE}/partials/stats`
const SUMMARY = `${BASE}/partials/summary`
const summary = { data: { nodes: [{ tag: 'p', children: [{ text: 'Summary of the widget' }] }] }, meta: {} }
const record = { data: { id: 1, name: 'Blue widget', color: '#0000ff' }, meta: { labels: {} } } const record = { data: { id: 1, name: 'Blue widget', color: '#0000ff' }, meta: { labels: {} } }
@@ -72,6 +91,7 @@ function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return { return {
[`GET ${BASE}/schema/form`]: { body: extensionFormSchemaFixture }, [`GET ${BASE}/schema/form`]: { body: extensionFormSchemaFixture },
[`GET ${RECORD}`]: { body: record }, [`GET ${RECORD}`]: { body: record },
[`GET ${SUMMARY}`]: { body: summary },
[`PUT ${RECORD}`]: async (request) => ({ [`PUT ${RECORD}`]: async (request) => ({
body: { data: { ...record.data, ...((await request.json()) as object) }, meta: record.meta }, body: { data: { ...record.data, ...((await request.json()) as object) }, meta: record.meta },
}), }),
@@ -80,7 +100,7 @@ function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
} }
/** A route whose reply the test releases, to observe the in-flight state. */ /** A route whose reply the test releases, to observe the in-flight state. */
function deferred(): { route: Route; release: (reply: Reply) => void } { function deferred(): { route: () => Promise<Reply>; release: (reply: Reply) => void } {
let release: (reply: Reply) => void = () => undefined let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => { const pending = new Promise<Reply>((resolve) => {
release = resolve release = resolve
@@ -300,3 +320,209 @@ describe('widget field (D-04, D-05, D-07, D-08)', () => {
expect(wrapper.findComponent(ExtensionFailure).exists()).toBe(true) expect(wrapper.findComponent(ExtensionFailure).exists()).toBe(true)
}) })
}) })
function listRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/list`]: { body: extensionListSchemaFixture },
[`GET ${BASE}`]: { body: listRows },
[`GET ${STATS}`]: { body: extensionPartialFixture },
...overrides,
}
}
describe('partial node allowlist (D-17)', () => {
it('mirrors the server lists and checks attributes and URLs', () => {
expect(PARTIAL_TAGS.has('dl')).toBe(true)
expect(PARTIAL_TAGS.has('script')).toBe(false)
expect(PARTIAL_DROPPED_TAGS.has('script')).toBe(true)
expect(partialAttrAllowed('a', 'href', '/admin-test/x')).toBe(true)
expect(partialAttrAllowed('a', 'href', '#top')).toBe(true)
expect(partialAttrAllowed('img', 'src', '#top')).toBe(false)
for (const href of ['javascript:alert(1)', '//evil.example.test', '/\\evil', 'https://evil.example.test', '/\tx']) {
expect(partialAttrAllowed('a', 'href', href), href).toBe(false)
}
expect(partialAttrAllowed('div', 'onclick', 'x')).toBe(false)
expect(partialAttrAllowed('div', 'id', 'x')).toBe(false)
expect(partialAttrAllowed('div', 'style', 'x')).toBe(false)
expect(partialAttrAllowed('div', 'aria-label', 'x')).toBe(true)
expect(partialAttrAllowed('td', 'colspan', '2')).toBe(true)
expect(partialAttrAllowed('div', 'colspan', '2')).toBe(false)
expect(renderPartialNodes([{ text: '<b>x</b>' }])).toEqual(['<b>x</b>'])
expect(renderPartialNodes([{ tag: 'script', children: [{ text: 'x' }] }])).toEqual([])
})
})
describe('list header partial (D-03, D-11, UI-SPEC S1)', () => {
it('renders the stats strip between the heading and the list card and drops hostile nodes', async () => {
stubAssets()
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes())
const host = wrapper.find('[data-partial="header"]')
expect(host.classes()).toContain('summer-partial')
expect(host.attributes('aria-busy')).toBeUndefined()
const section = host.element.parentElement!
const blocks = Array.from(section.children)
expect(blocks.indexOf(host.element)).toBe(1)
expect(blocks[0]!.tagName).toBe('HEADER')
expect(blocks[2]!.querySelector('table')).not.toBeNull()
expect(host.find('dl.summer-stats').exists()).toBe(true)
expect(host.findAll('.summer-stat').length).toBe(2)
expect(host.findAll('.summer-stat__label').map((item) => item.text())).toEqual(['All widgets', 'Small'])
expect(host.findAll('.summer-stat__value').map((item) => item.text())).toEqual(['12', '5'])
expect(host.find('script').exists()).toBe(false)
expect(host.text()).not.toContain('hijacked')
const paragraph = host.find('p[data-note="kept"]')
expect(paragraph.attributes('id')).toBeUndefined()
expect(paragraph.attributes('style')).toBeUndefined()
const unsafe = host.find('a.danger-link')
expect(unsafe.attributes('href')).toBeUndefined()
expect(unsafe.attributes('onclick')).toBeUndefined()
const links = host.findAll('a')
expect(links[1]!.attributes('href')).toBe('/admin-test/acme/demo/widgets')
expect(links[1]!.attributes('title')).toBe('All widgets')
expect(links[2]!.attributes('href')).toBeUndefined()
expect(host.find('custom-box').exists()).toBe(false)
const unwrapped = host.find('p.unwrapped')
expect(unwrapped.text()).toBe('Use <b>bold</b> & <i>italic</i>')
expect(unwrapped.find('b').exists()).toBe(false)
expect(wrapper.findComponent(PartialHost).props('variant')).toBe('header')
})
it('shows one skeleton block on the first load without holding back the table', async () => {
stubAssets()
const stats = deferred()
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes({ [`GET ${STATS}`]: stats.route }))
const loading = wrapper.find('[data-partial-loading]')
expect(loading.attributes('aria-busy')).toBe('true')
const skeleton = loading.find('[data-partial-skeleton]')
expect(skeleton.attributes('aria-hidden')).toBe('true')
expect(skeleton.classes()).toEqual(expect.arrayContaining(['h-[80px]', 'w-full', 'rounded-card', 'bg-skel']))
expect(wrapper.findAll('tbody tr').length).toBe(2)
stats.release({ body: extensionPartialFixture })
await flushPromises()
expect(wrapper.find('[data-partial-loading]').exists()).toBe(false)
expect(wrapper.find('dl.summer-stats').exists()).toBe(true)
})
it('renders nothing for zero nodes', async () => {
stubAssets()
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes({ [`GET ${STATS}`]: { body: { data: { nodes: [] }, meta: {} } } }))
expect(wrapper.find('[data-partial]').exists()).toBe(false)
expect(wrapper.find('[data-partial-loading]').exists()).toBe(false)
expect(wrapper.find('[data-partial-failed]').exists()).toBe(false)
})
it('shows the failure box on a 500 and keeps the list usable', async () => {
stubAssets()
const { wrapper } = await mountApp(
'/acme/demo/widgets',
listRoutes({ [`GET ${STATS}`]: { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } } }),
)
const box = wrapper.find('[data-partial-failed]')
expect(box.attributes('role')).toBe('alert')
expect(box.text()).toBe('Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.')
expect(wrapper.find('[data-partial]').exists()).toBe(false)
expect(wrapper.findAll('tbody tr').length).toBe(2)
})
it('refetches after a bulk delete with the previous nodes kept, and not on search', async () => {
stubAssets()
const later = deferred()
let gets = 0
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets',
listRoutes({
[`GET ${STATS}`]: () => (++gets === 1 ? { body: extensionPartialFixture } : later.route()),
[`POST ${BASE}/bulk-delete`]: { body: { data: { deleted: 2 }, meta: {} } },
}),
{ attach: true },
)
expect(requestsTo(calls, 'GET', STATS)).toHaveLength(1)
await wrapper.find('[data-list-search]').setValue('blue')
await wait(350)
await flushPromises()
expect(requestsTo(calls, 'GET', STATS)).toHaveLength(1)
await wrapper.find('th[data-select] [role="checkbox"]').trigger('click')
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLButtonElement).click()
await flushPromises()
expect(requestsTo(calls, 'GET', STATS)).toHaveLength(2)
const host = wrapper.find('[data-partial="header"]')
expect(host.attributes('aria-busy')).toBe('true')
expect(host.find('dl.summer-stats').exists()).toBe(true)
expect(wrapper.find('[data-partial-skeleton]').exists()).toBe(false)
later.release({ body: { data: { nodes: [{ tag: 'p', children: [{ text: 'Nothing left' }] }] }, meta: {} } })
await flushPromises()
expect(wrapper.find('[data-partial="header"]').attributes('aria-busy')).toBeUndefined()
expect(wrapper.find('[data-partial="header"]').text()).toBe('Nothing left')
})
})
describe('form partial (D-09, UI-SPEC S2)', () => {
it('is a registered, valueless, group-labelled type', () => {
expect(rendererFor('partial')).toBe(PartialField)
expect(isRegistered('partial')).toBe(false)
expect(needsRecord('partial')).toBe(false)
expect(groupLabelled('partial')).toBe(true)
expect(groupLabelled('widget')).toBe(true)
expect(groupLabelled('text')).toBe(false)
})
it('renders on update with ?id= inside a labelled group', async () => {
stubAssets()
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
await flushPromises()
const [get] = requestsTo(calls, 'GET', SUMMARY)
expect(queryOf(get!).get('id')).toBe('1')
const group = wrapper.find('[data-partial-field]')
expect(group.attributes('role')).toBe('group')
expect(group.attributes('aria-labelledby')).toBe('field-summary-label')
expect(wrapper.find('#field-summary-label').text()).toBe('Summary')
expect(group.find('[data-partial="field"]').text()).toBe('Summary of the widget')
})
it('renders on create without an id, shows its skeleton and no label row without a label', async () => {
stubAssets()
const schema = clone(extensionFormSchemaFixture)
schema.data.fields = schema.data.fields.map((field) => (field.name === 'summary' ? { ...field, label: undefined } : field))
const pending = deferred()
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets/create',
routes({ [`GET ${BASE}/schema/form`]: { body: schema }, [`GET ${SUMMARY}`]: pending.route }),
)
const [get] = requestsTo(calls, 'GET', SUMMARY)
expect(queryOf(get!).has('id')).toBe(false)
const skeleton = wrapper.find('[data-partial-field] [data-partial-skeleton]')
expect(skeleton.classes()).toEqual(expect.arrayContaining(['h-[44px]', 'rounded-control', 'bg-skel']))
expect(wrapper.find('#field-summary-label').exists()).toBe(false)
expect(wrapper.find('[data-field="summary"] label').exists()).toBe(false)
expect(wrapper.find('[data-partial-field]').attributes('aria-labelledby')).toBeUndefined()
pending.release({ body: { data: { nodes: [] }, meta: {} } })
await flushPromises()
expect(wrapper.find('[data-partial-field]').text()).toBe('')
})
it('shows the failure box in its row and keeps the form saveable', async () => {
stubAssets()
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${SUMMARY}`]: { status: 500, body: {} } }))
await flushPromises()
expect(wrapper.find('[data-partial-field] [data-partial-failed]').text()).toBe(
'Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.',
)
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const [put] = requestsTo(calls, 'PUT', RECORD)
expect(await put!.json()).toEqual({ name: 'Blue widget', color: '#0000ff' })
})
})

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

File diff suppressed because one or more lines are too long

View File

@@ -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-CnkxaoJL.js"></script> <script type="module" crossorigin src="./assets/index-D9vrCBt0.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DQRkkGSM.css"> <link rel="stylesheet" crossorigin href="./assets/index-CfeX_snf.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -67,6 +67,22 @@ A statistics strip above a list, for example:
</dl> </dl>
``` ```
### Partial style kit and plugin CSS variables
The admin SPA ships a small set of stable CSS classes that partial templates may use through the allowlisted `class` attribute, so server-rendered content looks native without any plugin CSS:
| Class | Use |
|-------|-----|
| `summer-partial` | Set by the SPA on every partial's root: 14px/1.5 body text, long words and URLs wrap, `p`/`ul`/`ol` spaced 8px apart, links underlined with the focus ring. |
| `summer-stats` | A card strip (surface background, border, 16px radius, card shadow, 16px 20px padding) whose items wrap onto more rows with a 32px column gap and an 8px row gap. Safe on a `<dl>`. |
| `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__value` | The item value: 20px, weight 600, tabular numbers. |
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.
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
`GET <prefix>/assets/{vendor}/{plugin}/{file...}` serves the files controllers declare through `pact.AdminClientAssets`. A plugin file `assets/js/lookup.js` of plugin `acme.blog` is served at `<prefix>/assets/acme/blog/js/lookup.js`, and the schemas list it as `<prefix>/assets/acme/blog/js/lookup.js?v=<first 12 hex characters of its sha256>`. The route is public, like the SPA shell, and serves only the exact files declared at boot, never the plugin's embedded tree: YAML and templates are not reachable, and any other path falls through to the SPA, which also serves its own build assets under `<prefix>/assets/`. Each response carries an explicit JavaScript or CSS `Content-Type`, `X-Content-Type-Options: nosniff`, the admin Content-Security-Policy (`script-src 'self'`), `Cross-Origin-Resource-Policy: same-origin`, `Cache-Control: no-cache` and a sha256 `ETag`, so conditional requests answer 304 and a rebuilt binary is picked up at once. `GET <prefix>/assets/{vendor}/{plugin}/{file...}` serves the files controllers declare through `pact.AdminClientAssets`. A plugin file `assets/js/lookup.js` of plugin `acme.blog` is served at `<prefix>/assets/acme/blog/js/lookup.js`, and the schemas list it as `<prefix>/assets/acme/blog/js/lookup.js?v=<first 12 hex characters of its sha256>`. The route is public, like the SPA shell, and serves only the exact files declared at boot, never the plugin's embedded tree: YAML and templates are not reachable, and any other path falls through to the SPA, which also serves its own build assets under `<prefix>/assets/`. Each response carries an explicit JavaScript or CSS `Content-Type`, `X-Content-Type-Options: nosniff`, the admin Content-Security-Policy (`script-src 'self'`), `Cross-Origin-Resource-Policy: same-origin`, `Cache-Control: no-cache` and a sha256 `ETag`, so conditional requests answer 304 and a rebuilt binary is picked up at once.