feat: entry editor at /content/:key/new and /content/:key/:id
This commit is contained in:
1 parent
91d916fb74
commit
fba20f83c5
94 files changed
+7116
-423
No files matched your search
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, watch } from 'vue'
|
||||
import ContentEntries from '@/components/ContentEntries.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import { useContentListPage } from '@/composables/useContentListPage'
|
||||
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const props = defineProps<{ typeKey: string }>()
|
||||
|
||||
const session = useSessionStore()
|
||||
|
||||
const detail = useContentTypeDetail(
|
||||
() => props.typeKey,
|
||||
() => session.status === 'ready',
|
||||
)
|
||||
const page = useContentListPage(props.typeKey, detail, () => session.canManageContent)
|
||||
|
||||
const notFound = computed(() => page.view.value === 'notFound')
|
||||
const centered = computed(() => page.centered.value)
|
||||
const displayName = computed(() =>
|
||||
notFound.value ? undefined : (detail.type.value?.name ?? props.typeKey),
|
||||
)
|
||||
const mobileTitle = computed(() => displayName.value ?? t('Content'))
|
||||
const breadcrumb = computed(() => [
|
||||
{ label: t('Content') },
|
||||
...(displayName.value ? [{ label: displayName.value }] : []),
|
||||
])
|
||||
|
||||
watch(
|
||||
() => session.status === 'ready',
|
||||
(ready, wasReady) => {
|
||||
if (ready && !wasReady) {
|
||||
page.list.start()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell
|
||||
compact-padding
|
||||
:full-bleed="centered"
|
||||
:mobile-title="mobileTitle"
|
||||
:no-current-nav="notFound"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="breadcrumb"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
:ui="{ link: 'text-sm font-medium' }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ContentEntries v-if="session.status === 'ready'" :page="page" />
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -1,64 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ContentEntries from '@/components/ContentEntries.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
|
||||
import type { ContentListState } from '@/lib/contentListState'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import ContentListPage from '@/views/ContentListPage.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
|
||||
const key = computed(() => String(route.params.key))
|
||||
const detail = useContentTypeDetail(() => key.value)
|
||||
const listView = ref<ContentListState>('loading')
|
||||
|
||||
watch(key, () => {
|
||||
listView.value = 'loading'
|
||||
})
|
||||
|
||||
const notFound = computed(() => detail.status.value === 'missing' || listView.value === 'notFound')
|
||||
const centered = computed(
|
||||
() => notFound.value || detail.status.value === 'forbidden' || listView.value === 'forbidden',
|
||||
)
|
||||
const displayName = computed(() =>
|
||||
notFound.value ? undefined : (detail.type.value?.name ?? key.value),
|
||||
)
|
||||
const mobileTitle = computed(() => displayName.value ?? t('Content'))
|
||||
const breadcrumb = computed(() => [
|
||||
{ label: t('Content') },
|
||||
...(displayName.value ? [{ label: displayName.value }] : []),
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell
|
||||
compact-padding
|
||||
:full-bleed="centered"
|
||||
:mobile-title="mobileTitle"
|
||||
:no-current-nav="notFound"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="breadcrumb"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
:ui="{ link: 'text-sm font-medium' }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ContentEntries
|
||||
v-if="session.status === 'ready'"
|
||||
:key="key"
|
||||
:type-key="key"
|
||||
:type="detail.type.value"
|
||||
:type-status="detail.status.value"
|
||||
:can-edit="session.canManageContent"
|
||||
@view="listView = $event"
|
||||
@retry-type="detail.retry"
|
||||
/>
|
||||
</DashboardShell>
|
||||
<ContentListPage :key="key" :type-key="key" />
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import EntryEditor from '@/components/EntryEditor.vue'
|
||||
import { useEntryEditorPage } from '@/composables/useEntryEditorPage'
|
||||
import type { EntryTarget } from '@/lib/entryEditorState'
|
||||
import { contentPath } from '@/lib/routePaths'
|
||||
|
||||
const props = defineProps<{
|
||||
typeKey: string
|
||||
target: EntryTarget
|
||||
adopt: (id: number | null) => void
|
||||
}>()
|
||||
|
||||
const page = useEntryEditorPage(props.typeKey, props.target, props.adopt)
|
||||
|
||||
const listPath = computed(() => contentPath(props.typeKey))
|
||||
const typeMissing = computed(() => page.phase.value === 'typeNotFound')
|
||||
const centered = computed(() =>
|
||||
['typeNotFound', 'entryNotFound', 'forbidden'].includes(page.phase.value),
|
||||
)
|
||||
const showsTitle = computed(() => page.phase.value === 'ready' && !centered.value)
|
||||
const mobileTitle = computed(() => {
|
||||
if (typeMissing.value || page.phase.value === 'forbidden') {
|
||||
return t('Content')
|
||||
}
|
||||
return showsTitle.value ? page.title.value : page.typeName.value
|
||||
})
|
||||
const breadcrumb = computed(() => {
|
||||
if (typeMissing.value) {
|
||||
return [{ label: t('Content') }]
|
||||
}
|
||||
return [
|
||||
{ label: t('Content') },
|
||||
{ label: page.typeName.value, to: listPath.value },
|
||||
...(showsTitle.value ? [{ label: page.title.value }] : []),
|
||||
]
|
||||
})
|
||||
const showDelete = computed(() => !page.isDesktop.value && page.ready.value && !page.creating.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell
|
||||
compact-padding
|
||||
sticky-footer
|
||||
:full-bleed="centered"
|
||||
:mobile-title="mobileTitle"
|
||||
:back-to="listPath"
|
||||
:back-label="page.typeName.value"
|
||||
:no-current-nav="typeMissing"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="breadcrumb"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
:ui="{ link: 'text-sm font-medium' }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #navbar-right>
|
||||
<UButton
|
||||
v-if="showDelete"
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-trash-2"
|
||||
:disabled="page.saving.value"
|
||||
:aria-label="t('Delete')"
|
||||
class="size-11 justify-center text-muted"
|
||||
:ui="{ leadingIcon: 'size-5' }"
|
||||
@click="page.removal.open"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<EntryEditor :page="page" />
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import { resolveEntryTarget } from '@/lib/entryEditorState'
|
||||
import EntryEditorPage from '@/views/EntryEditorPage.vue'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
|
||||
const typeKey = computed(() => String(route.params.key))
|
||||
const rawId = computed(() => (route.name === 'entry-new' ? 'new' : String(route.params.id)))
|
||||
const target = computed(() => resolveEntryTarget(rawId.value))
|
||||
const identity = computed(() => `${typeKey.value}/${rawId.value}`)
|
||||
|
||||
const instance = ref(0)
|
||||
let adopted: string | null = null
|
||||
|
||||
function adopt(id: number | null) {
|
||||
adopted = id === null ? null : `${typeKey.value}/${id}`
|
||||
}
|
||||
|
||||
watch(identity, (next) => {
|
||||
if (next !== adopted) {
|
||||
instance.value++
|
||||
}
|
||||
adopted = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EntryEditorPage
|
||||
v-if="session.status === 'ready'"
|
||||
:key="instance"
|
||||
:type-key="typeKey"
|
||||
:target="target"
|
||||
:adopt="adopt"
|
||||
/>
|
||||
<DashboardShell v-else compact-padding />
|
||||
</template>
|
||||
@@ -0,0 +1,226 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
MOBILE_WIDTH,
|
||||
chip,
|
||||
control,
|
||||
entryWith,
|
||||
fieldWrapper,
|
||||
installSelectPolyfills,
|
||||
mountEditor,
|
||||
openChoices,
|
||||
text,
|
||||
typeInto,
|
||||
unmountAll,
|
||||
} from './entryEditorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeAll(installSelectPolyfills)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
function preview() {
|
||||
return document.body.querySelector('[data-testid="markdown-preview"]')
|
||||
}
|
||||
|
||||
function tab(label: string) {
|
||||
return [...document.body.querySelectorAll<HTMLElement>('[role="tab"]')].find(
|
||||
(candidate) => text(candidate) === label,
|
||||
)!
|
||||
}
|
||||
|
||||
function toolbarButton(label: string) {
|
||||
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!
|
||||
}
|
||||
|
||||
async function pressTab(label: string) {
|
||||
const target = tab(label)
|
||||
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
target.focus()
|
||||
target.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('Form controls', () => {
|
||||
it('uses one control per field type', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
expect(fieldWrapper('title').querySelector('input[type="text"]')).toBeTruthy()
|
||||
expect(fieldWrapper('description').querySelector('textarea')).toBeTruthy()
|
||||
expect(fieldWrapper('capacity').querySelector('input')).toBeTruthy()
|
||||
expect(fieldWrapper('online').querySelector('[role="switch"]')).toBeTruthy()
|
||||
expect(fieldWrapper('starts_on').querySelector('[role="spinbutton"]')).toBeTruthy()
|
||||
expect(fieldWrapper('category').querySelector('[role="combobox"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('toggles the switch text between Off and On and marks the change', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
expect(text(fieldWrapper('online'))).toContain('Off')
|
||||
|
||||
fieldWrapper('online').querySelector<HTMLElement>('[role="switch"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text(fieldWrapper('online'))).toContain('On')
|
||||
expect(text(chip())).toBe('Unsaved changes')
|
||||
})
|
||||
|
||||
it('lists the choices in schema order with a None item for an optional select', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
const options = await openChoices('category')
|
||||
|
||||
expect(options.map((option) => text(option))).toEqual([
|
||||
'None',
|
||||
'Talk',
|
||||
'Workshop',
|
||||
'Meetup',
|
||||
])
|
||||
})
|
||||
|
||||
it('clears an optional select with None', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
const options = await openChoices('category')
|
||||
|
||||
options[0]!.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
options[0]!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text(fieldWrapper('category'))).toContain('Choose an option')
|
||||
expect(text(chip())).toBe('Unsaved changes')
|
||||
})
|
||||
|
||||
it('shows a counter while typing', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
await typeInto('title', 'Lime plaster workshop, two days')
|
||||
|
||||
expect(text(fieldWrapper('title'))).toContain('31 / 80')
|
||||
expect(
|
||||
fieldWrapper('title').querySelector('[data-testid="entry-counter"]')!.className,
|
||||
).not.toContain('text-error')
|
||||
})
|
||||
|
||||
it('has no counter for a text field without a max', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
entry: () => entryWith({}),
|
||||
type: () => ({
|
||||
id: 1,
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: null,
|
||||
created_at: '2026-09-01T09:30:00Z',
|
||||
updated_at: '2026-09-01T09:30:00Z',
|
||||
fields: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(fieldWrapper('title').querySelector('[data-testid="entry-counter"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('puts every field in one column below md', async () => {
|
||||
await mountEditor('/content/events/42', { width: MOBILE_WIDTH })
|
||||
|
||||
const row = fieldWrapper('starts_on').parentElement!
|
||||
expect(row.className).toContain('md:grid-cols-2')
|
||||
expect(row.className).not.toMatch(/(^|\s)grid-cols-2/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('4 Markdown preview', () => {
|
||||
it('renders bold text and a list in the Preview tab and keeps the value', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
const value = control('description').value
|
||||
|
||||
await pressTab('Preview')
|
||||
|
||||
expect(preview()!.innerHTML).toContain('<strong>lime putty</strong>')
|
||||
expect(preview()!.querySelectorAll('li')).toHaveLength(2)
|
||||
expect(toolbarButton('Bold')).toBeNull()
|
||||
expect(text(chip())).toBe('Saved')
|
||||
|
||||
await pressTab('Write')
|
||||
|
||||
expect(control('description').value).toBe(value)
|
||||
expect(preview()).toBeNull()
|
||||
expect(toolbarButton('Bold')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows raw HTML as text and keeps unsafe links inert', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
entry: () =>
|
||||
entryWith({
|
||||
description: '<b>raw</b> [bad](javascript:alert(1)) [ok](https://example.com)',
|
||||
}),
|
||||
})
|
||||
|
||||
await pressTab('Preview')
|
||||
|
||||
expect(preview()!.querySelector('b')).toBeNull()
|
||||
expect(text(preview())).toContain('<b>raw</b>')
|
||||
const links = [...preview()!.querySelectorAll('a')]
|
||||
expect(links.map((link) => link.getAttribute('href'))).toEqual(['https://example.com'])
|
||||
})
|
||||
|
||||
it('reads Nothing to preview yet. when empty', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
await pressTab('Preview')
|
||||
|
||||
expect(text(preview())).toBe('Nothing to preview yet.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Bold', '**lime**'],
|
||||
['Italic', '_lime_'],
|
||||
['Heading', '## lime'],
|
||||
['Link', '[lime](url)'],
|
||||
['List', '- lime'],
|
||||
])('the %s button turns the selection into %s', async (label, result) => {
|
||||
await mountEditor('/content/events/new')
|
||||
await typeInto('description', 'lime')
|
||||
const area = control('description') as HTMLTextAreaElement
|
||||
area.focus()
|
||||
area.setSelectionRange(0, 4)
|
||||
|
||||
toolbarButton(label).click()
|
||||
await flushPromises()
|
||||
|
||||
expect(area.value).toBe(result)
|
||||
expect(document.activeElement).toBe(area)
|
||||
})
|
||||
|
||||
it('exposes the tabs as a tablist and names the toolbar buttons', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
expect(document.body.querySelector('[role="tablist"]')).toBeTruthy()
|
||||
for (const label of ['Bold', 'Italic', 'Heading', 'Link', 'List']) {
|
||||
expect(toolbarButton(label)).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('announces the chip politely and ties labels to controls', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
expect(chip()!.getAttribute('role')).toBe('status')
|
||||
const label = fieldWrapper('title').querySelector('label')!
|
||||
expect(label.getAttribute('for')).toBe(control('title').id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
arrived,
|
||||
button,
|
||||
chip,
|
||||
control,
|
||||
entryWrites,
|
||||
failWith,
|
||||
failWithBody,
|
||||
hasText,
|
||||
main,
|
||||
mountEditor,
|
||||
networkFailure,
|
||||
pending,
|
||||
setField,
|
||||
text,
|
||||
typeInto,
|
||||
unmountAll,
|
||||
} from './entryEditorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
async function openDialog() {
|
||||
button('Delete')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function dialog() {
|
||||
return document.body.querySelector('[role="alertdialog"]')
|
||||
}
|
||||
|
||||
describe('13 Delete entry: confirm', () => {
|
||||
it('opens the dialog with no request', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
await openDialog()
|
||||
|
||||
expect(text(dialog())).toContain('Delete this entry?')
|
||||
expect(text(dialog())).toContain(
|
||||
'"Lime plaster workshop" will be deleted from Events. This can\'t be undone.',
|
||||
)
|
||||
expect(button('Cancel')).toBeTruthy()
|
||||
expect(button('Delete entry')).toBeTruthy()
|
||||
expect(entryWrites).toEqual([])
|
||||
})
|
||||
|
||||
it('closes on Cancel with nothing sent', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
await openDialog()
|
||||
|
||||
const cancel = [...dialog()!.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'Cancel',
|
||||
)!
|
||||
cancel.click()
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(entryWrites).toEqual([])
|
||||
})
|
||||
|
||||
it('returns focus to Delete after the dialog closes', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
const trigger = button('Delete')!
|
||||
trigger.focus()
|
||||
await openDialog()
|
||||
expect(document.activeElement).not.toBe(button('Delete entry'))
|
||||
|
||||
const cancel = [...dialog()!.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'Cancel',
|
||||
)!
|
||||
cancel.click()
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(trigger))
|
||||
})
|
||||
|
||||
it('locks the dialog while deleting and sends a single request', async () => {
|
||||
const gate = pending()
|
||||
await mountEditor('/content/events/42', { writeEntry: () => gate.promise })
|
||||
await openDialog()
|
||||
|
||||
button('Delete entry')!.click()
|
||||
await flushPromises()
|
||||
button('Deleting…')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(entryWrites).toEqual([
|
||||
{ method: 'DELETE', url: '/v1/content-type/events/entry/42', body: {} },
|
||||
])
|
||||
expect(button('Deleting…')!.disabled).toBe(true)
|
||||
expect(
|
||||
[...dialog()!.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'Cancel',
|
||||
)!.disabled,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves for the list with a toast and no prompt on 204', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
await typeInto('title', 'Edited but not saved')
|
||||
await openDialog()
|
||||
expect(text(dialog())).toContain('"Lime plaster workshop" will be deleted')
|
||||
|
||||
button('Delete entry')!.click()
|
||||
|
||||
await arrived('/content/events')
|
||||
expect(hasText('Entry deleted')).toBe(true)
|
||||
expect(hasText('"Lime plaster workshop" was deleted from Events.')).toBe(true)
|
||||
expect(hasText('Leave without saving?')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['entry_not_found', 'Entry not found'],
|
||||
['content_type_not_found', 'Content type not found'],
|
||||
])('shows the page for a 404 %s', async (body, shown) => {
|
||||
await mountEditor('/content/events/42', { writeEntry: failWithBody(404, body) })
|
||||
await openDialog()
|
||||
|
||||
button('Delete entry')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(text(main())).toContain(shown)
|
||||
})
|
||||
|
||||
it('shows the access page for a 403', async () => {
|
||||
await mountEditor('/content/events/42', { writeEntry: failWith(403) })
|
||||
await openDialog()
|
||||
|
||||
button('Delete entry')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text(main())).toContain("You don't have access to Events")
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', failWith(500)],
|
||||
['a network failure', networkFailure],
|
||||
])('keeps the dialog with an alert on %s', async (_name, failure) => {
|
||||
await mountEditor('/content/events/42', { writeEntry: failure })
|
||||
await openDialog()
|
||||
|
||||
button('Delete entry')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text(dialog())).toContain("Couldn't delete the entry")
|
||||
expect(text(dialog())).toContain(
|
||||
'Something went wrong on our side. Nothing was deleted. Try again in a moment.',
|
||||
)
|
||||
expect(button('Delete entry')!.disabled).toBe(false)
|
||||
expect(
|
||||
[...dialog()!.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'Cancel',
|
||||
)!.disabled,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the trash action on mobile that opens the same dialog', async () => {
|
||||
await mountEditor('/content/events/42', { width: 390 })
|
||||
|
||||
document.body.querySelector<HTMLButtonElement>('button[aria-label="Delete"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text(dialog())).toContain('Delete this entry?')
|
||||
})
|
||||
|
||||
it('is disabled while an edit is saving', async () => {
|
||||
const gate = pending()
|
||||
const wrapper = await mountEditor('/content/events/42', { writeEntry: () => gate.promise })
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(button('Delete')!.disabled).toBe(true)
|
||||
expect(text(chip())).toBe('Saving…')
|
||||
expect(control('title').disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import {
|
||||
answerWrite,
|
||||
arrived,
|
||||
button,
|
||||
calls,
|
||||
entryWrites,
|
||||
failWith,
|
||||
failWithBody,
|
||||
messageUnder,
|
||||
hasText,
|
||||
main,
|
||||
mountEditor,
|
||||
save,
|
||||
setField,
|
||||
text,
|
||||
typeInto,
|
||||
unmountAll,
|
||||
} from './entryEditorKit'
|
||||
import { customTypes } from './schemaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
return unmountAll()
|
||||
})
|
||||
|
||||
describe('save refusals outside the form', () => {
|
||||
it('shows the access page when the save answers 403', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42', { writeEntry: failWith(403) })
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
|
||||
await save()
|
||||
|
||||
expect(text(main())).toContain("You don't have access to Events")
|
||||
})
|
||||
|
||||
it('shows nothing and keeps the form when the save answers 401', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42', { writeEntry: failWith(401) })
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
|
||||
await save()
|
||||
|
||||
expect(hasText("Couldn't save the entry")).toBe(false)
|
||||
expect(hasText('Not saved')).toBe(false)
|
||||
expect(document.body.querySelector('form')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears the duplicate alert once the next save goes through', async () => {
|
||||
let attempts = 0
|
||||
await mountEditor('/content/events/42', {
|
||||
writeEntry: (write) => {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
throw new ApiError(409, undefined, 'invalid_entry: title: unique')
|
||||
}
|
||||
return answerWrite(write)
|
||||
},
|
||||
})
|
||||
await typeInto('title', 'Taken title')
|
||||
await save()
|
||||
expect(hasText('Change it and save again.')).toBe(true)
|
||||
|
||||
await save()
|
||||
|
||||
expect(entryWrites).toHaveLength(2)
|
||||
expect(hasText('Change it and save again.')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('required boolean', () => {
|
||||
it('shows the required message only from a mapped server answer', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42', {
|
||||
writeEntry: failWithBody(422, 'invalid_entry: online: required'),
|
||||
})
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
|
||||
await save()
|
||||
|
||||
expect(entryWrites).toHaveLength(1)
|
||||
expect(messageUnder('online')).toBe('Choose yes or no for Online.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('schema changed notice', () => {
|
||||
it.each([
|
||||
['invalid_entry: room: required', 'Room'],
|
||||
['invalid_entry: colour: unknown_field', 'Colour'],
|
||||
])('names the field of %s as %s', async (body, name) => {
|
||||
const wrapper = await mountEditor('/content/events/42', {
|
||||
writeEntry: failWithBody(422, body),
|
||||
})
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
|
||||
await save()
|
||||
|
||||
expect(text(document.body)).toContain(`a new required field, ${name}, was added`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('author names across a save', () => {
|
||||
it('keeps the names and looks nobody up again', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42')
|
||||
const lookups = () => calls.filter((url) => url.startsWith('/v1/user/lookup')).length
|
||||
expect(lookups()).toBe(1)
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
|
||||
await save()
|
||||
|
||||
expect(lookups()).toBe(1)
|
||||
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
|
||||
'Updated by Marta Alves',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('navigation after a save', () => {
|
||||
it('replaces the URL after a create and never pushes', async () => {
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
const push = vi.spyOn(router, 'push')
|
||||
const wrapper = await mountEditor('/content/events/new')
|
||||
await typeInto('title', 'Open day')
|
||||
await setField(wrapper, 'starts_on', '2026-11-21')
|
||||
push.mockClear()
|
||||
|
||||
await save()
|
||||
|
||||
await arrived('/content/events/43')
|
||||
expect(replace).toHaveBeenCalledWith('/content/events/43')
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the adopted route when the replace is cancelled', async () => {
|
||||
let blocking = true
|
||||
const remove = router.beforeEach((to) =>
|
||||
blocking && to.path === '/content/events/43' ? false : true,
|
||||
)
|
||||
const wrapper = await mountEditor('/content/events/new')
|
||||
await typeInto('title', 'Open day')
|
||||
await setField(wrapper, 'starts_on', '2026-11-21')
|
||||
await save()
|
||||
expect(router.currentRoute.value.path).toBe('/content/events/new')
|
||||
const before = calls.filter((url) => url.endsWith('/entry/43')).length
|
||||
blocking = false
|
||||
|
||||
await router.push('/content/events/43')
|
||||
await flushPromises()
|
||||
|
||||
remove()
|
||||
expect(calls.filter((url) => url.endsWith('/entry/43')).length).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('leaves the URL alone after an edit', async () => {
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
const wrapper = await mountEditor('/content/events/42')
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
|
||||
await save()
|
||||
|
||||
expect(entryWrites).toHaveLength(1)
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('content type load failure', () => {
|
||||
it('names the type request and retries only the type', async () => {
|
||||
let attempts = 0
|
||||
await mountEditor('/content/events/42', {
|
||||
type: (key) => {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
throw new ApiError(500)
|
||||
}
|
||||
return customTypes.find((candidate) => candidate.key === key)
|
||||
},
|
||||
})
|
||||
|
||||
expect(text(main())).toContain("Couldn't load the entry")
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/content-type/events failed. Check your connection and try again.',
|
||||
)
|
||||
const entryRequests = calls.filter((url) => url.endsWith('/entry/42')).length
|
||||
|
||||
button('Try again')!.click()
|
||||
await vi.waitFor(() => expect(document.body.querySelector('form')).toBeTruthy())
|
||||
|
||||
expect(attempts).toBe(2)
|
||||
expect(calls.filter((url) => url.endsWith('/entry/42')).length).toBe(entryRequests)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import {
|
||||
arrived,
|
||||
button,
|
||||
chip,
|
||||
failWithBody,
|
||||
hasText,
|
||||
mountEditor,
|
||||
setField,
|
||||
text,
|
||||
typeInto,
|
||||
unmountAll,
|
||||
} from './entryEditorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
function breadcrumbLink(label: string) {
|
||||
return [
|
||||
...document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a'),
|
||||
].find((candidate) => text(candidate) === label)!
|
||||
}
|
||||
|
||||
async function prompted() {
|
||||
await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true))
|
||||
}
|
||||
|
||||
describe('Leaving with unsaved edits', () => {
|
||||
it('navigates with no prompt when nothing changed', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
breadcrumbLink('Events').click()
|
||||
|
||||
await arrived('/content/events')
|
||||
expect(hasText('Leave without saving?')).toBe(false)
|
||||
})
|
||||
|
||||
it('prompts after an edit and stays on Keep editing', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
await typeInto('title', 'Changed')
|
||||
|
||||
breadcrumbLink('Events').click()
|
||||
await prompted()
|
||||
expect(hasText('Your changes to "Lime plaster workshop" haven\'t been saved.')).toBe(true)
|
||||
button('Keep editing')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events/42')
|
||||
})
|
||||
|
||||
it('leaves on Discard changes', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
await typeInto('title', 'Changed')
|
||||
|
||||
breadcrumbLink('Events').click()
|
||||
await prompted()
|
||||
button('Discard changes')!.click()
|
||||
|
||||
await arrived('/content/events')
|
||||
})
|
||||
|
||||
it('prompts for a typed value on a new entry but not for untouched defaults', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
breadcrumbLink('Events').click()
|
||||
await arrived('/content/events')
|
||||
expect(hasText('Leave without saving?')).toBe(false)
|
||||
|
||||
await router.push('/content/events/new')
|
||||
await flushPromises()
|
||||
await typeInto('title', 'x')
|
||||
breadcrumbLink('Events').click()
|
||||
await prompted()
|
||||
})
|
||||
|
||||
it('prompts through Cancel too', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
await typeInto('title', 'Changed')
|
||||
|
||||
button('Cancel')!.click()
|
||||
|
||||
await prompted()
|
||||
})
|
||||
|
||||
it('does not prompt after a save', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42')
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
breadcrumbLink('Events').click()
|
||||
|
||||
await arrived('/content/events')
|
||||
expect(hasText('Leave without saving?')).toBe(false)
|
||||
})
|
||||
|
||||
it('prompts in the schema changed state with typed values', async () => {
|
||||
const wrapper = await mountEditor('/content/events/new', {
|
||||
writeEntry: failWithBody(422, 'invalid_entry: room: required'),
|
||||
})
|
||||
await typeInto('title', 'Open day')
|
||||
await setField(wrapper, 'starts_on', '2026-11-21')
|
||||
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text(chip())).toBe('Not saved')
|
||||
breadcrumbLink('Events').click()
|
||||
await prompted()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel history', () => {
|
||||
it('goes back to the list with its query when it was the previous page', async () => {
|
||||
await router.push('/content/events?sort=id:asc')
|
||||
await mountEditor('/content/events/42')
|
||||
await router.push('/content/events/42')
|
||||
await flushPromises()
|
||||
|
||||
button('Cancel')!.click()
|
||||
|
||||
await arrived('/content/events?sort=id:asc')
|
||||
})
|
||||
|
||||
it('navigates to the list when the previous page is something else', async () => {
|
||||
await router.push('/home')
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
button('Cancel')!.click()
|
||||
|
||||
await arrived('/content/events')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,363 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import router from '@/router'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
MOBILE_WIDTH,
|
||||
arrived,
|
||||
button,
|
||||
calls,
|
||||
chip,
|
||||
control,
|
||||
customTypes,
|
||||
entry42,
|
||||
entryWith,
|
||||
eventsFields,
|
||||
failWith,
|
||||
failWithBody,
|
||||
fieldWrapper,
|
||||
hasText,
|
||||
setField,
|
||||
typeInto,
|
||||
main,
|
||||
mountEditor,
|
||||
networkFailure,
|
||||
pending,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './entryEditorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
function entryRequests() {
|
||||
return calls.filter((url) => /\/entry\/\d+$/.test(url))
|
||||
}
|
||||
|
||||
describe('1 New entry', () => {
|
||||
it('shows the title, the subtitle, the chip and the actions', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
expect(text(main())).toContain('New entry Events · Fields marked * are required.')
|
||||
expect(text(chip())).toBe('New entry')
|
||||
expect(button('Cancel')).toBeTruthy()
|
||||
expect(button('Save')!.disabled).toBe(false)
|
||||
expect(button('Delete')).toBeUndefined()
|
||||
expect(entryRequests()).toEqual([])
|
||||
})
|
||||
|
||||
it('marks required labels and shows helps, placeholders and defaults', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
expect(text(fieldWrapper('title'))).toContain('Title *')
|
||||
expect(text(fieldWrapper('starts_on'))).toContain('Starts on *')
|
||||
expect(text(fieldWrapper('ends_on'))).not.toContain('*')
|
||||
expect(text(fieldWrapper('title'))).toContain('Each entry needs a different title.')
|
||||
expect(text(fieldWrapper('capacity'))).toContain('Between 1 and 5000.')
|
||||
expect(control('capacity').getAttribute('placeholder')).toBe('Enter a number')
|
||||
expect(text(fieldWrapper('category'))).toContain('Choose an option')
|
||||
expect(text(fieldWrapper('starts_on'))).toContain('Pick a date')
|
||||
expect(text(fieldWrapper('online'))).toContain('Off')
|
||||
expect(text(fieldWrapper('online'))).toContain('Default')
|
||||
expect(text(fieldWrapper('title'))).not.toContain('Default')
|
||||
})
|
||||
|
||||
it('shows the footer of an unsaved entry', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe(
|
||||
'Entry Not saved yet Created by – Updated by –',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads Content, Events and New entry in the breadcrumb', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
|
||||
'Content Events New entry',
|
||||
)
|
||||
})
|
||||
|
||||
it('has no required marker for assistive technology', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
const star = fieldWrapper('title').querySelector('[aria-hidden="true"]')
|
||||
expect(star?.textContent?.trim()).toBe('*')
|
||||
expect(control('title').getAttribute('aria-required')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('2 Editing', () => {
|
||||
it('shows the entry, the chip, the actions and the footer', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
expect(text(main())).toContain('Lime plaster workshop Events · Entry 42')
|
||||
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
|
||||
'Content Events Lime plaster workshop',
|
||||
)
|
||||
expect(text(chip())).toBe('Saved')
|
||||
expect(button('Delete')).toBeTruthy()
|
||||
expect(button('Save')!.disabled).toBe(true)
|
||||
expect(control('title').value).toBe('Lime plaster workshop')
|
||||
expect(control('capacity').value).toBe('18')
|
||||
expect(text(fieldWrapper('online'))).not.toContain('Default')
|
||||
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe(
|
||||
'Entry events / 42 Created by Inès Duarte · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
|
||||
)
|
||||
})
|
||||
|
||||
it('sends the entry request with the type request and the lookup once', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
expect(calls).toContain('/v1/content-type/events')
|
||||
expect(calls).toContain('/v1/content-type/events/entry/42')
|
||||
expect(calls.filter((url) => url.startsWith('/v1/user/lookup'))).toEqual([
|
||||
'/v1/user/lookup?ids=4,3',
|
||||
])
|
||||
})
|
||||
|
||||
it('reads a dash and keeps the date when the lookup fails or lacks an id', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
lookup: () => [{ id: 3, display_name: 'Marta Alves' }],
|
||||
})
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
|
||||
'Created by – · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads dashes when the lookup fails', async () => {
|
||||
await mountEditor('/content/events/42', { lookup: failWith(500) })
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
|
||||
'Updated by – · 15 Oct 2026, 11:02',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows a trash action on mobile and no Delete button', async () => {
|
||||
await mountEditor('/content/events/42', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(button('Delete')).toBeUndefined()
|
||||
expect(document.body.querySelector('button[aria-label="Delete"]')).toBeTruthy()
|
||||
expect(button('Cancel')).toBeTruthy()
|
||||
expect(button('Save')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the new entry layout on mobile without an action', async () => {
|
||||
await mountEditor('/content/events/new', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(document.body.querySelector('button[aria-label="Delete"]')).toBeNull()
|
||||
expect(button('Cancel')).toBeTruthy()
|
||||
expect(text(chip())).toBe('New entry')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The page title', () => {
|
||||
it.each([
|
||||
['text', { title: 'Lime plaster workshop' }, 'Lime plaster workshop'],
|
||||
['null', { title: null }, 'Entry 42'],
|
||||
['number', { title: 18 }, '18'],
|
||||
])('reads %s', async (_name, data, title) => {
|
||||
await mountEditor('/content/events/42', { entry: () => entryWith(data) })
|
||||
|
||||
expect(text(main().querySelector('h1'))).toBe(title)
|
||||
})
|
||||
|
||||
it('reads the first line of rich text', async () => {
|
||||
const types = customTypes.map((type) =>
|
||||
type.key === 'events'
|
||||
? { ...type, fields: [{ ...eventsFields[6]!, key: 'body', label: 'Body' }] }
|
||||
: type,
|
||||
)
|
||||
await mountEditor('/content/events/42', {
|
||||
type: () => types[0],
|
||||
entry: () => entryWith({ body: 'First line\n\nSecond' }),
|
||||
})
|
||||
|
||||
expect(text(main().querySelector('h1'))).toBe('First line')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Defaults', () => {
|
||||
it.each([
|
||||
['Capacity', 'capacity', { default: 100 }, '100'],
|
||||
['Title', 'title', { default: 'Untitled' }, 'Untitled'],
|
||||
])('pre-fills and tags %s', async (_label, key, rest, shown) => {
|
||||
const fields = eventsFields.map((field) =>
|
||||
field.key === key ? { ...field, ...rest } : field,
|
||||
)
|
||||
await mountEditor('/content/events/new', {
|
||||
type: () => ({ ...customTypes[0]!, fields }),
|
||||
})
|
||||
|
||||
expect(control(key).value).toBe(shown)
|
||||
expect(text(fieldWrapper(key))).toContain('Default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create-only defaults', () => {
|
||||
const withDefaults = eventsFields.map((field) =>
|
||||
field.key === 'capacity'
|
||||
? { ...field, default: 100 }
|
||||
: field.key === 'title'
|
||||
? { ...field, default: 'Untitled' }
|
||||
: field,
|
||||
)
|
||||
const type = () => ({ ...customTypes[0]!, fields: withDefaults })
|
||||
|
||||
it('pre-fills and tags every default on a new entry', async () => {
|
||||
await mountEditor('/content/events/new', { type })
|
||||
|
||||
expect(control('capacity').value).toBe('100')
|
||||
expect(control('title').value).toBe('Untitled')
|
||||
expect(text(fieldWrapper('capacity'))).toContain('Default')
|
||||
expect(text(fieldWrapper('title'))).toContain('Default')
|
||||
})
|
||||
|
||||
it('never pre-fills or tags a default on an existing entry', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
type,
|
||||
entry: () => entryWith({ capacity: null, title: 'Stored' }),
|
||||
})
|
||||
|
||||
expect(control('capacity').value).toBe('')
|
||||
expect(control('title').value).toBe('Stored')
|
||||
expect(text(main())).not.toContain('Default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session not ready', () => {
|
||||
it('shows the shell and issues no request', async () => {
|
||||
await mountEditor('/content/events/42', { status: 'checking' })
|
||||
|
||||
expect(calls.filter((url) => url.includes('/entry') || url.includes('lookup'))).toEqual([])
|
||||
expect(document.body.querySelector('[data-entry-field]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigating between entries', () => {
|
||||
const entry = (_key: string, id: number) =>
|
||||
entryWith({ title: `Entry title ${id}`, capacity: id }, { id })
|
||||
|
||||
it('carries no value, error or chip from /42 to /43', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42', {
|
||||
entry,
|
||||
writeEntry: failWith(500),
|
||||
})
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
|
||||
await flushPromises()
|
||||
await setField(wrapper, 'capacity', 42)
|
||||
expect(hasText("Couldn't save the entry")).toBe(true)
|
||||
|
||||
await router.push('/content/events/43')
|
||||
await flushPromises()
|
||||
|
||||
expect(control('title').value).toBe('Entry title 43')
|
||||
expect(control('capacity').value).toBe('43')
|
||||
expect(hasText("Couldn't save the entry")).toBe(false)
|
||||
expect(text(chip())).toBe('Saved')
|
||||
expect(text(main().querySelector('h1'))).toBe('Entry title 43')
|
||||
})
|
||||
|
||||
it('carries nothing from an entry to another type new form', async () => {
|
||||
await mountEditor('/content/events/42', { entry })
|
||||
await typeInto('title', 'Typed here')
|
||||
|
||||
void router.push('/content/recipes/new')
|
||||
await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true))
|
||||
button('Discard changes')!.click()
|
||||
await arrived('/content/recipes/new')
|
||||
await flushPromises()
|
||||
|
||||
expect(control('title').value).toBe('')
|
||||
expect(text(chip())).toBe('New entry')
|
||||
expect(text(main().querySelector('h1'))).toBe('New entry')
|
||||
expect(hasText('Typed here')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Entry not found and load failures', () => {
|
||||
it.each(['abc', '4294967296', '-1'])(
|
||||
'shows Entry not found for /content/events/%s with no entry request',
|
||||
async (id) => {
|
||||
await mountEditor(`/content/events/${id}`)
|
||||
|
||||
expect(text(main())).toContain('Entry not found')
|
||||
expect(text(main())).toContain('It may have been deleted by someone else.')
|
||||
expect(entryRequests()).toEqual([])
|
||||
expect(button('Back to Events')).toBeTruthy()
|
||||
},
|
||||
)
|
||||
|
||||
it('sends id 0 as is and shows Entry not found when the API answers 404', async () => {
|
||||
await mountEditor('/content/events/0', { entry: failWithBody(404, 'entry_not_found') })
|
||||
|
||||
expect(entryRequests()).toEqual(['/v1/content-type/events/entry/0'])
|
||||
expect(text(main())).toContain('Entry not found')
|
||||
})
|
||||
|
||||
it('shows Entry not found on 404 entry_not_found and goes back to the list', async () => {
|
||||
await mountEditor('/content/events/999', { entry: failWithBody(404, 'entry_not_found') })
|
||||
|
||||
expect(text(main())).toContain('Entry not found')
|
||||
button('Back to Events')!.click()
|
||||
await arrived('/content/events')
|
||||
})
|
||||
|
||||
it('shows the content type page on 404 content_type_not_found', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
type: failWithBody(404, 'content_type_not_found'),
|
||||
entry: failWithBody(404, 'content_type_not_found'),
|
||||
})
|
||||
|
||||
expect(text(main())).toContain('Content type not found')
|
||||
})
|
||||
|
||||
it('shows the access page on 403', async () => {
|
||||
await mountEditor('/content/events/42', { entry: failWith(403) })
|
||||
|
||||
expect(text(main())).toContain("You don't have access to Events")
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', failWith(500)],
|
||||
['a network failure', networkFailure],
|
||||
])('shows the load error on %s and retries only the entry', async (_name, failure) => {
|
||||
let attempts = 0
|
||||
await mountEditor('/content/events/42', {
|
||||
entry: () => {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
return failure()
|
||||
}
|
||||
return entry42
|
||||
},
|
||||
})
|
||||
|
||||
expect(text(main())).toContain("Couldn't load the entry")
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/content-type/events/entry/42 failed. Check your connection and try again.',
|
||||
)
|
||||
expect(document.body.querySelector('form')).toBeNull()
|
||||
const typeRequests = calls.filter((url) => url === '/v1/content-type/events').length
|
||||
|
||||
button('Try again')!.click()
|
||||
await vi.waitFor(() => expect(control('title').value).toBe('Lime plaster workshop'))
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/content-type/events').length).toBe(typeRequests)
|
||||
})
|
||||
|
||||
it('shows a skeleton with aria-busy while loading', async () => {
|
||||
const gate = pending()
|
||||
await mountEditor('/content/events/42', { entry: () => gate.promise })
|
||||
|
||||
const skeleton = document.body.querySelector('[data-testid="entry-editor-skeleton"]')
|
||||
expect(skeleton?.getAttribute('aria-busy')).toBe('true')
|
||||
expect(button('Save')).toBeUndefined()
|
||||
expect(button('Delete')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,506 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { formatClock } from '@/lib/updateFormat'
|
||||
import {
|
||||
answerWrite,
|
||||
arrived,
|
||||
button,
|
||||
calls,
|
||||
chip,
|
||||
control,
|
||||
customTypes,
|
||||
entryWith,
|
||||
eventsFields,
|
||||
entryWrites,
|
||||
failWith,
|
||||
failWithBody,
|
||||
fieldWrapper,
|
||||
hasText,
|
||||
messageUnder,
|
||||
mountEditor,
|
||||
networkFailure,
|
||||
pending,
|
||||
save,
|
||||
setField,
|
||||
text,
|
||||
typeInto,
|
||||
unmountAll,
|
||||
} from './entryEditorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const SAVED_CLOCK = formatClock(new Date('2026-10-19T11:02:00'))
|
||||
|
||||
async function fillRequired(wrapper: Awaited<ReturnType<typeof mountEditor>>) {
|
||||
await typeInto('title', 'Open day')
|
||||
await setField(wrapper, 'starts_on', '2026-11-21')
|
||||
}
|
||||
|
||||
describe('Payloads', () => {
|
||||
it('sends every field of a create, with explicit nulls', async () => {
|
||||
const wrapper = await mountEditor('/content/events/new')
|
||||
await fillRequired(wrapper)
|
||||
|
||||
await save()
|
||||
|
||||
expect(entryWrites).toHaveLength(1)
|
||||
expect(entryWrites[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
url: '/v1/content-type/events/entry',
|
||||
body: {
|
||||
data: {
|
||||
title: 'Open day',
|
||||
starts_on: '2026-11-21',
|
||||
ends_on: null,
|
||||
online: false,
|
||||
capacity: null,
|
||||
category: null,
|
||||
description: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('sends every field of an edit with the unchanged loaded values', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42', {
|
||||
entry: () => entryWith({ removed: 'x' }),
|
||||
})
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
|
||||
await save()
|
||||
|
||||
expect(entryWrites).toHaveLength(1)
|
||||
expect(entryWrites[0]).toMatchObject({
|
||||
method: 'PUT',
|
||||
url: '/v1/content-type/events/entry/42',
|
||||
})
|
||||
const data = entryWrites[0]!.body.data as Record<string, unknown>
|
||||
expect(data.capacity).toBe(20)
|
||||
expect(data.title).toBe('Lime plaster workshop')
|
||||
expect(Object.keys(data)).toEqual([
|
||||
'title',
|
||||
'starts_on',
|
||||
'ends_on',
|
||||
'online',
|
||||
'capacity',
|
||||
'category',
|
||||
'description',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps leading and trailing spaces of a text', async () => {
|
||||
const wrapper = await mountEditor('/content/events/new')
|
||||
await fillRequired(wrapper)
|
||||
await typeInto('title', ' spaced ')
|
||||
|
||||
await save()
|
||||
|
||||
expect((entryWrites[0]!.body.data as Record<string, unknown>).title).toBe(' spaced ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Untouched optional boolean', () => {
|
||||
const noDefault = [
|
||||
...eventsFields.map((field) =>
|
||||
field.key === 'online' ? { ...field, default: null } : field,
|
||||
),
|
||||
]
|
||||
|
||||
it('sends a create with a null boolean and shows no type message', async () => {
|
||||
const wrapper = await mountEditor('/content/events/new', {
|
||||
type: () => ({ ...customTypes[0]!, fields: noDefault }),
|
||||
})
|
||||
await fillRequired(wrapper)
|
||||
|
||||
await save()
|
||||
|
||||
expect(entryWrites).toHaveLength(1)
|
||||
expect((entryWrites[0]!.body.data as Record<string, unknown>).online).toBeNull()
|
||||
expect(messageUnder('online')).toBe('')
|
||||
expect(hasText('Choose yes or no')).toBe(false)
|
||||
})
|
||||
|
||||
it('sends an edit with a stored null boolean and shows no type message', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42', {
|
||||
entry: () => entryWith({ online: null }),
|
||||
})
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
|
||||
await save()
|
||||
|
||||
expect(entryWrites).toHaveLength(1)
|
||||
expect((entryWrites[0]!.body.data as Record<string, unknown>).online).toBeNull()
|
||||
expect(messageUnder('online')).toBe('')
|
||||
expect(text(chip())).toBe(`Saved at ${SAVED_CLOCK}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The heading while typing', () => {
|
||||
function headings() {
|
||||
return [
|
||||
text(document.body.querySelector('#dashboard-content h1')),
|
||||
text(document.body.querySelector('nav[aria-label="breadcrumb"]')),
|
||||
]
|
||||
}
|
||||
|
||||
it('keeps the h1 and the breadcrumb until a save answers', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
const before = headings()
|
||||
expect(before[0]).toBe('Lime plaster workshop')
|
||||
|
||||
await typeInto('title', 'Brand new name')
|
||||
|
||||
expect(headings()).toEqual(before)
|
||||
|
||||
await save()
|
||||
|
||||
expect(headings()[0]).toBe('Brand new name')
|
||||
expect(headings()[1]).toContain('Brand new name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('5 Saving', () => {
|
||||
it('dims the form, disables the controls and sends one request', async () => {
|
||||
const gate = pending()
|
||||
const wrapper = await mountEditor('/content/events/42', { writeEntry: () => gate.promise })
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
|
||||
await save()
|
||||
await save()
|
||||
control('title')
|
||||
.closest('form')!
|
||||
.dispatchEvent(new Event('submit', { cancelable: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(entryWrites).toHaveLength(1)
|
||||
expect(text(chip())).toBe('Saving…')
|
||||
expect(button('Saving…')!.disabled).toBe(true)
|
||||
expect(button('Cancel')!.disabled).toBe(true)
|
||||
expect(button('Delete')!.disabled).toBe(true)
|
||||
expect(control('title').disabled).toBe(true)
|
||||
expect(document.body.querySelector('form')!.className).toContain('opacity-70')
|
||||
expect(document.body.querySelector('form')!.getAttribute('aria-busy')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('6 Saved', () => {
|
||||
it('shows the toast, the chip and the answered values after an edit', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42')
|
||||
await typeInto('title', 'Lime plaster workshop, two days')
|
||||
|
||||
await save()
|
||||
|
||||
expect(hasText('Entry saved')).toBe(true)
|
||||
expect(hasText('"Lime plaster workshop, two days" is up to date.')).toBe(true)
|
||||
expect(text(chip())).toBe(`Saved at ${SAVED_CLOCK}`)
|
||||
expect(button('Save')!.disabled).toBe(true)
|
||||
expect(text(document.body.querySelector('h1'))).toBe('Lime plaster workshop, two days')
|
||||
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
|
||||
'Updated by Marta Alves · 19 Oct 2026, 11:02',
|
||||
)
|
||||
await setField(wrapper, 'capacity', 21)
|
||||
expect(text(chip())).toBe('Unsaved changes')
|
||||
})
|
||||
|
||||
it('replaces the URL after a create without a new entry request', async () => {
|
||||
const wrapper = await mountEditor('/content/events/new')
|
||||
await fillRequired(wrapper)
|
||||
const before = calls.length
|
||||
|
||||
await save()
|
||||
|
||||
await arrived('/content/events/43')
|
||||
expect(calls.slice(before).filter((url) => /\/entry\/\d+$/.test(url))).toEqual([])
|
||||
expect(calls.slice(before).filter((url) => url === '/v1/content-type/events')).toEqual([])
|
||||
expect(text(chip())).toBe(`Saved at ${SAVED_CLOCK}`)
|
||||
expect(button('Delete')).toBeTruthy()
|
||||
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
|
||||
'events / 43',
|
||||
)
|
||||
})
|
||||
|
||||
it('reverting an edit returns to Saved and disables Save', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
await typeInto('title', 'x')
|
||||
expect(text(chip())).toBe('Unsaved changes')
|
||||
expect(button('Save')!.disabled).toBe(false)
|
||||
|
||||
await typeInto('title', 'Lime plaster workshop')
|
||||
|
||||
expect(text(chip())).toBe('Saved')
|
||||
expect(button('Save')!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('7 Save failed', () => {
|
||||
it.each([
|
||||
['500', failWith(500)],
|
||||
['a network failure', networkFailure],
|
||||
['400', failWith(400)],
|
||||
['422 with no field', failWithBody(422, 'invalid_entry')],
|
||||
])('shows the alert on %s and sends the same body again', async (_name, failure) => {
|
||||
let attempts = 0
|
||||
const wrapper = await mountEditor('/content/events/42', {
|
||||
writeEntry: (write) => {
|
||||
attempts++
|
||||
return attempts === 1 ? failure() : answerWrite(write)
|
||||
},
|
||||
})
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
|
||||
await save()
|
||||
|
||||
expect(text(document.body)).toContain("Couldn't save the entry")
|
||||
expect(text(document.body)).toContain(
|
||||
'Something went wrong on our side. Your changes are still here. Try again in a moment.',
|
||||
)
|
||||
expect(text(chip())).toBe('Save failed')
|
||||
expect(button('Save')!.disabled).toBe(false)
|
||||
expect(document.body.querySelector('form')!.className).not.toContain('opacity-70')
|
||||
|
||||
button('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(entryWrites).toHaveLength(2)
|
||||
expect(entryWrites[1]!.body).toEqual(entryWrites[0]!.body)
|
||||
expect(text(document.body)).not.toContain("Couldn't save the entry")
|
||||
})
|
||||
})
|
||||
|
||||
describe('8 Required fields empty', () => {
|
||||
it('sends nothing, shows every message and focuses the first field', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
await save()
|
||||
|
||||
expect(entryWrites).toEqual([])
|
||||
expect(messageUnder('title')).toBe('Fill in Title.')
|
||||
expect(messageUnder('starts_on')).toBe('Pick a date for Starts on.')
|
||||
expect(text(chip())).toBe('Not saved')
|
||||
expect(document.activeElement).toBe(control('title'))
|
||||
expect(control('title').getAttribute('aria-invalid')).toBe('true')
|
||||
expect(control('title').getAttribute('aria-describedby')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears a message when its field is edited and validates nothing on blur', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
await save()
|
||||
|
||||
await typeInto('title', 'Open day')
|
||||
|
||||
expect(messageUnder('title')).toBe('')
|
||||
expect(messageUnder('starts_on')).toBe('Pick a date for Starts on.')
|
||||
expect(text(chip())).toBe('Unsaved changes')
|
||||
control('title').dispatchEvent(new Event('blur'))
|
||||
await flushPromises()
|
||||
expect(messageUnder('title')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('9 Rule errors', () => {
|
||||
it('shows one message per field with the counter', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42')
|
||||
await typeInto('title', 'x'.repeat(91))
|
||||
await setField(wrapper, 'capacity', 6000)
|
||||
await setField(wrapper, 'category', 'Seminar')
|
||||
|
||||
await save()
|
||||
|
||||
expect(entryWrites).toEqual([])
|
||||
expect(messageUnder('title')).toBe('Title can be up to 80 characters.')
|
||||
expect(messageUnder('capacity')).toBe('Capacity must be at most 5000.')
|
||||
expect(messageUnder('category')).toBe('Choose one of: Talk, Workshop or Meetup.')
|
||||
expect(text(fieldWrapper('title'))).toContain('91 / 80')
|
||||
expect(
|
||||
fieldWrapper('title').querySelector('[data-testid="entry-counter"]')!.className,
|
||||
).toContain('text-error')
|
||||
expect(text(chip())).toBe('Not saved')
|
||||
expect(document.activeElement).toBe(control('title'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['title', 'min', 'Title needs at least 3 characters.'],
|
||||
['capacity', 'max', 'Capacity must be at most 5000.'],
|
||||
['ends_on', 'type', 'Ends on must be a date, like 2026-11-21.'],
|
||||
['category', 'options', 'Choose one of: Talk, Workshop or Meetup.'],
|
||||
['title', 'required', 'Fill in Title.'],
|
||||
])('maps a server answer %s %s onto its field', async (key, rule, message) => {
|
||||
const wrapper = await mountEditor('/content/events/42', {
|
||||
writeEntry: failWithBody(422, `invalid_entry: ${key}: ${rule}`),
|
||||
type: () => ({
|
||||
id: 1,
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: null,
|
||||
created_at: '2026-09-01T09:30:00Z',
|
||||
updated_at: '2026-09-01T09:30:00Z',
|
||||
fields: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: 3,
|
||||
max: 80,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'ends_on',
|
||||
label: 'Ends on',
|
||||
type: 'date',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'capacity',
|
||||
label: 'Capacity',
|
||||
type: 'number',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: 1,
|
||||
max: 5000,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
type: 'select',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
entry: () => entryWith({}),
|
||||
})
|
||||
await setField(wrapper, 'title', 'Open day')
|
||||
|
||||
await save()
|
||||
|
||||
expect(entryWrites).toHaveLength(1)
|
||||
expect(messageUnder(key)).toBe(message)
|
||||
expect(text(chip())).toBe('Not saved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('10 Duplicate unique value (409)', () => {
|
||||
it('shows the alert and the field message, and clears the alert on edit', async () => {
|
||||
const wrapper = await mountEditor('/content/events/new', {
|
||||
writeEntry: failWithBody(409, 'invalid_entry: title: unique'),
|
||||
})
|
||||
await fillRequired(wrapper)
|
||||
await typeInto('title', 'Heat pumps: what the numbers say')
|
||||
|
||||
await save()
|
||||
|
||||
expect(text(document.body)).toContain(
|
||||
'Another entry already uses this title. Change it and save again.',
|
||||
)
|
||||
expect(messageUnder('title')).toBe(
|
||||
'Another entry already uses this Title. Enter a different one.',
|
||||
)
|
||||
expect(text(chip())).toBe('Not saved')
|
||||
expect(document.activeElement).toBe(control('title'))
|
||||
expect(control('title').value).toBe('Heat pumps: what the numbers say')
|
||||
|
||||
await typeInto('title', 'Heat pumps')
|
||||
|
||||
expect(text(document.body)).not.toContain('Change it and save again.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('11 Entry not found on save', () => {
|
||||
it('shows Entry not found when the PUT answers 404 entry_not_found', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42', {
|
||||
writeEntry: failWithBody(404, 'entry_not_found'),
|
||||
})
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
|
||||
await save()
|
||||
|
||||
expect(text(document.body)).toContain('Entry not found')
|
||||
button('Back to Events')!.click()
|
||||
await arrived('/content/events')
|
||||
})
|
||||
})
|
||||
|
||||
describe('12 Schema changed since load', () => {
|
||||
it.each([
|
||||
['invalid_entry: room: required', 422],
|
||||
['invalid_entry: description: unknown_field', 422],
|
||||
['invalid_entry: colour: unknown_field', 422],
|
||||
['invalid_entry: room: unique', 409],
|
||||
])('shows the read-only form on %s', async (body, status) => {
|
||||
const wrapper = await mountEditor('/content/events/new', {
|
||||
writeEntry: failWithBody(status, body),
|
||||
})
|
||||
await fillRequired(wrapper)
|
||||
|
||||
await save()
|
||||
|
||||
expect(text(document.body)).toContain('The fields of Events changed')
|
||||
expect(text(chip())).toBe('Not saved')
|
||||
expect(button('Save')!.disabled).toBe(true)
|
||||
expect(button('Cancel')!.disabled).toBe(false)
|
||||
expect(control('title').disabled).toBe(true)
|
||||
expect(control('title').value).toBe('Open day')
|
||||
})
|
||||
|
||||
it('rebuilds the form from the new type when Reload form is pressed', async () => {
|
||||
let reloads = 0
|
||||
const wrapper = await mountEditor('/content/events/new', {
|
||||
writeEntry: failWithBody(422, 'invalid_entry: room: required'),
|
||||
type: (key) => {
|
||||
reloads++
|
||||
return {
|
||||
id: 1,
|
||||
key,
|
||||
name: 'Events',
|
||||
icon: null,
|
||||
created_at: '2026-09-01T09:30:00Z',
|
||||
updated_at: '2026-09-01T09:30:00Z',
|
||||
fields: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
})
|
||||
await typeInto('title', 'Open day')
|
||||
await save()
|
||||
expect(reloads).toBe(1)
|
||||
|
||||
button('Reload form')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(reloads).toBe(2)
|
||||
expect(control('title').value).toBe('')
|
||||
expect(text(chip())).toBe('New entry')
|
||||
expect(text(document.body)).not.toContain('The fields of Events changed')
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -150,7 +150,7 @@ export function entry(row: Row) {
|
||||
] = row
|
||||
return {
|
||||
id,
|
||||
content_type_id: 1,
|
||||
content_type: 'events',
|
||||
data: {
|
||||
title,
|
||||
starts_on: startsOn,
|
||||
@@ -181,7 +181,6 @@ export function listPage(items: unknown[], total: number, query: URLSearchParams
|
||||
total,
|
||||
page: Number(query.get('page')),
|
||||
size: Number(query.get('size')),
|
||||
sort: query.get('sort'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import router from '@/router'
|
||||
import { expect, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import EntryField from '@/components/EntryField.vue'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { mountAt, text, type EntryWrite, type SchemaMount } from './schemaTestKit'
|
||||
|
||||
export {
|
||||
button,
|
||||
calls,
|
||||
customTypes,
|
||||
entryWrites,
|
||||
failWith,
|
||||
failWithBody,
|
||||
networkFailure,
|
||||
pending,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './schemaTestKit'
|
||||
|
||||
export { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
export { eventsFields } from '@/lib/__tests__/schemaFixtures'
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
export const MARTA = 3
|
||||
export const INES = 4
|
||||
export const JONAS = 5
|
||||
|
||||
export const entry42 = {
|
||||
id: 42,
|
||||
content_type: 'events',
|
||||
data: {
|
||||
title: 'Lime plaster workshop',
|
||||
starts_on: '2026-11-21',
|
||||
ends_on: '2026-11-22',
|
||||
online: false,
|
||||
capacity: 18,
|
||||
category: 'Workshop',
|
||||
description:
|
||||
'Two days of hands-on work with **lime putty** and old stone.\n\n- Bring work clothes\n- Lunch is included',
|
||||
},
|
||||
created_by: INES,
|
||||
updated_by: MARTA,
|
||||
created_at: '2026-10-12T09:14:00',
|
||||
updated_at: '2026-10-15T11:02:00',
|
||||
}
|
||||
|
||||
export const userNames = [
|
||||
{ id: INES, display_name: 'Inès Duarte' },
|
||||
{ id: MARTA, display_name: 'Marta Alves' },
|
||||
{ id: JONAS, display_name: 'Jonas Pereira' },
|
||||
]
|
||||
|
||||
export function lookupNames(ids: number[]) {
|
||||
return userNames.filter((user) => ids.includes(user.id))
|
||||
}
|
||||
|
||||
export function entryWith(data: Record<string, unknown>, rest: Record<string, unknown> = {}) {
|
||||
return { ...entry42, ...rest, data: { ...entry42.data, ...data } }
|
||||
}
|
||||
|
||||
export function answerWrite(write: EntryWrite) {
|
||||
const data = write.body.data as Record<string, unknown>
|
||||
const base = write.method === 'POST' ? { ...entry42, id: 43 } : entry42
|
||||
return {
|
||||
...base,
|
||||
data,
|
||||
updated_by: MARTA,
|
||||
updated_at: '2026-10-19T11:02:00',
|
||||
}
|
||||
}
|
||||
|
||||
export function mountEditor(path: string, options: SchemaMount = {}) {
|
||||
return mountAt(path, {
|
||||
entry: () => entry42,
|
||||
writeEntry: answerWrite,
|
||||
lookup: lookupNames,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export async function settle() {
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function fieldWrapper(key: string) {
|
||||
return document.body.querySelector<HTMLElement>(`[data-entry-field="${key}"]`)!
|
||||
}
|
||||
|
||||
export function control(key: string) {
|
||||
return fieldWrapper(key).querySelector<HTMLInputElement | HTMLTextAreaElement>(
|
||||
'input, textarea',
|
||||
)!
|
||||
}
|
||||
|
||||
export async function typeInto(key: string, value: string) {
|
||||
const element = control(key)
|
||||
element.value = value
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function chip() {
|
||||
return document.body.querySelector('[data-testid="builder-chip"]')
|
||||
}
|
||||
|
||||
export async function save() {
|
||||
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function setField(wrapper: VueWrapper, key: string, value: unknown) {
|
||||
const owner = wrapper
|
||||
.findAllComponents(EntryField)
|
||||
.find((candidate) => candidate.props('field').key === key)!
|
||||
owner.vm.$emit('update', key, value)
|
||||
return flushPromises()
|
||||
}
|
||||
|
||||
export function main() {
|
||||
return document.body.querySelector('#dashboard-content')!
|
||||
}
|
||||
|
||||
export function toast() {
|
||||
return text(document.body.querySelector('[data-part="root"], [role="status"][data-state]'))
|
||||
}
|
||||
|
||||
export function hasText(value: string) {
|
||||
return text(document.body).includes(value)
|
||||
}
|
||||
|
||||
export function messageUnder(key: string) {
|
||||
return text(fieldWrapper(key).querySelector('[role="alert"]'))
|
||||
}
|
||||
|
||||
export async function arrived(path: string) {
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe(path))
|
||||
}
|
||||
|
||||
export function installSelectPolyfills() {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
}
|
||||
|
||||
export async function openChoices(key: string) {
|
||||
const trigger = fieldWrapper(key).querySelector('[role="combobox"]')!
|
||||
trigger.dispatchEvent(
|
||||
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
return [...document.body.querySelectorAll<HTMLElement>('[role="option"]')]
|
||||
}
|
||||
@@ -134,7 +134,15 @@ function defaultType(key: string) {
|
||||
return found
|
||||
}
|
||||
|
||||
export interface EntryWrite {
|
||||
method: string
|
||||
url: string
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SchemaMount {
|
||||
entry?: (key: string, id: number) => unknown
|
||||
writeEntry?: (write: EntryWrite) => unknown
|
||||
role?: string
|
||||
create?: (body: PostedRequest['body']) => unknown
|
||||
update?: (body: PostedRequest['body'], key: string) => unknown
|
||||
@@ -144,6 +152,7 @@ export interface SchemaMount {
|
||||
content?: Responder
|
||||
builtin?: Responder
|
||||
verified?: boolean
|
||||
status?: 'checking' | 'ready'
|
||||
actions?: string[]
|
||||
type?: (key: string) => unknown
|
||||
list?: (key: string, query: URLSearchParams) => unknown
|
||||
@@ -156,6 +165,8 @@ export const calls: string[] = []
|
||||
export const posts: PostedRequest[] = []
|
||||
export const puts: PostedRequest[] = []
|
||||
export const deletes: string[] = []
|
||||
export const entryWrites: EntryWrite[] = []
|
||||
const ENTRY_URL = /^\/v1\/content-type\/[^/?]+\/entry(\/\d+)?$/
|
||||
|
||||
export function updatedType(key: string, body: Record<string, unknown>) {
|
||||
const base = customTypes.find((type) => type.key === key)!
|
||||
@@ -167,6 +178,7 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
posts.length = 0
|
||||
puts.length = 0
|
||||
deletes.length = 0
|
||||
entryWrites.length = 0
|
||||
localStorage.clear()
|
||||
options.before?.()
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
@@ -187,8 +199,17 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
displayName: 'Marta Alves',
|
||||
role: (options.role ?? 'owner') as never,
|
||||
}
|
||||
session.status = 'ready'
|
||||
session.status = options.status ?? 'ready'
|
||||
const request = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method && ENTRY_URL.test(url)) {
|
||||
const write = {
|
||||
method: init.method,
|
||||
url,
|
||||
body: init.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {},
|
||||
}
|
||||
entryWrites.push(write)
|
||||
return options.writeEntry?.(write)
|
||||
}
|
||||
if (init?.method === 'DELETE') {
|
||||
deletes.push(url)
|
||||
return (options.remove ?? (() => undefined))(url.split('/').pop()!)
|
||||
@@ -223,6 +244,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
if (looked) {
|
||||
return (options.lookup ?? (() => []))(looked[1]!.split(',').map(Number))
|
||||
}
|
||||
const single = url.match(/^\/v1\/content-type\/([^/?]+)\/entry\/(\d+)$/)
|
||||
if (single) {
|
||||
return options.entry?.(single[1]!, Number(single[2]))
|
||||
}
|
||||
const detailed = url.match(/^\/v1\/content-type\/([^/?]+)$/)
|
||||
if (detailed) {
|
||||
return (options.type ?? defaultType)(detailed[1]!)
|
||||
@@ -233,7 +258,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
return undefined
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta' }),
|
||||
getMe:
|
||||
options.status === 'checking'
|
||||
? vi.fn(() => new Promise(() => undefined))
|
||||
: vi.fn().mockResolvedValue({ verified: true, username: 'marta' }),
|
||||
request,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(path)
|
||||
|
||||
Reference in new issue
Block a user