251 lines
8.7 KiB
TypeScript
251 lines
8.7 KiB
TypeScript
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 router from '@/router'
|
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
|
import { button, calls, failWith, mountAt, text, unmountAll } from './schemaTestKit'
|
|
import {
|
|
all,
|
|
chip,
|
|
click,
|
|
discardButton,
|
|
el,
|
|
inspector,
|
|
keyField,
|
|
keyInput,
|
|
labelInput,
|
|
main,
|
|
nameInput,
|
|
rowLabels,
|
|
saveButton,
|
|
typeInto,
|
|
} from './builderKit'
|
|
|
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
|
return { ...actual, useApi: vi.fn() }
|
|
})
|
|
|
|
addCollection(lucide)
|
|
|
|
afterEach(unmountAll)
|
|
|
|
const NOT_FOUND_TITLE = 'Content type not found'
|
|
const ACCESS_TITLE = "You don't have access to this page"
|
|
|
|
function newTypeButtons() {
|
|
return all('button').filter((candidate) => text(candidate) === 'New content type')
|
|
}
|
|
|
|
describe('Reaching the builder', () => {
|
|
it('opens from the header button', async () => {
|
|
await mountAt('/schema')
|
|
|
|
await click(newTypeButtons()[0])
|
|
|
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
|
expect(nameInput()).not.toBeNull()
|
|
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
|
|
})
|
|
|
|
it('opens from the empty-state button', async () => {
|
|
await mountAt('/schema', { content: () => [] })
|
|
|
|
await click(newTypeButtons().at(-1))
|
|
|
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
|
expect(nameInput()).not.toBeNull()
|
|
})
|
|
|
|
it('opens from the mobile floating button', async () => {
|
|
await mountAt('/schema', { width: MOBILE_WIDTH })
|
|
|
|
await click(newTypeButtons().find((candidate) => candidate.className.includes('fixed')))
|
|
|
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
|
expect(nameInput()).not.toBeNull()
|
|
})
|
|
|
|
it('still says not found at /schema/unknown', async () => {
|
|
await mountAt('/schema/unknown')
|
|
|
|
expect(text(main())).toContain(NOT_FOUND_TITLE)
|
|
expect(nameInput()).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Roles and sessions', () => {
|
|
it.each(['owner', 'admin'])('shows the builder to a verified %s', async (role) => {
|
|
await mountAt('/schema/new', { role })
|
|
|
|
expect(nameInput()).not.toBeNull()
|
|
expect(text(main())).not.toContain(ACCESS_TITLE)
|
|
})
|
|
|
|
it('tells a verified user who is neither to go home, leaving the URL', async () => {
|
|
await mountAt('/schema/new', { role: 'user' })
|
|
|
|
expect(text(main())).toContain(ACCESS_TITLE)
|
|
expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement)
|
|
expect(router.currentRoute.value.path).toBe('/schema/new')
|
|
expect(nameInput()).toBeNull()
|
|
expect(el('[data-testid="field-row"]')).toBeNull()
|
|
})
|
|
|
|
it('shows the same page when the content types answer 403', async () => {
|
|
await mountAt('/schema/new', { content: failWith(403) })
|
|
|
|
expect(text(main())).toContain(ACCESS_TITLE)
|
|
})
|
|
})
|
|
|
|
describe('The builder needs no built-in list', () => {
|
|
it('calls the custom list once, never the built-in one, and shows no skeleton', async () => {
|
|
await mountAt('/schema/new')
|
|
|
|
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
|
expect(calls).not.toContain('/v1/builtin-type')
|
|
expect(nameInput()).not.toBeNull()
|
|
expect(el('[data-testid="schema-skeleton-row"]')).toBeNull()
|
|
})
|
|
|
|
it('shows the builder while the custom list is still loading', async () => {
|
|
let release!: () => void
|
|
const gate = new Promise<void>((done) => (release = done))
|
|
await mountAt('/schema/new', { content: () => gate.then(() => []) })
|
|
|
|
expect(nameInput()).not.toBeNull()
|
|
release()
|
|
await flushPromises()
|
|
})
|
|
})
|
|
|
|
describe('New type, desktop (design state 1)', () => {
|
|
it('shows the breadcrumb, Name, Key and the list header', async () => {
|
|
await mountAt('/schema/new')
|
|
|
|
expect(text(el('nav[aria-label="breadcrumb"]'))).toBe('Schema New content type')
|
|
expect(el('nav[aria-label="breadcrumb"] a[href="/schema"]')).not.toBeNull()
|
|
expect(all('nav[aria-label="breadcrumb"] a')).toHaveLength(1)
|
|
|
|
expect(nameInput()!.value).toBe('')
|
|
expect(nameInput()!.placeholder).toBe('For example, Events')
|
|
expect(nameInput()!.required).toBe(true)
|
|
expect(nameInput()!.maxLength).toBe(100)
|
|
expect(keyInput()!.value).toBe('')
|
|
expect(keyInput()!.placeholder).toBe('events')
|
|
expect(keyInput()!.readOnly).toBe(true)
|
|
expect(text(main())).toContain(
|
|
"Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save.",
|
|
)
|
|
expect(text(main())).toContain('Fields')
|
|
expect(text(el('[data-testid="field-count"]'))).toBe('1 of 50')
|
|
})
|
|
|
|
it('shows the starter row and the inspector of Title', async () => {
|
|
await mountAt('/schema/new')
|
|
|
|
expect(rowLabels()).toEqual(['Title'])
|
|
const row = el('[data-testid="field-row"]')!
|
|
expect(text(row.querySelector('[data-testid="field-row-key"]'))).toBe('title')
|
|
expect(row.querySelector('[data-field-row]')!.getAttribute('aria-current')).toBe('true')
|
|
expect(text(row.querySelector('[data-testid="field-row-badges"]'))).toBe('New')
|
|
expect(button('Add field')).toBeInstanceOf(HTMLButtonElement)
|
|
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Title')
|
|
expect(labelInput()!.value).toBe('Title')
|
|
expect(el<HTMLInputElement>('input[name="field-key"]')!.value).toBe('title')
|
|
const copy = text(inspector())
|
|
expect(copy).toContain('Auto')
|
|
expect(copy).toContain(
|
|
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
|
|
)
|
|
expect(all('[role="radio"]')).toHaveLength(8)
|
|
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
|
|
'Text',
|
|
)
|
|
expect(copy).toContain("An entry can't be saved with this field empty.")
|
|
expect(copy).toContain('Unique values')
|
|
expect(copy).toContain('Used when an entry is created and this field is left out.')
|
|
expect(copy).toContain('Shortest')
|
|
expect(copy).toContain('Longest')
|
|
expect(copy).toContain('Counted in characters. Entries show a counter.')
|
|
expect(all('[role="switch"]').map((toggle) => toggle.getAttribute('aria-checked'))).toEqual(
|
|
['false', 'false'],
|
|
)
|
|
})
|
|
|
|
it('disables Save and Discard, shows no chip, no entries bar and a collapsed API bar', async () => {
|
|
await mountAt('/schema/new')
|
|
|
|
expect(saveButton()!.disabled).toBe(true)
|
|
expect(discardButton()!.disabled).toBe(true)
|
|
expect(chip()).toBe('')
|
|
expect(text(main())).not.toContain('entries use this content type')
|
|
expect(el('[data-testid="api-preview-toggle"]')!.getAttribute('aria-expanded')).toBe(
|
|
'false',
|
|
)
|
|
expect(el('[data-testid="api-json"]')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('New type, mobile (design state 1)', () => {
|
|
it('shows the topbar, stacked Name and Key, the list and the API preview row', async () => {
|
|
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
|
|
|
expect(el('a[href="/schema"][aria-label="Schema"]')).not.toBeNull()
|
|
expect(text(el('[data-slot="title"]'))).toBe('New content type')
|
|
expect(nameInput()).not.toBeNull()
|
|
expect(keyInput()).not.toBeNull()
|
|
expect(rowLabels()).toEqual(['Title'])
|
|
expect(text(el('[data-testid="api-row"]'))).toBe('API preview')
|
|
expect(el('[data-testid="api-row"] svg')).not.toBeNull()
|
|
expect(inspector()).toBeNull()
|
|
expect(el('[data-testid="inspector-pane"]')).toBeNull()
|
|
expect(discardButton()).toBeInstanceOf(HTMLButtonElement)
|
|
expect(saveButton()).toBeInstanceOf(HTMLButtonElement)
|
|
expect(el('[data-testid="api-preview"]')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('The key follows the name until it is edited', () => {
|
|
it.each([
|
|
['Events', 'events', 'auto'],
|
|
['Press mentions', 'press-mentions', 'auto'],
|
|
['Café & Bar', 'cafe-bar', 'auto'],
|
|
[' Job openings!', 'job-openings', 'auto'],
|
|
['2026 Awards', '2026-awards', 'auto'],
|
|
['!!!', '', 'empty'],
|
|
['x'.repeat(60), 'x'.repeat(40), 'auto'],
|
|
])('%j gives the key %j', async (name, key, mode) => {
|
|
await mountAt('/schema/new')
|
|
|
|
await typeInto(nameInput(), name)
|
|
|
|
expect(keyInput()!.value).toBe(key)
|
|
expect(keyInput()!.readOnly).toBe(true)
|
|
expect(text(keyField()).includes('Auto')).toBe(mode === 'auto')
|
|
})
|
|
|
|
it('unlocks with the pencil, keeps a typed key, and resets to the name', async () => {
|
|
await mountAt('/schema/new')
|
|
await typeInto(nameInput(), 'Events')
|
|
|
|
await click(keyField().querySelector('button[aria-label="Edit key"]'))
|
|
|
|
expect(keyInput()!.readOnly).toBe(false)
|
|
expect(text(keyField())).not.toContain('Auto')
|
|
expect(button('Reset to match the name')).toBeInstanceOf(HTMLButtonElement)
|
|
|
|
await typeInto(keyInput(), 'agenda')
|
|
await typeInto(nameInput(), 'Events and talks')
|
|
expect(keyInput()!.value).toBe('agenda')
|
|
|
|
await click(button('Reset to match the name'))
|
|
|
|
expect(keyInput()!.value).toBe('events-and-talks')
|
|
expect(keyInput()!.readOnly).toBe(true)
|
|
expect(text(keyField())).toContain('Auto')
|
|
})
|
|
})
|