feat: schema builder's create flow
This commit is contained in:
1 parent
f514146fed
commit
083cc9e23e
71 files changed
+7223
-61
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import ApiPreview from '../ApiPreview.vue'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
const writeText = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
writeText.mockReset().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
})
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
const JSON_TEXT = '{\n "id": 42\n}'
|
||||
|
||||
const toggle = () => document.body.querySelector<HTMLElement>('[data-testid="api-preview-toggle"]')!
|
||||
|
||||
describe('ApiPreview', () => {
|
||||
it('is collapsed, then opens and closes with the toggle', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.body.querySelector('[data-testid="api-json"]')).toBeNull()
|
||||
|
||||
toggle().click()
|
||||
await flushPromises()
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('true')
|
||||
expect(document.body.querySelector('[data-testid="api-json"]')!.textContent).toBe(JSON_TEXT)
|
||||
expect(holder.wrapper!.findComponent(ApiPreview).emitted('update:open')![0]).toEqual([true])
|
||||
})
|
||||
|
||||
it('shows the first route in the bar and falls back to events', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: '', json: JSON_TEXT })
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="api-preview"]'))).toContain(
|
||||
'GET /v1/content-type/events/entry',
|
||||
)
|
||||
})
|
||||
|
||||
it('copies the JSON and reads Copied', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
|
||||
|
||||
document.body.querySelector<HTMLElement>('[data-testid="api-copy"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(JSON_TEXT)
|
||||
expect(text(document.body.querySelector('[data-testid="api-copy"]'))).toBe('Copied')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import ChoiceList from '../ChoiceList.vue'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return { options: ['Talk', 'Workshop', 'Meetup'], disabled: false, ...extra }
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(ChoiceList).emitted(name) ?? []
|
||||
}
|
||||
|
||||
const addInput = () =>
|
||||
document.body.querySelector<HTMLInputElement>('input[aria-label="Add a choice"]')!
|
||||
|
||||
async function type(input: HTMLInputElement, value: string) {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function addButton() {
|
||||
return [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(candidate) => text(candidate) === 'Add',
|
||||
)!
|
||||
}
|
||||
|
||||
describe('ChoiceList', () => {
|
||||
it('shows the count, the rows and the help', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="choice-count"]'))).toBe('3 of 50')
|
||||
expect(document.body.querySelectorAll('[data-testid="choice-row"]')).toHaveLength(3)
|
||||
expect(text(document.body)).toContain('Add 1 to 50 choices.')
|
||||
})
|
||||
|
||||
it('emits add from the button and from Enter, and ignores a blank draft', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
expect(addButton().disabled).toBe(true)
|
||||
|
||||
await type(addInput(), 'Panel')
|
||||
addButton().click()
|
||||
await flushPromises()
|
||||
await type(addInput(), 'Social')
|
||||
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await flushPromises()
|
||||
await type(addInput(), ' ')
|
||||
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('add')).toEqual([['Panel'], ['Social']])
|
||||
expect(addInput().value).toBe(' ')
|
||||
})
|
||||
|
||||
it('emits rename and remove for a row', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
|
||||
await type(
|
||||
document.body.querySelectorAll<HTMLInputElement>(
|
||||
'[data-testid="choice-row"] input',
|
||||
)[1]!,
|
||||
'Seminar',
|
||||
)
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Remove Talk"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('rename')).toEqual([[1, 'Seminar']])
|
||||
expect(emitted('remove')).toEqual([[0]])
|
||||
})
|
||||
|
||||
it('shows a row error with an alert icon and ties it to the input', async () => {
|
||||
await mountInApp(ChoiceList, props({ rowErrors: { 1: 'Two choices are the same.' } }))
|
||||
|
||||
const row = document.body.querySelectorAll('[data-testid="choice-row"]')[1]!
|
||||
expect(text(row.querySelector('[role="alert"]'))).toBe('Two choices are the same.')
|
||||
expect(row.querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
const input = row.querySelector('input')!
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(input.getAttribute('aria-describedby')).toBe('choice-error-1')
|
||||
})
|
||||
|
||||
it('shows a list error and disables Add at 50 choices', async () => {
|
||||
await mountInApp(
|
||||
ChoiceList,
|
||||
props({ options: Array.from({ length: 50 }, (_, index) => `C${index}`) }),
|
||||
)
|
||||
await type(addInput(), 'more')
|
||||
|
||||
expect(addButton().disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the list error', async () => {
|
||||
await mountInApp(ChoiceList, props({ options: [], listError: 'Add at least one choice.' }))
|
||||
|
||||
expect(text(document.body.querySelector('p[role="alert"]'))).toBe(
|
||||
'Add at least one choice.',
|
||||
)
|
||||
})
|
||||
|
||||
it('emits the new order on drop only', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
const grip = () => document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
const key = async (name: string) => {
|
||||
grip().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
await key(' ')
|
||||
await key('ArrowDown')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
await key(' ')
|
||||
|
||||
expect(emitted('reorder')).toEqual([[['Workshop', 'Talk', 'Meetup']]])
|
||||
})
|
||||
|
||||
it('keeps each error with its choice while a row is being moved', async () => {
|
||||
await mountInApp(
|
||||
ChoiceList,
|
||||
props({
|
||||
options: ['Talk', 'Talk', 'Meetup'],
|
||||
rowErrors: { 1: 'Two choices are the same.' },
|
||||
}),
|
||||
)
|
||||
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const rows = [...document.body.querySelectorAll('[data-testid="choice-row"]')]
|
||||
expect(rows.map((row) => text(row.querySelector('[role="alert"]')))).toEqual([
|
||||
'Two choices are the same.',
|
||||
'',
|
||||
'',
|
||||
])
|
||||
})
|
||||
|
||||
it('emits the data index for rename and remove while a row is being moved', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const removers = [...document.body.querySelectorAll<HTMLButtonElement>('button')].filter(
|
||||
(candidate) => candidate.getAttribute('aria-label')?.startsWith('Remove '),
|
||||
)
|
||||
removers[0]!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('remove')).toEqual([[1]])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import FieldInspector from '../FieldInspector.vue'
|
||||
import { createField } from '@/lib/schemaBuilderModel'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
field: createField(1, { label: 'Title', key: 'title' }),
|
||||
errors: {},
|
||||
disabled: false,
|
||||
compact: false,
|
||||
focusToken: 0,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(FieldInspector).emitted(name) ?? []
|
||||
}
|
||||
|
||||
async function type(selector: string, value: string) {
|
||||
const input = document.body.querySelector<HTMLInputElement>(selector)!
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('FieldInspector', () => {
|
||||
it('emits label, key, type, limit and default edits', async () => {
|
||||
await mountInApp(
|
||||
FieldInspector,
|
||||
props({ field: createField(1, { label: 'T', key: 't', keyMode: 'manual' }) }),
|
||||
)
|
||||
|
||||
await type('input[name="label"]', 'Headline')
|
||||
await type('input[name="field-key"]', 'headline')
|
||||
await type('input[name="max"]', '80')
|
||||
await type('input[name="default"]', 'Draft')
|
||||
document.body.querySelector<HTMLElement>('[role="radio"][value="number"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('label')).toEqual([['Headline']])
|
||||
expect(emitted('key')).toEqual([['headline']])
|
||||
expect(emitted('limit')).toEqual([['max', '80']])
|
||||
expect(emitted('default')).toEqual([['Draft']])
|
||||
expect(emitted('type')).toEqual([['number']])
|
||||
})
|
||||
|
||||
it('emits the Required switch', async () => {
|
||||
await mountInApp(FieldInspector, props())
|
||||
|
||||
const label = [...document.body.querySelectorAll('label')].find((node) =>
|
||||
text(node).startsWith('Required'),
|
||||
)!
|
||||
document.getElementById(label.getAttribute('for')!)!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('required')).toEqual([[true]])
|
||||
})
|
||||
|
||||
it('dims and disables every control while disabled', async () => {
|
||||
await mountInApp(FieldInspector, props({ disabled: true }))
|
||||
|
||||
const root = document.body.querySelector('[data-testid="field-inspector"]')!
|
||||
expect(root.className).toContain('opacity-60')
|
||||
expect(
|
||||
[...root.querySelectorAll<HTMLInputElement>('input')].every((input) => input.disabled),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the errors under their controls', async () => {
|
||||
await mountInApp(
|
||||
FieldInspector,
|
||||
props({
|
||||
field: createField(1, { type: 'number', min: '6', max: '5', defaultValue: '0' }),
|
||||
errors: {
|
||||
label: 'Give the field a label.',
|
||||
key: 'Bad key.',
|
||||
min: 'Order.',
|
||||
default: 'Outside.',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const alerts = [...document.body.querySelectorAll('[role="alert"]')].map(text)
|
||||
expect(alerts).toEqual(['Give the field a label.', 'Bad key.', 'Outside.', 'Order.'])
|
||||
})
|
||||
|
||||
it('lays the controls out in one column when compact', async () => {
|
||||
await mountInApp(FieldInspector, props({ compact: true }))
|
||||
|
||||
expect(document.body.querySelector('.grid-cols-1')).not.toBeNull()
|
||||
expect(document.body.querySelector('.grid-cols-2')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import FieldList from '../FieldList.vue'
|
||||
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
const FIELDS: BuilderField[] = [
|
||||
createField(1, { label: 'Title', key: 'title' }),
|
||||
createField(2, { label: 'Starts on', key: 'starts_on', type: 'date' }),
|
||||
createField(3, { label: 'Ends on', key: 'ends_on', type: 'date' }),
|
||||
]
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
fields: FIELDS,
|
||||
selectedUid: 2,
|
||||
errors: new Map(),
|
||||
disabled: false,
|
||||
mobile: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(FieldList).emitted(name) ?? []
|
||||
}
|
||||
|
||||
function grip(label: string) {
|
||||
return document.body.querySelector<HTMLElement>(`[aria-label="Move ${label}"]`)!
|
||||
}
|
||||
|
||||
async function press(target: Element, key: string) {
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function labels() {
|
||||
return [...document.body.querySelectorAll('[data-testid="field-row-label"]')].map(text)
|
||||
}
|
||||
|
||||
describe('FieldList', () => {
|
||||
it('lists the rows and counts them against 50', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
|
||||
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('3 of 50')
|
||||
})
|
||||
|
||||
it('emits select, add, move and remove', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
document.body.querySelector<HTMLElement>('[data-field-row][data-uid="3"]')!.click()
|
||||
;(document.body.querySelector('[data-action="up"]') as HTMLElement).click()
|
||||
;(document.body.querySelector('[data-action="remove"]') as HTMLElement).click()
|
||||
const add = [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'Add field',
|
||||
)!
|
||||
add.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('select')).toEqual([[3]])
|
||||
expect(emitted('move')).toEqual([[2, -1]])
|
||||
expect(emitted('remove')).toEqual([[2]])
|
||||
expect(emitted('add')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('disables Add field at 50 rows and when disabled', async () => {
|
||||
const many = Array.from({ length: 50 }, (_, index) =>
|
||||
createField(index + 1, { label: 'F' }),
|
||||
)
|
||||
await mountInApp(FieldList, props({ fields: many, selectedUid: 1 }))
|
||||
|
||||
const add = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(candidate) => text(candidate) === 'Add field',
|
||||
)!
|
||||
expect(add.disabled).toBe(true)
|
||||
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('50 of 50')
|
||||
})
|
||||
|
||||
it('shows the empty state with the error message in the error colour', async () => {
|
||||
await mountInApp(FieldList, props({ fields: [], selectedUid: null, listError: 'Nope.' }))
|
||||
|
||||
const empty = document.body.querySelector('[data-testid="field-empty"]')!
|
||||
expect(text(empty)).toBe('No fields yet Nope.')
|
||||
expect(empty.querySelector('.text-error')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('emits the new order once, on drop, and nothing on cancel', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
await press(grip('Starts on'), 'Escape')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
await press(grip('Starts on'), ' ')
|
||||
|
||||
expect(emitted('reorder')).toEqual([[[2, 1, 3]]])
|
||||
})
|
||||
|
||||
it('moves the focus between rows with the arrow keys', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
const rows = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')]
|
||||
rows[0]!.focus()
|
||||
|
||||
await press(rows[0]!, 'ArrowDown')
|
||||
expect(document.activeElement).toBe(rows[1])
|
||||
await press(rows[1]!, 'ArrowUp')
|
||||
expect(document.activeElement).toBe(rows[0])
|
||||
})
|
||||
|
||||
it('keeps one roving tab stop for the rows', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
const stops = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')].filter(
|
||||
(row) => row.tabIndex === 0,
|
||||
)
|
||||
expect(stops.map((row) => row.getAttribute('data-uid'))).toEqual(['2'])
|
||||
})
|
||||
|
||||
it('hides the row actions on mobile', async () => {
|
||||
await mountInApp(FieldList, props({ mobile: true }))
|
||||
|
||||
expect(document.body.querySelector('[data-action]')).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user