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

@@ -24,6 +24,12 @@
"action": "lookup",
"actionLabel": "Look up",
"fill": ["name"]
},
{
"name": "summary",
"label": "Summary",
"type": "partial",
"path": "summary"
}
],
"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 settingsJson from './settings.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 listJson from './widgets.list.json'
import listSchemaJson from './widgets.list-schema.json'
@@ -30,6 +32,15 @@ export const settingsFixture: {
export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson
/** A form with a plugin widget and its controller script (Phase 10.1). */
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 listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
export const optionsFixture: {