Files
RenewCMS/web/src/views/__tests__/SchemaBuilderEditLoad.spec.ts
T

449 lines
14 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 { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
import {
button,
calls,
entryPage,
failWith,
mountAt,
networkFailure,
pending,
text,
unmountAll,
} from './schemaTestKit'
import {
all,
badgesOf,
chip,
click,
discardButton,
el,
fieldKeyInput,
inspector,
inspectorTitle,
keyInput,
limit,
main,
nameInput,
notice,
pickTile,
rowLabels,
rows,
saveButton,
selectRow,
tile,
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 SKELETON_DELAY_MS = 150
const NOT_FOUND = 'Content type not found'
const COUNT_ERROR = "Couldn't load content type"
const COUNT_LINE = 'GET /v1/content-type/events/entry failed. Check your connection and try again.'
describe('Resolving the key of /schema/:key', () => {
it('opens the builder in edit mode for a custom type', async () => {
await mountAt('/schema/events')
expect(nameInput()!.value).toBe('Events')
expect(keyInput()!.value).toBe('events')
expect(rowLabels()).toHaveLength(7)
})
it('keeps the read-only view for a built-in type', async () => {
await mountAt('/schema/articles')
expect(main().querySelector('h1')?.textContent).toBe('Articles')
expect(nameInput()).toBeNull()
})
it('says not found for an unknown key', async () => {
await mountAt('/schema/unknown')
expect(text(main())).toContain(NOT_FOUND)
expect(nameInput()).toBeNull()
expect(calls.some((url) => url.includes('/entry'))).toBe(false)
})
it('keeps /schema/new as the create builder', async () => {
await mountAt('/schema/new')
expect(nameInput()!.value).toBe('')
expect(keyInput()!.readOnly).toBe(true)
expect(rowLabels()).toEqual(['Title'])
})
})
describe('Loading, the session store and the entry count', () => {
it('calls each list once and the count once, and never reads the type on its own', async () => {
await mountAt('/schema/events')
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
expect(
calls.filter((url) => url === '/v1/content-type/events/entry?page=1&size=5'),
).toHaveLength(1)
expect(calls.filter((url) => url === '/v1/content-type/events')).toHaveLength(0)
})
it('shows the skeleton, and no builder, until the count is known', async () => {
vi.useFakeTimers()
const gate = pending()
await mountAt('/schema/events', { entries: () => gate.promise })
expect(document.body.querySelector('[data-testid="schema-skeleton-row"]')).toBeNull()
vi.advanceTimersByTime(SKELETON_DELAY_MS)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="schema-skeleton-row"]').length,
).toBeGreaterThan(0)
expect(nameInput()).toBeNull()
expect(saveButton()).toBeUndefined()
gate.resolve(entryPage(37))
await flushPromises()
expect(nameInput()!.value).toBe('Events')
expect(document.body.querySelector('[data-testid="schema-skeleton-row"]')).toBeNull()
})
it('shows the skeleton until the built-in list is known', async () => {
vi.useFakeTimers()
const gate = pending()
await mountAt('/schema/events', { builtin: () => gate.promise })
vi.advanceTimersByTime(SKELETON_DELAY_MS)
await flushPromises()
expect(nameInput()).toBeNull()
expect(calls.filter((url) => url.includes('/entry'))).toHaveLength(0)
gate.resolve([])
await flushPromises()
expect(nameInput()!.value).toBe('Events')
})
})
describe('The count cannot be loaded', () => {
it.each([
['answers 500', failWith(500)],
['fails (network)', networkFailure],
])('shows the error when the count %s, then opens on Try again', async (_, failure) => {
let respond = failure as () => unknown
await mountAt('/schema/events', { entries: () => respond() })
expect(text(main())).toContain(COUNT_ERROR)
expect(text(main())).toContain(COUNT_LINE)
expect(nameInput()).toBeNull()
expect(saveButton()).toBeUndefined()
respond = () => entryPage(37)
await click(button('Try again'))
expect(nameInput()!.value).toBe('Events')
expect(
calls.filter((url) => url === '/v1/content-type/events/entry?page=1&size=5'),
).toHaveLength(2)
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
})
it('says not found when the count answers 404', async () => {
await mountAt('/schema/events', { entries: failWith(404) })
expect(text(main())).toContain(NOT_FOUND)
expect(nameInput()).toBeNull()
})
it('shows the access page when the count answers 403', async () => {
await mountAt('/schema/events', { entries: failWith(403) })
expect(text(main())).toContain("You don't have access to this page")
expect(nameInput()).toBeNull()
})
it('shows the list error when the custom list fails, and retries it', async () => {
let respond = failWith(500) as () => unknown
await mountAt('/schema/events', { content: () => respond() })
expect(text(main())).toContain("Couldn't load content types")
expect(nameInput()).toBeNull()
const { customTypes } = await import('./schemaTestKit')
respond = () => [...customTypes]
await click(button('Try again'))
expect(nameInput()!.value).toBe('Events')
})
})
describe('Roles and sessions in edit mode', () => {
it.each(['owner', 'admin'])('shows the builder to a verified %s', async (role) => {
await mountAt('/schema/events', { role })
expect(nameInput()!.value).toBe('Events')
})
it('tells a verified user who is neither to go home, leaving the URL', async () => {
await mountAt('/schema/events', { role: 'user' })
expect(text(main())).toContain("You don't have access to this page")
expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement)
expect(router.currentRoute.value.path).toBe('/schema/events')
expect(calls.filter((url) => url.includes('/entry'))).toHaveLength(0)
})
it('redirects an unverified user to /register/pending', async () => {
await mountAt('/schema/events', { verified: false })
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
it('redirects a signed-out visitor to /login', async () => {
await mountAt('/schema/events')
useSessionStore().clear()
await router.push('/schema/events')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
describe('Session-owned store', () => {
it('resets both lists on sign-out and reloads them for the next session', async () => {
await mountAt('/schema/events')
const store = useContentTypesStore()
expect(store.items).toHaveLength(3)
expect(store.builtinItems).toHaveLength(5)
useSessionStore().clear()
await flushPromises()
expect(store.items).toEqual([])
expect(store.builtinItems).toEqual([])
expect(store.status).toBe('idle')
expect(store.builtinStatus).toBe('idle')
})
it('starts a second visit from the store, not from an old draft', async () => {
await mountAt('/schema/events')
await typeInto(nameInput(), 'Events and talks')
await unmountAll()
await mountAt('/schema/events')
expect(nameInput()!.value).toBe('Events')
expect(chip()).toBe('Saved')
})
})
describe('Editing an existing type, desktop (design state 2)', () => {
it('shows the breadcrumb, a locked key, the chip, the notice and the list', async () => {
await mountAt('/schema/events')
const crumbs = text(document.body.querySelector('nav[aria-label="breadcrumb"]'))
expect(crumbs).toContain('Schema')
expect(crumbs).toContain('Events')
expect(
document.body.querySelector('nav[aria-label="breadcrumb"] a[href="/schema"]'),
).not.toBeNull()
expect(nameInput()!.readOnly).toBe(false)
expect(nameInput()!.disabled).toBe(false)
expect(keyInput()!.readOnly).toBe(true)
expect(keyInput()!.className).toContain('font-mono')
expect(el('[data-testid="slug-lock"]')).not.toBeNull()
expect(document.body.querySelector('button[aria-label="Edit key"]')).toBeNull()
expect(text(main())).not.toContain('Auto')
expect(text(main())).not.toContain('Reset to match the name')
expect(chip()).toBe('Saved')
expect(notice()).toBe(
'37 entries use this content type. Changes are checked against them when you save.',
)
expect(text(el('[data-testid="field-count"]'))).toBe('7 of 50')
expect(rowLabels()).toEqual([
'Title',
'Starts on',
'Ends on',
'Online',
'Capacity',
'Category',
'Description',
])
expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true)
expect(button('Add field')).toBeInstanceOf(HTMLButtonElement)
expect(inspectorTitle()).toBe('Field: Title')
expect(text(el('[data-testid="inspector-pane"] h2')!.parentElement)).not.toContain('New')
expect(saveButton()!.disabled).toBe(true)
expect(discardButton()!.disabled).toBe(true)
expect(el('[data-testid="api-preview-toggle"]')!.getAttribute('aria-expanded')).toBe(
'false',
)
})
it('puts the notice under the type bar and above the list', async () => {
await mountAt('/schema/events')
const bar = el('[data-testid="entries-notice"]')!
const header = el('[data-testid="field-count"]')!
expect(bar.compareDocumentPosition(header) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(
nameInput()!.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
})
describe('Editing an existing type, mobile (design state 2)', () => {
it('stacks the type bar, the notice, the rows and the API row, with the bottom bar', async () => {
await mountAt('/schema/events', { width: MOBILE_WIDTH })
expect(text(main())).not.toContain('Field: Title')
expect(el('[data-testid="inspector-pane"]')).toBeNull()
const order = [
nameInput(),
keyInput(),
el('[data-testid="entries-notice"]'),
rows()[0],
el('[data-testid="api-row"]'),
]
order.forEach((node, index) => {
expect(node).not.toBeNull()
if (index > 0) {
expect(
order[index - 1]!.compareDocumentPosition(node!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
}
})
const bar = el('.sticky')!
expect(text(bar)).toBe('Saved Discard Save')
expect(text(document.body)).toContain('Events')
})
})
describe('The notice follows the count', () => {
it.each([
[0, null],
[1, '1 entry uses this content type. Changes are checked against it when you save.'],
[37, '37 entries use this content type. Changes are checked against them when you save.'],
[
1200,
'1,200 entries use this content type. Changes are checked against them when you save.',
],
])('for %s entries', async (total, copy) => {
await mountAt('/schema/events', { entries: () => entryPage(total) })
if (copy === null) {
expect(el('[data-testid="entries-notice"]')).toBeNull()
} else {
expect(notice()).toBe(copy)
}
})
})
describe('The locked type key (design state 3)', () => {
const TIP = "The key is used in URLs and the API, so it can't change after the type is created."
it('opens a tooltip on focus, closes it on blur and Esc, and refuses typing', async () => {
await mountAt('/schema/events')
keyInput()!.focus()
await flushPromises()
expect(text(document.body)).toContain(TIP)
keyInput()!.blur()
await flushPromises()
expect(text(document.body)).not.toContain(TIP)
keyInput()!.focus()
await flushPromises()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(text(document.body)).not.toContain(TIP)
await typeInto(keyInput(), 'changed')
expect(keyInput()!.readOnly).toBe(true)
expect(text(main())).not.toContain('Use 2 to 40 lowercase letters')
})
})
describe('Saved fields are locked', () => {
it('shows the lock, the help and the dimmed tiles for a saved field', async () => {
await mountAt('/schema/events')
await selectRow('Starts on')
expect(fieldKeyInput()!.value).toBe('starts_on')
expect(fieldKeyInput()!.readOnly).toBe(true)
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
expect(text(inspector())).toContain("Used in the API, so it can't change.")
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).toBeNull()
expect(text(inspector())).toContain("Can't change after saving")
expect(all('[role="radio"]')).toHaveLength(8)
expect(tile('Date').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter(
(radio) => radio !== tile('Date'),
)
expect(others.every((radio) => radio.disabled || radio.hasAttribute('data-disabled'))).toBe(
true,
)
expect(tile('Date').hasAttribute('data-disabled')).toBe(false)
})
it('does not change the type when another tile is activated', async () => {
await mountAt('/schema/events')
await selectRow('Starts on')
await pickTile('Number')
expect(tile('Date').getAttribute('data-state')).toBe('checked')
expect(chip()).toBe('Saved')
})
it('keeps label, Required, Unique, default and limits editable', async () => {
await mountAt('/schema/events')
await selectRow('Capacity')
expect(limit('max')!.disabled).toBe(false)
expect(limit('min')!.disabled).toBe(false)
expect(el<HTMLInputElement>('input[name="label"]')!.disabled).toBe(false)
expect(el<HTMLInputElement>('input[name="default"]')!.disabled).toBe(false)
})
})
describe('Fields added since the last save', () => {
it('selects a new row with New, an Auto key, seven selectable tiles and no lock', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
expect(inspectorTitle()).toBe('Field: Untitled field')
expect(text(el('[data-testid="inspector-pane"]')!)).toContain('New')
expect(badgesOf('Untitled field')).toContain('New')
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).toBeNull()
expect(text(inspector())).not.toContain("Can't change after saving")
expect(all<HTMLButtonElement>('[role="radio"]').every((radio) => !radio.disabled)).toBe(
true,
)
})
it('derives a key for a typed label that does not collide with a saved key', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'Title')
expect(fieldKeyInput()!.value).toBe('title_2')
expect(text(inspector())).toContain('Auto')
expect(chip()).toBe('Unsaved changes')
})
})