test(17-06): framework and admin SPA unit tests for the blog companions
- signpost: activation refusals, host overrides, D-02 pattern layers incl. PatternFrom, ReferenceOptions depths - seaglass: removal report, bluemonday bypass corpus, Check - pact Option.Depth omitempty and the menu item type contracts; party activates menu item types after Register and before the first Boot - cabana: repeater compile and decode, dependsOn compile and recheck, richeditor keys and notices, reorder and singleton boot checks (Docker-free); repeater save, dependent recheck, richeditor save, reorder and singleton routes on Postgres; conformance cases for the documented 404, 409 and 422 answers of the new routes - admin: richeditor value contract, link popover, notices and chunk failure; repeater bounds, remove and reorder, collapsed rows; dependent dropdown states and last request wins; reorder announcements, partition switch and tree rules; SingletonForm spec - gate: required names in the go, admin and postgres stages
This commit is contained in:
@@ -126,3 +126,110 @@ describe('DependentDropdown', () => {
|
||||
expect(wrapper.find('#field-reference').text()).toContain('Hello world')
|
||||
})
|
||||
})
|
||||
|
||||
// Plan 06 coverage: every UI-SPEC state, last request wins, stale stored
|
||||
// values, the save recheck message and a dropdown inside a repeater row.
|
||||
function deferred() {
|
||||
let resolve: (reply: Reply) => void = () => undefined
|
||||
const promise = new Promise<Reply>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('DependentDropdown states', () => {
|
||||
it('shows loading while the options load, then the empty state naming the type', async () => {
|
||||
const pending = deferred()
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes('category', null, { [`GET ${REFERENCES}`]: () => pending.promise }),
|
||||
{ attach: true },
|
||||
)
|
||||
const select = () => wrapper.find<HTMLSelectElement>('#field-reference')
|
||||
expect(wrapper.find('[data-field="reference"] [data-dependent]').attributes('data-dependent')).toBe('loading')
|
||||
expect(select().attributes('disabled')).toBeDefined()
|
||||
expect(select().attributes('aria-busy')).toBe('true')
|
||||
expect(select().text()).toBe('Wczytywanie…')
|
||||
|
||||
pending.resolve({ body: { data: { applies: true, options: [] }, meta: {} } })
|
||||
await flushPromises()
|
||||
expect(select().attributes('aria-busy')).toBeUndefined()
|
||||
expect(select().attributes('disabled')).toBeDefined()
|
||||
const hint = wrapper.find('[data-dependent-hint]')
|
||||
expect(hint.text()).toBe('Brak rekordów typu „Category”. Najpierw dodaj rekord albo wybierz inny typ.')
|
||||
expect(select().attributes('aria-describedby')).toContain(hint.attributes('id'))
|
||||
})
|
||||
|
||||
it('drops a late answer for an old sibling value: the last request wins', async () => {
|
||||
const slow = deferred()
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes('post', '7', {
|
||||
[`GET ${REFERENCES}`]: (request) => (queryOf(request).get('values[type]') === 'category' ? slow.promise : references(request)),
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const select = () => wrapper.find<HTMLSelectElement>('#field-reference')
|
||||
await wrapper.find('#field-type').setValue('2') // category: pending
|
||||
await flushPromises()
|
||||
expect(select().text()).toBe('Wczytywanie…')
|
||||
await wrapper.find('#field-type').setValue('0') // url: answered at once
|
||||
await flushPromises()
|
||||
expect(select().text()).toBe('Nie dotyczy tego wyboru')
|
||||
|
||||
slow.resolve({ body: { data: { applies: true, options: [{ value: '3', label: 'News', depth: 0 }] }, meta: {} } })
|
||||
await flushPromises()
|
||||
expect(select().text()).toBe('Nie dotyczy tego wyboru')
|
||||
// The dropped answer was not cached: choosing category again asks again.
|
||||
await wrapper.find('#field-type').setValue('2')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', REFERENCES).filter((request) => queryOf(request).get('values[type]') === 'category')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows the placeholder for a stored value no option offers, without clearing it', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes('post', '99', {
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return {
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { reference: ['Wybierz jedną z dostępnych opcji.'] } } },
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const select = wrapper.find<HTMLSelectElement>('#field-reference')
|
||||
expect(select.element.selectedOptions[0]!.text).toBe('Wybierz…')
|
||||
expect(wrapper.find('[data-form-live]').text()).toBe('')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(body).toMatchObject({ type: 'post', reference: '99' })
|
||||
expect(wrapper.find('[data-field="reference"]').text()).toContain('Wybierz jedną z dostępnych opcji.')
|
||||
expect(wrapper.find('#field-reference').attributes('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('a dropdown inside a repeater row asks with the row path and its row sibling', async () => {
|
||||
const TARGETS = `${BASE}/fields/rows.target/dependent-options`
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes('url', null, {
|
||||
[`GET ${RECORD}`]: {
|
||||
body: { data: { id: 1, title: 'Docs', type: 'url', reference: null, rows: [{ question: 'Q', kind: 'post', target: '8', visible: true }] }, meta: {} },
|
||||
},
|
||||
[`GET ${TARGETS}`]: (request) =>
|
||||
queryOf(request).get('values[kind]') === 'post'
|
||||
? { body: { data: { applies: true, options: [{ value: '7', label: 'Hello world', depth: 0 }, { value: '8', label: 'Nested post', depth: 1 }] }, meta: {} } }
|
||||
: { status: 500, body: {} },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const asked = requestsTo(calls, 'GET', TARGETS)
|
||||
expect(asked).toHaveLength(1)
|
||||
expect(queryOf(asked[0]!).get('values[kind]')).toBe('post')
|
||||
const target = wrapper.find<HTMLSelectElement>('[data-repeater-row="0"] [data-field="target"] select')
|
||||
expect(target.element.selectedOptions[0]!.text.trim()).toBe('Nested post')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import type { AdminRecord, FormField } from '../../src/api/types'
|
||||
// The registry first: it imports RepeaterField, whose FormGrid imports it back.
|
||||
import '../../src/components/form/registry'
|
||||
import RepeaterField from '../../src/components/form/fields/RepeaterField.vue'
|
||||
import { FORM_FEEDBACK, type FormFeedback } from '../../src/components/form/formContext'
|
||||
import { companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
@@ -164,3 +170,172 @@ describe('RepeaterField', () => {
|
||||
expect(wrapper.find('[data-action="repeater-add"]').text()).toBe('Add question')
|
||||
})
|
||||
})
|
||||
|
||||
// Plan 06 coverage on the field alone, with a recording form feedback:
|
||||
// row bounds, remove and reorder semantics, collapsed rows, read-only.
|
||||
const field: FormField = {
|
||||
name: 'rows',
|
||||
label: 'Rows',
|
||||
type: 'repeater',
|
||||
span: 'full',
|
||||
titleFrom: 'title',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
form: {
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', label: 'Title', span: 'left' },
|
||||
{ name: 'body', type: 'richeditor', label: 'Body', span: 'full' },
|
||||
{ name: 'on', type: 'checkbox', label: 'On', span: 'right', default: true },
|
||||
],
|
||||
},
|
||||
} as FormField
|
||||
|
||||
function repeater(rows: AdminRecord[], options: { confirm?: boolean; readOnly?: boolean } = {}) {
|
||||
const announced: string[] = []
|
||||
const asked: string[] = []
|
||||
const feedback: FormFeedback = {
|
||||
errors: ref({}),
|
||||
notices: ref({}),
|
||||
clearError: () => undefined,
|
||||
announce: (text) => announced.push(text),
|
||||
confirm: async (request) => {
|
||||
asked.push(request.message)
|
||||
return options.confirm ?? true
|
||||
},
|
||||
}
|
||||
const wrapper: VueWrapper = mount(RepeaterField, {
|
||||
props: {
|
||||
field: options.readOnly ? { ...field, readOnly: true } : field,
|
||||
modelValue: rows,
|
||||
controlId: 'field-rows',
|
||||
'onUpdate:modelValue': (value: unknown) => wrapper.setProps({ modelValue: value }),
|
||||
},
|
||||
global: { provide: { [FORM_FEEDBACK as symbol]: feedback } },
|
||||
attachTo: document.body,
|
||||
})
|
||||
const values = () => (wrapper.props() as { modelValue: AdminRecord[] }).modelValue
|
||||
return { wrapper, announced, asked, values }
|
||||
}
|
||||
|
||||
const rowsOf = (wrapper: VueWrapper) => wrapper.findAll('[data-repeater-title]').map((node) => node.text())
|
||||
const row = (wrapper: VueWrapper, index: number) => wrapper.find(`[data-repeater-row="${index}"]`)
|
||||
|
||||
describe('RepeaterField rows', () => {
|
||||
it('enforces minItems and maxItems on remove, add and the counter', async () => {
|
||||
const { wrapper, values } = repeater([{ title: 'Only', body: null, on: true }])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(row(wrapper, 0).find('[data-action="repeater-remove"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 1 z 3')
|
||||
expect(wrapper.find('[data-action="repeater-toggle-all"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.find('[data-action="repeater-add"]').trigger('click')
|
||||
await wrapper.find('[data-action="repeater-add"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(values()).toEqual([
|
||||
{ title: 'Only', body: null, on: true },
|
||||
{ title: null, body: null, on: true },
|
||||
{ title: null, body: null, on: true },
|
||||
])
|
||||
expect(rowsOf(wrapper)).toEqual(['Only', 'Pozycja 2', 'Pozycja 3'])
|
||||
expect(wrapper.find('[data-action="repeater-add"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 3 z 3')
|
||||
// A fourth add does nothing.
|
||||
await wrapper.find('[data-action="repeater-add"]').trigger('click')
|
||||
expect(values()).toHaveLength(3)
|
||||
expect(row(wrapper, 0).find('[data-action="repeater-remove"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks before removing a row with a value, never for an empty or default row, and keeps it on cancel', async () => {
|
||||
const { wrapper, asked, values } = repeater(
|
||||
[
|
||||
{ title: 'Kept', body: null, on: true },
|
||||
{ title: '', body: '', on: true },
|
||||
{ title: null, body: '<p>text</p>', on: true },
|
||||
],
|
||||
{ confirm: false },
|
||||
)
|
||||
// Only defaults and empty values: removed at once.
|
||||
await row(wrapper, 1).find('[data-action="repeater-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(asked).toEqual([])
|
||||
expect(values()).toHaveLength(2)
|
||||
// A rich text value: asked, and cancel keeps the row.
|
||||
await row(wrapper, 1).find('[data-action="repeater-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(asked).toEqual(['Usunąć „Pozycja 2”? Zmiana zostanie zapisana razem z rekordem.'])
|
||||
expect(values()).toHaveLength(2)
|
||||
expect(rowsOf(wrapper)).toEqual(['Kept', 'Pozycja 2'])
|
||||
// Removing moves focus to the handle that takes the row's place.
|
||||
const confirmed = repeater([{ title: 'A' }, { title: 'B' }, { title: 'C' }], { confirm: true })
|
||||
await row(confirmed.wrapper, 1).find('[data-action="repeater-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmed.asked).toHaveLength(1)
|
||||
expect(rowsOf(confirmed.wrapper)).toEqual(['A', 'C'])
|
||||
expect(document.activeElement).toBe(row(confirmed.wrapper, 1).find('[data-repeater-handle]').element)
|
||||
})
|
||||
|
||||
it('reorders by the arrow keys and the buttons, announces repeater.moved and stops at the ends', async () => {
|
||||
const { wrapper, announced, values } = repeater([{ title: 'A' }, { title: 'B' }, { title: 'C' }])
|
||||
expect(row(wrapper, 0).find('[data-action="repeater-up"]').attributes('disabled')).toBeDefined()
|
||||
expect(row(wrapper, 2).find('[data-action="repeater-down"]').attributes('disabled')).toBeDefined()
|
||||
|
||||
keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
|
||||
const down = keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowDown')
|
||||
await flushPromises()
|
||||
expect(down.defaultPrevented).toBe(true)
|
||||
expect(rowsOf(wrapper)).toEqual(['B', 'A', 'C'])
|
||||
expect(document.activeElement).toBe(row(wrapper, 1).find('[data-repeater-handle]').element)
|
||||
expect(announced).toEqual(['Przeniesiono A na pozycję 2 z 3.'])
|
||||
|
||||
await row(wrapper, 2).find('[data-action="repeater-up"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(rowsOf(wrapper)).toEqual(['B', 'C', 'A'])
|
||||
await row(wrapper, 0).find('[data-action="repeater-down"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(rowsOf(wrapper)).toEqual(['C', 'B', 'A'])
|
||||
expect(announced).toEqual(['Przeniesiono A na pozycję 2 z 3.', 'Przeniesiono C na pozycję 2 z 3.', 'Przeniesiono B na pozycję 2 z 3.'])
|
||||
expect(values().map((item) => item.title)).toEqual(['C', 'B', 'A'])
|
||||
// Other keys do nothing.
|
||||
const other = keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowRight')
|
||||
expect(other.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('collapsed rows do not mount their richeditors and keep their values', async () => {
|
||||
const { wrapper, values } = repeater([
|
||||
{ title: 'A', body: '<p>one</p>' },
|
||||
{ title: 'B', body: '<p>two</p>' },
|
||||
])
|
||||
const editors = () => wrapper.findAll('[data-field="body"]').length
|
||||
expect(editors()).toBe(2)
|
||||
await row(wrapper, 0).find('[data-action="repeater-toggle"]').trigger('click')
|
||||
expect(row(wrapper, 0).find('[data-repeater-body]').exists()).toBe(false)
|
||||
expect(row(wrapper, 0).find('[data-action="repeater-toggle"]').attributes('aria-expanded')).toBe('false')
|
||||
expect(editors()).toBe(1)
|
||||
|
||||
const all = wrapper.find('[data-action="repeater-toggle-all"]')
|
||||
expect(all.text()).toBe('Zwiń wszystkie')
|
||||
await all.trigger('click')
|
||||
expect(editors()).toBe(0)
|
||||
expect(wrapper.find('[data-action="repeater-toggle-all"]').text()).toBe('Rozwiń wszystkie')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(values()).toEqual([
|
||||
{ title: 'A', body: '<p>one</p>' },
|
||||
{ title: 'B', body: '<p>two</p>' },
|
||||
])
|
||||
|
||||
await wrapper.find('[data-action="repeater-toggle-all"]').trigger('click')
|
||||
expect(editors()).toBe(2)
|
||||
expect(row(wrapper, 1).find('[data-action="repeater-toggle"]').attributes('aria-controls')).toBe(row(wrapper, 1).find('[data-repeater-body]').attributes('id'))
|
||||
})
|
||||
|
||||
it('read-only shows every row expanded with no controls', () => {
|
||||
const { wrapper } = repeater([{ title: 'A', body: '<p>one</p>' }], { readOnly: true })
|
||||
expect(wrapper.find('[data-repeater-handle]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="repeater-add"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="repeater-remove"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-repeater-body]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FormField } from '../../src/api/types'
|
||||
import RichEditorField from '../../src/components/form/fields/RichEditorField.vue'
|
||||
import { displayHtml, isAllowedLink } from '../../src/components/form/richeditor/html'
|
||||
import { isLossy } from '../../src/components/form/richeditor/lossy'
|
||||
import { companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { companionsFormSchemaFixture, langFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the richeditor field (D-10). Plan 06
|
||||
@@ -188,3 +188,173 @@ describe('richeditor policy helpers', () => {
|
||||
expect(displayHtml('<section><p class="x">a</p></section><script>b</script>')).toBe('<p>a</p>')
|
||||
})
|
||||
})
|
||||
|
||||
// Plan 06 coverage: the value contract, the link popover, the notices
|
||||
// callout and the chunk-failure fallback (UI-SPEC RichEditorField).
|
||||
describe('RichEditorField value contract', () => {
|
||||
it('an empty paragraph is the empty string, any content its HTML', async () => {
|
||||
const { createRichEditor, editorValue, loadContent } = await import('../../src/components/form/richeditor/editor')
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
let changes = 0
|
||||
const editor = createRichEditor({ element, content: '<p></p>', attributes: {}, onChange: () => changes++, onTransaction: () => undefined })
|
||||
try {
|
||||
expect(editorValue(editor)).toBe('')
|
||||
loadContent(editor, '<p>Hello</p>')
|
||||
expect(changes).toBe(0)
|
||||
expect(editorValue(editor)).toBe('<p>Hello</p>')
|
||||
editor.commands.clearContent(true)
|
||||
expect(changes).toBe(1)
|
||||
expect(editorValue(editor)).toBe('')
|
||||
editor.commands.setContent('<h3>Head</h3><ul><li><p>one</p></li></ul>', { emitUpdate: true })
|
||||
expect(editorValue(editor)).toBe('<h3>Head</h3><ul><li><p>one</p></li></ul>')
|
||||
// Headings other than level 3 and the dropped nodes do not survive the editor.
|
||||
loadContent(editor, '<h1>Big</h1><pre><code>x</code></pre><hr>')
|
||||
expect(editorValue(editor)).not.toContain('<h1')
|
||||
expect(editorValue(editor)).not.toContain('<pre')
|
||||
expect(editorValue(editor)).not.toContain('<hr')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('a value from outside replaces the document and leaves the field untouched', async () => {
|
||||
const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
|
||||
const wrapper = mount(RichEditorField, { props: { field, modelValue: '<p>One</p>', controlId: 'f-body' }, attachTo: document.body })
|
||||
await editorReady(wrapper)
|
||||
await wrapper.setProps({ modelValue: '<P>Two <b>b</b></P>' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-richeditor-area]').text()).toBe('Two b')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
// An empty value shows the bare editing area and emits nothing.
|
||||
await wrapper.setProps({ modelValue: null })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-richeditor-area]').text()).toBe('')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows the lossy note only for markup outside the editor schema', async () => {
|
||||
const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
|
||||
const clean = mount(RichEditorField, { props: { field, modelValue: '<p>a <strong>b</strong></p><ul><li>c</li></ul>', controlId: 'f-a' }, attachTo: document.body })
|
||||
await editorReady(clean)
|
||||
expect(clean.find('[data-richeditor-lossy]').exists()).toBe(false)
|
||||
const lossy = mount(RichEditorField, { props: { field, modelValue: '<p>a</p><iframe src="x"></iframe>', controlId: 'f-b' }, attachTo: document.body })
|
||||
await editorReady(lossy)
|
||||
expect(lossy.find('[data-richeditor-lossy]').text()).toContain('Części formatowania tego tekstu nie da się pokazać w edytorze.')
|
||||
})
|
||||
|
||||
it('reads empty and read-only values', () => {
|
||||
const field = { name: 'body', label: 'Body', type: 'richeditor', readOnly: true } as FormField
|
||||
const empty = mount(RichEditorField, { props: { field, modelValue: '', controlId: 'f-body' } })
|
||||
expect(empty.find('[data-richeditor-readonly]').text()).toBe('—')
|
||||
expect(empty.find('[data-richeditor-loading]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('richeditor link popover', () => {
|
||||
async function popover(props: { href?: string; inLink?: boolean } = {}) {
|
||||
const LinkPopover = (await import('../../src/components/form/richeditor/LinkPopover.vue')).default
|
||||
const wrapper = mount(LinkPopover, {
|
||||
props: { open: false, href: props.href ?? '', inLink: props.inLink ?? false, idPrefix: 'f-body' },
|
||||
slots: { default: '<button type="button" data-anchor>Link</button>' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
const input = () => document.body.querySelector<HTMLInputElement>('#f-body-link-url')!
|
||||
return { wrapper, input }
|
||||
}
|
||||
|
||||
it('refuses a URL outside the allowlist and applies a policy URL on Enter', async () => {
|
||||
const { wrapper, input } = await popover()
|
||||
expect(document.activeElement).toBe(input())
|
||||
for (const bad of ['javascript:alert(1)', 'data:text/html,x', 'ftp://example.test/x']) {
|
||||
input().value = bad
|
||||
input().dispatchEvent(new Event('input'))
|
||||
;(document.body.querySelector('[data-action="richeditor-link-apply"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('apply')).toBeUndefined()
|
||||
expect(document.body.querySelector('#f-body-link-error')?.textContent).toContain('Link musi używać http, https, mailto, tel albo adresu względnego.')
|
||||
expect(input().getAttribute('aria-invalid')).toBe('true')
|
||||
}
|
||||
// Typing clears the error; Enter applies the trimmed URL and never submits.
|
||||
input().value = ' https://example.test/a '
|
||||
input().dispatchEvent(new Event('input'))
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('#f-body-link-error')).toBeNull()
|
||||
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
||||
input().dispatchEvent(enter)
|
||||
await flushPromises()
|
||||
expect(enter.defaultPrevented).toBe(true)
|
||||
expect(wrapper.emitted('apply')).toEqual([['https://example.test/a']])
|
||||
expect(document.body.querySelector('[data-action="richeditor-unlink"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('starts from the current link and offers Remove link inside one', async () => {
|
||||
const { wrapper, input } = await popover({ href: 'mailto:a@example.test', inLink: true })
|
||||
expect(input().value).toBe('mailto:a@example.test')
|
||||
;(document.body.querySelector('[data-action="richeditor-unlink"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('remove')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('richeditor notices callout', () => {
|
||||
it('lists every notice of the field in one warning callout after a save', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({
|
||||
[`PUT ${RECORD}`]: {
|
||||
body: {
|
||||
data: { id: 1, title: 'Docs', description: '<p>x</p>' },
|
||||
meta: {
|
||||
notices: [
|
||||
{ field: 'description', message: 'Usunięto 2 elementy: script, iframe' },
|
||||
{ field: 'description', message: 'Usunięto 1 atrybut: onclick' },
|
||||
{ field: 'title', message: 'other field' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await editorReady(wrapper)
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
const callout = wrapper.find('[data-field="description"] [data-field-notice]')
|
||||
expect(callout.attributes('role')).toBe('status')
|
||||
expect(callout.classes()).toContain('summer-callout--warning')
|
||||
expect(callout.findAll('p').map((line) => line.text())).toEqual(['Usunięto 2 elementy: script, iframe', 'Usunięto 1 atrybut: onclick'])
|
||||
})
|
||||
})
|
||||
|
||||
// Last in the file: it replaces the editor chunk for the rest of the run.
|
||||
describe('RichEditorField chunk failure', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('../../src/components/form/richeditor/editor')
|
||||
})
|
||||
|
||||
it('falls back to the HTML view with the load_failed note, and saving still works', async () => {
|
||||
vi.resetModules()
|
||||
vi.doMock('../../src/components/form/richeditor/editor', () => {
|
||||
throw new Error('chunk failed')
|
||||
})
|
||||
// The fresh module graph has its own i18n state: load the strings there.
|
||||
;(await import('../../src/app/i18n')).setBundle(langFixture.data, 'pl')
|
||||
const Field = (await import('../../src/components/form/fields/RichEditorField.vue')).default
|
||||
const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
|
||||
const wrapper = mount(Field, { props: { field, modelValue: '<p>Stored <b>x</b></p>', controlId: 'f-body' }, attachTo: document.body })
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-richeditor-load-failed]').exists()).toBe(true)
|
||||
})
|
||||
expect(wrapper.find('[data-richeditor-load-failed]').text()).toBe('Nie udało się wczytać edytora. Możesz edytować HTML bezpośrednio.')
|
||||
const textarea = wrapper.find<HTMLTextAreaElement>('[data-richeditor-source]')
|
||||
expect(textarea.element.value).toBe('<p>Stored <b>x</b></p>')
|
||||
expect(textarea.attributes('id')).toBe('f-body')
|
||||
expect(wrapper.find('[role="toolbar"]').exists()).toBe(false)
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
await textarea.setValue('<p>Edited</p>')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['<p>Edited</p>']])
|
||||
})
|
||||
})
|
||||
|
||||
126
admin/tests/form/SingletonForm.test.ts
Normal file
126
admin/tests/form/SingletonForm.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { clone, companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, notFound, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 06 specs for the singleton form (D-11, UI-SPEC "Singleton
|
||||
// form"): a settings link to a pact.AdminSingleton controller opens its one
|
||||
// record through GET …/singleton; the form has no Delete, and back, Cancel
|
||||
// and Save and close return to the settings index.
|
||||
const PROFILES = `${API}/acme/companions/profiles`
|
||||
|
||||
function singletonSchema() {
|
||||
const schema = clone(companionsFormSchemaFixture)
|
||||
schema.data.singleton = true
|
||||
schema.data.fields = schema.data.fields.filter((field) => field.name === 'title')
|
||||
return schema
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${PROFILES}/singleton`]: { body: { data: { id: 9 }, meta: {} } },
|
||||
[`GET ${PROFILES}/schema/form`]: { body: singletonSchema() },
|
||||
[`GET ${PROFILES}/9`]: { body: { data: { id: 9, title: 'Main site' }, meta: {} } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setSettings([
|
||||
{
|
||||
code: 'profile',
|
||||
label: 'Site profile',
|
||||
description: 'The one site profile',
|
||||
icon: 'settings',
|
||||
category: 'CMS',
|
||||
keywords: [],
|
||||
model: '',
|
||||
controller: 'acme.companions.profiles',
|
||||
order: 1,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('SingletonForm', () => {
|
||||
it('opens through /singleton from settings, has no Delete, and Save and close returns to settings', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const { wrapper, router, calls } = await mountApp(
|
||||
'/settings',
|
||||
routes({
|
||||
[`PUT ${PROFILES}/9`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return { body: { data: { id: 9, ...body }, meta: {} } }
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const link = wrapper.find('[data-settings="profile"]')
|
||||
expect(link.attributes('href')).toBe('/acme/companions/profiles')
|
||||
await link.trigger('click', { button: 0 })
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.path).toBe('/acme/companions/profiles/9')
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do ustawień')
|
||||
expect(wrapper.find<HTMLInputElement>('#field-title').element.value).toBe('Main site')
|
||||
|
||||
// Save stays on the form.
|
||||
await wrapper.find('#field-title').setValue('Renamed')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(body).toEqual({ title: 'Renamed' })
|
||||
expect(router.currentRoute.value.path).toBe('/acme/companions/profiles/9')
|
||||
|
||||
// Save and close returns to the settings index.
|
||||
await wrapper.find('[data-action="save-close"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', `${PROFILES}/9`)).toHaveLength(2)
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
})
|
||||
|
||||
it('back returns to the settings index', async () => {
|
||||
const { wrapper, router } = await mountApp('/acme/companions/profiles/9', routes(), { attach: true })
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
|
||||
await wrapper.find('[data-action="back"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
})
|
||||
|
||||
it('a link to a controller that is not a singleton opens its list', async () => {
|
||||
const { wrapper, router, calls } = await mountApp(
|
||||
'/settings',
|
||||
routes({ [`GET ${PROFILES}/singleton`]: { status: 404, body: notFound } }),
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-settings="profile"]').trigger('click', { button: 0 })
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.path).toBe('/acme/companions/profiles')
|
||||
})
|
||||
|
||||
it('a modified click opens the link as it is, without asking for the record', async () => {
|
||||
const { wrapper, router, calls } = await mountApp('/settings', routes(), { attach: true })
|
||||
await wrapper.find('[data-settings="profile"]').trigger('click', { button: 0, ctrlKey: true })
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(0)
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
})
|
||||
|
||||
it('a form without the singleton flag keeps Delete and returns to its list', async () => {
|
||||
const schema = singletonSchema()
|
||||
schema.data.singleton = false
|
||||
const { wrapper, router } = await mountApp('/acme/companions/profiles/9', routes({ [`GET ${PROFILES}/schema/form`]: { body: schema } }), { attach: true })
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
|
||||
await wrapper.find('[data-action="back"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/companions/profiles')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { applyDrop, applyMove, buildTree, canDrop, canMove, flatten, type TreeNode } from '../../src/components/reorder/tree'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, companionsFormSchemaFixture, listFixture, listSchemaFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
import { API, keydown, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the tree reorder page (D-09), the list's
|
||||
// Reorder button and the singleton form (D-11). Plan 06 completes them.
|
||||
@@ -219,3 +220,155 @@ describe('singleton form', () => {
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
})
|
||||
})
|
||||
|
||||
// Plan 06 coverage: announcements per keyboard move, children moving with
|
||||
// their parent, loading and load failure, the partition switch flushing a
|
||||
// pending save, and the tree rules (no drop into the own subtree).
|
||||
describe('ReorderView moves', () => {
|
||||
it('announces every keyboard move and moves children with their parent', async () => {
|
||||
let body: { nodes: unknown[] } | null = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/reorder',
|
||||
{
|
||||
[`GET ${REORDER}`]: tree(),
|
||||
[`POST ${REORDER}`]: async (request) => {
|
||||
body = (await request.json()) as { nodes: unknown[] }
|
||||
return tree()
|
||||
},
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
const live = () => wrapper.find('[data-reorder-live]').text()
|
||||
|
||||
keydown(handle(wrapper, 2).element, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)')
|
||||
await vi.waitFor(() => expect(live()).toBe('Przeniesiono Blog na pozycję 1 z 3.'))
|
||||
|
||||
keydown(handle(wrapper, 1).element, 'ArrowRight')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(News Home) Bez tytułu (#4)')
|
||||
await vi.waitFor(() => expect(live()).toBe('Home jest teraz wewnątrz Blog.'))
|
||||
|
||||
keydown(handle(wrapper, 3).element, 'ArrowLeft')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(Home) News Bez tytułu (#4)')
|
||||
await vi.waitFor(() => expect(live()).toBe('Przeniesiono News na poziom 1.'))
|
||||
expect(document.activeElement).toBe(handle(wrapper, 3).element)
|
||||
|
||||
// A move that cannot run changes and announces nothing.
|
||||
const before = live()
|
||||
keydown(handle(wrapper, 2).element, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(Home) News Bez tytułu (#4)')
|
||||
expect(live()).toBe(before)
|
||||
|
||||
await vi.waitFor(() => expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1))
|
||||
expect(body!.nodes).toEqual([
|
||||
{ id: 2, parent_id: null, sort_order: 1 },
|
||||
{ id: 1, parent_id: 2, sort_order: 2 },
|
||||
{ id: 3, parent_id: null, sort_order: 3 },
|
||||
{ id: 4, parent_id: null, sort_order: 4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the loading skeleton, then the load failure alert', async () => {
|
||||
let release: (reply: Reply) => void = () => undefined
|
||||
const pending = new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: () => pending })
|
||||
expect(wrapper.find('[data-reorder-card]').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.findAll('[data-reorder-skeleton]')).toHaveLength(5)
|
||||
release({ status: 500, body: { error: { code: 'x', message: 'x', details: {} } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').text()).toContain('Nie udało się wczytać rekordów.')
|
||||
expect(wrapper.find('[data-reorder-card]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('a partition switch sends the pending save first, then loads the chosen partition', async () => {
|
||||
const order: string[] = []
|
||||
const { wrapper, router, calls } = await mountApp(
|
||||
'/acme/companions/links/reorder',
|
||||
{
|
||||
[`GET ${REORDER}`]: (request) => {
|
||||
order.push(`GET ${queryOf(request).get('partition') ?? '-'}`)
|
||||
return tree()
|
||||
},
|
||||
[`POST ${REORDER}`]: async (request) => {
|
||||
const sent = (await request.json()) as { partition: number }
|
||||
order.push(`POST ${sent.partition}`)
|
||||
return tree()
|
||||
},
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
keydown(handle(wrapper, 1).element, 'ArrowDown')
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-reorder-partition]').setValue('6')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query.partition).toBe('6')
|
||||
expect(order).toEqual(['GET -', 'POST 5', 'GET 6'])
|
||||
await wait(500)
|
||||
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorder tree rules', () => {
|
||||
// The OpenAPI document types parent_id as a number although the wire
|
||||
// carries null at the top (17-02 deferred item 3), hence the cast.
|
||||
type Wire = Parameters<typeof buildTree>[0]
|
||||
const nodes = [
|
||||
{ id: 1, name: 'Home', parent_id: null, sort_order: 2 },
|
||||
{ id: 2, name: 'Blog', parent_id: null, sort_order: 1 },
|
||||
{ id: 3, name: 'News', parent_id: 2, sort_order: 1 },
|
||||
{ id: 4, name: 'Deep', parent_id: 3, sort_order: 1 },
|
||||
{ id: 5, name: 'Orphan', parent_id: 99, sort_order: 3 },
|
||||
] as unknown as Wire
|
||||
const names = (list: TreeNode[]): string => list.map((node) => (node.children.length ? `${node.name}(${names(node.children)})` : node.name)).join(' ')
|
||||
|
||||
it('builds the tree by sort order and puts unknown parents and cycles at the top', () => {
|
||||
expect(names(buildTree(nodes))).toBe('Blog(News(Deep)) Home Orphan')
|
||||
const cycle = buildTree([
|
||||
{ id: 1, name: 'A', parent_id: 2, sort_order: 1 },
|
||||
{ id: 2, name: 'B', parent_id: 1, sort_order: 2 },
|
||||
{ id: 3, name: 'C', parent_id: null, sort_order: 3 },
|
||||
] as unknown as Wire)
|
||||
expect(names(cycle).split(' ').sort()).toEqual(['A(B)', 'C'].sort())
|
||||
})
|
||||
|
||||
it('refuses a drop into the node itself or its own subtree and leaves the tree as it was', () => {
|
||||
const tree = buildTree(nodes)
|
||||
for (const target of [2, 3, 4]) {
|
||||
expect(canDrop(tree, 2, target)).toBe(false)
|
||||
for (const zone of ['before', 'after', 'inside'] as const) {
|
||||
expect(applyDrop(tree, 2, target, zone)).toBe(false)
|
||||
}
|
||||
}
|
||||
expect(names(tree)).toBe('Blog(News(Deep)) Home Orphan')
|
||||
expect(canDrop(tree, 4, 2)).toBe(true)
|
||||
expect(applyDrop(tree, 4, 1, 'inside')).toBe(true)
|
||||
expect(applyDrop(tree, 5, 2, 'before')).toBe(true)
|
||||
expect(applyDrop(tree, 3, 1, 'after')).toBe(true)
|
||||
expect(names(tree)).toBe('Orphan Blog Home(Deep) News')
|
||||
})
|
||||
|
||||
it('moves only where a sibling or parent exists, and flattens pre-order from 1', () => {
|
||||
const tree = buildTree(nodes)
|
||||
expect(canMove(tree, 2, 'up')).toBe(false)
|
||||
expect(canMove(tree, 2, 'indent')).toBe(false)
|
||||
expect(canMove(tree, 2, 'outdent')).toBe(false)
|
||||
expect(canMove(tree, 5, 'down')).toBe(false)
|
||||
expect(canMove(tree, 99, 'down')).toBe(false)
|
||||
expect(applyMove(tree, 4, 'outdent')).toBe(true)
|
||||
expect(applyMove(tree, 1, 'indent')).toBe(true)
|
||||
expect(names(tree)).toBe('Blog(News Deep Home) Orphan')
|
||||
expect(flatten(tree)).toEqual([
|
||||
{ id: 2, parent_id: null, sort_order: 1 },
|
||||
{ id: 3, parent_id: 2, sort_order: 2 },
|
||||
{ id: 4, parent_id: 2, sort_order: 3 },
|
||||
{ id: 1, parent_id: 2, sort_order: 4 },
|
||||
{ id: 5, parent_id: null, sort_order: 5 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user