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
@@ -1,102 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import ApiPreview from '@/components/ApiPreview.vue'
|
||||
import ApiPreviewSheet from '@/components/ApiPreviewSheet.vue'
|
||||
import BuilderActions from '@/components/BuilderActions.vue'
|
||||
import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||
import BuilderInspector from '@/components/BuilderInspector.vue'
|
||||
import BuilderTypeBar from '@/components/BuilderTypeBar.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import FieldList from '@/components/FieldList.vue'
|
||||
import FieldSheet from '@/components/FieldSheet.vue'
|
||||
import RetryAlert from '@/components/RetryAlert.vue'
|
||||
import SchemaBuilderPage from '@/components/SchemaBuilderPage.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import { useSchemaBuilder } from '@/composables/useSchemaBuilder'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { sampleJson } from '@/lib/schemaBuilderSample'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const OFFLINE_COPY = "You're offline. Changes will be saved when you reconnect."
|
||||
|
||||
const offlineMessage = computed(() =>
|
||||
t("You're offline. Changes will be saved when you reconnect."),
|
||||
)
|
||||
const router = useRouter()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const { sessionReady, forbidden } = useSchemaGate('custom')
|
||||
const builder = useSchemaBuilder()
|
||||
const { form } = builder
|
||||
|
||||
useUnsavedChangesGuard({
|
||||
isDirty: () => builder.dirty.value,
|
||||
title: () => form.value.name.trim() || t('New content type'),
|
||||
})
|
||||
|
||||
const apiOpen = ref(false)
|
||||
const apiSheetOpen = ref(false)
|
||||
const fieldSheetOpen = ref(false)
|
||||
|
||||
const showForbidden = computed(() => forbidden.value || builder.failure.value === 'forbidden')
|
||||
const mobileTitle = computed(() => t('New content type'))
|
||||
const breadcrumb = computed(() => [
|
||||
{ label: t('Schema'), to: SCHEMA_PATH },
|
||||
{ label: t('New content type') },
|
||||
])
|
||||
const json = computed(() => sampleJson(form.value.key, form.value.fields))
|
||||
const selectedIndex = computed(() =>
|
||||
form.value.fields.findIndex((field) => field.uid === builder.selectedUid.value),
|
||||
)
|
||||
const selectedErrors = computed(() =>
|
||||
builder.selected.value
|
||||
? (builder.shown.value.fields.get(builder.selected.value.uid) ?? {})
|
||||
: {},
|
||||
)
|
||||
const selectedName = computed(() => builder.selected.value?.label.trim() || t('Untitled field'))
|
||||
const sheetTitle = computed(() => t('Field: {0}', [selectedName.value]))
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
|
||||
function onSelect(uid: number) {
|
||||
builder.selectField(uid)
|
||||
if (!isDesktop.value) {
|
||||
fieldSheetOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
builder.addField()
|
||||
if (!isDesktop.value) {
|
||||
fieldSheetOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onRemoveSelected() {
|
||||
const uid = builder.selectedUid.value
|
||||
if (uid !== null) {
|
||||
builder.removeField(uid)
|
||||
}
|
||||
if (!isDesktop.value) {
|
||||
fieldSheetOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onMoveSelected(delta: number) {
|
||||
const uid = builder.selectedUid.value
|
||||
if (uid !== null) {
|
||||
builder.moveField(uid, delta)
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreRowFocus() {
|
||||
await nextTick()
|
||||
const uid = builder.selectedUid.value
|
||||
document.querySelector<HTMLElement>(`[data-field-row][data-uid="${uid}"]`)?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -105,8 +32,9 @@ async function restoreRowFocus() {
|
||||
:mobile-title="mobileTitle"
|
||||
:back-to="SCHEMA_PATH"
|
||||
:back-label="t('Schema')"
|
||||
:offline-message="t(OFFLINE_COPY)"
|
||||
:offline-message="offlineMessage"
|
||||
offline-icon="i-lucide-wifi-off"
|
||||
flush-offline-banner
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
@@ -117,171 +45,7 @@ async function restoreRowFocus() {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SchemaForbidden v-if="showForbidden" @back="goHome" />
|
||||
|
||||
<div
|
||||
v-else-if="sessionReady"
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
data-testid="schema-builder"
|
||||
>
|
||||
<div class="shrink-0 border-b border-default bg-default p-4 md:px-6">
|
||||
<BuilderTypeBar
|
||||
:name="form.name"
|
||||
:key-value="form.key"
|
||||
:key-mode="form.keyMode"
|
||||
:name-error="builder.shown.value.type.name"
|
||||
:key-error="builder.shown.value.type.key"
|
||||
:disabled="builder.saving.value"
|
||||
:key-focus-token="builder.keyFocusToken.value"
|
||||
@update:name="builder.setName"
|
||||
@update:key="builder.setKey"
|
||||
@unlock="builder.unlockKey"
|
||||
@reset="builder.resetKey"
|
||||
>
|
||||
<template v-if="isDesktop" #actions>
|
||||
<BuilderActions
|
||||
:status="builder.status.value"
|
||||
:problems="builder.problems.value"
|
||||
:can-save="builder.canSave.value"
|
||||
:can-reveal="builder.canReveal.value"
|
||||
:can-discard="builder.canDiscard.value"
|
||||
:saving="builder.saving.value"
|
||||
@save="builder.save"
|
||||
@discard="builder.discard"
|
||||
/>
|
||||
</template>
|
||||
</BuilderTypeBar>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="builder.failure.value === 'failed' || builder.failure.value === 'invalid'"
|
||||
class="px-4 pt-3 md:px-6"
|
||||
>
|
||||
<RetryAlert
|
||||
v-if="builder.failure.value === 'failed'"
|
||||
:title="t('Couldn\'t save the content type')"
|
||||
:description="
|
||||
t(
|
||||
'Something went wrong on our side. Your changes are still here. Try again in a moment.',
|
||||
)
|
||||
"
|
||||
@retry="builder.save"
|
||||
/>
|
||||
<UAlert
|
||||
v-else
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="t('Check the fields marked in the list.')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<div
|
||||
class="flex flex-col gap-2 p-4 md:w-95 md:shrink-0 md:border-r md:border-default md:bg-muted"
|
||||
>
|
||||
<FieldList
|
||||
:fields="form.fields"
|
||||
:selected-uid="builder.selectedUid.value"
|
||||
:errors="builder.shown.value.fields"
|
||||
:list-error="builder.shown.value.type.fields"
|
||||
:disabled="builder.saving.value"
|
||||
:mobile="!isDesktop"
|
||||
@select="onSelect"
|
||||
@add="onAdd"
|
||||
@remove="builder.removeField"
|
||||
@move="builder.moveField"
|
||||
@reorder="builder.reorderFields"
|
||||
>
|
||||
<button
|
||||
v-if="!isDesktop"
|
||||
type="button"
|
||||
data-testid="api-row"
|
||||
class="flex min-h-11 items-center gap-2.5 border border-default bg-default px-3 text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
|
||||
@click="apiSheetOpen = true"
|
||||
>
|
||||
<UIcon name="i-lucide-braces" class="size-4" />
|
||||
<span class="flex-1 text-left font-medium">{{ t('API preview') }}</span>
|
||||
<UIcon name="i-lucide-chevron-right" class="size-3.5 text-muted" />
|
||||
</button>
|
||||
</FieldList>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDesktop"
|
||||
class="flex min-w-0 flex-1 flex-col gap-3.5 bg-default px-6 py-4"
|
||||
data-testid="inspector-pane"
|
||||
>
|
||||
<template v-if="builder.selected.value">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-md font-semibold text-highlighted">
|
||||
{{ sheetTitle }}
|
||||
</h2>
|
||||
<BuilderBadge kind="new" />
|
||||
</div>
|
||||
<BuilderInspector
|
||||
:builder="builder"
|
||||
:field="builder.selected.value"
|
||||
:errors="selectedErrors"
|
||||
:compact="false"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2 class="text-md font-semibold text-highlighted">
|
||||
{{ t('Field settings') }}
|
||||
</h2>
|
||||
<p class="text-muted">
|
||||
{{ t('Add a field, then choose its type and rules here.') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApiPreview
|
||||
v-if="isDesktop"
|
||||
v-model:open="apiOpen"
|
||||
:key-value="form.key"
|
||||
:json="json"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="sticky bottom-0 mt-auto border-t border-default bg-default px-4 py-3"
|
||||
>
|
||||
<BuilderActions
|
||||
:status="builder.status.value"
|
||||
:problems="builder.problems.value"
|
||||
:can-save="builder.canSave.value"
|
||||
:can-reveal="builder.canReveal.value"
|
||||
:can-discard="builder.canDiscard.value"
|
||||
:saving="builder.saving.value"
|
||||
@save="builder.save"
|
||||
@discard="builder.discard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!isDesktop && sessionReady && !showForbidden">
|
||||
<ApiPreviewSheet v-model:open="apiSheetOpen" :key-value="form.key" :json="json" />
|
||||
<FieldSheet
|
||||
v-model:open="fieldSheetOpen"
|
||||
:title="sheetTitle"
|
||||
:first="selectedIndex <= 0"
|
||||
:last="selectedIndex === form.fields.length - 1"
|
||||
:disabled="builder.saving.value"
|
||||
@up="onMoveSelected(-1)"
|
||||
@down="onMoveSelected(1)"
|
||||
@remove="onRemoveSelected"
|
||||
@closed="restoreRowFocus"
|
||||
>
|
||||
<BuilderInspector
|
||||
v-if="builder.selected.value"
|
||||
:builder="builder"
|
||||
:field="builder.selected.value"
|
||||
:errors="selectedErrors"
|
||||
compact
|
||||
/>
|
||||
</FieldSheet>
|
||||
</template>
|
||||
<SchemaForbidden v-if="forbidden" @back="goHome" />
|
||||
<SchemaBuilderPage v-else-if="sessionReady" />
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -1,30 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import DirectoryBadge from '@/components/DirectoryBadge.vue'
|
||||
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||
import SchemaBuilderPage from '@/components/SchemaBuilderPage.vue'
|
||||
import SchemaFieldTable from '@/components/SchemaFieldTable.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import SchemaTable from '@/components/SchemaTable.vue'
|
||||
import SchemaTypeNotFound from '@/components/SchemaTypeNotFound.vue'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { useEntryCount } from '@/composables/useEntryCount'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
|
||||
const route = useRoute()
|
||||
const offlineMessage = computed(() =>
|
||||
t("You're offline. Changes will be saved when you reconnect."),
|
||||
)
|
||||
const router = useRouter()
|
||||
const skeleton = useDelayedSkeleton()
|
||||
|
||||
const { sessionReady, forbidden, loading, showSkeleton, builtinFailed, builtinItems, retry } =
|
||||
useSchemaGate('builtin')
|
||||
const failed = computed(() => !forbidden.value && builtinFailed.value)
|
||||
const type = computed(() => builtinItems.value.find((item) => item.key === route.params.key))
|
||||
const mobileTitle = computed(() => type.value?.name ?? t('Schema'))
|
||||
const {
|
||||
sessionReady,
|
||||
forbidden: listsForbidden,
|
||||
loading: listsLoading,
|
||||
customFailed,
|
||||
builtinFailed,
|
||||
customItems,
|
||||
builtinItems,
|
||||
retry: retryLists,
|
||||
} = useSchemaGate('all')
|
||||
|
||||
const key = computed(() => String(route.params.key))
|
||||
const builtinType = computed(() => builtinItems.value.find((item) => item.key === key.value))
|
||||
const customType = computed(() =>
|
||||
builtinType.value ? undefined : customItems.value.find((item) => item.key === key.value),
|
||||
)
|
||||
const count = useEntryCount(() =>
|
||||
customType.value && sessionReady.value && !listsForbidden.value && !listsLoading.value
|
||||
? customType.value.key
|
||||
: null,
|
||||
)
|
||||
|
||||
const forbidden = computed(() => listsForbidden.value || count.status.value === 'forbidden')
|
||||
const countPending = computed(
|
||||
() =>
|
||||
customType.value !== undefined &&
|
||||
(count.status.value === 'idle' || count.status.value === 'loading'),
|
||||
)
|
||||
const loading = computed(() => !forbidden.value && (listsLoading.value || countPending.value))
|
||||
const listsFailed = computed(() => !forbidden.value && !loading.value && customFailed.value)
|
||||
const builtinListFailed = computed(
|
||||
() => !forbidden.value && !loading.value && !listsFailed.value && builtinFailed.value,
|
||||
)
|
||||
const countFailed = computed(
|
||||
() => !forbidden.value && !loading.value && count.status.value === 'failed',
|
||||
)
|
||||
const editable = computed(
|
||||
() => !forbidden.value && !loading.value && customType.value && count.status.value === 'ready',
|
||||
)
|
||||
const notFound = computed(
|
||||
() =>
|
||||
!forbidden.value &&
|
||||
!loading.value &&
|
||||
!listsFailed.value &&
|
||||
!builtinListFailed.value &&
|
||||
!countFailed.value &&
|
||||
!builtinType.value &&
|
||||
!editable.value,
|
||||
)
|
||||
const showSkeleton = computed(() => loading.value && skeleton.visible.value)
|
||||
const bleed = computed(() => forbidden.value || notFound.value || Boolean(editable.value))
|
||||
|
||||
const displayName = computed(() => customType.value?.name ?? builtinType.value?.name)
|
||||
const mobileTitle = computed(() => displayName.value ?? t('Schema'))
|
||||
const breadcrumb = computed(() => [
|
||||
{ label: t('Schema'), to: SCHEMA_PATH },
|
||||
...(type.value ? [{ label: type.value.name }] : []),
|
||||
...(displayName.value && !notFound.value ? [{ label: displayName.value }] : []),
|
||||
])
|
||||
const edit = computed(() =>
|
||||
customType.value ? { type: customType.value, total: count.total.value } : undefined,
|
||||
)
|
||||
const countLine = computed(() =>
|
||||
t('GET /v1/content-type/{0}/entry failed. Check your connection and try again.', [key.value]),
|
||||
)
|
||||
|
||||
watch(
|
||||
loading,
|
||||
(value) => {
|
||||
if (value) {
|
||||
skeleton.start()
|
||||
} else {
|
||||
skeleton.stop()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
@@ -33,11 +107,14 @@ function goHome() {
|
||||
|
||||
<template>
|
||||
<DashboardShell
|
||||
compact-padding
|
||||
:full-bleed="forbidden || (sessionReady && !loading && !failed && !type)"
|
||||
:compact-padding="!editable"
|
||||
:full-bleed="bleed"
|
||||
:mobile-title="mobileTitle"
|
||||
:back-to="SCHEMA_PATH"
|
||||
:back-label="t('Schema')"
|
||||
:offline-message="editable ? offlineMessage : undefined"
|
||||
:offline-icon="editable ? 'i-lucide-wifi-off' : undefined"
|
||||
:flush-offline-banner="Boolean(editable)"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
@@ -54,18 +131,38 @@ function goHome() {
|
||||
<SchemaTable v-if="showSkeleton" :custom="[]" :builtin="[]" loading />
|
||||
|
||||
<DirectoryLoadError
|
||||
v-else-if="failed"
|
||||
v-else-if="listsFailed"
|
||||
:title="t('Couldn\'t load content types')"
|
||||
:description="
|
||||
t('GET /v1/content-type failed. Check your connection and try again.')
|
||||
"
|
||||
@retry="retryLists"
|
||||
/>
|
||||
|
||||
<DirectoryLoadError
|
||||
v-else-if="builtinListFailed"
|
||||
:title="t('Couldn\'t load content types')"
|
||||
:description="
|
||||
t('GET /v1/builtin-type failed. Check your connection and try again.')
|
||||
"
|
||||
@retry="retry"
|
||||
@retry="retryLists"
|
||||
/>
|
||||
|
||||
<div v-else-if="type" class="flex flex-col gap-4">
|
||||
<DirectoryLoadError
|
||||
v-else-if="countFailed"
|
||||
:title="t('Couldn\'t load content type')"
|
||||
:description="countLine"
|
||||
@retry="count.retry"
|
||||
/>
|
||||
|
||||
<SchemaBuilderPage v-else-if="editable" :key="key" :edit="edit" />
|
||||
|
||||
<div v-else-if="builtinType" class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-lucide-lock" class="size-4.5 shrink-0 text-muted" />
|
||||
<h1 class="text-xl font-semibold text-highlighted">{{ type.name }}</h1>
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ builtinType.name }}
|
||||
</h1>
|
||||
<DirectoryBadge kind="builtin" />
|
||||
</div>
|
||||
<UAlert
|
||||
@@ -76,7 +173,7 @@ function goHome() {
|
||||
:description="
|
||||
t(
|
||||
'{0} is a built-in type. You can read its fields here, but you can\'t edit it, delete it or add fields.',
|
||||
[type.name],
|
||||
[builtinType.name],
|
||||
)
|
||||
"
|
||||
:ui="{
|
||||
@@ -85,7 +182,7 @@ function goHome() {
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
<SchemaFieldTable :fields="type.fields" />
|
||||
<SchemaFieldTable :fields="builtinType.fields" />
|
||||
<p class="text-xs/5 text-muted">
|
||||
{{
|
||||
t(
|
||||
@@ -95,7 +192,7 @@ function goHome() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SchemaTypeNotFound v-else-if="!loading" />
|
||||
<SchemaTypeNotFound v-else-if="notFound" />
|
||||
</template>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,514 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import router from '@/router'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { button, mountAt, puts, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
addChoice,
|
||||
addField,
|
||||
all,
|
||||
badgesOf,
|
||||
byLabel,
|
||||
chip,
|
||||
chooseDefault,
|
||||
click,
|
||||
defaultInput,
|
||||
discardButton,
|
||||
el,
|
||||
fieldKeyInput,
|
||||
inspector,
|
||||
inspectorTitle,
|
||||
keyInput,
|
||||
labelInput,
|
||||
limit,
|
||||
live,
|
||||
makeUnsavedChanges,
|
||||
nameInput,
|
||||
notice,
|
||||
press,
|
||||
removeSelected,
|
||||
rowLabels,
|
||||
rows,
|
||||
saveButton,
|
||||
selectRow,
|
||||
setMax,
|
||||
toggle,
|
||||
typeInto,
|
||||
undoButtons,
|
||||
undoRows,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = () => undefined
|
||||
})
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const SAVED_ORDER = [
|
||||
'Title',
|
||||
'Starts on',
|
||||
'Ends on',
|
||||
'Online',
|
||||
'Capacity',
|
||||
'Category',
|
||||
'Description',
|
||||
]
|
||||
|
||||
describe('What marks a saved field as changed', () => {
|
||||
it.each([
|
||||
['Capacity', async () => setMax('2000')],
|
||||
[
|
||||
'Title',
|
||||
async () => {
|
||||
await selectRow('Title')
|
||||
await typeInto(labelInput(), 'Name of event')
|
||||
},
|
||||
],
|
||||
[
|
||||
'Starts on',
|
||||
async () => {
|
||||
await selectRow('Starts on')
|
||||
await toggle('Required')
|
||||
},
|
||||
],
|
||||
[
|
||||
'Category',
|
||||
async () => {
|
||||
await selectRow('Category')
|
||||
await addChoice('Panel')
|
||||
},
|
||||
],
|
||||
[
|
||||
'Online',
|
||||
async () => {
|
||||
await selectRow('Online')
|
||||
await toggle('On by default')
|
||||
},
|
||||
],
|
||||
])('marks %s Changed in its row and inspector', async (label, edit) => {
|
||||
await mountAt('/schema/events')
|
||||
|
||||
await edit()
|
||||
|
||||
const row = rows().find((candidate) =>
|
||||
text(candidate).includes(label === 'Title' ? 'Name of event' : label),
|
||||
)
|
||||
expect(text(row)).toContain('Changed')
|
||||
expect(text(el('[data-testid="inspector-pane"] h2')!.parentElement)).toContain('Changed')
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
})
|
||||
|
||||
it('clears the badge when the original value comes back', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await setMax('2000')
|
||||
expect(badgesOf('Capacity')).toContain('Changed')
|
||||
|
||||
await typeInto(limit('max'), '5000')
|
||||
|
||||
expect(badgesOf('Capacity')).not.toContain('Changed')
|
||||
expect(chip()).toBe('Saved')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not treat a reorder as a change of a field', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await selectRow('Ends on')
|
||||
|
||||
await click(byLabel('Move up'))
|
||||
|
||||
expect(rowLabels().slice(0, 3)).toEqual(['Title', 'Ends on', 'Starts on'])
|
||||
expect(rows().every((row) => !text(row).includes('Changed'))).toBe(true)
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unsaved changes (design state 4)', () => {
|
||||
it('shows the badges, the undo row, the count, the chip and keeps the notice', async () => {
|
||||
await mountAt('/schema/events')
|
||||
|
||||
await makeUnsavedChanges()
|
||||
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
expect(discardButton()!.disabled).toBe(false)
|
||||
expect(badgesOf('Capacity')).toContain('Changed')
|
||||
expect(badgesOf('Speaker')).toContain('New')
|
||||
expect(badgesOf('Room')).toContain('New')
|
||||
expect(undoRows()).toEqual(['Description will be removed Undo'])
|
||||
expect(el('[data-testid="undo-row"] svg')).not.toBeNull()
|
||||
expect(el('[data-testid="undo-row"]')!.className).toContain('border-dashed')
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('8 of 50')
|
||||
expect(rowLabels()).not.toContain('Description')
|
||||
expect(notice()).toContain('37 entries')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Removing a saved field and undoing it', () => {
|
||||
it('selects the previous row at the end, then restores the field at position 7 with no badge', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await selectRow('Description')
|
||||
|
||||
await removeSelected()
|
||||
|
||||
expect(rowLabels()).toEqual(SAVED_ORDER.slice(0, 6))
|
||||
expect(inspectorTitle()).toBe('Field: Category')
|
||||
expect(undoRows()).toEqual(['Description will be removed Undo'])
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
|
||||
await click(undoButtons()[0])
|
||||
|
||||
expect(rowLabels()).toEqual(SAVED_ORDER)
|
||||
expect(badgesOf('Description')).toBe('')
|
||||
expect(undoRows()).toEqual([])
|
||||
expect(chip()).toBe('Saved')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('selects the next row when a middle row is removed', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await selectRow('Ends on')
|
||||
|
||||
await removeSelected()
|
||||
|
||||
expect(inspectorTitle()).toBe('Field: Online')
|
||||
})
|
||||
|
||||
it('shows one undo row per removal, in removal order, each restoring its own place', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await selectRow('Description')
|
||||
await removeSelected()
|
||||
await selectRow('Ends on')
|
||||
await removeSelected()
|
||||
|
||||
expect(undoRows()).toEqual([
|
||||
'Description will be removed Undo',
|
||||
'Ends on will be removed Undo',
|
||||
])
|
||||
|
||||
await click(undoButtons()[1])
|
||||
expect(rowLabels()).toEqual([
|
||||
'Title',
|
||||
'Starts on',
|
||||
'Ends on',
|
||||
'Online',
|
||||
'Capacity',
|
||||
'Category',
|
||||
])
|
||||
await click(undoButtons()[0])
|
||||
expect(rowLabels()).toEqual(SAVED_ORDER)
|
||||
expect(chip()).toBe('Saved')
|
||||
})
|
||||
|
||||
it('does not offer an undo for a field added and removed since the last save', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await addField('Speaker')
|
||||
|
||||
await removeSelected()
|
||||
|
||||
expect(undoRows()).toEqual([])
|
||||
expect(chip()).toBe('Saved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A removed key stays reserved', () => {
|
||||
it('gives a new Description field the key description_2 and still restores description', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await selectRow('Description')
|
||||
await removeSelected()
|
||||
|
||||
await addField('Description')
|
||||
|
||||
expect(fieldKeyInput()!.value).toBe('description_2')
|
||||
|
||||
await click(undoButtons()[0])
|
||||
|
||||
const keys = all('[data-testid="field-row-key"]').map(text)
|
||||
expect(keys).toContain('description')
|
||||
expect(keys).toContain('description_2')
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Undo at the limit of 50 fields', () => {
|
||||
it('disables Undo until a field is removed', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await selectRow('Description')
|
||||
await removeSelected()
|
||||
for (let count = 0; count < 44; count++) {
|
||||
await click(button('Add field'))
|
||||
}
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('50 of 50')
|
||||
expect(undoButtons()[0]!.disabled).toBe(true)
|
||||
|
||||
await removeSelected()
|
||||
|
||||
expect(undoButtons()[0]!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Removing every field', () => {
|
||||
it('shows the empty list with its error, the undo rows, one problem and a disabled Save', async () => {
|
||||
await mountAt('/schema/events')
|
||||
for (let count = 0; count < 7; count++) {
|
||||
await removeSelected()
|
||||
}
|
||||
|
||||
expect(el('[data-testid="field-empty"]')).not.toBeNull()
|
||||
expect(text(el('[data-testid="field-empty"]'))).toContain('No fields yet')
|
||||
expect(text(el('[data-testid="field-empty"]'))).toContain(
|
||||
'A content type needs at least one field.',
|
||||
)
|
||||
expect(undoRows()).toHaveLength(7)
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Name edit only', () => {
|
||||
it('keeps the key, marks the form changed and leaves the sidebar and list alone', async () => {
|
||||
await mountAt('/schema/events')
|
||||
|
||||
await typeInto(nameInput(), 'Events and talks')
|
||||
|
||||
expect(keyInput()!.value).toBe('events')
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toContain(
|
||||
'Events',
|
||||
)
|
||||
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).not.toContain(
|
||||
'Events and talks',
|
||||
)
|
||||
expect(rows().every((row) => !text(row).includes('Changed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('flags an empty or blank name under Name and disables Save without sending', async () => {
|
||||
await mountAt('/schema/events')
|
||||
|
||||
await typeInto(nameInput(), ' ')
|
||||
|
||||
expect(text(nameInput()!.closest('[data-orientation]'))).toContain(
|
||||
'Give the content type a name.',
|
||||
)
|
||||
expect(nameInput()!.closest('[data-orientation]')!.querySelector('svg')).not.toBeNull()
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(puts).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Field errors (design state 5)', () => {
|
||||
it('flags three rows, counts three problems and shows the four messages', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await selectRow('Ends on')
|
||||
await typeInto(labelInput(), '')
|
||||
await selectRow('Capacity')
|
||||
await typeInto(limit('min'), '6000')
|
||||
await selectRow('Category')
|
||||
await typeInto(all<HTMLInputElement>('[data-testid="choice-row"] input')[2], '')
|
||||
await addChoice('Talk')
|
||||
|
||||
expect(chip()).toBe('3 problems to fix')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(all('[data-testid="field-row-alert"]')).toHaveLength(3)
|
||||
expect(text(inspector())).toContain("Choices can't be blank.")
|
||||
expect(text(inspector())).toContain('Two choices are the same.')
|
||||
await selectRow('Capacity')
|
||||
expect(text(inspector())).toContain("Lowest value can't be above the highest value.")
|
||||
await selectRow('Untitled field')
|
||||
expect(text(inspector())).toContain('Give the field a label.')
|
||||
})
|
||||
|
||||
it('shows a single key error for a new field and none for a saved field', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await addField('Start date')
|
||||
await click(
|
||||
fieldKeyInput()!
|
||||
.closest<HTMLElement>('[data-orientation]')!
|
||||
.querySelector('button[aria-label="Edit key"]'),
|
||||
)
|
||||
await typeInto(fieldKeyInput(), 'start-date')
|
||||
|
||||
expect(text(inspector())).toContain(
|
||||
'Start with a letter. Use lowercase letters, digits and underscores.',
|
||||
)
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
|
||||
await selectRow('Title')
|
||||
expect(text(inspector())).not.toContain('Start with a letter')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The choice list of a saved Choice field (design state 8)', () => {
|
||||
it('lists the saved choices with the removal warning, and works like the create list', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await selectRow('Category')
|
||||
|
||||
expect(
|
||||
all<HTMLInputElement>('[data-testid="choice-row"] input').map((input) => input.value),
|
||||
).toEqual(['Talk', 'Workshop', 'Meetup'])
|
||||
expect(text(inspector())).toContain(
|
||||
'Removing a choice that entries use will be refused when you save.',
|
||||
)
|
||||
|
||||
await addChoice('Panel')
|
||||
expect(text(el('[data-testid="choice-count"]'))).toBe('4 of 50')
|
||||
|
||||
await chooseDefault('Workshop')
|
||||
expect(text(el('button[role="combobox"]'))).toContain('Workshop')
|
||||
await click(byLabel('Remove Workshop'))
|
||||
expect(text(el('button[role="combobox"]'))).toContain('None')
|
||||
expect(defaultInput()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard reorder in edit mode (design state 9)', () => {
|
||||
const grip = (label: string) => byLabel(`Move ${label}`)!
|
||||
|
||||
it('announces the move from position 3, restores on Esc and drops on Space', async () => {
|
||||
await mountAt('/schema/events')
|
||||
|
||||
await press(grip('Ends on'), ' ')
|
||||
await press(grip('Ends on'), 'ArrowUp')
|
||||
|
||||
expect(live()).toBe(
|
||||
'Moving Ends on. Position 2 of 7. Press Up or Down to move, Space to drop, Esc to cancel.',
|
||||
)
|
||||
|
||||
await press(grip('Ends on'), 'Escape')
|
||||
expect(rowLabels()).toEqual(SAVED_ORDER)
|
||||
|
||||
await press(grip('Ends on'), ' ')
|
||||
await press(grip('Ends on'), 'ArrowUp')
|
||||
await press(grip('Ends on'), ' ')
|
||||
expect(rowLabels().slice(0, 3)).toEqual(['Title', 'Ends on', 'Starts on'])
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('API preview in edit mode (design state 10)', () => {
|
||||
it('uses the locked key, follows the fields and keeps the note', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await click(el('[data-testid="api-preview-toggle"]'))
|
||||
|
||||
expect(all('[data-testid="api-route"]').map(text)[0]).toBe(
|
||||
'GET /v1/content-type/events/entry',
|
||||
)
|
||||
expect(text(el('[data-testid="api-json"]'))).toContain('"description"')
|
||||
expect(text(document.body)).toContain(
|
||||
'Updates as you edit. Fields you add or remove here appear after you save.',
|
||||
)
|
||||
|
||||
await selectRow('Description')
|
||||
await removeSelected()
|
||||
await addField('Speaker')
|
||||
|
||||
const json = text(el('[data-testid="api-json"]'))
|
||||
expect(json).not.toContain('"description"')
|
||||
expect(json).toContain('"speaker"')
|
||||
expect(json).toContain('"content_type": "events"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Discard', () => {
|
||||
it('returns to the loaded type with the first row selected and nothing sent', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await typeInto(nameInput(), 'Other')
|
||||
await setMax('2000')
|
||||
await addField('Speaker')
|
||||
await selectRow('Description')
|
||||
await removeSelected()
|
||||
|
||||
await click(discardButton())
|
||||
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
expect(rowLabels()).toEqual(SAVED_ORDER)
|
||||
expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true)
|
||||
expect(undoRows()).toEqual([])
|
||||
expect(inspectorTitle()).toBe('Field: Title')
|
||||
expect(chip()).toBe('Saved')
|
||||
expect(puts).toHaveLength(0)
|
||||
expect(router.currentRoute.value.path).toBe('/schema/events')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Leaving with unsaved edits in edit mode', () => {
|
||||
const DIALOG_TITLE = 'Leave without saving?'
|
||||
|
||||
it('navigates with no prompt when nothing was edited', async () => {
|
||||
await mountAt('/schema/events')
|
||||
|
||||
await router.push('/schema')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
expect(text(document.body)).not.toContain(DIALOG_TITLE)
|
||||
})
|
||||
|
||||
it('asks first for an edit, even one only in an undo row', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await selectRow('Description')
|
||||
await removeSelected()
|
||||
|
||||
const leaving = router.push('/schema')
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain(DIALOG_TITLE)
|
||||
await click(button('Keep editing'))
|
||||
await leaving
|
||||
expect(router.currentRoute.value.path).toBe('/schema/events')
|
||||
|
||||
const again = router.push('/schema')
|
||||
await flushPromises()
|
||||
await click(button('Discard changes'))
|
||||
await again
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
})
|
||||
|
||||
it('navigates with no prompt after a Discard', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await typeInto(nameInput(), 'Other')
|
||||
await click(discardButton())
|
||||
|
||||
await router.push('/schema')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile field sheet in edit mode', () => {
|
||||
it('opens Field: Capacity with the locked key, and Remove closes it and shows the undo row', async () => {
|
||||
await mountAt('/schema/events', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(rows()[4]!.querySelector('[data-field-row]'))
|
||||
|
||||
const sheet = el('[role="dialog"]')!
|
||||
expect(text(sheet)).toContain('Field: Capacity')
|
||||
expect(sheet.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
|
||||
expect(byLabel('Move up')).not.toBeNull()
|
||||
expect(byLabel('Move down')).not.toBeNull()
|
||||
expect(button('Done')).toBeInstanceOf(HTMLButtonElement)
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
await vi.waitFor(() => expect(el('[role="dialog"]')).toBeNull())
|
||||
|
||||
expect(undoRows()).toEqual(['Capacity will be removed Undo'])
|
||||
})
|
||||
|
||||
it('shows the bottom bar with the chip, Discard and Save', async () => {
|
||||
await mountAt('/schema/events', { width: MOBILE_WIDTH })
|
||||
await typeInto(nameInput(), 'Other')
|
||||
|
||||
expect(text(el('.sticky'))).toBe('Unsaved changes Discard Save')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,846 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import router from '@/router'
|
||||
import BuilderActions from '@/components/BuilderActions.vue'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import {
|
||||
button,
|
||||
calls,
|
||||
eventsFields,
|
||||
failWithBody,
|
||||
mountAt,
|
||||
networkFailure,
|
||||
pending,
|
||||
puts,
|
||||
text,
|
||||
unmountAll,
|
||||
updatedType,
|
||||
} from './schemaTestKit'
|
||||
import {
|
||||
addField,
|
||||
all,
|
||||
badgesOf,
|
||||
byLabel,
|
||||
chip,
|
||||
click,
|
||||
dialog,
|
||||
discardButton,
|
||||
el,
|
||||
fieldKeyInput,
|
||||
keyInput,
|
||||
labelInput,
|
||||
main,
|
||||
makeUnsavedChanges,
|
||||
nameInput,
|
||||
notice,
|
||||
rowLabels,
|
||||
rows,
|
||||
saveButton,
|
||||
selectRow,
|
||||
setMax,
|
||||
typeInto,
|
||||
undoButtons,
|
||||
undoRows,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = () => undefined
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
const SAVE_FAILED_TITLE = "Couldn't save the content type"
|
||||
const SAVE_FAILED_TEXT =
|
||||
'Something went wrong on our side. Your changes are still here. Try again in a moment.'
|
||||
const SAVED_AT = /^Saved at \d{2}:\d{2}$/
|
||||
const FIELD_KEYS = [
|
||||
'key',
|
||||
'label',
|
||||
'type',
|
||||
'required',
|
||||
'unique',
|
||||
'default',
|
||||
'min',
|
||||
'max',
|
||||
'options',
|
||||
]
|
||||
|
||||
async function editRecipes() {
|
||||
await selectRow('Servings')
|
||||
await typeInto(labelInput(), 'Portions')
|
||||
}
|
||||
|
||||
describe('Saving a type that has no entries', () => {
|
||||
it('sends one PUT with no review and exactly name, icon and fields', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
|
||||
await editRecipes()
|
||||
await click(saveButton())
|
||||
|
||||
expect(puts).toHaveLength(1)
|
||||
expect(puts[0]!.url).toBe('/v1/content-type/recipes')
|
||||
expect(dialog()).toBeNull()
|
||||
const body = puts[0]!.body as {
|
||||
name: string
|
||||
icon: unknown
|
||||
fields: Record<string, unknown>[]
|
||||
}
|
||||
expect(Object.keys(body)).toEqual(['name', 'icon', 'fields'])
|
||||
expect(body.icon).toBeNull()
|
||||
expect(body.fields.map((field) => field.label)).toEqual([
|
||||
'Title',
|
||||
'Portions',
|
||||
'Vegetarian',
|
||||
'Method',
|
||||
])
|
||||
expect(body.fields.every((field) => Object.keys(field).join() === FIELD_KEYS.join())).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The PUT body', () => {
|
||||
it('keeps the loaded icon, sends no key, trims the name and keeps the saved keys and types', async () => {
|
||||
await mountAt('/schema/events')
|
||||
|
||||
await typeInto(nameInput(), ' Events and talks ')
|
||||
await click(saveButton())
|
||||
|
||||
expect(puts).toHaveLength(1)
|
||||
const body = puts[0]!.body as {
|
||||
name: string
|
||||
icon: unknown
|
||||
fields: Record<string, unknown>[]
|
||||
}
|
||||
expect(Object.keys(body)).toEqual(['name', 'icon', 'fields'])
|
||||
expect(body.name).toBe('Events and talks')
|
||||
expect(body.icon).toBe('files')
|
||||
expect('key' in body).toBe(false)
|
||||
expect(body.fields.map((field) => [field.key, field.type])).toEqual(
|
||||
eventsFields.map((field) => [field.key, field.type]),
|
||||
)
|
||||
expect(body.fields[3]).toMatchObject({
|
||||
key: 'online',
|
||||
default: null,
|
||||
options: [],
|
||||
})
|
||||
expect(body.fields[4]).toMatchObject({ min: 1, max: 5000 })
|
||||
expect(body.fields[5]).toMatchObject({
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a null icon null and leaves undone removals out', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
await selectRow('Method')
|
||||
await click(byLabel('Remove'))
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
const body = puts[0]!.body as {
|
||||
icon: unknown
|
||||
fields: { key: string }[]
|
||||
}
|
||||
expect(body.icon).toBeNull()
|
||||
expect(body.fields.map((field) => field.key)).toEqual(['title', 'servings', 'vegetarian'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving (design state 11, no entries)', () => {
|
||||
async function startSaving() {
|
||||
const gate = pending()
|
||||
await mountAt('/schema/recipes', { update: () => gate.promise })
|
||||
await editRecipes()
|
||||
await selectRow('Method')
|
||||
await click(byLabel('Remove'))
|
||||
await click(saveButton())
|
||||
return gate
|
||||
}
|
||||
|
||||
it('shows the chip and the loader, dims and disables everything, and sends once', async () => {
|
||||
await startSaving()
|
||||
|
||||
expect(chip()).toBe('Saving…')
|
||||
expect(el('[data-testid="builder-chip"] svg')).not.toBeNull()
|
||||
expect(saveButton()).toBeUndefined()
|
||||
const saving = button('Saving…') as HTMLButtonElement
|
||||
expect(saving.disabled).toBe(true)
|
||||
expect(saving.querySelector('svg')).not.toBeNull()
|
||||
expect(discardButton()!.disabled).toBe(true)
|
||||
expect(nameInput()!.disabled).toBe(true)
|
||||
expect(labelInput()!.disabled).toBe(true)
|
||||
expect((button('Add field') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(all<HTMLButtonElement>('[data-grip]').every((grip) => grip.disabled)).toBe(true)
|
||||
expect((byLabel('Move up') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((byLabel('Remove') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(undoButtons().every((undo) => undo.disabled)).toBe(true)
|
||||
|
||||
saving.click()
|
||||
await flushPromises()
|
||||
expect(puts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores a second Save however it is activated', async () => {
|
||||
const gate = pending()
|
||||
const mounted = await mountAt('/schema/recipes', {
|
||||
update: () => gate.promise,
|
||||
})
|
||||
await editRecipes()
|
||||
await click(saveButton())
|
||||
const actions = mounted.findComponent(BuilderActions)
|
||||
|
||||
actions.vm.$emit('save')
|
||||
actions.vm.$emit('save')
|
||||
await flushPromises()
|
||||
|
||||
expect(puts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saved (design state 12)', () => {
|
||||
it('toasts, stays, clears the marks, locks the new fields and reads Saved at', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
await editRecipes()
|
||||
await click(button('Add field'))
|
||||
await typeInto(labelInput(), 'Notes')
|
||||
await selectRow('Method')
|
||||
await click(byLabel('Remove'))
|
||||
|
||||
await click(saveButton())
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain('Content type saved')
|
||||
expect(text(document.body)).toContain('Recipes now has 4 fields.')
|
||||
expect(router.currentRoute.value.path).toBe('/schema/recipes')
|
||||
expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true)
|
||||
expect(undoRows()).toEqual([])
|
||||
expect(chip()).toMatch(SAVED_AT)
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(discardButton()!.disabled).toBe(true)
|
||||
await selectRow('Notes')
|
||||
expect(fieldKeyInput()!.readOnly).toBe(true)
|
||||
expect(el('[data-testid="field-inspector"] [data-testid="slug-lock"]')).not.toBeNull()
|
||||
expect(text(el('[data-testid="field-inspector"]'))).toContain("Can't change after saving")
|
||||
})
|
||||
|
||||
it('writes the answered type into the store, the list and the sidebar with no second GET', async () => {
|
||||
await mountAt('/schema/events', {
|
||||
update: (body) => ({
|
||||
...updatedType('events', body),
|
||||
name: 'Events and talks',
|
||||
}),
|
||||
})
|
||||
await typeInto(nameInput(), 'Events and talks')
|
||||
|
||||
await click(saveButton())
|
||||
await flushPromises()
|
||||
|
||||
const store = useContentTypesStore()
|
||||
expect(store.items[0]!.name).toBe('Events and talks')
|
||||
expect(el('nav a[href="/content/events"]')!.textContent).toContain('Events and talks')
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
await router.push('/schema')
|
||||
await flushPromises()
|
||||
expect(
|
||||
all('[data-testid="schema-row"]').some((row) => text(row).includes('Events and talks')),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('reads "1 field" for a single field', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
for (let count = 0; count < 3; count++) {
|
||||
await click(byLabel('Remove'))
|
||||
}
|
||||
|
||||
await click(saveButton())
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain('Recipes now has 1 field.')
|
||||
})
|
||||
|
||||
it('does not prompt when leaving afterwards', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
await editRecipes()
|
||||
await click(saveButton())
|
||||
await flushPromises()
|
||||
|
||||
await router.push('/schema')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
expect(text(document.body)).not.toContain('Leave without saving?')
|
||||
})
|
||||
|
||||
it('goes back to Unsaved changes on an edit and Discard returns to the last saved state', async () => {
|
||||
await mountAt('/schema/recipes')
|
||||
await editRecipes()
|
||||
await click(saveButton())
|
||||
await flushPromises()
|
||||
|
||||
await typeInto(nameInput(), 'Dishes')
|
||||
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
await click(discardButton())
|
||||
|
||||
expect(nameInput()!.value).toBe('Recipes')
|
||||
expect(rowLabels()).toEqual(['Title', 'Portions', 'Vegetarian', 'Method'])
|
||||
expect(chip()).toMatch(SAVED_AT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Save on a type with entries opens the review (group 3, state 1)', () => {
|
||||
it('sends nothing yet and opens the dialog with the summary, the rows in order and the note', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await makeUnsavedChanges()
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(puts).toHaveLength(0)
|
||||
const open = dialog()!
|
||||
expect(text(open)).toContain('Review changes to Events')
|
||||
expect(text(open)).toContain('Apply them to save the content type.')
|
||||
expect(text(el('[data-testid="review-summary"]'))).toBe('Add 2 fields, change 1, remove 1')
|
||||
expect(text(open)).toContain(
|
||||
'Events has 37 entries. Each one is checked against the new fields when you apply.',
|
||||
)
|
||||
expect(all('[data-testid="change-title"]').map(text)).toEqual([
|
||||
'Add Speaker',
|
||||
'Add Room',
|
||||
'Change Capacity',
|
||||
'Remove Description',
|
||||
])
|
||||
expect(all('[data-testid="change-text"]').map(text)).toEqual([
|
||||
'Text, optional.',
|
||||
'Choice, required, default "Main hall". Existing entries get the default, so they still fit.',
|
||||
'Highest value goes from 5000 to 2000.',
|
||||
'Entries stop returning its values.',
|
||||
])
|
||||
expect(all('[data-testid="change-flag"]').map(text)).toEqual([
|
||||
'Entries may not fit',
|
||||
'Removes data',
|
||||
])
|
||||
expect(text(open)).toContain(
|
||||
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.",
|
||||
)
|
||||
expect(button('Back to editing')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(button('Apply changes')).toBeInstanceOf(HTMLButtonElement)
|
||||
})
|
||||
|
||||
it('opens as a bottom sheet on mobile', async () => {
|
||||
await mountAt('/schema/events', { width: MOBILE_WIDTH })
|
||||
await click(rows()[6]!.querySelector('[data-field-row]'))
|
||||
await click(byLabel('Remove'))
|
||||
await vi.waitFor(() => expect(el('[data-testid="field-sheet-title"]')).toBeNull())
|
||||
await click(saveButton())
|
||||
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(dialog()!.className).toContain('max-sm:')
|
||||
expect(text(dialog())).toContain('Review changes to Events')
|
||||
})
|
||||
})
|
||||
|
||||
describe('When the review opens', () => {
|
||||
it('sends a name-only edit straight away', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await typeInto(nameInput(), 'Events and talks')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(puts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sends a reorder-only edit straight away', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await selectRow('Ends on')
|
||||
await click(byLabel('Move up'))
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(puts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a field added', async () => addField('Speaker')],
|
||||
['a field changed', async () => setMax('2000')],
|
||||
[
|
||||
'a field removed',
|
||||
async () => {
|
||||
await selectRow('Description')
|
||||
await click(byLabel('Remove'))
|
||||
},
|
||||
],
|
||||
])('opens for %s', async (_, edit) => {
|
||||
await mountAt('/schema/events')
|
||||
await edit()
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(puts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lists only the field change when the name changed too', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await typeInto(nameInput(), 'Events and talks')
|
||||
await setMax('2000')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(all('[data-testid="change-title"]').map(text)).toEqual(['Change Capacity'])
|
||||
expect(text(el('[data-testid="review-summary"]'))).toBe('Change 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The review title after a rename', () => {
|
||||
it('reads the saved name, not the name loaded with the page', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await typeInto(nameInput()!, 'Seminars')
|
||||
await setMax('2000')
|
||||
await click(saveButton())
|
||||
await click(button('Apply changes'))
|
||||
await flushPromises()
|
||||
await setMax('3000')
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(dialog()!)).toContain('Review changes to Seminars')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Back to editing from the review', () => {
|
||||
it('closes with the button and keeps every edit', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await makeUnsavedChanges()
|
||||
await click(saveButton())
|
||||
|
||||
await click(button('Back to editing'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
|
||||
expect(puts).toHaveLength(0)
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(badgesOf('Speaker')).toContain('New')
|
||||
expect(undoRows()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('closes on Esc', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await setMax('2000')
|
||||
await click(saveButton())
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
|
||||
expect(puts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns the focus to Save', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await setMax('2000')
|
||||
const save = saveButton()!
|
||||
save.focus()
|
||||
await click(save)
|
||||
|
||||
await click(button('Back to editing'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
|
||||
expect(document.activeElement).toBe(save)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Applying (group 3, state 2) and Applied', () => {
|
||||
async function apply() {
|
||||
const gate = pending()
|
||||
await mountAt('/schema/events', { update: () => gate.promise })
|
||||
await setMax('2000')
|
||||
await click(saveButton())
|
||||
await click(button('Apply changes'))
|
||||
return gate
|
||||
}
|
||||
|
||||
it('sends one PUT, reads Applying… with a loader and cannot be dismissed', async () => {
|
||||
await apply()
|
||||
|
||||
expect(puts).toHaveLength(1)
|
||||
const confirm = button('Applying…') as HTMLButtonElement
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect(confirm.querySelector('svg')).not.toBeNull()
|
||||
expect((button('Back to editing') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(dialog()!.getAttribute('aria-busy')).toBe('true')
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(dialog()).not.toBeNull()
|
||||
|
||||
confirm.click()
|
||||
await flushPromises()
|
||||
expect(puts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the focus on the title of the dialog', async () => {
|
||||
await apply()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(document.activeElement?.textContent).toBe('Review changes to Events'),
|
||||
)
|
||||
})
|
||||
|
||||
it('closes the dialog on success with the toast, the cleared marks and Saved at', async () => {
|
||||
const gate = await apply()
|
||||
|
||||
gate.resolve(updatedType('events', puts[0]!.body))
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(text(document.body)).toContain('Content type saved')
|
||||
expect(text(document.body)).toContain('Events now has 7 fields.')
|
||||
expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true)
|
||||
expect(chip()).toMatch(SAVED_AT)
|
||||
expect(notice()).toContain('37 entries')
|
||||
expect(text(document.body)).not.toContain('Leave without saving?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Refused (design state 15 and group 3, state 3)', () => {
|
||||
const REFUSAL_TEXT =
|
||||
"RenewCMS refused the changes because some entries don't fit the new fields. It doesn't say which entries."
|
||||
|
||||
async function refused(
|
||||
edit: () => Promise<void> = async () => {
|
||||
await setMax('2000')
|
||||
await selectRow('Description')
|
||||
await click(byLabel('Remove'))
|
||||
},
|
||||
) {
|
||||
await mountAt('/schema/events', {
|
||||
update: failWithBody(409, 'schema_incompatible'),
|
||||
})
|
||||
await edit()
|
||||
await click(saveButton())
|
||||
await click(button('Apply changes'))
|
||||
}
|
||||
|
||||
it('replaces the review with the refusal dialog that lists only the flagged change', async () => {
|
||||
await refused()
|
||||
|
||||
const open = dialog()!
|
||||
expect(text(open)).toContain('Nothing was changed')
|
||||
expect(text(open)).toContain(REFUSAL_TEXT)
|
||||
expect(text(open)).toContain('Your content type is as it was')
|
||||
expect(text(open)).toContain('No field was added, changed or removed.')
|
||||
expect(text(open)).toContain('The change that can cause this in your edit')
|
||||
expect(all('[data-testid="change-title"]').map(text)).toEqual(['Change Capacity'])
|
||||
expect(text(el('[data-testid="change-text"]'))).toBe(
|
||||
'Highest value goes from 5000 to 2000. An entry may have a higher value.',
|
||||
)
|
||||
expect(text(el('[data-testid="change-flag"]'))).toBe('Possible cause')
|
||||
expect(text(open)).not.toContain('Description')
|
||||
expect(text(open)).toContain(
|
||||
'This kind of change is refused when an entry is over the new limit, a new required field has no default, a field becomes unique while entries share a value, or a removed choice is still in use.',
|
||||
)
|
||||
expect(button('Discard changes')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(button('Back to editing')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(text(open)).not.toContain('37')
|
||||
const store = useContentTypesStore()
|
||||
expect(store.items[0]!.fields).toHaveLength(7)
|
||||
expect((store.items[0]!.fields[4] as { max: number }).max).toBe(5000)
|
||||
expect(nameInput()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('omits the cause section when no change is flagged', async () => {
|
||||
await refused(async () => {
|
||||
await selectRow('Description')
|
||||
await click(byLabel('Remove'))
|
||||
})
|
||||
|
||||
const open = dialog()!
|
||||
expect(text(open)).toContain('Nothing was changed')
|
||||
expect(text(open)).toContain('Your content type is as it was')
|
||||
expect(text(open)).not.toContain('The change that can cause this in your edit')
|
||||
expect(el('[data-testid="refusal-causes"]')).toBeNull()
|
||||
expect(text(open)).toContain('This kind of change is refused')
|
||||
})
|
||||
|
||||
it('returns to a banner with Review changes, marks the flagged row and keeps Save', async () => {
|
||||
await refused()
|
||||
|
||||
await click(button('Back to editing'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
|
||||
expect(text(main())).toContain('Nothing was changed')
|
||||
expect(text(main())).toContain(
|
||||
"Some entries don't fit the new fields, so the whole change was refused. RenewCMS can't tell which entries. The changes that can cause this are marked.",
|
||||
)
|
||||
expect(badgesOf('Capacity')).toContain('Changed')
|
||||
expect(badgesOf('Capacity')).toContain('Possible cause')
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
|
||||
await click(button('Review changes'))
|
||||
|
||||
expect(text(dialog())).toContain('Review changes to Events')
|
||||
expect(all('[data-testid="change-title"]').map(text)).toEqual([
|
||||
'Change Capacity',
|
||||
'Remove Description',
|
||||
])
|
||||
})
|
||||
|
||||
it('also returns to the banner on Esc or the close icon', async () => {
|
||||
await refused()
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
|
||||
expect(text(main())).toContain('Nothing was changed')
|
||||
})
|
||||
|
||||
it('resets the form on Discard changes, with no banner and no marker', async () => {
|
||||
await refused()
|
||||
|
||||
await click(button('Discard changes'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
await flushPromises()
|
||||
|
||||
expect(text(main())).not.toContain('Nothing was changed')
|
||||
expect(chip()).toBe('Saved')
|
||||
expect(rowLabels()).toHaveLength(7)
|
||||
expect(badgesOf('Capacity')).toBe('')
|
||||
})
|
||||
|
||||
it('loses the marker when the flagged change is reverted, and the banner on success', async () => {
|
||||
await refused()
|
||||
await click(button('Back to editing'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
|
||||
await selectRow('Capacity')
|
||||
await typeInto(el('input[name="max"]'), '5000')
|
||||
|
||||
expect(badgesOf('Capacity')).not.toContain('Possible cause')
|
||||
expect(badgesOf('Description')).toBe('')
|
||||
expect(text(main())).toContain('Nothing was changed')
|
||||
})
|
||||
|
||||
it('clears the banner and every marker once a PUT succeeds', async () => {
|
||||
let answer: (body: Record<string, unknown>) => unknown = () => {
|
||||
throw Object.assign(new Error('refused'), {
|
||||
status: 409,
|
||||
body: 'schema_incompatible',
|
||||
})
|
||||
}
|
||||
const { ApiError } = await import('@/composables/useApi')
|
||||
answer = () => {
|
||||
throw new ApiError(409, undefined, 'schema_incompatible')
|
||||
}
|
||||
await mountAt('/schema/events', { update: (body) => answer(body) })
|
||||
await setMax('2000')
|
||||
await click(saveButton())
|
||||
await click(button('Apply changes'))
|
||||
await click(button('Back to editing'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(badgesOf('Capacity')).toContain('Possible cause')
|
||||
|
||||
answer = (body) => updatedType('events', body)
|
||||
await click(button('Review changes'))
|
||||
await click(button('Apply changes'))
|
||||
await flushPromises()
|
||||
|
||||
expect(text(main())).not.toContain('Nothing was changed')
|
||||
expect(badgesOf('Capacity')).toBe('')
|
||||
expect(chip()).toMatch(SAVED_AT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Other answers of the PUT', () => {
|
||||
async function answered(failure: () => unknown, edit = async () => setMax('2000')) {
|
||||
await mountAt('/schema/events', { update: failure })
|
||||
await edit()
|
||||
await click(saveButton())
|
||||
if (dialog()) {
|
||||
await click(button('Apply changes'))
|
||||
}
|
||||
}
|
||||
|
||||
it('closes the review and shows the banner for 422 invalid_field, keeping the edits', async () => {
|
||||
await answered(failWithBody(422, 'invalid_field'))
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(text(main())).toContain('Check the fields marked in the list.')
|
||||
expect(badgesOf('Capacity')).toContain('Changed')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the name message for 400 and keeps the edits', async () => {
|
||||
await answered(failWithBody(400, 'Bad request body'))
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(text(nameInput()!.closest('[data-orientation]'))).toContain(
|
||||
'Give the content type a name.',
|
||||
)
|
||||
expect(badgesOf('Capacity')).toContain('Changed')
|
||||
expect(puts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the name message off a 400 that is about the key', async () => {
|
||||
await answered(failWithBody(400, 'invalid_content_type_key'))
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(text(nameInput()!.closest('[data-orientation]'))).not.toContain(
|
||||
'Give the content type a name.',
|
||||
)
|
||||
expect(badgesOf('Capacity')).toContain('Changed')
|
||||
})
|
||||
|
||||
it('shows the not-found page for 404 and drops the type from the store', async () => {
|
||||
await answered(failWithBody(404, 'content_type_not_found'))
|
||||
|
||||
expect(text(main())).toContain('Content type not found')
|
||||
expect(useContentTypesStore().items.map((type) => type.key)).toEqual([
|
||||
'recipes',
|
||||
'press-mentions',
|
||||
])
|
||||
expect(nameInput()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the access page for 403', async () => {
|
||||
await answered(failWithBody(403, 'Only owners and admins'))
|
||||
|
||||
expect(text(main())).toContain("You don't have access to Schema")
|
||||
expect(nameInput()).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the redirect to the API client for 401', async () => {
|
||||
await answered(failWithBody(401, ''))
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(text(main())).not.toContain(SAVE_FAILED_TITLE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Save failed (design state 13)', () => {
|
||||
it.each([
|
||||
['answers 500', failWithBody(500, 'boom')],
|
||||
['fails (network)', networkFailure],
|
||||
])(
|
||||
'closes the review, keeps the edits and retries through the review when the PUT %s',
|
||||
async (_, failure) => {
|
||||
let respond: () => unknown = failure
|
||||
await mountAt('/schema/events', {
|
||||
update: (body) => respond() ?? body,
|
||||
})
|
||||
await setMax('2000')
|
||||
await click(saveButton())
|
||||
await click(button('Apply changes'))
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(text(main())).toContain(SAVE_FAILED_TITLE)
|
||||
expect(text(main())).toContain(SAVE_FAILED_TEXT)
|
||||
expect(chip()).toBe('Save failed')
|
||||
expect(badgesOf('Capacity')).toContain('Changed')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
|
||||
respond = () => undefined
|
||||
const first = puts[0]!.body
|
||||
await click(button('Try again'))
|
||||
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(puts).toHaveLength(1)
|
||||
await click(button('Apply changes'))
|
||||
expect(puts).toHaveLength(2)
|
||||
expect(puts[1]!.body).toEqual(first)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Offline (design state 16)', () => {
|
||||
function setOnline(value: boolean) {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value)
|
||||
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
|
||||
}
|
||||
|
||||
it('shows the banner and the chip, disables Save, keeps the edits and sends nothing on reconnect', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await typeInto(nameInput(), 'Events and talks')
|
||||
|
||||
setOnline(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain(
|
||||
"You're offline. Changes will be saved when you reconnect.",
|
||||
)
|
||||
expect(chip()).toBe('Waiting for connection')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(nameInput()!.value).toBe('Events and talks')
|
||||
|
||||
setOnline(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).not.toContain("You're offline.")
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
expect(puts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disables Apply changes while the review is open offline and keeps the edits', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await setMax('2000')
|
||||
await click(saveButton())
|
||||
|
||||
setOnline(false)
|
||||
await flushPromises()
|
||||
|
||||
expect((button('Apply changes') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(badgesOf('Capacity')).toContain('Changed')
|
||||
|
||||
setOnline(true)
|
||||
await flushPromises()
|
||||
expect((button('Apply changes') as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility of the edit mode', () => {
|
||||
it('keeps Undo a reachable button', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await selectRow('Description')
|
||||
await click(byLabel('Remove'))
|
||||
|
||||
const undo = undoButtons()[0]!
|
||||
expect(undo.tagName).toBe('BUTTON')
|
||||
expect(undo.tabIndex).toBe(0)
|
||||
expect(undo.getAttribute('type')).toBe('button')
|
||||
})
|
||||
|
||||
it('ties the lock icons to a text or a help', async () => {
|
||||
await mountAt('/schema/events')
|
||||
|
||||
expect(keyInput()!.readOnly).toBe(true)
|
||||
expect(keyInput()!.closest('[data-orientation]')).not.toBeNull()
|
||||
await selectRow('Starts on')
|
||||
expect(text(el('[data-testid="field-inspector"]'))).toContain(
|
||||
"Used in the API, so it can't change.",
|
||||
)
|
||||
expect(text(el('[data-testid="field-inspector"]'))).toContain("Can't change after saving")
|
||||
})
|
||||
|
||||
it('names the dialog and keeps the focus inside it', async () => {
|
||||
await mountAt('/schema/events')
|
||||
await setMax('2000')
|
||||
await click(saveButton())
|
||||
|
||||
const open = dialog()!
|
||||
expect(open.getAttribute('role')).toBe('alertdialog')
|
||||
expect(open.getAttribute('aria-labelledby')).not.toBeNull()
|
||||
expect(open.contains(document.activeElement)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -67,8 +67,8 @@ describe('Reaching the builder', () => {
|
||||
expect(nameInput()).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each(['/schema/events', '/schema/unknown'])('still says not found at %s', async (path) => {
|
||||
await mountAt(path)
|
||||
it('still says not found at /schema/unknown', async () => {
|
||||
await mountAt('/schema/unknown')
|
||||
|
||||
expect(text(main())).toContain(NOT_FOUND_TITLE)
|
||||
expect(nameInput()).toBeNull()
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('Schema list: built-in rows are links', () => {
|
||||
it('links each built-in row to its view, named after the type, with a chevron', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
const links = [...document.body.querySelectorAll('a[data-testid="schema-row"]')]
|
||||
const links = [...document.body.querySelectorAll('a[data-testid="schema-row"]')].slice(3)
|
||||
expect(links.map((link) => link.getAttribute('href'))).toEqual(
|
||||
builtinTypes.map((type) => `/schema/${type.key}`),
|
||||
)
|
||||
@@ -52,11 +52,6 @@ describe('Schema list: built-in rows are links', () => {
|
||||
builtinTypes.map((type) => type.name),
|
||||
)
|
||||
expect(links.every((link) => link.querySelectorAll('svg').length === 3)).toBe(true)
|
||||
const rows = [...document.body.querySelectorAll('[data-testid="schema-row"]')]
|
||||
expect(rows.slice(0, 3).every((row) => row.tagName !== 'A')).toBe(true)
|
||||
expect(
|
||||
rows.slice(0, 3).every((row) => row.querySelector('[class*="chevron-right"]') === null),
|
||||
).toBe(true)
|
||||
expect(text(links[0])).toContain('Managed by RenewCMS')
|
||||
})
|
||||
|
||||
@@ -64,17 +59,16 @@ describe('Schema list: built-in rows are links', () => {
|
||||
await mountAt('/schema', { width: MOBILE_WIDTH })
|
||||
|
||||
const cards = [...document.body.querySelectorAll('[data-testid="schema-card"]')]
|
||||
const links = cards.filter((card) => card.tagName === 'A')
|
||||
const links = cards.filter((card) => card.tagName === 'A').slice(3)
|
||||
expect(links).toHaveLength(5)
|
||||
expect(text(links[0])).toBe('Articles Built-in articles · 5 fields')
|
||||
expect(links[0]!.getAttribute('href')).toBe('/schema/articles')
|
||||
expect(links[0]!.querySelector('svg:last-of-type')).not.toBeNull()
|
||||
expect(cards.slice(0, 3).every((card) => card.tagName !== 'A')).toBe(true)
|
||||
})
|
||||
|
||||
it('opens the type without loading the built-in types a second time', async () => {
|
||||
await mountAt('/schema')
|
||||
;(document.body.querySelector('a[data-testid="schema-row"]') as HTMLElement).click()
|
||||
;(document.body.querySelectorAll('a[data-testid="schema-row"]')[3] as HTMLElement).click()
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/articles'))
|
||||
@@ -178,18 +172,15 @@ describe('Schema built-in view: loading', () => {
|
||||
})
|
||||
|
||||
describe('Schema built-in view: unknown key', () => {
|
||||
it.each(['/schema/unknown', '/schema/events'])(
|
||||
'shows the not-found page at %s without redirecting',
|
||||
async (path) => {
|
||||
await mountAt(path)
|
||||
it('shows the not-found page at /schema/unknown without redirecting', async () => {
|
||||
await mountAt('/schema/unknown')
|
||||
|
||||
expect(text(main())).toContain(NOT_FOUND_TITLE)
|
||||
expect(text(main())).toContain(NOT_FOUND_TEXT)
|
||||
expect(router.currentRoute.value.path).toBe(path)
|
||||
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
|
||||
expect(main().querySelector('button')).toBeNull()
|
||||
},
|
||||
)
|
||||
expect(text(main())).toContain(NOT_FOUND_TITLE)
|
||||
expect(text(main())).toContain(NOT_FOUND_TEXT)
|
||||
expect(router.currentRoute.value.path).toBe('/schema/unknown')
|
||||
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
|
||||
expect(main().querySelector('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema built-in view: error', () => {
|
||||
|
||||
@@ -83,21 +83,44 @@ describe('Schema list: populated', () => {
|
||||
expect(text(main())).toContain('3 content types · 5 built-in')
|
||||
})
|
||||
|
||||
it('does not make rows links', async () => {
|
||||
it('makes every row a link to its type, custom rows included', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
expect(document.body.querySelectorAll('[data-testid="schema-row"] a')).toHaveLength(0)
|
||||
await document.body.querySelector<HTMLElement>('[data-testid="schema-row"]')!.click()
|
||||
const links = [...document.body.querySelectorAll('a[data-testid="schema-row"]')]
|
||||
expect(links.map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/schema/events',
|
||||
'/schema/recipes',
|
||||
'/schema/press-mentions',
|
||||
'/schema/articles',
|
||||
'/schema/authors',
|
||||
'/schema/sites',
|
||||
'/schema/media',
|
||||
'/schema/users',
|
||||
])
|
||||
expect(links[0]!.getAttribute('aria-label')).toBe('Events')
|
||||
})
|
||||
|
||||
it('opens the editor when the Events row is activated', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
document.body.querySelector<HTMLElement>('a[data-testid="schema-row"]')!.click()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/events'))
|
||||
})
|
||||
|
||||
it('turns into cards below 640 px with the same content', async () => {
|
||||
await mountAt('/schema', { width: 390 })
|
||||
|
||||
const cards = [...document.body.querySelectorAll('[data-testid="schema-card"]')].map(text)
|
||||
const elements = [...document.body.querySelectorAll('[data-testid="schema-card"]')]
|
||||
const cards = elements.map(text)
|
||||
expect(cards).toHaveLength(8)
|
||||
expect(cards[0]).toBe('Events events · 7 fields · Updated 15 Oct 2026')
|
||||
expect(elements.slice(0, 3).map((card) => card.getAttribute('href'))).toEqual([
|
||||
'/schema/events',
|
||||
'/schema/recipes',
|
||||
'/schema/press-mentions',
|
||||
])
|
||||
expect(cards[3]).toBe('Articles Built-in articles · 5 fields')
|
||||
expect(document.body.querySelector('[data-testid="schema-row"]')).toBeNull()
|
||||
})
|
||||
@@ -348,8 +371,12 @@ describe('Schema route', () => {
|
||||
const to = router.resolve('/schema')
|
||||
|
||||
expect(to.meta).toMatchObject({ auth: 'verified', title: 'Schema' })
|
||||
expect(resolveAuthRedirect(to, session)).toMatchObject({ path: '/login' })
|
||||
expect(resolveAuthRedirect(to, session)).toMatchObject({
|
||||
path: '/login',
|
||||
})
|
||||
session.session = { verified: false, displayName: '' }
|
||||
expect(resolveAuthRedirect(to, session)).toEqual({ path: '/register/pending' })
|
||||
expect(resolveAuthRedirect(to, session)).toEqual({
|
||||
path: '/register/pending',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -105,3 +105,43 @@ export async function buildEvents() {
|
||||
await addChoice('Workshop')
|
||||
await addChoice('Meetup')
|
||||
}
|
||||
|
||||
export const rowOf = (label: string) =>
|
||||
rows().find((row) => text(row.querySelector('[data-testid="field-row-label"]')) === label)
|
||||
export const badgesOf = (label: string) =>
|
||||
text(rowOf(label)?.querySelector('[data-testid="field-row-badges"]'))
|
||||
export const undoRows = () => all('[data-testid="undo-row"]').map(text)
|
||||
export const undoButtons = () => all<HTMLButtonElement>('[data-testid="undo-action"]')
|
||||
export const notice = () => text(el('[data-testid="entries-notice"]'))
|
||||
export const dialog = () => el('[role="alertdialog"]')
|
||||
export const inspectorTitle = () => text(el('[data-testid="inspector-pane"] h2'))
|
||||
|
||||
export async function setMax(value: string, row = 'Capacity') {
|
||||
await selectRow(row)
|
||||
await typeInto(limit('max'), value)
|
||||
}
|
||||
|
||||
export async function removeSelected() {
|
||||
await click(byLabel('Remove'))
|
||||
}
|
||||
|
||||
export async function chooseDefault(name: string) {
|
||||
await press(el('button[role="combobox"]'), 'ArrowDown')
|
||||
await press(
|
||||
all('[role="option"]').find((option) => text(option) === name),
|
||||
'Enter',
|
||||
)
|
||||
}
|
||||
|
||||
export async function makeUnsavedChanges() {
|
||||
await setMax('2000')
|
||||
await addField('Speaker')
|
||||
await addField('Room')
|
||||
await pickTile('Choice')
|
||||
await toggle('Required')
|
||||
await addChoice('Main hall')
|
||||
await addChoice('Studio')
|
||||
await chooseDefault('Main hall')
|
||||
await selectRow('Description')
|
||||
await removeSelected()
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
|
||||
|
||||
export { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
|
||||
|
||||
export type Responder = () => unknown
|
||||
|
||||
@@ -20,7 +23,7 @@ export const customTypes = [
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: 'files',
|
||||
fields: new Array(7),
|
||||
fields: eventsFields,
|
||||
updated_at: '2026-10-15T09:30:00Z',
|
||||
},
|
||||
{
|
||||
@@ -28,7 +31,7 @@ export const customTypes = [
|
||||
key: 'recipes',
|
||||
name: 'Recipes',
|
||||
icon: null,
|
||||
fields: new Array(4),
|
||||
fields: recipesFields,
|
||||
updated_at: '2026-10-02T09:30:00Z',
|
||||
},
|
||||
{
|
||||
@@ -117,9 +120,17 @@ export interface PostedRequest {
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const entryCounts: Record<string, number> = { events: 37, recipes: 0 }
|
||||
|
||||
export function entryPage(total: number) {
|
||||
return { items: [], total, page: 1, size: 1 }
|
||||
}
|
||||
|
||||
export interface SchemaMount {
|
||||
role?: string
|
||||
create?: (body: PostedRequest['body']) => unknown
|
||||
update?: (body: PostedRequest['body'], key: string) => unknown
|
||||
entries?: (key: string) => unknown
|
||||
width?: number
|
||||
content?: Responder
|
||||
builtin?: Responder
|
||||
@@ -130,10 +141,17 @@ export interface SchemaMount {
|
||||
let wrapper: VueWrapper | undefined
|
||||
export const calls: string[] = []
|
||||
export const posts: PostedRequest[] = []
|
||||
export const puts: PostedRequest[] = []
|
||||
|
||||
export function updatedType(key: string, body: Record<string, unknown>) {
|
||||
const base = customTypes.find((type) => type.key === key)!
|
||||
return { ...base, ...body, key, updated_at: '2026-10-19T11:02:00Z' }
|
||||
}
|
||||
|
||||
export async function mountAt(path: string, options: SchemaMount = {}, shellOnly = false) {
|
||||
calls.length = 0
|
||||
posts.length = 0
|
||||
puts.length = 0
|
||||
localStorage.clear()
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
const pinia = createPinia()
|
||||
@@ -155,6 +173,12 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
}
|
||||
session.status = 'ready'
|
||||
const request = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body)) as PostedRequest['body']
|
||||
const key = url.split('/').pop()!
|
||||
puts.push({ url, body })
|
||||
return (options.update ?? ((sent, target) => updatedType(target, sent)))(body, key)
|
||||
}
|
||||
if (init?.method === 'POST') {
|
||||
const body = JSON.parse(String(init.body)) as PostedRequest['body']
|
||||
posts.push({ url, body })
|
||||
@@ -164,6 +188,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
if (url === '/v1/content-type') {
|
||||
return (options.content ?? (() => [...customTypes]))()
|
||||
}
|
||||
const counted = url.match(/^\/v1\/content-type\/([^/]+)\/entry\?page=1&size=5$/)
|
||||
if (counted) {
|
||||
return (options.entries ?? ((key) => entryPage(entryCounts[key] ?? 0)))(counted[1]!)
|
||||
}
|
||||
if (url === '/v1/builtin-type') {
|
||||
return (options.builtin ?? (() => builtinTypes))()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user