- type: permissioneditor with mode radio (1, -1) or checkbox (1); the controller serves the options per request through cabana.PermissionEditorProvider and reads and stores the values - a save answers 422 for a non-object, an unknown code or a value outside the mode's set and 403 for a changed locked code; stored codes that are not offered are kept - record responses carry the stored permissions as an object - SPA: PermissionEditorField with sections by tab, locked rows and a read-only mode for the preview - README, docs, OpenAPI document, TS types and dist updated
393 lines
20 KiB
TypeScript
393 lines
20 KiB
TypeScript
// 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;
|
|
// 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'
|
|
|
|
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' },
|
|
}
|
|
|
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
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<HTMLInputElement>(`#field-${name}`)
|
|
const toggle = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-field="${name}"] [data-password-toggle]`)
|
|
|
|
async function save(wrapper: VueWrapper): Promise<void> {
|
|
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<string, unknown>
|
|
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<string, unknown>
|
|
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<string, unknown>
|
|
// 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<string, unknown>
|
|
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<string, Route> = {}, 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<string, unknown>
|
|
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<string, unknown>
|
|
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<string, unknown>
|
|
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<string, unknown>
|
|
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')
|
|
})
|
|
})
|