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

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