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

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