- list: the bulk menu in the toolbar and on the list view with its three failure rows, row states and invisible columns in the table - form: the forbidden banner, password and preset on the form view, locked relation options, the save body of password and permission fields - preview view: context fields, hidden tabs, the status hint, the footer with zero, one and several actions, load failures - routing: the preview route and mapWinterUrl - backstops: focus returns to the bulk menu trigger; preview URL mapping and the route replacement; locked relation options - the slug preset runs on the table the user plugin's slug test uses
557 lines
26 KiB
TypeScript
557 lines
26 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
|
import { setBundle } from '../../src/app/i18n'
|
|
import FormView from '../../src/views/FormView.vue'
|
|
import { recordCrumb } from '../../src/state/useBreadcrumbs'
|
|
import { toasts } from '../../src/state/useToasts'
|
|
import { WIDGET_EVENT } from '../../src/components/form/formContext'
|
|
import {
|
|
clone,
|
|
extensionFormSchemaFixture,
|
|
formSchemaFixture,
|
|
langFixture,
|
|
recordFixture,
|
|
rosterFormSchemaFixture,
|
|
rosterRecordFixture,
|
|
} from '../fixtures/typed'
|
|
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
|
|
|
const BASE = `${API}/acme/demo/widgets`
|
|
const RECORD = `${BASE}/1`
|
|
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
|
|
|
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${BASE}/schema/form`]: { body: formSchemaFixture },
|
|
[`GET ${RECORD}`]: { body: recordFixture },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/** A form schema of plain, untabbed fields. */
|
|
function flatSchema() {
|
|
const envelope = clone(formSchemaFixture)
|
|
envelope.data.fields = envelope.data.fields.filter((field) => ['name', 'code', 'weight'].includes(field.name))
|
|
return { body: envelope }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetState()
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
enableAutoUnmount(afterEach)
|
|
|
|
describe('form view', () => {
|
|
it('is the component of the record and create routes', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
|
expect(wrapper.findComponent(FormView).exists()).toBe(true)
|
|
})
|
|
|
|
it('titles a record by its first text field, with the update message as subtitle, and publishes the crumb', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
|
expect(wrapper.find('h1').text()).toBe('Blue widget')
|
|
expect(wrapper.find('header p').text()).toBe('Editing widget')
|
|
expect(recordCrumb.value).toBe('Blue widget')
|
|
wrapper.unmount()
|
|
expect(recordCrumb.value).toBe('')
|
|
})
|
|
|
|
it('titles a new record by the create message and offers no delete', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/create', routes())
|
|
expect(wrapper.find('h1').text()).toBe('New widget')
|
|
expect(wrapper.find('header p').exists()).toBe(false)
|
|
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
|
|
expect(recordCrumb.value).toBe('New widget')
|
|
})
|
|
|
|
it('makes the form the tab panel of the active tab', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
|
const form = wrapper.find('form')
|
|
expect(form.attributes('role')).toBe('tabpanel')
|
|
expect(form.attributes('id')).toBe('field-panel-0')
|
|
expect(form.attributes('aria-labelledby')).toBe('field-tab-0')
|
|
expect(wrapper.find('#field-tab-0').attributes('aria-controls')).toBe('field-panel-0')
|
|
})
|
|
|
|
it('has no tabs and no tab panel role for an untabbed form', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema() }))
|
|
expect(wrapper.find('[role="tablist"]').exists()).toBe(false)
|
|
expect(wrapper.find('form').attributes('role')).toBeUndefined()
|
|
expect(wrapper.findAll('[data-field]').map((field) => field.attributes('data-field'))).toEqual(['name', 'code', 'weight'])
|
|
})
|
|
|
|
it.each([
|
|
['the record', { [`GET ${RECORD}`]: failure(404, 'Not found') }],
|
|
['the schema', { [`GET ${BASE}/schema/form`]: failure(500, 'Boom') }],
|
|
])('announces a failed load of %s and disables saving', async (_label, override) => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes(override))
|
|
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać formularza.')
|
|
expect(wrapper.find('form').exists()).toBe(false)
|
|
if (_label === 'the schema') {
|
|
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeDefined()
|
|
}
|
|
})
|
|
|
|
it('toasts the generic error when the save cannot reach the server and re-enables saving', async () => {
|
|
const { wrapper, calls } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
routes({
|
|
[`PUT ${RECORD}`]: () => {
|
|
throw new TypeError('offline')
|
|
},
|
|
}),
|
|
)
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
|
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
|
|
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
|
|
expect(wrapper.find('[data-action="save"]').text()).toBe('Zapisz')
|
|
})
|
|
|
|
it('toasts the server message when a delete is refused and stays on the record', async () => {
|
|
const { wrapper, router } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
routes({ [`DELETE ${RECORD}`]: failure(409, 'Widget is in use') }),
|
|
{ attach: true },
|
|
)
|
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
|
await flushPromises()
|
|
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
|
await flushPromises()
|
|
expect(toasts.value[0]).toMatchObject({ text: 'Widget is in use', tone: 'danger' })
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1')
|
|
})
|
|
|
|
it('keeps a create on the form when the save fails', async () => {
|
|
const { wrapper, router } = await mountApp(
|
|
'/acme/demo/widgets/create',
|
|
routes({ [`POST ${BASE}`]: failure(500, 'Boom') }),
|
|
)
|
|
await wrapper.find('#field-name').setValue('New one')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/create')
|
|
expect(toasts.value[0]).toMatchObject({ text: 'Boom', tone: 'danger' })
|
|
})
|
|
|
|
it('follows the default redirect when the form declares no create redirect', async () => {
|
|
const envelope = clone(formSchemaFixture)
|
|
envelope.data.redirects.create.redirect = ''
|
|
envelope.data.redirects.default = 'acme/demo/widgets/update/:id'
|
|
const { wrapper, router } = await mountApp(
|
|
'/acme/demo/widgets/create',
|
|
routes({
|
|
[`GET ${BASE}/schema/form`]: { body: envelope },
|
|
[`POST ${BASE}`]: { status: 201, body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
|
|
[`GET ${BASE}/5`]: { body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
|
|
}),
|
|
)
|
|
await wrapper.find('#field-name').setValue('X')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/5')
|
|
})
|
|
})
|
|
|
|
describe('form view extension context (Phase 10.1, D-05, D-07, D-08, D-09, D-14)', () => {
|
|
const ACTION = `${BASE}/widgets/lookup`
|
|
const SUMMARY = `${BASE}/partials/summary`
|
|
const record = { data: { id: 1, name: 'Blue widget', color: '#0000ff' }, meta: { labels: {} } }
|
|
const summary = { data: { nodes: [{ tag: 'p', children: [{ text: 'Summary' }] }] }, meta: {} }
|
|
|
|
class AcmeDemoLookup extends HTMLElement {}
|
|
if (!customElements.get('acme-demo-lookup')) {
|
|
customElements.define('acme-demo-lookup', AcmeDemoLookup)
|
|
}
|
|
|
|
/** Records appended plugin scripts; each loads when the test fires it (or at once). */
|
|
function stubHead(autoload = true): HTMLScriptElement[] {
|
|
const scripts: HTMLScriptElement[] = []
|
|
const head = document.head
|
|
const append = head.appendChild.bind(head)
|
|
vi.spyOn(head, 'appendChild').mockImplementation(<T extends Node>(node: T): T => {
|
|
if (node instanceof HTMLScriptElement) {
|
|
scripts.push(node)
|
|
if (autoload) {
|
|
queueMicrotask(() => node.dispatchEvent(new Event('load')))
|
|
}
|
|
return node
|
|
}
|
|
if (node instanceof HTMLLinkElement) {
|
|
return node
|
|
}
|
|
return append(node)
|
|
})
|
|
return scripts
|
|
}
|
|
|
|
function extensionRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${BASE}/schema/form`]: { body: extensionFormSchemaFixture },
|
|
[`GET ${RECORD}`]: { body: record },
|
|
[`GET ${SUMMARY}`]: { body: summary },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function widget(root: Element): HTMLElement {
|
|
const element = root.querySelector('acme-demo-lookup')
|
|
if (!(element instanceof HTMLElement)) {
|
|
throw new Error('widget element not mounted')
|
|
}
|
|
return element
|
|
}
|
|
|
|
function beforeUnloadPrevented(): boolean {
|
|
const event = new Event('beforeunload', { cancelable: true })
|
|
window.dispatchEvent(event)
|
|
return event.defaultPrevented
|
|
}
|
|
|
|
it('provides the values and the schema locale to widgets', async () => {
|
|
stubHead()
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes())
|
|
await flushPromises()
|
|
const element = widget(wrapper.element)
|
|
expect(element.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
|
|
// The UI speaks Polish; the widget gets the schema's locale.
|
|
expect(element.getAttribute('locale')).toBe('en')
|
|
await wrapper.find('#field-name').setValue('Green widget')
|
|
expect(element.getAttribute('fill-values')).toBe('{"name":"Green widget"}')
|
|
})
|
|
|
|
it('falls back to the UI locale when the schema has none', async () => {
|
|
stubHead()
|
|
const schema = clone(extensionFormSchemaFixture)
|
|
schema.data.meta.locale = ''
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes({ [`GET ${BASE}/schema/form`]: { body: schema } }))
|
|
await flushPromises()
|
|
expect(widget(wrapper.element).getAttribute('locale')).toBe('pl')
|
|
})
|
|
|
|
it('patches like an edit: the form turns dirty and the field error clears', async () => {
|
|
stubHead()
|
|
const { wrapper, calls } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
extensionRoutes({
|
|
[`PUT ${RECORD}`]: {
|
|
status: 422,
|
|
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { name: ['Name is taken.'] } } },
|
|
},
|
|
[`POST ${ACTION}`]: { body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget' } }, meta: {} } },
|
|
}),
|
|
)
|
|
await flushPromises()
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
|
expect(wrapper.find('#field-name-error').text()).toContain('Name is taken.')
|
|
expect(beforeUnloadPrevented()).toBe(false)
|
|
|
|
widget(wrapper.element).dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
|
|
await flushPromises()
|
|
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Catalogue widget')
|
|
expect(wrapper.find('#field-name-error').exists()).toBe(false)
|
|
expect(beforeUnloadPrevented()).toBe(true)
|
|
// Nothing was saved by the action itself.
|
|
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
|
})
|
|
|
|
it('renders widgets and partials on create', async () => {
|
|
stubHead()
|
|
const { wrapper, calls } = await mountApp('/acme/demo/widgets/create', extensionRoutes())
|
|
await flushPromises()
|
|
const element = widget(wrapper.element)
|
|
expect(element.getAttribute('record-id')).toBe('')
|
|
const [partial] = requestsTo(calls, 'GET', SUMMARY)
|
|
expect(queryOf(partial!).has('id')).toBe(false)
|
|
expect(wrapper.find('[data-partial-field] [data-partial="field"]').text()).toBe('Summary')
|
|
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(0)
|
|
})
|
|
|
|
it('loads the controller assets when the schema arrives and mounts the widget after its script', async () => {
|
|
const scripts = stubHead(false)
|
|
// A fresh URL: the loader remembers scripts across the tests of this file.
|
|
const fresh = '/admin-test/assets/acme/demo/js/form.js?v=1'
|
|
const schema = clone(extensionFormSchemaFixture)
|
|
schema.data.assets.scripts = [fresh]
|
|
let release: (reply: Reply) => void = () => undefined
|
|
const pending = new Promise<Reply>((resolve) => (release = resolve))
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes({ [`GET ${BASE}/schema/form`]: () => pending }))
|
|
expect(scripts).toHaveLength(0)
|
|
|
|
release({ body: schema })
|
|
await flushPromises()
|
|
expect(scripts.map((script) => script.getAttribute('src'))).toEqual([fresh])
|
|
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBe('true')
|
|
expect(wrapper.element.querySelector('acme-demo-lookup')).toBeNull()
|
|
|
|
scripts[0]!.dispatchEvent(new Event('load'))
|
|
await flushPromises()
|
|
expect(widget(wrapper.element).getAttribute('record-id')).toBe('1')
|
|
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('forbidden save on the form (UI-SPEC S6, D-27)', () => {
|
|
const FALLBACK = 'You do not have permission to make this change. Nothing was saved.'
|
|
const strings = {
|
|
'backend::lang.form.forbidden': { other: FALLBACK },
|
|
'backend::lang.form.error_title': { other: 'The record could not be saved.' },
|
|
'backend::lang.form.error_fields': { one: 'Correct :count field.', other: 'Correct :count fields.' },
|
|
}
|
|
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
|
|
const refused = (message: string, details: Record<string, string[]> = {}): Reply => ({
|
|
status: 403,
|
|
body: { error: { code: 'forbidden', message, details } },
|
|
})
|
|
const banner = (wrapper: Mounted) => wrapper.find('[data-forbidden-banner]')
|
|
const name = (wrapper: Mounted) => wrapper.find<HTMLInputElement>('#field-name')
|
|
const code = (wrapper: Mounted) => wrapper.find<HTMLInputElement>('#field-code')
|
|
async function save(wrapper: Mounted): Promise<void> {
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
}
|
|
|
|
beforeEach(() => {
|
|
setBundle({ ...langFixture.data, ...strings }, 'en')
|
|
})
|
|
|
|
it('shows the banner with the server message, or the framework text without one', async () => {
|
|
const withMessage = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Only makers may rename widgets.') }))
|
|
await name(withMessage.wrapper).setValue('Renamed')
|
|
await save(withMessage.wrapper)
|
|
expect(banner(withMessage.wrapper).attributes('role')).toBe('alert')
|
|
expect(banner(withMessage.wrapper).text()).toBe('Only makers may rename widgets.')
|
|
expect(toasts.value).toHaveLength(0)
|
|
withMessage.wrapper.unmount()
|
|
|
|
const bare = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('') }))
|
|
await name(bare.wrapper).setValue('Renamed')
|
|
await save(bare.wrapper)
|
|
expect(banner(bare.wrapper).text()).toBe(FALLBACK)
|
|
expect(bare.wrapper.find('[aria-invalid="true"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('puts details on their fields, focuses the first one and shows no 422 banner', async () => {
|
|
const { wrapper } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Refused.', { code: ['This code is reserved.'], name: ['This name is reserved.'] }) }),
|
|
{ attach: true },
|
|
)
|
|
await code(wrapper).setValue('W-99')
|
|
await save(wrapper)
|
|
expect(banner(wrapper).text()).toBe('Refused.')
|
|
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
|
|
expect(name(wrapper).attributes('aria-invalid')).toBe('true')
|
|
expect(code(wrapper).attributes('aria-invalid')).toBe('true')
|
|
expect(wrapper.text()).toContain('This name is reserved.')
|
|
expect(wrapper.text()).toContain('This code is reserved.')
|
|
// name comes first in the form, so it takes the focus.
|
|
expect(document.activeElement).toBe(name(wrapper).element)
|
|
})
|
|
|
|
it('keeps the typed values and the dirty state: nothing was saved', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Refused.') }), { attach: true })
|
|
await name(wrapper).setValue('Typed by hand')
|
|
await save(wrapper)
|
|
expect(name(wrapper).element.value).toBe('Typed by hand')
|
|
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(1)
|
|
// Leaving still asks, because the form is dirty.
|
|
await wrapper.find('[data-action="cancel"]').trigger('click')
|
|
await flushPromises()
|
|
expect(document.body.querySelector('[data-confirm-dialog]')).not.toBeNull()
|
|
})
|
|
|
|
it('clears the banner when the next save starts, and shows nothing after a save that succeeds', async () => {
|
|
let attempt = 0
|
|
const saved = clone(recordFixture)
|
|
saved.data.name = 'Second try'
|
|
const { wrapper } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
routes({
|
|
[`GET ${BASE}/schema/form`]: flatSchema(),
|
|
[`PUT ${RECORD}`]: () => {
|
|
attempt += 1
|
|
return attempt === 1 ? refused('Refused.', { name: ['Reserved.'] }) : { body: saved }
|
|
},
|
|
}),
|
|
)
|
|
await name(wrapper).setValue('First try')
|
|
await save(wrapper)
|
|
expect(banner(wrapper).exists()).toBe(true)
|
|
// Editing does not dismiss it: only the next save attempt does.
|
|
await name(wrapper).setValue('Second try')
|
|
expect(banner(wrapper).exists()).toBe(true)
|
|
await save(wrapper)
|
|
expect(attempt).toBe(2)
|
|
expect(banner(wrapper).exists()).toBe(false)
|
|
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
|
expect(name(wrapper).attributes('aria-invalid')).toBeUndefined()
|
|
})
|
|
|
|
it('replaces the banner with the 422 banner when the next save fails validation', async () => {
|
|
let attempt = 0
|
|
const { wrapper } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
routes({
|
|
[`GET ${BASE}/schema/form`]: flatSchema(),
|
|
[`PUT ${RECORD}`]: () => {
|
|
attempt += 1
|
|
return attempt === 1
|
|
? refused('Refused.')
|
|
: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { name: ['Too short.'] } } } }
|
|
},
|
|
}),
|
|
)
|
|
await name(wrapper).setValue('x')
|
|
await save(wrapper)
|
|
expect(banner(wrapper).exists()).toBe(true)
|
|
await save(wrapper)
|
|
expect(banner(wrapper).exists()).toBe(false)
|
|
expect(wrapper.find('[data-error-banner]').text()).toContain('Correct 1 field.')
|
|
})
|
|
|
|
it('refuses a create the same way and stays on the create route', async () => {
|
|
const { wrapper, router } = await mountApp('/acme/demo/widgets/create', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`POST ${BASE}`]: refused('You may not add widgets.') }))
|
|
await name(wrapper).setValue('Brand new')
|
|
await save(wrapper)
|
|
expect(banner(wrapper).text()).toBe('You may not add widgets.')
|
|
expect(router.currentRoute.value.name).toBe('create')
|
|
expect(name(wrapper).element.value).toBe('Brand new')
|
|
})
|
|
|
|
it('shows a refused delete as a danger toast, not as the banner, and stays on the record', async () => {
|
|
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`DELETE ${RECORD}`]: refused('This widget is in use.') }), { attach: true })
|
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
|
await flushPromises()
|
|
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
|
await flushPromises()
|
|
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['This widget is in use.', 'danger']])
|
|
expect(banner(wrapper).exists()).toBe(false)
|
|
expect(router.currentRoute.value.name).toBe('record')
|
|
})
|
|
})
|
|
|
|
describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => {
|
|
const PEOPLE = `${API}/acme/roster/people`
|
|
const PERSON = `${PEOPLE}/1`
|
|
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
|
|
const field = (wrapper: Mounted, name: string) => wrapper.find<HTMLInputElement>(`#field-${name}`)
|
|
function people(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${PEOPLE}/schema/form`]: { body: rosterFormSchemaFixture },
|
|
[`GET ${PERSON}`]: { body: rosterRecordFixture },
|
|
[`GET ${PEOPLE}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } },
|
|
...overrides,
|
|
}
|
|
}
|
|
async function save(wrapper: Mounted): Promise<void> {
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
}
|
|
|
|
beforeEach(() => {
|
|
setBundle(
|
|
{ ...langFixture.data, 'backend::lang.form.show_password': { other: 'Show password' }, 'backend::lang.form.hide_password': { other: 'Hide password' } },
|
|
'en',
|
|
)
|
|
})
|
|
|
|
it('loads both password fields empty and masked, and puts no password into the page', async () => {
|
|
// The guarantee is the server's: a record response never carries a
|
|
// password (the framework test T-12.1-10). The form adds none of its own.
|
|
const { wrapper } = await mountApp('/acme/roster/people/1', people())
|
|
for (const name of ['password', 'password_confirmation']) {
|
|
expect(field(wrapper, name).attributes('type')).toBe('password')
|
|
expect(field(wrapper, name).element.value).toBe('')
|
|
expect(field(wrapper, name).attributes('autocomplete')).toBe('new-password')
|
|
}
|
|
expect('password' in rosterRecordFixture.data).toBe(false)
|
|
})
|
|
|
|
it('leaves the password out of an update that does not type one', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/roster/people/1', people({ [`PUT ${PERSON}`]: { body: rosterRecordFixture } }))
|
|
await field(wrapper, 'name').setValue('Ada King')
|
|
await save(wrapper)
|
|
const body = (await requestsTo(calls, 'PUT', PERSON)[0]!.clone().json()) as Record<string, unknown>
|
|
expect(body.name).toBe('Ada King')
|
|
expect('password' in body).toBe(false)
|
|
expect('password_confirmation' in body).toBe(false)
|
|
})
|
|
|
|
it('sends the typed pair and clears and hides both fields after the save', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/roster/people/1', people({ [`PUT ${PERSON}`]: { body: rosterRecordFixture } }))
|
|
await field(wrapper, 'password').setValue('new-secret-1')
|
|
await field(wrapper, 'password_confirmation').setValue('new-secret-1')
|
|
await wrapper.find('[data-field="password"] [data-password-toggle]').trigger('click')
|
|
expect(field(wrapper, 'password').attributes('type')).toBe('text')
|
|
await save(wrapper)
|
|
const body = (await requestsTo(calls, 'PUT', PERSON)[0]!.clone().json()) as Record<string, unknown>
|
|
expect(body.password).toBe('new-secret-1')
|
|
expect(body.password_confirmation).toBe('new-secret-1')
|
|
for (const name of ['password', 'password_confirmation']) {
|
|
expect(field(wrapper, name).element.value).toBe('')
|
|
expect(field(wrapper, name).attributes('type')).toBe('password')
|
|
}
|
|
// A second save sends no password again.
|
|
await field(wrapper, 'name').setValue('Ada King')
|
|
await save(wrapper)
|
|
const second = (await requestsTo(calls, 'PUT', PERSON)[1]!.clone().json()) as Record<string, unknown>
|
|
expect('password' in second).toBe(false)
|
|
})
|
|
|
|
it('keeps the typed password when the save is refused', async () => {
|
|
const { wrapper } = await mountApp(
|
|
'/acme/roster/people/1',
|
|
people({ [`PUT ${PERSON}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Refused.', details: { password: ['Not allowed.'] } } } } }),
|
|
)
|
|
await field(wrapper, 'password').setValue('new-secret-1')
|
|
await field(wrapper, 'password_confirmation').setValue('new-secret-1')
|
|
await save(wrapper)
|
|
expect(wrapper.find('[data-forbidden-banner]').text()).toBe('Refused.')
|
|
expect(field(wrapper, 'password').element.value).toBe('new-secret-1')
|
|
expect(field(wrapper, 'password').attributes('aria-invalid')).toBe('true')
|
|
})
|
|
|
|
it('on create, sends a password that was typed and cleared as empty, and leaves an untouched one to the server rules', async () => {
|
|
const invalid = { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { password: ['The password is required.'] } } } }
|
|
const { wrapper, calls } = await mountApp('/acme/roster/people/create', people({ [`POST ${PEOPLE}`]: invalid }))
|
|
await field(wrapper, 'name').setValue('Grace Hopper')
|
|
await save(wrapper)
|
|
const untouched = (await requestsTo(calls, 'POST', PEOPLE)[0]!.clone().json()) as Record<string, unknown>
|
|
expect(untouched.password ?? '').toBe('')
|
|
expect(wrapper.text()).toContain('The password is required.')
|
|
expect(field(wrapper, 'password').attributes('aria-invalid')).toBe('true')
|
|
|
|
await field(wrapper, 'password').setValue('typed')
|
|
await field(wrapper, 'password').setValue('')
|
|
await save(wrapper)
|
|
const cleared = (await requestsTo(calls, 'POST', PEOPLE)[1]!.clone().json()) as Record<string, unknown>
|
|
expect(cleared.password).toBe('')
|
|
})
|
|
|
|
it('fills a preset target on create only, and stops at the first manual edit', async () => {
|
|
const create = await mountApp('/acme/roster/people/create', people())
|
|
await field(create.wrapper, 'name').setValue('Zażółć gęślą')
|
|
expect(field(create.wrapper, 'slug').element.value).toBe('za-g-l')
|
|
await field(create.wrapper, 'slug').setValue('by-hand')
|
|
await field(create.wrapper, 'name').setValue('Something Else')
|
|
expect(field(create.wrapper, 'slug').element.value).toBe('by-hand')
|
|
// Clearing the target by hand is an edit too: it stays empty.
|
|
await field(create.wrapper, 'slug').setValue('')
|
|
await field(create.wrapper, 'name').setValue('Third Name')
|
|
expect(field(create.wrapper, 'slug').element.value).toBe('')
|
|
create.wrapper.unmount()
|
|
|
|
const update = await mountApp('/acme/roster/people/1', people())
|
|
await field(update.wrapper, 'name').setValue('Ada King')
|
|
expect(field(update.wrapper, 'slug').element.value).toBe('ada-lovelace')
|
|
})
|
|
})
|