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']
|
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). */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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"
|
: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"
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
106
admin/tests/fixtures/extension.list-schema.json
vendored
Normal file
106
admin/tests/fixtures/extension.list-schema.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
151
admin/tests/fixtures/extension.partial.json
vendored
Normal file
151
admin/tests/fixtures/extension.partial.json
vendored
Normal 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": {}
|
||||||
|
}
|
||||||
11
admin/tests/fixtures/typed.ts
vendored
11
admin/tests/fixtures/typed.ts
vendored
@@ -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: {
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
1
modules/boardwalk/dist/assets/index-CfeX_snf.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-CfeX_snf.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
4
modules/boardwalk/dist/assets/index-D9vrCBt0.js
vendored
Normal file
4
modules/boardwalk/dist/assets/index-D9vrCBt0.js
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
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-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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user