From aced6c7df3b9cdb215b8631a6aab881287d397ca Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 15:36:00 +0200 Subject: [PATCH] test(12.1-05): unit tests for the list, form, preview and routing behaviours of Phase 12.1 in the SPA - 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 --- admin/tests/app/router.test.ts | 36 +++- admin/tests/app/winterUrl.test.ts | 70 ++++++- admin/tests/form/FormView.test.ts | 268 +++++++++++++++++++++++- admin/tests/form/PreviewView.test.ts | 278 +++++++++++++++++++++++++ admin/tests/form/RelationField.test.ts | 123 ++++++++++- admin/tests/form/formState.test.ts | 83 +++++++- admin/tests/form/registry.test.ts | 29 +++ admin/tests/list/DataTable.test.ts | 102 +++++++++ admin/tests/list/ListToolbar.test.ts | 74 ++++++- admin/tests/list/ListView.test.ts | 196 ++++++++++++++++- 10 files changed, 1251 insertions(+), 8 deletions(-) create mode 100644 admin/tests/form/PreviewView.test.ts diff --git a/admin/tests/app/router.test.ts b/admin/tests/app/router.test.ts index 968c912..58e03f4 100644 --- a/admin/tests/app/router.test.ts +++ b/admin/tests/app/router.test.ts @@ -4,7 +4,7 @@ import { activateStyles, loadStyles } from '../../src/app/pluginAssets' import { createAdminRouter, routeControllerId, safeRedirect } from '../../src/app/router' import { setNavigation } from '../../src/state/useNavigation' import { navigationFixture } from '../fixtures/typed' -import { resetState, routerAt, signIn } from '../helpers' +import { mockApi, resetState, routerAt, signIn } from '../helpers' beforeEach(() => { resetState() @@ -149,3 +149,37 @@ describe('plugin stylesheets follow the route (D-16, T-10.1-16)', () => { expect(enabled()).toEqual([]) }) }) + +describe('preview route (UI-SPEC S3, D-11)', () => { + it('names the preview route for a numeric id only and keeps it inside the shell', async () => { + await signIn() + const preview = await routerAt('/acme/demo/widgets/12/preview') + expect(preview.currentRoute.value.name).toBe('preview') + expect(preview.currentRoute.value.params).toMatchObject({ vendor: 'acme', plugin: 'demo', controller: 'widgets', id: '12' }) + expect(preview.currentRoute.value.meta.shell).toBe(true) + for (const path of ['/acme/demo/widgets/abc/preview', '/acme/demo/widgets/1e3/preview', '/acme/demo/widgets/12/preview/extra', '/acme/demo/widgets/-1/preview']) { + expect((await routerAt(path)).currentRoute.value.name, path).toBe('not-found') + } + }) + + it('counts preview among the controller routes, so plugin assets follow it', async () => { + await signIn() + // CONTROLLER_ROUTES holds list, create, record and preview: each of them + // answers its controller id, and no other route does. + for (const path of ['/acme/demo/widgets', '/acme/demo/widgets/create', '/acme/demo/widgets/12', '/acme/demo/widgets/12/preview']) { + expect(routeControllerId((await routerAt(path)).currentRoute.value), path).toBe('acme.demo.widgets') + } + for (const path of ['/settings', '/settings/mail', '/nowhere', '/acme/demo/widgets/abc/preview']) { + expect(routeControllerId((await routerAt(path)).currentRoute.value), path).toBeFalsy() + } + }) + + it('requires a signed-in admin for the preview route and returns to it after login', async () => { + mockApi({}) + const router = createAdminRouter(createMemoryHistory()) + await router.push('/acme/demo/widgets/12/preview') + expect(router.currentRoute.value.name).toBe('login') + expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets/12/preview') + expect(safeRedirect(router.currentRoute.value.query.redirect)).toBe('/acme/demo/widgets/12/preview') + }) +}) diff --git a/admin/tests/app/winterUrl.test.ts b/admin/tests/app/winterUrl.test.ts index c71a2b4..7281710 100644 --- a/admin/tests/app/winterUrl.test.ts +++ b/admin/tests/app/winterUrl.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { flushPromises } from '@vue/test-utils' import { mapWinterUrl } from '../../src/app/winterUrl' +import { clone, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed' +import { API, mountApp, resetState } from '../helpers' const ID = 'acme.demo.widgets' const LIST = '/acme/demo/widgets' @@ -59,3 +62,68 @@ describe('mapWinterUrl', () => { expect(mapWinterUrl('acme/demo/widgets', 'acme.demo.wid gets')).toBe('/') }) }) + +describe('preview URLs (UI-SPEC S3, D-11, T-12.1-16)', () => { + const PEOPLE = `${API}/acme/roster/people` + + beforeEach(() => { + resetState() + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('backstop: mapWinterUrl maps preview/:id to the preview route, and opening the preview route of a form without a preview replaces it with the record route', async () => { + const id = 'acme.roster.people' + expect(mapWinterUrl('acme/roster/people/preview/:id', id, 12)).toBe('/acme/roster/people/12/preview') + expect(mapWinterUrl('acme/roster/people/preview/12', id)).toBe('/acme/roster/people/12/preview') + expect(mapWinterUrl('/Acme/Roster/People/preview/:id/', id, '12')).toBe('/acme/roster/people/12/preview') + + const plain = clone(rosterFormSchemaFixture) + delete plain.data.preview + const { wrapper, router, calls } = await mountApp('/acme/roster/people/1/preview', { + [`GET ${PEOPLE}/schema/form`]: { body: plain }, + [`GET ${PEOPLE}/1`]: { body: rosterRecordFixture }, + }) + await flushPromises() + expect(router.currentRoute.value.name).toBe('record') + expect(router.currentRoute.value.path).toBe('/acme/roster/people/1') + // Replaced, not pushed: going back does not return to the preview URL. + expect(wrapper.find('[data-preview]').exists()).toBe(false) + expect(calls.some((request) => new URL(request.url).pathname.includes('/partials/'))).toBe(false) + wrapper.unmount() + + // With a preview block the same URL stays on the preview route. + const kept = await mountApp('/acme/roster/people/1/preview', { + [`GET ${PEOPLE}/schema/form`]: { body: rosterFormSchemaFixture }, + [`GET ${PEOPLE}/1`]: { body: rosterRecordFixture }, + [`GET ${PEOPLE}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } }, + }) + await flushPromises() + expect(kept.router.currentRoute.value.name).toBe('preview') + expect(kept.wrapper.find('[data-preview]').exists()).toBe(true) + kept.wrapper.unmount() + }) + + it('rejects a foreign controller, a foreign plugin and a foreign vendor', () => { + expect(mapWinterUrl('acme/demo/gadgets/preview/:id', ID, 1)).toBe(LIST) + expect(mapWinterUrl('acme/other/widgets/preview/:id', ID, 1)).toBe(LIST) + expect(mapWinterUrl('evil/demo/widgets/preview/:id', ID, 1)).toBe(LIST) + expect(mapWinterUrl('https://evil.example/acme/demo/widgets/preview/1', ID)).toBe(LIST) + expect(mapWinterUrl('//evil.example/acme/demo/widgets/preview/1', ID)).toBe(LIST) + }) + + it.each([['abc'], ['1e3'], ['-1'], ['1.5'], ['0x10'], [' 1'], ['1/..'], ['../1'], ['%31'], ['']])('rejects the non-numeric preview id %j', (bad) => { + expect(mapWinterUrl(`acme/demo/widgets/preview/${bad}`, ID)).toBe(LIST) + expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID, bad)).toBe(LIST) + }) + + it('accepts no extra segment, no other case of the action and no missing id', () => { + expect(mapWinterUrl('acme/demo/widgets/preview', ID, 1)).toBe(LIST) + expect(mapWinterUrl('acme/demo/widgets/preview/1/edit', ID)).toBe(LIST) + expect(mapWinterUrl('acme/demo/widgets/Preview/1', ID)).toBe(LIST) + expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID, undefined)).toBe(LIST) + expect(mapWinterUrl('acme/demo/widgets/preview/1?next=//evil.example#x', ID)).toBe(`${LIST}/1/preview`) + }) +}) diff --git a/admin/tests/form/FormView.test.ts b/admin/tests/form/FormView.test.ts index 1800941..50679d6 100644 --- a/admin/tests/form/FormView.test.ts +++ b/admin/tests/form/FormView.test.ts @@ -1,10 +1,19 @@ 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, recordFixture } from '../fixtures/typed' +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` @@ -288,3 +297,260 @@ describe('form view extension context (Phase 10.1, D-05, D-07, D-08, D-09, D-14) 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>['wrapper'] + const refused = (message: string, details: Record = {}): Reply => ({ + status: 403, + body: { error: { code: 'forbidden', message, details } }, + }) + const banner = (wrapper: Mounted) => wrapper.find('[data-forbidden-banner]') + const name = (wrapper: Mounted) => wrapper.find('#field-name') + const code = (wrapper: Mounted) => wrapper.find('#field-code') + async function save(wrapper: Mounted): Promise { + 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>['wrapper'] + const field = (wrapper: Mounted, name: string) => wrapper.find(`#field-${name}`) + function people(overrides: Record = {}): Record { + 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 { + 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 + 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 + 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 + 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 + 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 + 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') + }) +}) diff --git a/admin/tests/form/PreviewView.test.ts b/admin/tests/form/PreviewView.test.ts new file mode 100644 index 0000000..07dc850 --- /dev/null +++ b/admin/tests/form/PreviewView.test.ts @@ -0,0 +1,278 @@ +// The preview screen (UI-SPEC S3, D-11) with its footer (UI-SPEC S2, D-10). +// 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 { setBundle } from '../../src/app/i18n' +import { toasts } from '../../src/state/useToasts' +import PreviewView from '../../src/views/PreviewView.vue' +import { clone, langFixture, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed' +import { API, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers' + +const LIST = `${API}/acme/roster/people` +const RECORD = `${LIST}/1` +const HINT = `${LIST}/partials/status` +const PREVIEW = '/acme/roster/people/1/preview' + +const strings = { + 'backend::lang.form.return_to_list': { other: 'Back to list' }, + 'backend::lang.form.load_failed': { other: 'The form could not be loaded.' }, + 'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' }, + 'backend::lang.form.action_done': { other: 'Action completed.' }, + 'backend::lang.form.action_stale': { other: 'This action no longer applies to this record.' }, + 'backend::lang.list.empty_value': { other: '—' }, + 'backend::lang.extension.partial_failed': { other: 'This section could not be loaded.' }, + 'backend::lang.permissioneditor.other': { other: 'Other' }, +} + +const hint = (text: string) => ({ + data: { nodes: [{ tag: 'div', attrs: { class: 'summer-callout summer-callout--warning', role: 'status' }, children: [{ tag: 'p', attrs: { class: 'summer-callout__title' }, children: [{ text }] }] }] }, + meta: {}, +}) +const noHint = { data: { nodes: [] }, meta: {} } + +function routes(overrides: Record = {}): Record { + return { + [`GET ${LIST}/schema/form`]: { body: rosterFormSchemaFixture }, + [`GET ${RECORD}`]: { body: rosterRecordFixture }, + [`GET ${HINT}`]: { body: hint('This person is not active') }, + ...overrides, + } +} + +function schemaWith(change: (schema: typeof rosterFormSchemaFixture.data) => void) { + const envelope = clone(rosterFormSchemaFixture) + change(envelope.data) + return { body: envelope } +} + +function recordWith(change: (record: typeof rosterRecordFixture) => void) { + const record = clone(rosterRecordFixture) + change(record) + return { body: record } +} + +const fields = (wrapper: VueWrapper) => wrapper.findAll('[data-preview-field]').map((item) => item.attributes('data-preview-field')) +const footerButtons = (wrapper: VueWrapper) => + wrapper.findAll('[data-preview] footer button').map((item) => item.attributes('data-record-action') ?? item.attributes('data-action')) +const dialog = () => document.body.querySelector('[data-confirm-dialog]') + +async function runAction(wrapper: VueWrapper, name: string): Promise { + await wrapper.find(`[data-record-action="${name}"]`).trigger('click') + await flushPromises() + dialog()!.querySelector('[data-action="confirm"]')!.click() + await flushPromises() + await wait(10) + await flushPromises() +} + +beforeEach(() => { + resetState() + setBundle({ ...langFixture.data, ...strings }, 'en') +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('preview view (UI-SPEC S3, D-11)', () => { + it('is the component of the preview route and renders values as a dl, with no input', async () => { + const { wrapper } = await mountApp(PREVIEW, routes()) + expect(wrapper.findComponent(PreviewView).exists()).toBe(true) + expect(wrapper.find('[data-preview] dl').exists()).toBe(true) + expect(wrapper.find('[data-preview] dl input, [data-preview] dl textarea, [data-preview] dl select').exists()).toBe(false) + expect(wrapper.find('[data-action="save"]').exists()).toBe(false) + expect(wrapper.find('[data-action="delete"]').exists()).toBe(false) + }) + + it('shows the fields of the preview context: preview-only ones in, create and update ones out', async () => { + const { wrapper } = await mountApp(PREVIEW, routes()) + const shown = fields(wrapper) + expect(shown).toContain('joined_ip') + expect(shown).toEqual(expect.arrayContaining(['name', 'email', 'team', 'tags', 'slug'])) + for (const hidden of ['password', 'password_confirmation', 'notify', 'permissions']) { + expect(shown).not.toContain(hidden) + } + // A password is never rendered here, even if a response carried one. + expect(wrapper.html()).not.toMatch(/type="password"/) + }) + + it('hides a tab none of whose fields show on the preview, and shows it once one does', async () => { + // The Permissions tab holds one update-only field: no such tab here. + const { wrapper } = await mountApp(PREVIEW, routes()) + expect(wrapper.findAll('[role="tab"]').some((tab) => tab.text().includes('Permissions'))).toBe(false) + expect(wrapper.find('[data-permission-editor]').exists()).toBe(false) + wrapper.unmount() + + const shownOnPreview = schemaWith((schema) => { + const permissions = schema.fields.find((field) => field.name === 'permissions')! + permissions.context = ['update', 'preview'] + }) + const withTab = await mountApp(PREVIEW, routes({ [`GET ${LIST}/schema/form`]: shownOnPreview })) + const tab = withTab.wrapper.findAll('[role="tab"]').find((item) => item.text().includes('Permissions')) + expect(tab).toBeDefined() + await tab!.trigger('click') + await flushPromises() + // The editor is read-only on this screen. + const editor = withTab.wrapper.find('[data-permission-editor]') + expect(editor.exists()).toBe(true) + expect(editor.findAll('[role="radiogroup"]').every((group) => group.attributes('aria-disabled') === 'true')).toBe(true) + }) + + it('shows the muted dash for an empty value and the label of a relation, never its id', async () => { + const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: recordWith((record) => (record.data.joined_ip = null)) })) + expect(wrapper.find('[data-preview-field="joined_ip"] [data-empty]').text()).toBe('—') + expect(wrapper.find('[data-preview-field="team"] [data-preview-value]').text()).toBe('Home') + expect(wrapper.findAll('[data-preview-field="tags"] [data-chip]').map((chip) => chip.find('.font-semibold').text())).toEqual(['staff', 'news']) + expect(wrapper.find('[data-preview-field="tags"] button').exists()).toBe(false) + }) + + it('asks for the status hint with the record id and renders it between the header and the card', async () => { + const { wrapper, calls } = await mountApp(PREVIEW, routes()) + const [request] = requestsTo(calls, 'GET', HINT) + expect(new URL(request!.url).searchParams.get('id')).toBe('1') + const callout = wrapper.find('[data-preview] .summer-callout') + expect(callout.text()).toBe('This person is not active') + expect(callout.attributes('role')).toBe('status') + // One callout, above the card with the fields. + expect(wrapper.findAll('[data-preview] .summer-callout')).toHaveLength(1) + const order = wrapper.find('[data-preview]').html() + expect(order.indexOf('summer-callout')).toBeLessThan(order.indexOf(' { + const plain = schemaWith((schema) => (schema.preview = {})) + const { wrapper, calls } = await mountApp(PREVIEW, routes({ [`GET ${LIST}/schema/form`]: plain })) + expect(requestsTo(calls, 'GET', HINT)).toHaveLength(0) + expect(wrapper.find('.summer-callout').exists()).toBe(false) + expect(wrapper.find('[data-preview] dl').exists()).toBe(true) + }) + + it('keeps the previous hint on screen while it reloads after an action', async () => { + let release: (reply: Reply) => void = () => undefined + let hints = 0 + const { wrapper } = await mountApp( + PREVIEW, + routes({ + [`GET ${HINT}`]: () => { + hints += 1 + if (hints === 1) { + return { body: hint('This person is not active') } + } + return new Promise((resolve) => { + release = resolve + }) + }, + [`POST ${RECORD}/actions/activate`]: { body: { data: { message: '', fill: {} }, meta: {} } }, + }), + { attach: true }, + ) + await runAction(wrapper, 'activate') + expect(hints).toBe(2) + // Still the first hint, no skeleton in its place. + expect(wrapper.find('.summer-callout').text()).toBe('This person is not active') + release({ body: noHint }) + await flushPromises() + expect(wrapper.find('.summer-callout').exists()).toBe(false) + }) + + it('shows the load failure, the back link and no footer when the record cannot be loaded', async () => { + for (const status of [404, 403, 500]) { + const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: { status, body: { error: { code: 'x', message: 'Hidden detail', details: {} } } } })) + expect(wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.') + expect(wrapper.text()).not.toContain('Hidden detail') + expect(wrapper.find('[data-preview] dl').exists()).toBe(false) + expect(wrapper.find('[data-action="edit"]').exists()).toBe(false) + expect(wrapper.find('[data-record-action]').exists()).toBe(false) + expect(wrapper.find('[data-action="back"]').attributes('href')).toBe('/acme/roster/people') + wrapper.unmount() + resetState() + setBundle({ ...langFixture.data, ...strings }, 'en') + } + }) + + it('shows the load failure when the form schema cannot be loaded', async () => { + const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${LIST}/schema/form`]: { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } } })) + expect(wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.') + expect(wrapper.find('[data-action="edit"]').exists()).toBe(false) + }) +}) + +describe('preview view footer (UI-SPEC S2, D-10)', () => { + it('zero actions: the edit button alone', async () => { + for (const change of [(record: typeof rosterRecordFixture) => delete record.meta.actions, (record: typeof rosterRecordFixture) => (record.meta.actions = [])]) { + const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: recordWith(change) })) + expect(footerButtons(wrapper)).toEqual(['edit']) + wrapper.unmount() + resetState() + setBundle({ ...langFixture.data, ...strings }, 'en') + } + }) + + it('one action: it comes before the one primary edit button', async () => { + const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: recordWith((record) => (record.meta.actions = [{ name: 'activate', label: 'Activate' }])) })) + expect(footerButtons(wrapper)).toEqual(['activate', 'edit']) + expect(wrapper.findAll('[data-preview] footer .bg-primary')).toHaveLength(1) + expect(wrapper.find('[data-action="edit"]').classes()).toContain('bg-primary') + }) + + it('several actions: in the order the server offers them, edit last', async () => { + const offered = [ + { name: 'reinstate', label: 'Reinstate' }, + { name: 'activate', label: 'Activate' }, + { name: 'resend', label: 'Send the invitation again' }, + ] + const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: recordWith((record) => (record.meta.actions = offered)) })) + expect(footerButtons(wrapper)).toEqual(['reinstate', 'activate', 'resend', 'edit']) + expect(wrapper.find('[data-preview] footer').classes()).toContain('flex-wrap') + }) + + it('after an action: a success toast, then the record and the hint are read again', async () => { + const after = recordWith((record) => (record.meta.actions = [{ name: 'reinstate', label: 'Reinstate' }])) + let reads = 0 + const { wrapper, calls } = await mountApp( + PREVIEW, + routes({ + [`GET ${RECORD}`]: () => { + reads += 1 + return reads === 1 ? { body: rosterRecordFixture } : after + }, + [`POST ${RECORD}/actions/activate`]: { body: { data: { message: 'The person was activated.', fill: {} }, meta: {} } }, + }), + { attach: true }, + ) + await runAction(wrapper, 'activate') + expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['The person was activated.', 'success']]) + expect(reads).toBe(2) + expect(requestsTo(calls, 'GET', HINT)).toHaveLength(2) + // The action that no longer applies is gone from the footer. + expect(footerButtons(wrapper)).toEqual(['reinstate', 'edit']) + }) + + it('409: the record is read again; 404: the screen turns into the load failure', async () => { + const stale = await mountApp(PREVIEW, routes({ [`POST ${RECORD}/actions/activate`]: { status: 409, body: { error: { code: 'conflict', message: 'x', details: {} } } } }), { attach: true }) + await runAction(stale.wrapper, 'activate') + expect(requestsTo(stale.calls, 'GET', RECORD)).toHaveLength(2) + expect(toasts.value.map((toast) => toast.tone)).toEqual(['danger']) + expect(stale.wrapper.find('[data-preview] dl').exists()).toBe(true) + stale.wrapper.unmount() + document.body.innerHTML = '' + resetState() + setBundle({ ...langFixture.data, ...strings }, 'en') + + const gone = await mountApp(PREVIEW, routes(), { attach: true }) + await runAction(gone.wrapper, 'activate') + expect(gone.wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.') + expect(gone.wrapper.find('[data-action="edit"]').exists()).toBe(false) + }) + + it('edit leads to the record route of the same record', async () => { + const { wrapper, router } = await mountApp(PREVIEW, routes()) + await wrapper.find('[data-action="edit"]').trigger('click') + await flushPromises() + expect(router.currentRoute.value.name).toBe('record') + expect(router.currentRoute.value.path).toBe('/acme/roster/people/1') + }) +}) diff --git a/admin/tests/form/RelationField.test.ts b/admin/tests/form/RelationField.test.ts index fa71e93..9eeb1d0 100644 --- a/admin/tests/form/RelationField.test.ts +++ b/admin/tests/form/RelationField.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { setBundle } from '../../src/app/i18n' import RelationField from '../../src/components/form/fields/RelationField.vue' -import { formField, optionsFixture, recordFixture } from '../fixtures/typed' +import { formField, langFixture, optionsFixture, recordFixture } from '../fixtures/typed' import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers' const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' } @@ -241,3 +242,123 @@ describe('multiple relation (D-18)', () => { expect(wrapper.find('[data-chip] .font-semibold').text()).toBe('99') }) }) + +describe('locked relation options (UI-SPEC S6, D-07)', () => { + const NOTE = 'Items marked with a lock need an additional permission to change.' + const strings = { + 'backend::lang.form.locked_item': { other: 'Locked: :name' }, + 'backend::lang.form.locked_note': { other: NOTE }, + 'backend::lang.form.remove_item': { other: 'Remove: :name' }, + } + // Round (3) is a locked value; Large (4) is a locked choice; Extra (7) is free. + const lockedLabels = [ + { value: 3, label: 'Round', locked: true }, + { value: 1, label: 'Small' }, + ] + const lockedOptions = { + data: [ + { value: 1, label: 'Small' }, + { value: 3, label: 'Round', locked: true }, + { value: 4, label: 'Large', locked: true }, + { value: 7, label: 'Extra' }, + ], + meta: { page: 1, per_page: 20, total: 4, last_page: 1 }, + } + const chipNames = (wrapper: VueWrapper) => wrapper.findAll('[data-chip]').map((chip) => chip.find('.font-semibold').text()) + const option = (wrapper: VueWrapper, value: number) => wrapper.find(`[role="option"][data-value="${value}"]`) + const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0]) + + function mountLocked(modelValue: unknown = [3, 1], labels = lockedLabels) { + mockApi(routes({ [`GET ${TAGS}`]: { body: lockedOptions } })) + return mountField('tags', modelValue, { labels }) + } + + beforeEach(() => { + setBundle({ ...langFixture.data, ...strings }, 'en') + }) + + it('shows a locked chip with a lock, its name for screen readers and no remove button', () => { + const wrapper = mountLocked() + const [round, small] = wrapper.findAll('[data-chip]') + expect(round!.find('button').exists()).toBe(false) + expect(round!.find('[data-chip-locked] svg').attributes('aria-hidden')).toBe('true') + expect(round!.find('[data-chip-locked] .sr-only').text()).toBe('Locked: Round') + expect(small!.find('[data-chip-locked]').exists()).toBe(false) + expect(small!.find('button').attributes('aria-label')).toBe('Remove: Small') + }) + + it('shows the note once a locked label or option was seen, and names it in aria-describedby', async () => { + const wrapper = mountLocked() + const note = wrapper.find('[data-locked-note]') + expect(note.text()).toBe(NOTE) + expect(combobox(wrapper).attributes('aria-describedby')).toContain(note.attributes('id')) + + // No locked label: no note until an options page brings a locked choice. + const plain = mountLocked([1], [{ value: 1, label: 'Small' }]) + expect(plain.find('[data-locked-note]').exists()).toBe(false) + await openList(plain) + expect(plain.find('[data-locked-note]').text()).toBe(NOTE) + }) + + it('has no note and no lock without locked items', async () => { + mockApi(routes()) + const wrapper = mountField('tags', [3, 1]) + await openList(wrapper) + expect(wrapper.find('[data-locked-note]').exists()).toBe(false) + expect(wrapper.find('[data-chip-locked]').exists()).toBe(false) + expect(wrapper.findAll('[role="option"]').every((item) => item.attributes('aria-disabled') === undefined)).toBe(true) + }) + + it('backstop: a locked option cannot be chosen by click, Enter or arrow keys, and a locked chip cannot be removed by click or Backspace', async () => { + const wrapper = mountLocked() + await openList(wrapper) + + // The list offers the unselected choices: Large is locked, Extra is free. + expect(optionTexts(wrapper)).toEqual(['Large', 'Extra']) + const large = option(wrapper, 4) + expect(large.attributes('aria-disabled')).toBe('true') + expect(large.classes()).toEqual(expect.arrayContaining(['text-muted', 'cursor-not-allowed'])) + expect(large.find('svg').exists()).toBe(true) + expect(option(wrapper, 7).attributes('aria-disabled')).toBeUndefined() + + // By click. + await large.trigger('mousedown') + await large.trigger('click') + expect(emitted(wrapper)).toEqual([]) + + // By the arrow keys: the active option is never the locked one, in + // either direction and however often the key is pressed. + const search = combobox(wrapper) + for (const key of ['ArrowDown', 'ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp', 'Home', 'End']) { + keydown(search.element, key) + await flushPromises() + expect(search.attributes('aria-activedescendant')).not.toBe(large.attributes('id')) + } + // By Enter: whatever is active, the locked id is not what gets added. + keydown(search.element, 'Enter') + await flushPromises() + for (const value of emitted(wrapper)) { + expect(value).not.toContain(4) + } + expect(emitted(wrapper)).toEqual([[3, 1, 7]]) + + // A locked chip has nothing to click, and Backspace removes only + // unlocked chips: with the locked one left it does nothing. + const chips = mountLocked([3, 1]) + expect(chips.findAll('[data-chip]')[0]!.find('button').exists()).toBe(false) + await chips.findAll('[data-chip]')[0]!.trigger('click') + expect(emitted(chips)).toEqual([]) + keydown(combobox(chips).element, 'Backspace') + expect(emitted(chips)).toEqual([[3]]) + await chips.setProps({ modelValue: [3] }) + keydown(combobox(chips).element, 'Backspace') + keydown(combobox(chips).element, 'Delete') + expect(emitted(chips)).toEqual([[3]]) + expect(chipNames(chips)).toEqual(['Round']) + + // A locked chip in the last position is skipped, not removed. + const tail = mountLocked([1, 3]) + keydown(combobox(tail).element, 'Backspace') + expect(emitted(tail)).toEqual([[3]]) + }) +}) diff --git a/admin/tests/form/formState.test.ts b/admin/tests/form/formState.test.ts index 2608de8..02a2f82 100644 --- a/admin/tests/form/formState.test.ts +++ b/admin/tests/form/formState.test.ts @@ -7,11 +7,12 @@ import { focusField, initialValues, panelDomId, + presetValue, snapshot, tabDomId, tabOf, } from '../../src/components/form/formState' -import { extensionFormSchemaFixture, formField, formSchemaFixture } from '../fixtures/typed' +import { extensionFormSchemaFixture, formField, formSchemaFixture, rosterFormSchemaFixture } from '../fixtures/typed' afterEach(() => { document.body.innerHTML = '' @@ -97,3 +98,83 @@ describe('form state helpers', () => { expect(focusField('field', 'missing')).toBe(false) }) }) + +describe('password and permission values in the save body (UI-SPEC S5 and S7)', () => { + const fields = rosterFormSchemaFixture.data.fields + + it('leaves an empty password out on update and sends it as entered on create', () => { + const pair = fields.filter((field) => field.type === 'password') + expect(pair.map((field) => field.name)).toEqual(['password', 'password_confirmation']) + expect(editablePayload(pair, { password: '', password_confirmation: '' }, 'update')).toEqual({}) + expect(editablePayload(pair, { password: null, password_confirmation: null }, 'update')).toEqual({}) + expect(editablePayload(pair, { password: '', password_confirmation: '' }, 'create')).toEqual({ password: '', password_confirmation: '' }) + expect(editablePayload(pair, { password: 'new-secret', password_confirmation: 'new-secret' }, 'update')).toEqual({ + password: 'new-secret', + password_confirmation: 'new-secret', + }) + // A lone password is sent as entered: the server's confirmed rule answers. + expect(editablePayload(pair, { password: 'new-secret', password_confirmation: '' }, 'update')).toEqual({ password: 'new-secret' }) + // The default mode is create. + expect(editablePayload(pair, { password: '' })).toEqual({ password: '' }) + }) + + it('starts password fields empty, whatever the record holds', () => { + const initial = initialValues(fields.filter((field) => field.type === 'password')) + expect(initial.password ?? '').toBe('') + expect(initial.password_confirmation ?? '').toBe('') + }) + + it('shows a field with context preview on the preview only, and hides create and update fields there', () => { + const byName = (name: string) => fields.find((field) => field.name === name)! + expect(contextAllows(byName('joined_ip'), 'preview')).toBe(true) + expect(contextAllows(byName('joined_ip'), 'update')).toBe(false) + expect(contextAllows(byName('joined_ip'), 'create')).toBe(false) + expect(contextAllows(byName('password'), 'preview')).toBe(false) + expect(contextAllows(byName('notify'), 'preview')).toBe(false) + expect(contextAllows(byName('permissions'), 'preview')).toBe(false) + expect(contextAllows(byName('name'), 'preview')).toBe(true) + }) +}) + +describe('preset values (D-27 G7)', () => { + // The same table as the user plugin's slug test (models/slug_test.go, + // slugCases): the form and the model derive the same slug from a name. + const slugCases: Array<[string, string]> = [ + ['Acme Records', 'acme-records'], + [' Acme -- Records ', 'acme-records'], + ['ACME_Records 2', 'acme-records-2'], + ['Zażółć gęślą', 'za-g-l'], + ['Rock & Roll!!!', 'rock-roll'], + ['...leading and trailing...', 'leading-and-trailing'], + ['tab\tand\nnewline', 'tab-and-newline'], + ['emoji 🎵 music', 'emoji-music'], + ['CamelCaseText', 'camelcasetext'], + ['9 lives', '9-lives'], + ['a', 'a'], + ['---', ''], + [' ', ''], + ['', ''], + ['ąęść', ''], + ['Long Name '.repeat(40), 'long-name-'.repeat(40).replace(/-$/, '')], + ] + + it.each(slugCases)('slug preset matches the server rule: %j', (text, want) => { + expect(presetValue('slug', text)).toBe(want) + // A slug is a fixed point of the rule. + expect(presetValue('slug', want)).toBe(want) + }) + + it('copies the text for type exact and for any type it does not know', () => { + expect(presetValue('exact', ' Acme -- Records ')).toBe(' Acme -- Records ') + expect(presetValue('camel', 'Acme Records')).toBe('Acme Records') + }) + + it('reads a number as its text and anything else as empty', () => { + expect(presetValue('slug', 2026)).toBe('2026') + expect(presetValue('exact', 7)).toBe('7') + for (const source of [null, undefined, true, { a: 1 }, ['x']]) { + expect(presetValue('slug', source)).toBe('') + expect(presetValue('exact', source)).toBe('') + } + }) +}) diff --git a/admin/tests/form/registry.test.ts b/admin/tests/form/registry.test.ts index fd01da8..043926d 100644 --- a/admin/tests/form/registry.test.ts +++ b/admin/tests/form/registry.test.ts @@ -7,6 +7,8 @@ import SwitchField from '../../src/components/form/fields/SwitchField.vue' import TextField from '../../src/components/form/fields/TextField.vue' import TextareaField from '../../src/components/form/fields/TextareaField.vue' import PartialField from '../../src/components/form/fields/PartialField.vue' +import PasswordField from '../../src/components/form/fields/PasswordField.vue' +import PermissionEditorField from '../../src/components/form/fields/PermissionEditorField.vue' import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue' import WidgetField from '../../src/components/form/fields/WidgetField.vue' import RelationManager from '../../src/components/relation/RelationManager.vue' @@ -148,3 +150,30 @@ describe('control module', () => { expect(toggleValue).toBe(control.toggleValue) }) }) + +describe('Phase 12.1 field types (UI-SPEC S5 and S7)', () => { + it('registers password and permissioneditor as editable types with their own controls', () => { + expect(rendererFor('password')).toBe(PasswordField) + expect(rendererFor('permissioneditor')).toBe(PermissionEditorField) + expect(isRegistered('password')).toBe(true) + expect(isRegistered('permissioneditor')).toBe(true) + // Both hold a form value: they are part of the save body. + expect(needsRecord('password')).toBe(false) + expect(needsRecord('permissioneditor')).toBe(false) + }) + + it('labels a password as an input and a permission editor as a group', () => { + expect(ownsLabel('password')).toBe(false) + expect(ownsLabel('permissioneditor')).toBe(false) + expect(groupLabelled('password')).toBe(false) + expect(groupLabelled('permissioneditor')).toBe(true) + }) + + it('still answers the unsupported control for a type it does not know', () => { + for (const type of ['passwordeditor', 'Password', 'permission', '']) { + expect(isRegistered(type), type).toBe(false) + expect(rendererFor(type), type).not.toBe(PasswordField) + expect(rendererFor(type), type).not.toBe(PermissionEditorField) + } + }) +}) diff --git a/admin/tests/list/DataTable.test.ts b/admin/tests/list/DataTable.test.ts index 0d21bb6..47e0da0 100644 --- a/admin/tests/list/DataTable.test.ts +++ b/admin/tests/list/DataTable.test.ts @@ -141,3 +141,105 @@ describe('data table', () => { expect(wrapper.findAll('[data-skeleton]')).toHaveLength(3) }) }) + +describe('row states in the table (UI-SPEC S4, D-12)', () => { + const stateLabels = { deleted: 'Deleted', negative: 'Blocked', disabled: 'Not active' } + const rows = (wrapper: Awaited>['wrapper']) => wrapper.findAll('tbody tr') + const firstCell = (wrapper: Awaited>['wrapper'], index: number) => rows(wrapper)[index]!.findAll('td:not([data-select])')[0]! + + it('leaves every row unchanged without states', async () => { + const { wrapper } = await mountTable({ rowLink, stateLabels }) + for (const row of rows(wrapper)) { + expect(row.attributes('data-row-states')).toBeUndefined() + expect(row.find('[data-row-state]').exists()).toBe(false) + } + expect(firstCell(wrapper, 0).find('a').classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline'])) + const empty = await mountTable({ rowLink, stateLabels, rowStates: { '1': [] } }) + expect(rows(empty.wrapper)[0]!.attributes('data-row-states')).toBeUndefined() + }) + + it.each([ + ['deleted', ['line-through', 'text-muted'], ['text-danger']], + ['negative', ['no-underline', 'text-danger'], ['line-through']], + ['disabled', ['no-underline', 'text-muted'], ['line-through', 'text-danger']], + ])('styles the first-cell text of a %s row and says the state in a badge', async (state, present, absent) => { + const { wrapper } = await mountTable({ rowLink, stateLabels, rowStates: { '1': [state] } }) + const row = rows(wrapper)[0]! + expect(row.attributes('data-row-states')).toBe(state) + const link = firstCell(wrapper, 0).find('a') + expect(link.classes()).toEqual(expect.arrayContaining(present)) + for (const name of absent) { + expect(link.classes()).not.toContain(name) + } + const badge = firstCell(wrapper, 0).find(`[data-row-state="${state}"]`) + expect(badge.text()).toBe(stateLabels[state as keyof typeof stateLabels]) + // The badge sits beside the link, never inside the struck text. + expect(link.find('[data-row-state]').exists()).toBe(false) + // The other row is untouched. + expect(rows(wrapper)[1]!.attributes('data-row-states')).toBeUndefined() + }) + + it('combines states: the attribute lists them and danger wins over muted', async () => { + const { wrapper } = await mountTable({ rowLink, stateLabels, rowStates: { '1': ['deleted', 'negative', 'disabled'] } }) + expect(rows(wrapper)[0]!.attributes('data-row-states')).toBe('deleted negative disabled') + const link = firstCell(wrapper, 0).find('a') + expect(link.classes()).toEqual(expect.arrayContaining(['line-through', 'text-danger'])) + expect(firstCell(wrapper, 0).findAll('[data-row-state]').map((badge) => badge.attributes('data-row-state'))).toEqual(['deleted', 'negative', 'disabled']) + // Every other cell of a row with a state is muted. + expect(rows(wrapper)[0]!.findAll('td:not([data-select])')[1]!.classes()).toContain('text-muted') + }) + + it('styles plain text the same way when the row has no link', async () => { + const { wrapper } = await mountTable({ stateLabels, rowStates: { '1': ['deleted'] } }) + const cell = firstCell(wrapper, 0) + expect(cell.find('a').exists()).toBe(false) + expect(cell.find('span.line-through').classes()).toEqual(expect.arrayContaining(['line-through', 'text-muted', 'truncate'])) + expect(cell.find('[data-row-state="deleted"]').text()).toBe('Deleted') + }) + + it('keys states by the row id as text and ignores ids that are not on the page', async () => { + const { wrapper } = await mountTable({ rowLink, stateLabels, rowStates: { '2': ['disabled'], '99': ['deleted'], abc: ['negative'] } }) + expect(rows(wrapper)[0]!.attributes('data-row-states')).toBeUndefined() + expect(rows(wrapper)[1]!.attributes('data-row-states')).toBe('disabled') + expect(wrapper.findAll('[data-row-state]')).toHaveLength(1) + }) + + it('backstop: a row state outside the fixed set renders no badge and no class', async () => { + const hostile = ['starred', 'bg-danger-soft', 'line-through', 'x', 'DELETED', 'deleted negative'] + const { wrapper } = await mountTable({ rowLink, stateLabels: { ...stateLabels, starred: 'Starred' }, rowStates: { '1': hostile } }) + const row = rows(wrapper)[0]! + expect(row.attributes('data-row-states')).toBeUndefined() + expect(row.find('[data-row-state]').exists()).toBe(false) + expect(row.html()).not.toMatch(/starred|Starred|bg-danger-soft|x<\/b>|DELETED/) + const link = firstCell(wrapper, 0).find('a') + expect(link.classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline'])) + expect(link.classes()).not.toContain('line-through') + expect(firstCell(wrapper, 0).classes()).not.toContain('text-muted') + + // Beside a known state, only the known one counts. + const mixed = await mountTable({ rowLink, stateLabels, rowStates: { '1': ['starred', 'disabled', 'x'] } }) + expect(rows(mixed.wrapper)[0]!.attributes('data-row-states')).toBe('disabled') + expect(rows(mixed.wrapper)[0]!.findAll('[data-row-state]').map((badge) => badge.text())).toEqual(['Not active']) + expect(rows(mixed.wrapper)[0]!.html()).not.toContain('starred') + }) +}) + +describe('invisible columns (D-27 G6)', () => { + it('renders no header and no cell for an invisible column, and keeps the first visible column first', async () => { + const hidden = [{ ...listColumn('name'), invisible: true }, listColumn('code'), { ...listColumn('tags'), invisible: true }] + const { wrapper } = await mountTable({ rowLink, columns: hidden }) + expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Code']) + const cells = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text())) + expect(cells).toEqual([['W-01'], ['W-02']]) + // The value of an invisible column is nowhere in the markup. + expect(wrapper.html()).not.toContain('Blue widget') + // The first visible column carries the row link. + expect(wrapper.find('tbody tr td a').text()).toBe('W-01') + }) + + it('treats invisible false and a missing flag as visible', async () => { + const shown = [{ ...listColumn('name'), invisible: false }, listColumn('code')] + const { wrapper } = await mountTable({ columns: shown }) + expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code']) + }) +}) diff --git a/admin/tests/list/ListToolbar.test.ts b/admin/tests/list/ListToolbar.test.ts index 0c55d7c..bc1a4b2 100644 --- a/admin/tests/list/ListToolbar.test.ts +++ b/admin/tests/list/ListToolbar.test.ts @@ -1,6 +1,8 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { setBundle } from '../../src/app/i18n' import ListToolbar from '../../src/components/list/ListToolbar.vue' +import { langFixture } from '../fixtures/typed' import { resetState } from '../helpers' const base = { @@ -108,3 +110,71 @@ describe('registered toolbar actions (D-12, UI-SPEC S4)', () => { expect(bare.find('[data-action]').exists()).toBe(false) }) }) + +describe('bulk actions in the toolbar (UI-SPEC S1, D-09)', () => { + const bulkActions = [ + { name: 'activate', label: 'Activate' }, + { name: 'archive', label: 'Archive' }, + ] + const trigger = (wrapper: ReturnType) => wrapper.find('[data-action="bulk-actions"]') + + beforeEach(() => { + setBundle({ ...langFixture.data, 'backend::lang.list.bulk_actions': { other: 'Bulk actions' } }, 'en') + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('renders no menu with zero permitted actions', () => { + const none = mount(ListToolbar, { props: { ...base, selectedCount: 2, selectedLabel: 'Selected 2' } }) + expect(trigger(none).exists()).toBe(false) + const empty = mount(ListToolbar, { props: { ...base, selectedCount: 2, selectedLabel: 'Selected 2', bulkActions: [] } }) + expect(trigger(empty).exists()).toBe(false) + // The built-in delete is not a menu item: it stays the toolbar button. + expect(empty.find('[data-action="delete"]').exists()).toBe(true) + }) + + it('keeps the trigger labelled and disabled until rows are selected, and while a bulk action runs', async () => { + const wrapper = mount(ListToolbar, { props: { ...base, bulkActions } }) + expect(trigger(wrapper).text()).toBe('Bulk actions') + expect(trigger(wrapper).attributes('disabled')).toBeDefined() + await wrapper.setProps({ selectedCount: 1, selectedLabel: 'Selected 1' }) + expect(trigger(wrapper).attributes('disabled')).toBeUndefined() + await wrapper.setProps({ bulkBusy: true }) + expect(trigger(wrapper).attributes('disabled')).toBeDefined() + expect(trigger(wrapper).text()).toBe('Bulk actions') + }) + + it('places the menu after the selection pill and before delete and the registered actions', () => { + const wrapper = mount(ListToolbar, { + props: { ...base, buttons: ['delete', 'recount'], actions: [{ name: 'recount', label: 'Recount' }], bulkActions, selectedCount: 1, selectedLabel: 'Selected 1' }, + }) + const order = wrapper.findAll('[data-selected-pill], [data-action]').map((node) => node.attributes('data-action') ?? 'pill') + expect(order).toEqual(['pill', 'bulk-actions', 'delete', 'recount']) + }) + + it('emits bulk with the chosen name and nothing else', async () => { + const wrapper = mount(ListToolbar, { props: { ...base, bulkActions, selectedCount: 2, selectedLabel: 'Selected 2' }, attachTo: document.body }) + await trigger(wrapper).trigger('keydown', { key: 'Enter' }) + await flushPromises() + const items = Array.from(document.body.querySelectorAll('[data-bulk-menu] [data-bulk-action]')) + expect(items.map((item) => item.dataset.bulkAction)).toEqual(['activate', 'archive']) + items[1]!.click() + await flushPromises() + expect(wrapper.emitted('bulk')).toEqual([['archive']]) + expect(wrapper.emitted('action')).toBeUndefined() + expect(wrapper.emitted('delete')).toBeUndefined() + wrapper.unmount() + }) + + it('exposes focusBulk(), which focuses the menu trigger and is harmless without a menu', () => { + const wrapper = mount(ListToolbar, { props: { ...base, bulkActions, selectedCount: 1, selectedLabel: 'Selected 1' }, attachTo: document.body }) + ;(wrapper.vm as unknown as { focusBulk: () => void }).focusBulk() + expect(document.activeElement).toBe(trigger(wrapper).element) + wrapper.unmount() + const bare = mount(ListToolbar, { props: base, attachTo: document.body }) + expect(() => (bare.vm as unknown as { focusBulk: () => void }).focusBulk()).not.toThrow() + bare.unmount() + }) +}) diff --git a/admin/tests/list/ListView.test.ts b/admin/tests/list/ListView.test.ts index 6e54774..feb94a2 100644 --- a/admin/tests/list/ListView.test.ts +++ b/admin/tests/list/ListView.test.ts @@ -1,9 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises } from '@vue/test-utils' +import { setBundle } from '../../src/app/i18n' import ListView from '../../src/views/ListView.vue' import { toasts } from '../../src/state/useToasts' import { OWNER_ATTRIBUTE } from '../../src/app/pluginAssets' -import { clone, extensionListSchemaFixture, extensionPartialFixture, listFixture, listSchemaFixture } from '../fixtures/typed' +import { + clone, + extensionListSchemaFixture, + extensionPartialFixture, + langFixture, + listFixture, + listSchemaFixture, + rosterListFixture, + rosterListSchemaFixture, +} from '../fixtures/typed' import { API, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers' const BASE = `${API}/acme/demo/widgets` @@ -298,3 +308,187 @@ describe('list view extension points (Phase 10.1, D-03, D-11, D-12, D-14)', () = expect(seen.links[0]!.disabled).toBe(false) }) }) + +describe('bulk actions on the list (UI-SPEC S1, D-09)', () => { + const PEOPLE = `${API}/acme/roster/people` + const strings = { + 'backend::lang.list.bulk_actions': { other: 'Bulk actions' }, + 'backend::lang.list.bulk_confirm': { other: 'Run “:action” on the selected (:count)?' }, + 'backend::lang.list.bulk_done': { one: 'Action completed for :count record.', other: 'Action completed for :count records.' }, + 'backend::lang.list.bulk_stale': { other: 'Some of the selected records are no longer available.' }, + 'backend::lang.list.action_forbidden': { other: 'You do not have permission to run this action.' }, + 'backend::lang.extension.action_failed': { other: 'The action could not be completed.' }, + } + type Mounted = Awaited>['wrapper'] + + function people(overrides: Record = {}): Record { + return { + [`GET ${PEOPLE}/schema/list`]: { body: rosterListSchemaFixture }, + [`GET ${PEOPLE}`]: { body: rosterListFixture }, + ...overrides, + } + } + const ran = (affected: number, message = ''): Reply => ({ body: { data: { message, affected }, meta: {} } }) + const refused = (status: number, message: string): Reply => ({ status, body: { error: { code: 'x', message, details: {} } } }) + const trigger = (wrapper: Mounted) => wrapper.find('[data-action="bulk-actions"]') + const dialog = () => document.body.querySelector('[data-confirm-dialog]') + const listGets = (calls: Request[]) => requestsTo(calls, 'GET', PEOPLE).length + + async function select(wrapper: Mounted, count: number): Promise { + for (const box of wrapper.findAll('td[data-select] [role="checkbox"]').slice(0, count)) { + await box.trigger('click') + } + } + async function choose(wrapper: Mounted, name: string): Promise { + await trigger(wrapper).trigger('keydown', { key: 'Enter' }) + await flushPromises() + document.body.querySelector(`[data-bulk-menu] [data-bulk-action="${name}"]`)!.click() + await flushPromises() + } + async function answer(action: 'confirm' | 'cancel'): Promise { + dialog()!.querySelector(`[data-action="${action}"]`)!.click() + await flushPromises() + await wait(10) + await flushPromises() + } + + beforeEach(() => { + setBundle({ ...langFixture.data, ...strings }, 'en') + }) + + it('offers the declared actions without the built-in delete, and no menu when none is permitted', async () => { + const { wrapper } = await mountApp('/acme/roster/people', people(), { attach: true }) + await select(wrapper, 1) + await trigger(wrapper).trigger('keydown', { key: 'Enter' }) + await flushPromises() + const names = Array.from(document.body.querySelectorAll('[data-bulk-menu] [data-bulk-action]')).map((item) => item.dataset.bulkAction) + expect(names).toEqual(['activate', 'archive']) + wrapper.unmount() + document.body.innerHTML = '' + + const only = clone(rosterListSchemaFixture) + only.data.bulkActions = [{ name: 'delete', label: 'Delete selected' }] + const none = await mountApp('/acme/roster/people', people({ [`GET ${PEOPLE}/schema/list`]: { body: only } }), { attach: true }) + await select(none.wrapper, 1) + expect(trigger(none.wrapper).exists()).toBe(false) + expect(none.wrapper.find('[data-action="delete"]').exists()).toBe(true) + }) + + it('asks with the action text, or the default naming the action and the count', async () => { + const { wrapper } = await mountApp('/acme/roster/people', people(), { attach: true }) + await select(wrapper, 2) + await choose(wrapper, 'activate') + expect(dialog()!.textContent).toContain('Activate the selected people?') + await answer('cancel') + await choose(wrapper, 'archive') + expect(dialog()!.textContent).toContain('Run “Archive” on the selected (2)?') + expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe('Archive') + }) + + it('posts the selected ids once, toasts the outcome, clears the selection and reloads', async () => { + const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(2) }), { attach: true }) + const before = listGets(calls) + await select(wrapper, 2) + await choose(wrapper, 'archive') + await answer('confirm') + const posts = requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`) + expect(posts).toHaveLength(1) + expect(await posts[0]!.json()).toEqual({ ids: [1, 2] }) + expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['Action completed for 2 records.', 'success']]) + expect(wrapper.find('[data-selected-pill]').exists()).toBe(false) + expect(listGets(calls)).toBe(before + 1) + }) + + it('shows the server message when the action sends one', async () => { + const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(1, 'One person was archived.') }), { attach: true }) + await select(wrapper, 1) + await choose(wrapper, 'archive') + await answer('confirm') + expect(toasts.value.map((toast) => toast.text)).toEqual(['One person was archived.']) + }) + + it('keeps the dialog busy and the trigger disabled until the request settles', async () => { + let release: (reply: Reply) => void = () => undefined + const pending = new Promise((resolve) => { + release = resolve + }) + const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: () => pending }), { attach: true }) + await select(wrapper, 1) + await choose(wrapper, 'archive') + dialog()!.querySelector('[data-action="confirm"]')!.click() + await flushPromises() + expect(dialog()).not.toBeNull() + expect(dialog()!.querySelector('[data-action="confirm"]')!.disabled).toBe(true) + expect(trigger(wrapper).attributes('disabled')).toBeDefined() + expect(toasts.value).toHaveLength(0) + release(ran(1)) + await flushPromises() + await wait(10) + await flushPromises() + expect(dialog()).toBeNull() + expect(toasts.value).toHaveLength(1) + }) + + it.each([ + [409, 'Conflict', 'Some of the selected records are no longer available.', false, 1], + [403, 'Only team leads may archive people.', 'Only team leads may archive people.', true, 0], + [403, '', 'You do not have permission to run this action.', true, 0], + [500, '', 'The action could not be completed.', true, 0], + ])('on %i (%j): toast %j, selection kept %s, reloads %i', async (status, message, text, kept, reloads) => { + const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: refused(status, message) }), { attach: true }) + const before = listGets(calls) + await select(wrapper, 2) + await choose(wrapper, 'archive') + await answer('confirm') + expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([[text, 'danger']]) + expect(wrapper.find('[data-selected-pill]').exists()).toBe(kept) + expect(listGets(calls)).toBe(before + reloads) + }) + + it('backstop: focus returns to the bulk menu trigger after the confirm dialog closes, by confirm and by cancel', async () => { + const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: refused(403, 'Refused.') }), { attach: true }) + await select(wrapper, 1) + + // By cancel: nothing is sent and the trigger has focus again. + await choose(wrapper, 'archive') + expect(dialog()).not.toBeNull() + expect(document.activeElement).not.toBe(trigger(wrapper).element) + await answer('cancel') + expect(dialog()).toBeNull() + expect(requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`)).toHaveLength(0) + expect(document.activeElement).toBe(trigger(wrapper).element) + + // By Escape, which is a cancel. + await choose(wrapper, 'archive') + dialog()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })) + await flushPromises() + await wait(10) + await flushPromises() + if (dialog()) { + await answer('cancel') + } + expect(document.activeElement).toBe(trigger(wrapper).element) + + // By confirm: the request ran and the trigger has focus again. + ;(document.activeElement as HTMLElement).blur() + await choose(wrapper, 'archive') + await answer('confirm') + expect(dialog()).toBeNull() + expect(requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`)).toHaveLength(1) + expect(document.activeElement).toBe(trigger(wrapper).element) + // The trigger is enabled, so the focus is on a control that can take it. + expect(trigger(wrapper).attributes('disabled')).toBeUndefined() + }) + + it('returns focus to the trigger before a successful run clears the selection', async () => { + const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(1) }), { attach: true }) + await select(wrapper, 1) + const focused: boolean[] = [] + trigger(wrapper).element.addEventListener('focus', () => focused.push(trigger(wrapper).attributes('disabled') === undefined)) + await choose(wrapper, 'archive') + await answer('confirm') + // Focus reached the trigger while it was still enabled. + expect(focused).toContain(true) + expect(wrapper.find('[data-selected-pill]').exists()).toBe(false) + }) +})