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:
Jakub Zych
2026-10-05 10:58:38 +02:00
parent f50d9b8f10
commit df5cace852
39 changed files with 1115 additions and 84 deletions

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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"

View File

@@ -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"] },

View File

@@ -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": [],

View File

@@ -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,

View File

@@ -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?" }

View File

@@ -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.

View File

@@ -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'])
})
})