feat(12.1-02): writable foreign keys, locked relation options, invisible columns
- FieldRelationContract.WritableForeignKey makes a belongsTo field over a protected foreign key writable; the protected key list is unchanged - cabana.RelationLockProvider names related ids an administrator may not add or remove: options and labels carry locked, and a create or update that changes the locked subset is 403 before any row is written - columns.yaml invisible keeps a column searchable and out of the rows - a controller implementing pact.FilterOptions serves a scope filter's choices before the model - SPA: locked chips and options in RelationField, DataTable skips invisible columns - README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
@@ -1040,6 +1040,10 @@
|
||||
},
|
||||
"cabana.ListColumn": {
|
||||
"properties": {
|
||||
"invisible": {
|
||||
"description": "Invisible marks a column that is searched and sorted on the server but\nnot shown: the admin renders no header or cell for it and list rows do\nnot carry its value (columns.yaml invisible).",
|
||||
"type": "boolean"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1701,6 +1705,9 @@
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"locked": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer"
|
||||
}
|
||||
@@ -3345,7 +3352,7 @@
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||
"get": {
|
||||
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).",
|
||||
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
|
||||
9
admin/src/api/schema.d.ts
vendored
9
admin/src/api/schema.d.ts
vendored
@@ -1216,7 +1216,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Admin form schema
|
||||
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).
|
||||
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
@@ -4540,6 +4540,12 @@ export interface components {
|
||||
[key: string]: components["schemas"]["cabana.MessageForms"];
|
||||
};
|
||||
"cabana.ListColumn": {
|
||||
/**
|
||||
* @description Invisible marks a column that is searched and sorted on the server but
|
||||
* not shown: the admin renders no header or cell for it and list rows do
|
||||
* not carry its value (columns.yaml invisible).
|
||||
*/
|
||||
invisible?: boolean;
|
||||
key: string;
|
||||
label: string;
|
||||
relation?: string;
|
||||
@@ -4723,6 +4729,7 @@ export interface components {
|
||||
};
|
||||
"cabana.RelationOption": {
|
||||
label: string;
|
||||
locked?: boolean;
|
||||
value: number;
|
||||
};
|
||||
"cabana.RelationPanel": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ChevronDown, X } from '@lucide/vue'
|
||||
import { ChevronDown, Lock, X } from '@lucide/vue'
|
||||
import { api } from '../../../api/client'
|
||||
import type { RelationOption } from '../../../api/types'
|
||||
import { t } from '../../../app/i18n'
|
||||
@@ -15,6 +15,11 @@ import type { FieldControlProps } from '../control'
|
||||
// Options are searched with a 300 ms debounce, 20 per page, with more loaded
|
||||
// on scroll or through the "more" action. Labels come from meta.labels and
|
||||
// from every options page seen.
|
||||
// Phase 12.1 (UI-SPEC S6, D-07): an option or label the server marks locked
|
||||
// cannot be added or removed by this administrator. A locked chip has no
|
||||
// remove button, a locked option cannot be chosen, and a locked single value
|
||||
// renders as the read-only box. The lock here is a display aid; the server
|
||||
// refuses the change in any case.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | number[] | null] }>()
|
||||
|
||||
@@ -25,17 +30,34 @@ const mode = computed(() => (props.field.readOnly ? 'readonly' : props.field.mul
|
||||
const listId = computed(() => `${props.controlId}-listbox`)
|
||||
|
||||
const known = ref<Record<number, string>>({})
|
||||
watch(
|
||||
() => props.labels,
|
||||
(labels) => {
|
||||
const next = { ...known.value }
|
||||
for (const option of labels ?? []) {
|
||||
next[option.value] = option.label
|
||||
// Ids the server marked locked, in labels or in any options page seen.
|
||||
const lockedIds = ref<Set<number>>(new Set())
|
||||
|
||||
function remember(rows: RelationOption[]): void {
|
||||
const next = { ...known.value }
|
||||
let locked: Set<number> | null = null
|
||||
for (const option of rows) {
|
||||
next[option.value] = option.label
|
||||
if (option.locked && !lockedIds.value.has(option.value)) {
|
||||
locked ??= new Set(lockedIds.value)
|
||||
locked.add(option.value)
|
||||
}
|
||||
known.value = next
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
known.value = next
|
||||
if (locked) {
|
||||
lockedIds.value = locked
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.labels, (labels) => remember(labels ?? []), { immediate: true })
|
||||
|
||||
function isLocked(id: number | null): boolean {
|
||||
return id !== null && lockedIds.value.has(id)
|
||||
}
|
||||
|
||||
const noteId = computed(() => `${props.controlId}-locked-note`)
|
||||
const hasLocked = computed(() => lockedIds.value.size > 0)
|
||||
const described = computed(() => [props.describedBy, hasLocked.value ? noteId.value : ''].filter(Boolean).join(' '))
|
||||
|
||||
function asId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
@@ -106,11 +128,7 @@ async function fetchPage(nextPage: number, append: boolean): Promise<void> {
|
||||
return
|
||||
}
|
||||
const rows = data?.data ?? []
|
||||
const labels = { ...known.value }
|
||||
for (const option of rows) {
|
||||
labels[option.value] = option.label
|
||||
}
|
||||
known.value = labels
|
||||
remember(rows)
|
||||
options.value = append ? [...options.value, ...rows] : rows
|
||||
page.value = data?.meta.page ?? nextPage
|
||||
lastPage.value = data?.meta.last_page ?? nextPage
|
||||
@@ -130,19 +148,27 @@ interface Entry {
|
||||
label: string
|
||||
value: number | null
|
||||
muted: boolean
|
||||
/** Shown, but not selectable by this administrator. */
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
const entries = computed<Entry[]>(() => {
|
||||
const out: Entry[] = []
|
||||
if (mode.value === 'single' && props.field.emptyOption !== undefined) {
|
||||
out.push({ key: 'empty', label: props.field.emptyOption, value: null, muted: true })
|
||||
out.push({ key: 'empty', label: props.field.emptyOption, value: null, muted: true, locked: false })
|
||||
}
|
||||
const taken = new Set(selectedIds.value)
|
||||
for (const option of options.value) {
|
||||
if (mode.value === 'multiple' && taken.has(option.value)) {
|
||||
continue
|
||||
}
|
||||
out.push({ key: `option-${option.value}`, label: option.label, value: option.value, muted: false })
|
||||
out.push({
|
||||
key: `option-${option.value}`,
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
muted: false,
|
||||
locked: !!option.locked || isLocked(option.value),
|
||||
})
|
||||
}
|
||||
return out
|
||||
})
|
||||
@@ -197,6 +223,9 @@ function onScroll(event: Event): void {
|
||||
}
|
||||
|
||||
function choose(entry: Entry): void {
|
||||
if (entry.locked) {
|
||||
return
|
||||
}
|
||||
if (mode.value === 'multiple') {
|
||||
if (entry.value !== null && !selectedIds.value.includes(entry.value)) {
|
||||
emit('update:modelValue', [...selectedIds.value, entry.value])
|
||||
@@ -208,12 +237,25 @@ function choose(entry: Entry): void {
|
||||
}
|
||||
|
||||
function remove(id: number): void {
|
||||
if (isLocked(id)) {
|
||||
return
|
||||
}
|
||||
emit(
|
||||
'update:modelValue',
|
||||
selectedIds.value.filter((item) => item !== id),
|
||||
)
|
||||
}
|
||||
|
||||
/** The next selectable entry from index in direction step; locked ones are skipped. */
|
||||
function move(from: number, step: 1 | -1): number {
|
||||
for (let index = from + step; index >= 0 && index < entries.value.length; index += step) {
|
||||
if (!entries.value[index]!.locked) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return from
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
@@ -221,12 +263,12 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
if (!open.value) {
|
||||
openList()
|
||||
} else {
|
||||
active.value = Math.min(active.value + 1, entries.value.length - 1)
|
||||
active.value = move(active.value, 1)
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
active.value = Math.max(active.value - 1, 0)
|
||||
active.value = move(active.value < 0 ? entries.value.length : active.value, -1)
|
||||
break
|
||||
case 'Enter':
|
||||
if (open.value) {
|
||||
@@ -244,8 +286,12 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
}
|
||||
break
|
||||
case 'Backspace':
|
||||
if (mode.value === 'multiple' && query.value === '' && selectedIds.value.length > 0) {
|
||||
remove(selectedIds.value[selectedIds.value.length - 1]!)
|
||||
// The last unlocked chip goes; with only locked chips nothing happens.
|
||||
if (mode.value === 'multiple' && query.value === '') {
|
||||
const last = [...selectedIds.value].reverse().find((id) => !isLocked(id))
|
||||
if (last !== undefined) {
|
||||
remove(last)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -277,6 +323,22 @@ onBeforeUnmount(() => {
|
||||
{{ readOnlyText }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="mode === 'single' && isLocked(singleId)">
|
||||
<div
|
||||
:id="controlId"
|
||||
data-relation-locked
|
||||
:aria-describedby="described || undefined"
|
||||
class="flex min-h-input items-center justify-between gap-2 rounded-control border border-border bg-subtle px-3.5"
|
||||
>
|
||||
<span class="min-w-0 [overflow-wrap:anywhere]">{{ singleText }}</span>
|
||||
<Lock :size="14" class="shrink-0 text-muted" aria-hidden="true" />
|
||||
</div>
|
||||
<p :id="noteId" data-locked-note class="mt-1.5 flex items-center gap-2 text-[13px] text-muted">
|
||||
<Lock :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.form.locked_note') }}</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div v-else class="relative" :data-relation="mode">
|
||||
<div
|
||||
v-if="mode === 'multiple'"
|
||||
@@ -295,7 +357,12 @@ onBeforeUnmount(() => {
|
||||
>{{ initials(labelOf(id)) }}</span
|
||||
>
|
||||
<span class="font-semibold">{{ labelOf(id) }}</span>
|
||||
<span v-if="isLocked(id)" data-chip-locked class="flex size-6 items-center justify-center text-muted">
|
||||
<Lock :size="14" aria-hidden="true" />
|
||||
<span class="sr-only">{{ t('backend::lang.form.locked_item', { name: labelOf(id) }) }}</span>
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:aria-label="t('backend::lang.form.remove_item', { name: labelOf(id) })"
|
||||
class="flex size-6 items-center justify-center rounded-full text-muted hover:bg-hover hover:text-text"
|
||||
@@ -315,7 +382,7 @@ onBeforeUnmount(() => {
|
||||
:aria-activedescendant="open && active >= 0 ? optionId(active) : undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:aria-describedby="described || undefined"
|
||||
:value="query"
|
||||
:placeholder="t('backend::lang.form.add_item')"
|
||||
class="h-[30px] min-w-[120px] flex-1 border-0 bg-transparent px-2 focus-visible:shadow-none"
|
||||
@@ -338,7 +405,7 @@ onBeforeUnmount(() => {
|
||||
:aria-activedescendant="open && active >= 0 ? optionId(active) : undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:aria-describedby="described || undefined"
|
||||
:value="open ? query : singleText"
|
||||
:placeholder="open ? singleText || t('backend::lang.form.select_placeholder') : t('backend::lang.form.select_placeholder')"
|
||||
:class="[
|
||||
@@ -372,11 +439,17 @@ onBeforeUnmount(() => {
|
||||
role="option"
|
||||
:data-value="entry.value ?? 'empty'"
|
||||
:aria-selected="mode === 'single' && entry.value === singleId ? 'true' : 'false'"
|
||||
:class="[entry.muted ? 'text-muted' : 'text-text', index === active ? 'bg-hover' : '']"
|
||||
class="flex h-10 cursor-pointer items-center rounded-control px-3 hover:bg-hover"
|
||||
:aria-disabled="entry.locked ? 'true' : undefined"
|
||||
:class="[
|
||||
entry.muted || entry.locked ? 'text-muted' : 'text-text',
|
||||
entry.locked ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-hover',
|
||||
index === active && !entry.locked ? 'bg-hover' : '',
|
||||
]"
|
||||
class="flex h-10 items-center justify-between gap-2 rounded-control px-3"
|
||||
@mousedown.prevent="choose(entry)"
|
||||
>
|
||||
{{ entry.label }}
|
||||
<span class="min-w-0 truncate">{{ entry.label }}</span>
|
||||
<Lock v-if="entry.locked" :size="14" class="shrink-0" aria-hidden="true" />
|
||||
</li>
|
||||
<li v-if="loading" role="presentation" class="px-3 py-2 text-[13px] text-muted">
|
||||
{{ t('backend::lang.form.loading_options') }}
|
||||
@@ -399,5 +472,9 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="hasLocked" :id="noteId" data-locked-note class="mt-1.5 flex items-center gap-2 text-[13px] text-muted">
|
||||
<Lock :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.form.locked_note') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -187,8 +187,12 @@ function stateTextClass(states: string[]): string[] {
|
||||
]
|
||||
}
|
||||
|
||||
// An invisible column is searched on the server and never rendered (the
|
||||
// rows do not carry it either).
|
||||
const visibleColumns = computed(() => props.columns.filter((column) => !column.invisible))
|
||||
|
||||
const span = computed(() =>
|
||||
Math.max(props.columns.length + (props.selectable ? 1 : 0) + (props.trailing ? 1 : 0), 1),
|
||||
Math.max(visibleColumns.value.length + (props.selectable ? 1 : 0) + (props.trailing ? 1 : 0), 1),
|
||||
)
|
||||
|
||||
function openFrom(event: MouseEvent, row: AdminRecord): void {
|
||||
@@ -220,7 +224,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
v-for="column in visibleColumns"
|
||||
:key="column.key"
|
||||
scope="col"
|
||||
:aria-sort="canSort(column) ? (sortState(column) ?? 'none') : undefined"
|
||||
@@ -249,7 +253,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
|
||||
<td v-if="selectable" class="pl-5">
|
||||
<span class="block size-[18px] rounded-checkbox bg-skel" />
|
||||
</td>
|
||||
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
|
||||
<td v-for="column in visibleColumns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
|
||||
<span :class="widths[(n - 1) % widths.length]" class="block h-3 rounded-[6px] bg-skel" />
|
||||
</td>
|
||||
<td v-if="trailing" class="pr-5" />
|
||||
@@ -290,7 +294,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
v-for="(column, columnIndex) in columns"
|
||||
v-for="(column, columnIndex) in visibleColumns"
|
||||
:key="column.key"
|
||||
:class="cellClass(column, columnIndex, statesOf(row))"
|
||||
class="px-3.5 first:pl-5 last:pr-5"
|
||||
|
||||
2
admin/tests/fixtures/roster.form-schema.json
vendored
2
admin/tests/fixtures/roster.form-schema.json
vendored
@@ -5,6 +5,8 @@
|
||||
"fields": [
|
||||
{ "name": "name", "type": "text", "label": "Name", "span": "left" },
|
||||
{ "name": "email", "type": "text", "label": "Email", "span": "right" },
|
||||
{ "name": "team", "type": "relation", "label": "Team", "nameFrom": "name", "emptyOption": "No team" },
|
||||
{ "name": "tags", "type": "relation", "label": "Tags", "nameFrom": "name", "multiple": true },
|
||||
{ "name": "slug", "type": "text", "label": "Slug", "preset": { "field": "name", "type": "slug" } },
|
||||
{ "name": "password", "type": "password", "label": "Password", "span": "left", "context": ["create", "update"] },
|
||||
{ "name": "password_confirmation", "type": "password", "label": "Repeat the password", "span": "right", "context": ["create", "update"] },
|
||||
|
||||
3
admin/tests/fixtures/roster.list-schema.json
vendored
3
admin/tests/fixtures/roster.list-schema.json
vendored
@@ -14,7 +14,8 @@
|
||||
"assets": { "scripts": [], "styles": [] },
|
||||
"columns": [
|
||||
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
|
||||
{ "key": "email", "label": "Email", "searchable": true, "sortable": true }
|
||||
{ "key": "email", "label": "Email", "searchable": true, "sortable": true, "invisible": true },
|
||||
{ "key": "slug", "label": "Slug", "searchable": false, "sortable": true }
|
||||
],
|
||||
"filters": [],
|
||||
"rowActions": [],
|
||||
|
||||
6
admin/tests/fixtures/roster.list.json
vendored
6
admin/tests/fixtures/roster.list.json
vendored
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"data": [
|
||||
{ "id": 1, "name": "Ada Lovelace", "email": "ada@example.test" },
|
||||
{ "id": 2, "name": "Bob Stone", "email": "bob@example.test" },
|
||||
{ "id": 3, "name": "Cy Young", "email": "cy@example.test" }
|
||||
{ "id": 1, "name": "Ada Lovelace", "slug": "ada-lovelace" },
|
||||
{ "id": 2, "name": "Bob Stone", "slug": "bob-stone" },
|
||||
{ "id": 3, "name": "Cy Young", "slug": "cy-young" }
|
||||
],
|
||||
"meta": {
|
||||
"page": 1,
|
||||
|
||||
4
admin/tests/fixtures/roster.record.json
vendored
4
admin/tests/fixtures/roster.record.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "slug": "ada-lovelace", "joined_ip": "203.0.113.7", "permissions": { "legacy.code": 1, "posts.edit": 1, "reports.export": 1 } },
|
||||
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "team": 2, "tags": [5, 6], "slug": "ada-lovelace", "joined_ip": "203.0.113.7", "permissions": { "legacy.code": 1, "posts.edit": 1, "reports.export": 1 } },
|
||||
"meta": {
|
||||
"labels": {},
|
||||
"labels": { "team": [{ "value": 2, "label": "Home" }], "tags": [{ "value": 5, "label": "staff", "locked": true }, { "value": 6, "label": "news" }] },
|
||||
"actions": [
|
||||
{ "name": "activate", "label": "Activate" },
|
||||
{ "name": "reinstate", "label": "Reinstate", "confirm": "Lift the ban on this person?" }
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('preview screen (UI-SPEC S3, D-11)', () => {
|
||||
|
||||
const grid = wrapper.find('[data-preview] dl')
|
||||
expect(grid.exists()).toBe(true)
|
||||
expect(grid.findAll('dt').map((item) => item.text())).toEqual(['Name', 'Email', 'Slug', 'Joined from IP address'])
|
||||
expect(grid.findAll('dt').map((item) => item.text())).toEqual(['Name', 'Email', 'Team', 'Tags', 'Slug', 'Joined from IP address'])
|
||||
expect(value(wrapper, 'name').text()).toBe('Ada Lovelace')
|
||||
expect(value(wrapper, 'email').text()).toBe('ada@example.test')
|
||||
// Values wrap and are never truncated; labels carry no required mark.
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
// Phase 12.1 form seams in the admin SPA: the password field (UI-SPEC S7,
|
||||
// D-19), preset fields (D-27 G7) and the permission editor (UI-SPEC S5,
|
||||
// D-16). Fixtures are neutral acme.roster.* data;
|
||||
// D-19), preset fields (D-27 G7), the permission editor (UI-SPEC S5, D-16),
|
||||
// locked relation options (UI-SPEC S6, D-07) and invisible list columns
|
||||
// (D-27 G6). Fixtures are neutral acme.roster.* data;
|
||||
// no application names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { editablePayload, presetValue } from '../../src/components/form/formState'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { clone, langFixture, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
import {
|
||||
clone,
|
||||
langFixture,
|
||||
rosterFormSchemaFixture,
|
||||
rosterListFixture,
|
||||
rosterListSchemaFixture,
|
||||
rosterRecordFixture,
|
||||
} from '../fixtures/typed'
|
||||
import { API, keydown, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
const LIST = `${API}/acme/roster/people`
|
||||
const RECORD = `${LIST}/1`
|
||||
@@ -22,6 +30,9 @@ const strings = {
|
||||
'backend::lang.permissioneditor.locked': { other: 'You cannot change this permission.' },
|
||||
'backend::lang.permissioneditor.empty': { other: 'No permissions are defined yet.' },
|
||||
'backend::lang.permissioneditor.other': { other: 'Other' },
|
||||
'backend::lang.form.locked_item': { other: 'Locked: :name' },
|
||||
'backend::lang.form.locked_note': { other: 'Items marked with a lock need an additional permission to change.' },
|
||||
'backend::lang.form.remove_item': { other: 'Remove: :name' },
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
@@ -390,3 +401,159 @@ describe('permission editor (UI-SPEC S5, D-16)', () => {
|
||||
expect(shown.find('[data-permission="posts.edit"] [data-state="checked"]').text()).toBe('Allow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('locked relation options (UI-SPEC S6, D-07)', () => {
|
||||
const NOTE = 'Items marked with a lock need an additional permission to change.'
|
||||
const tagOptions = {
|
||||
data: [
|
||||
{ value: 5, label: 'staff', locked: true },
|
||||
{ value: 6, label: 'news' },
|
||||
{ value: 7, label: 'beta' },
|
||||
{ value: 8, label: 'owners', locked: true },
|
||||
],
|
||||
meta: { page: 1, per_page: 20, total: 4, last_page: 1 },
|
||||
}
|
||||
const teamOptions = {
|
||||
data: [
|
||||
{ value: 2, label: 'Home' },
|
||||
{ value: 3, label: 'Board', locked: true },
|
||||
{ value: 4, label: 'Guests' },
|
||||
],
|
||||
meta: { page: 1, per_page: 20, total: 3, last_page: 1 },
|
||||
}
|
||||
|
||||
function relationRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return routes({
|
||||
[`GET ${LIST}/fields/tags/options`]: { body: tagOptions },
|
||||
[`GET ${LIST}/fields/team/options`]: { body: teamOptions },
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
const field = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-field="${name}"]`)
|
||||
const chips = (wrapper: VueWrapper) => field(wrapper, 'tags').findAll('[data-chip]')
|
||||
const option = (wrapper: VueWrapper, name: string, value: number) => field(wrapper, name).find(`[role="option"][data-value="${value}"]`)
|
||||
|
||||
async function openOptions(wrapper: VueWrapper, name: string): Promise<void> {
|
||||
await input(wrapper, name).trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('shows a locked chip without a remove button and keeps it on Backspace', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/roster/people/1', relationRoutes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }), { attach: true })
|
||||
const [staff, news] = chips(wrapper)
|
||||
expect(staff!.text()).toContain('staff')
|
||||
expect(staff!.find('button').exists()).toBe(false)
|
||||
expect(staff!.find('[data-chip-locked] svg').attributes('aria-hidden')).toBe('true')
|
||||
expect(staff!.find('.sr-only').text()).toBe('Locked: staff')
|
||||
expect(news!.find('button').attributes('aria-label')).toBe('Remove: news')
|
||||
|
||||
// Backspace in the empty search removes the last unlocked chip, then
|
||||
// does nothing: only the locked chip is left.
|
||||
keydown(input(wrapper, 'tags').element, 'Backspace')
|
||||
await flushPromises()
|
||||
expect(chips(wrapper).map((chip) => chip.find('.font-semibold').text())).toEqual(['staff'])
|
||||
keydown(input(wrapper, 'tags').element, 'Backspace')
|
||||
await flushPromises()
|
||||
expect(chips(wrapper).map((chip) => chip.find('.font-semibold').text())).toEqual(['staff'])
|
||||
|
||||
await save(wrapper)
|
||||
const [request] = requestsTo(calls, 'PUT', RECORD)
|
||||
const body = (await request!.clone().json()) as Record<string, unknown>
|
||||
expect(body.tags).toEqual([5])
|
||||
})
|
||||
|
||||
it('shows the note under the control and names it in aria-describedby', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people/1', relationRoutes())
|
||||
const note = field(wrapper, 'tags').find('[data-locked-note]')
|
||||
expect(note.text()).toBe(NOTE)
|
||||
expect(note.classes()).toEqual(expect.arrayContaining(['flex', 'items-center', 'gap-2', 'text-[13px]', 'text-muted']))
|
||||
expect(input(wrapper, 'tags').attributes('aria-describedby')).toContain(note.attributes('id'))
|
||||
// A field with no locked option seen has no note.
|
||||
expect(field(wrapper, 'team').find('[data-locked-note]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not let a locked option be chosen by click, Enter or the arrow keys', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people/1', relationRoutes(), { attach: true })
|
||||
await openOptions(wrapper, 'tags')
|
||||
// staff (5) and news (6) are selected, so the list offers beta and the
|
||||
// locked owners.
|
||||
const owners = option(wrapper, 'tags', 8)
|
||||
expect(owners.attributes('aria-disabled')).toBe('true')
|
||||
expect(owners.classes()).toEqual(expect.arrayContaining(['text-muted', 'cursor-not-allowed']))
|
||||
expect(owners.classes()).not.toContain('hover:bg-hover')
|
||||
expect(owners.find('svg').exists()).toBe(true)
|
||||
expect(option(wrapper, 'tags', 7).attributes('aria-disabled')).toBeUndefined()
|
||||
|
||||
await owners.trigger('mousedown')
|
||||
expect(chips(wrapper)).toHaveLength(2)
|
||||
|
||||
// Arrow keys stop on beta and never reach owners; Enter chooses beta.
|
||||
const search = input(wrapper, 'tags')
|
||||
keydown(search.element, 'ArrowDown')
|
||||
keydown(search.element, 'ArrowDown')
|
||||
keydown(search.element, 'ArrowDown')
|
||||
await flushPromises()
|
||||
expect(search.attributes('aria-activedescendant')).toBe(option(wrapper, 'tags', 7).attributes('id'))
|
||||
keydown(search.element, 'Enter')
|
||||
await flushPromises()
|
||||
expect(chips(wrapper).map((chip) => chip.find('.font-semibold').text())).toEqual(['staff', 'news', 'beta'])
|
||||
})
|
||||
|
||||
it('offers a locked option of a single field as disabled and shows the note once it was seen', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people/1', relationRoutes(), { attach: true })
|
||||
await openOptions(wrapper, 'team')
|
||||
const board = option(wrapper, 'team', 3)
|
||||
expect(board.attributes('aria-disabled')).toBe('true')
|
||||
await board.trigger('mousedown')
|
||||
await flushPromises()
|
||||
expect(input(wrapper, 'team').element.value).not.toBe('Board')
|
||||
expect(field(wrapper, 'team').find('[data-locked-note]').text()).toBe(NOTE)
|
||||
|
||||
await openOptions(wrapper, 'team')
|
||||
await option(wrapper, 'team', 4).trigger('mousedown')
|
||||
await flushPromises()
|
||||
expect(input(wrapper, 'team').element.value).toBe('Guests')
|
||||
})
|
||||
|
||||
it('renders a locked single value as the read-only box with a lock', async () => {
|
||||
const record = clone(rosterRecordFixture)
|
||||
record.meta.labels.team = [{ value: 2, label: 'Home', locked: true }]
|
||||
const { wrapper, calls } = await mountApp('/acme/roster/people/1', relationRoutes({ [`GET ${RECORD}`]: { body: record }, [`PUT ${RECORD}`]: { body: record } }))
|
||||
const box = field(wrapper, 'team').find('[data-relation-locked]')
|
||||
expect(box.text()).toBe('Home')
|
||||
expect(box.classes()).toEqual(expect.arrayContaining(['min-h-input', 'bg-subtle']))
|
||||
expect(box.find('svg').attributes('aria-hidden')).toBe('true')
|
||||
expect(field(wrapper, 'team').find('input').exists()).toBe(false)
|
||||
expect(field(wrapper, 'team').find('[data-locked-note]').text()).toBe(NOTE)
|
||||
// The value is still part of the save body, unchanged.
|
||||
await save(wrapper)
|
||||
const [request] = requestsTo(calls, 'PUT', RECORD)
|
||||
const body = (await request!.clone().json()) as Record<string, unknown>
|
||||
expect(body.team).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps chips without remove buttons on the preview', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people/1/preview', relationRoutes())
|
||||
await flushPromises()
|
||||
const shown = wrapper.find('[data-preview-field="tags"]')
|
||||
expect(shown.findAll('[data-chip]').map((chip) => chip.text())).toEqual(['Sstaff', 'Nnews'])
|
||||
expect(shown.find('button').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invisible list column (D-27 G6)', () => {
|
||||
it('renders no header and no cell for it and keeps the first visible column first', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', {
|
||||
[`GET ${LIST}/schema/list`]: { body: rosterListSchemaFixture },
|
||||
[`GET ${LIST}`]: { body: rosterListFixture },
|
||||
})
|
||||
await wait(10)
|
||||
await flushPromises()
|
||||
const headers = wrapper.findAll('thead th').map((th) => th.text()).filter(Boolean)
|
||||
expect(headers).toEqual(['Name', 'Slug'])
|
||||
expect(wrapper.find('thead').text()).not.toContain('Email')
|
||||
const row = wrapper.findAll('tbody tr').find((tr) => tr.text().includes('Ada Lovelace'))!
|
||||
expect(row.findAll('td:not([data-select])').map((td) => td.text())).toEqual(['Ada Lovelace', 'ada-lovelace'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user