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:
@@ -40,6 +40,10 @@ export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
|
||||
export type AdminActionResult = Schemas['cabana.AdminActionResult']
|
||||
/** A controller's plugin script and stylesheet URLs under {base}/assets/. */
|
||||
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). */
|
||||
export type MessageForms = Schemas['cabana.MessageForms']
|
||||
/** The public backend::lang bundle: full key to CLDR forms (D-20). */
|
||||
|
||||
@@ -39,10 +39,10 @@ const describedBy = computed(() =>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
|
||||
<span v-if="labelsGroup && field.label" :id="`${controlId}-label`" class="font-semibold">
|
||||
{{ field.label }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</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>
|
||||
</label>
|
||||
<FieldRenderer
|
||||
|
||||
31
admin/src/components/form/fields/PartialField.vue
Normal file
31
admin/src/components/form/fields/PartialField.vue
Normal 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>
|
||||
@@ -162,7 +162,7 @@ onBeforeUnmount(() => {
|
||||
:id="controlId"
|
||||
role="group"
|
||||
data-widget-host
|
||||
:aria-labelledby="`${controlId}-label`"
|
||||
:aria-labelledby="field.label ? `${controlId}-label` : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:aria-busy="status === 'loading' ? 'true' : undefined"
|
||||
class="flex min-h-input items-center"
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
// 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.
|
||||
// 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
|
||||
// create and update.
|
||||
// element (D-04, D-09) and a partial renders a server template (D-17). They
|
||||
// hold no form value either, but they render on create and update.
|
||||
import type { Component } from 'vue'
|
||||
import CheckboxField from './fields/CheckboxField.vue'
|
||||
import DropdownField from './fields/DropdownField.vue'
|
||||
import NumberField from './fields/NumberField.vue'
|
||||
import PartialField from './fields/PartialField.vue'
|
||||
import RelationManager from '../relation/RelationManager.vue'
|
||||
import RelationField from './fields/RelationField.vue'
|
||||
import SwitchField from './fields/SwitchField.vue'
|
||||
@@ -37,6 +38,7 @@ const renderers = new Map<string, Component>([
|
||||
['relation', RelationField],
|
||||
[RELATION_MANAGER, RelationManager],
|
||||
['widget', WidgetField],
|
||||
['partial', PartialField],
|
||||
])
|
||||
|
||||
/** 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])
|
||||
|
||||
/** 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
|
||||
* 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 {
|
||||
return renderers.get(type) ?? UnsupportedField
|
||||
@@ -75,7 +77,10 @@ export function ownsLabel(type: string): boolean {
|
||||
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 {
|
||||
return groupLabelledTypes.has(type)
|
||||
}
|
||||
|
||||
82
admin/src/components/partial/PartialHost.vue
Normal file
82
admin/src/components/partial/PartialHost.vue
Normal 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>
|
||||
131
admin/src/components/partial/partialNodes.ts
Normal file
131
admin/src/components/partial/partialNodes.ts
Normal 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 })
|
||||
}
|
||||
@@ -207,6 +207,72 @@
|
||||
opacity: 0;
|
||||
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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import DataTable from '../components/list/DataTable.vue'
|
||||
import FilterBar from '../components/list/FilterBar.vue'
|
||||
import ListToolbar from '../components/list/ListToolbar.vue'
|
||||
import Pagination from '../components/list/Pagination.vue'
|
||||
import PartialHost from '../components/partial/PartialHost.vue'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../components/ui/confirm'
|
||||
@@ -46,6 +47,10 @@ const listFailed = ref(false)
|
||||
const failed = computed(() => schemaFailed.value || listFailed.value)
|
||||
const selected = ref<RowId[]>([])
|
||||
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 query = computed(() => parseListQuery(route.query))
|
||||
@@ -201,6 +206,7 @@ async function onDelete(): Promise<void> {
|
||||
showToast(message(messages.value?.deleted, result.data.data.deleted))
|
||||
selected.value = []
|
||||
await loadList()
|
||||
partialReload.value += 1
|
||||
return
|
||||
}
|
||||
showToast(result.error?.error.message || t('backend::lang.list.delete_failed'), 'danger')
|
||||
@@ -232,6 +238,13 @@ async function onDelete(): Promise<void> {
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
<ListToolbar
|
||||
v-if="schema"
|
||||
|
||||
Reference in New Issue
Block a user