// Phase 12.1 form seams in the admin SPA: the password field (UI-SPEC S7, // 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, 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` // The framework strings these controls use, as GET /lang serves them in en. const strings = { 'backend::lang.form.show_password': { other: 'Show password' }, 'backend::lang.form.hide_password': { other: 'Hide password' }, 'backend::lang.permissioneditor.allow': { other: 'Allow' }, 'backend::lang.permissioneditor.inherit': { other: 'Inherit' }, 'backend::lang.permissioneditor.deny': { other: 'Deny' }, '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 = {}): Record { return { [`GET ${LIST}/schema/form`]: { body: rosterFormSchemaFixture }, [`GET ${RECORD}`]: { body: rosterRecordFixture }, [`GET ${LIST}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } }, ...overrides, } } const input = (wrapper: VueWrapper, name: string) => wrapper.find(`#field-${name}`) const toggle = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-field="${name}"] [data-password-toggle]`) async function save(wrapper: VueWrapper): Promise { await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() } beforeEach(() => { resetState() setBundle({ ...langFixture.data, ...strings }, 'en') }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('password field (UI-SPEC S7, D-19)', () => { it('renders empty and masked although the record is loaded, with the toggle after the input', async () => { // The record response carries no password, as on the server. const { wrapper } = await mountApp('/acme/roster/people/1', routes()) const password = input(wrapper, 'password') expect(password.element.value).toBe('') expect(password.attributes('type')).toBe('password') expect(password.attributes('autocomplete')).toBe('new-password') expect(password.attributes('spellcheck')).toBe('false') expect(password.classes()).toEqual(expect.arrayContaining(['h-input', 'pr-12'])) expect(input(wrapper, 'password_confirmation').element.value).toBe('') const button = toggle(wrapper, 'password') expect(button.attributes('type')).toBe('button') expect(button.attributes('aria-pressed')).toBe('false') expect(button.attributes('aria-label')).toBe('Show password') // The toggle follows the input in the DOM, so Tab reaches it second. expect(password.element.compareDocumentPosition(button.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() }) it('shows and hides the text with the toggle', async () => { const { wrapper } = await mountApp('/acme/roster/people/1', routes()) await input(wrapper, 'password').setValue('correct horse') await toggle(wrapper, 'password').trigger('click') expect(input(wrapper, 'password').attributes('type')).toBe('text') expect(input(wrapper, 'password').element.value).toBe('correct horse') expect(toggle(wrapper, 'password').attributes('aria-pressed')).toBe('true') expect(toggle(wrapper, 'password').attributes('aria-label')).toBe('Hide password') // The confirmation has its own toggle and stays masked. expect(input(wrapper, 'password_confirmation').attributes('type')).toBe('password') await toggle(wrapper, 'password').trigger('click') expect(input(wrapper, 'password').attributes('type')).toBe('password') }) it('leaves an empty password out of the update body', async () => { const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } })) await input(wrapper, 'name').setValue('Ada B') // Typed and removed again: still "unchanged". await input(wrapper, 'password').setValue('x') await input(wrapper, 'password').setValue('') await save(wrapper) const [request] = requestsTo(calls, 'PUT', RECORD) const body = (await request!.clone().json()) as Record expect(body.name).toBe('Ada B') expect('password' in body).toBe(false) expect('password_confirmation' in body).toBe(false) // notify is a create-only field and joined_ip a preview-only one. expect('notify' in body).toBe(false) expect('joined_ip' in body).toBe(false) }) it('sends the password pair as entered and clears and hides both after a successful save', async () => { const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } })) await input(wrapper, 'password').setValue('correct horse') await input(wrapper, 'password_confirmation').setValue('correct horse') await toggle(wrapper, 'password').trigger('click') await toggle(wrapper, 'password_confirmation').trigger('click') await save(wrapper) const [request] = requestsTo(calls, 'PUT', RECORD) const body = (await request!.clone().json()) as Record expect(body.password).toBe('correct horse') expect(body.password_confirmation).toBe('correct horse') for (const name of ['password', 'password_confirmation']) { expect(input(wrapper, name).element.value).toBe('') expect(input(wrapper, name).attributes('type')).toBe('password') expect(toggle(wrapper, name).attributes('aria-pressed')).toBe('false') } // The password never reaches a toast or the URL. expect(document.body.textContent).not.toContain('correct horse') expect(window.location.href).not.toContain('correct') }) it('sends a lone password as entered and shows the server 422 on the field', async () => { const refused: Reply = { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { password: ['The password confirmation does not match.'] }, }, }, } const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: refused }), { attach: true }) await input(wrapper, 'password').setValue('correct horse') await save(wrapper) const [request] = requestsTo(calls, 'PUT', RECORD) const body = (await request!.clone().json()) as Record // The SPA does not compare the two fields: the save goes out as entered. expect(body.password).toBe('correct horse') expect('password_confirmation' in body).toBe(false) expect(wrapper.find('[data-field="password"]').text()).toContain('The password confirmation does not match.') expect(input(wrapper, 'password').attributes('aria-invalid')).toBe('true') // A refused save keeps what was typed. expect(input(wrapper, 'password').element.value).toBe('correct horse') }) it('keeps an empty password in a create body and drops it only on update', () => { const fields = rosterFormSchemaFixture.data.fields.filter((field) => field.type === 'password') expect(editablePayload(fields, { password: '' }, 'create')).toEqual({ password: '' }) expect(editablePayload(fields, { password: '' }, 'update')).toEqual({}) expect(editablePayload(fields, { password: 'abc' }, 'update')).toEqual({ password: 'abc' }) }) }) describe('preset (D-27 G7)', () => { it('slugs lower-case ASCII with single hyphens and no cut', () => { expect(presetValue('slug', 'Ada Lovelace')).toBe('ada-lovelace') expect(presetValue('slug', ' Hello, World!! ')).toBe('hello-world') expect(presetValue('slug', 'Zażółć 42')).toBe('za-42') expect(presetValue('slug', '')).toBe('') expect(presetValue('exact', 'Ada Lovelace')).toBe('Ada Lovelace') const long = 'word '.repeat(80).trim() expect(presetValue('slug', long)).toHaveLength(long.length) }) it('fills the target from the source on create until the target is edited by hand', async () => { const { wrapper } = await mountApp('/acme/roster/people/create', routes()) expect(input(wrapper, 'slug').element.value).toBe('') await input(wrapper, 'name').setValue('Grace Hopper') expect(input(wrapper, 'slug').element.value).toBe('grace-hopper') await input(wrapper, 'name').setValue('Grace B. Hopper') expect(input(wrapper, 'slug').element.value).toBe('grace-b-hopper') // An empty source leaves the target empty. await input(wrapper, 'name').setValue('') expect(input(wrapper, 'slug').element.value).toBe('') // The first manual edit stops it for the session. await input(wrapper, 'slug').setValue('admiral') await input(wrapper, 'name').setValue('Grace Hopper') expect(input(wrapper, 'slug').element.value).toBe('admiral') // The target is an ordinary input with no marker. expect(input(wrapper, 'slug').attributes('readonly')).toBeUndefined() }) it('sends the preset value with the create body', async () => { const created = clone(rosterRecordFixture) created.data.id = 7 const { wrapper, calls } = await mountApp('/acme/roster/people/create', routes({ [`POST ${LIST}`]: { status: 201, body: created }, [`GET ${LIST}/7`]: { body: created } })) await input(wrapper, 'name').setValue('Grace Hopper') await save(wrapper) const [request] = requestsTo(calls, 'POST', LIST) const body = (await request!.clone().json()) as Record expect(body.slug).toBe('grace-hopper') expect(body.notify).toBe(true) }) it('does not follow the source on update', async () => { const { wrapper } = await mountApp('/acme/roster/people/1', routes()) expect(input(wrapper, 'slug').element.value).toBe('ada-lovelace') await input(wrapper, 'name').setValue('Ada King') expect(input(wrapper, 'slug').element.value).toBe('ada-lovelace') }) }) describe('permission editor (UI-SPEC S5, D-16)', () => { const editor = (wrapper: VueWrapper) => wrapper.find('[data-permission-editor]') const row = (wrapper: VueWrapper, code: string) => wrapper.find(`[data-permission="${code}"]`) const segment = (wrapper: VueWrapper, code: string, value: string) => row(wrapper, code).find(`[data-segment="${value}"]`) const checked = (wrapper: VueWrapper, code: string) => row(wrapper, code).find('[data-state="checked"]') /** Opens the update form on its Permissions tab. */ async function open(overrides: Record = {}, schema = rosterFormSchemaFixture) { const mounted = await mountApp( '/acme/roster/people/1', routes({ [`GET ${LIST}/schema/form`]: { body: schema }, ...overrides }), { attach: true }, ) const tab = mounted.wrapper.findAll('[role="tab"]').find((item) => item.text().includes('Permissions')) await tab!.trigger('click') await flushPromises() return mounted } function withField(change: (field: (typeof rosterFormSchemaFixture.data.fields)[number]) => void) { const schema = clone(rosterFormSchemaFixture) change(schema.data.fields.find((field) => field.name === 'permissions')!) return schema } it('groups the options by tab into sections of one list, untabbed ones last', async () => { const { wrapper } = await open() expect(editor(wrapper).attributes('role')).toBe('group') expect(editor(wrapper).attributes('aria-labelledby')).toBe('field-permissions-label') expect(wrapper.find('#field-permissions-label').text()).toBe('Permissions') expect(editor(wrapper).classes()).toEqual(expect.arrayContaining(['overflow-hidden', 'rounded-inner', 'border', 'border-border'])) const sections = wrapper.findAll('[data-permission-section]') expect(sections.map((section) => section.find('h3').text())).toEqual(['Content', 'Reports', 'Other']) expect(sections.map((section) => section.findAll('[data-permission]').map((item) => item.attributes('data-permission')))).toEqual([ ['posts.edit', 'posts.publish'], ['reports.export'], ['misc.beta'], ]) // Each section is a group named by its header; there is no inner tablist // and no inner scroll. expect(sections[0]!.attributes('aria-labelledby')).toBe(sections[0]!.find('h3').attributes('id')) expect(editor(wrapper).find('[role="tablist"]').exists()).toBe(false) expect(editor(wrapper).html()).not.toContain('overflow-y-auto') expect(editor(wrapper).html()).not.toContain('sticky') // Label and comment; a radio group of three named segments per row. expect(row(wrapper, 'posts.edit').text()).toContain('Edit posts') expect(row(wrapper, 'posts.edit').text()).toContain('Change the text of any post.') expect(row(wrapper, 'posts.edit').findAll('[role="radio"]').map((item) => item.text())).toEqual(['Allow', 'Inherit', 'Deny']) expect(row(wrapper, 'posts.edit').find('[role="radiogroup"]').attributes('aria-labelledby')).toBe( row(wrapper, 'posts.edit').find('span[id$="-label"]').attributes('id'), ) // Stored allow shows as Allow; a code with no value shows as Inherit. expect(checked(wrapper, 'posts.edit').text()).toBe('Allow') expect(checked(wrapper, 'posts.publish').text()).toBe('Inherit') expect(checked(wrapper, 'posts.edit').classes()).toEqual( expect.arrayContaining(['data-[state=checked]:bg-ok-bg', 'data-[state=checked]:text-ok-text']), ) // The row wraps below 640px and the control never shrinks. expect(row(wrapper, 'posts.edit').classes()).toEqual(expect.arrayContaining(['min-h-[56px]', 'max-sm:flex-col'])) expect(row(wrapper, 'posts.edit').find('[role="radiogroup"]').classes()).toContain('shrink-0') }) it('sends a chosen Deny as -1, leaves inherited codes out and never sends a code that is not offered', async () => { const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }) await segment(wrapper, 'posts.publish', '-1').trigger('click') expect(checked(wrapper, 'posts.publish').text()).toBe('Deny') expect(checked(wrapper, 'posts.publish').classes()).toContain('data-[state=checked]:text-danger') await save(wrapper) const [request] = requestsTo(calls, 'PUT', RECORD) const body = (await request!.clone().json()) as Record expect(body.permissions).toEqual({ 'posts.edit': 1, 'posts.publish': -1, 'reports.export': 1 }) }) it('goes back to inherit by leaving the code out', async () => { const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }) await segment(wrapper, 'posts.edit', '0').trigger('click') await save(wrapper) const [request] = requestsTo(calls, 'PUT', RECORD) const body = (await request!.clone().json()) as Record expect(body.permissions).toEqual({ 'reports.export': 1 }) }) it('shows a locked row with its stored value, a disabled control, the lock and its text', async () => { const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }) const locked = row(wrapper, 'reports.export') expect(locked.attributes('data-locked')).toBeDefined() expect(locked.find('[data-permission-locked]').text()).toBe('You cannot change this permission.') expect(locked.find('svg').attributes('aria-hidden')).toBe('true') expect(checked(wrapper, 'reports.export').text()).toBe('Allow') for (const item of locked.findAll('[role="radio"]')) { expect(item.attributes('disabled')).toBeDefined() } expect(locked.find('[role="radiogroup"]').attributes('aria-disabled')).toBe('true') // A click changes nothing, and other rows stay editable. await segment(wrapper, 'reports.export', '-1').trigger('click') expect(checked(wrapper, 'reports.export').text()).toBe('Allow') expect(row(wrapper, 'posts.edit').find('[role="radio"]').attributes('disabled')).toBeUndefined() expect(row(wrapper, 'posts.edit').find('[data-permission-locked]').exists()).toBe(false) await segment(wrapper, 'misc.beta', '1').trigger('click') await save(wrapper) const [request] = requestsTo(calls, 'PUT', RECORD) const body = (await request!.clone().json()) as Record expect(body.permissions).toEqual({ 'posts.edit': 1, 'reports.export': 1, 'misc.beta': 1 }) }) it('edits allow with a labelled checkbox per row in checkbox mode', async () => { const schema = withField((field) => (field.mode = 'checkbox')) const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }, schema) expect(editor(wrapper).attributes('data-mode')).toBe('checkbox') // The section header carries the column heading. expect(wrapper.find('[data-permission-section] h3').text()).toContain('Allow') const box = (code: string) => row(wrapper, code).find('[role="checkbox"]') expect(box('posts.edit').attributes('aria-checked')).toBe('true') expect(box('posts.publish').attributes('aria-checked')).toBe('false') expect(box('posts.edit').classes()).toEqual(expect.arrayContaining(['size-[18px]', 'rounded-checkbox'])) // The row label is a label for the box. expect(row(wrapper, 'posts.publish').find('label').attributes('for')).toBe(box('posts.publish').attributes('id')) expect(box('reports.export').attributes('disabled')).toBeDefined() await box('posts.publish').trigger('click') await box('posts.edit').trigger('click') await save(wrapper) const [request] = requestsTo(calls, 'PUT', RECORD) const body = (await request!.clone().json()) as Record expect(body.permissions).toEqual({ 'posts.publish': 1, 'reports.export': 1 }) }) it('shows the empty text when the server supplies no options', async () => { const { wrapper } = await open({}, withField((field) => delete field.permissionOptions)) expect(editor(wrapper).exists()).toBe(false) const empty = wrapper.find('[data-permission-empty]') expect(empty.text()).toBe('No permissions are defined yet.') expect(empty.classes()).toEqual(expect.arrayContaining(['min-h-input', 'bg-subtle', 'text-muted'])) }) it('renders a 422 on the field error line with a danger border and clears it on a change', async () => { const refused: Reply = { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { permissions: ['The permissions field contains an unknown permission.'] }, }, }, } const { wrapper } = await open({ [`PUT ${RECORD}`]: refused }) await segment(wrapper, 'posts.publish', '1').trigger('click') await save(wrapper) expect(wrapper.find('[data-field="permissions"]').text()).toContain('The permissions field contains an unknown permission.') expect(editor(wrapper).classes()).toContain('border-danger') expect(editor(wrapper).attributes('aria-describedby')).toBe('field-permissions-error') await segment(wrapper, 'posts.publish', '0').trigger('click') expect(editor(wrapper).classes()).toContain('border-border') expect(wrapper.find('#field-permissions-error').exists()).toBe(false) }) it('is read-only on the preview: every control disabled, no lock icon and no locked text', async () => { const schema = withField((field) => (field.context = ['update', 'preview'])) const { wrapper } = await mountApp('/acme/roster/people/1/preview', routes({ [`GET ${LIST}/schema/form`]: { body: schema } })) await flushPromises() const tab = wrapper.findAll('[role="tab"]').find((item) => item.text().includes('Permissions')) await tab!.trigger('click') await flushPromises() const shown = wrapper.find('[data-preview-field="permissions"]') expect(shown.find('[data-permission-editor]').exists()).toBe(true) const radios = shown.findAll('[role="radio"]') expect(radios).toHaveLength(12) for (const item of radios) { expect(item.attributes('disabled')).toBeDefined() } expect(shown.find('[data-permission-locked]').exists()).toBe(false) expect(shown.find('[data-locked]').exists()).toBe(false) 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 = {}): Record { 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 { 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 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 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']) }) })