feat: edit an existing custom type
This commit is contained in:
1 parent
083cc9e23e
commit
8e344b2500
57 files changed
+5648
-425
No files matched your search
@@ -0,0 +1,448 @@
|
||||
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 Schema")
|
||||
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 Schema")
|
||||
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(6)
|
||||
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, six 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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user