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