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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user