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
@@ -5,6 +5,8 @@ import { computed } from 'vue'
|
||||
import { useJsonCopy } from '@/composables/useJsonCopy'
|
||||
import { previewRoutes } from '@/lib/schemaBuilderSample'
|
||||
|
||||
const PANEL_COLUMNS = '1fr 1.4fr'
|
||||
|
||||
const props = defineProps<{ keyValue: string; json: string }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
@@ -55,9 +57,12 @@ const summary = computed(() => `${routes.value[0]!.method} ${routes.value[0]!.pa
|
||||
</div>
|
||||
<UCollapsible :open="open" :ui="{ content: 'overflow-hidden' }">
|
||||
<template #content>
|
||||
<div class="grid h-62 grid-cols-12 grid-rows-1 gap-4 bg-default px-6 pb-4">
|
||||
<div class="col-span-5 flex min-h-0 min-w-0 flex-col gap-1.5">
|
||||
<span class="text-xs text-muted">{{
|
||||
<div
|
||||
class="grid h-62 grid-rows-1 gap-4 bg-default px-6 pb-4"
|
||||
:style="{ gridTemplateColumns: PANEL_COLUMNS }"
|
||||
>
|
||||
<div class="flex min-h-0 min-w-0 flex-col gap-1.5">
|
||||
<span class="text-xs/4.5 text-muted">{{
|
||||
t('Where entries of this type live')
|
||||
}}</span>
|
||||
<ul
|
||||
@@ -67,7 +72,7 @@ const summary = computed(() => `${routes.value[0]!.method} ${routes.value[0]!.pa
|
||||
v-for="(route, index) in routes"
|
||||
:key="index"
|
||||
data-testid="api-route"
|
||||
class="truncate font-mono text-xs/5 text-highlighted"
|
||||
class="truncate font-mono text-xs/4.75 text-highlighted"
|
||||
>
|
||||
<span class="inline-block w-14 text-muted">{{ route.method }}</span
|
||||
>{{ route.path }}
|
||||
@@ -79,14 +84,14 @@ const summary = computed(() => `${routes.value[0]!.method} ${routes.value[0]!.pa
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="col-span-7 flex min-h-0 min-w-0 flex-col gap-1.5">
|
||||
<span class="text-xs text-muted">{{
|
||||
<div class="flex min-h-0 min-w-0 flex-col gap-1.5">
|
||||
<span class="text-xs/4.5 text-muted">{{
|
||||
t("A sample entry. Values come from each field's type and default.")
|
||||
}}</span>
|
||||
<pre
|
||||
tabindex="0"
|
||||
data-testid="api-json"
|
||||
class="m-0 min-h-0 flex-1 overflow-auto border border-default bg-elevated px-3 py-2.5 font-mono text-xs/5 whitespace-pre text-highlighted"
|
||||
class="m-0 min-h-0 flex-1 overflow-auto border border-default bg-elevated px-3 py-2.5 font-mono text-xs/4.75 whitespace-pre text-highlighted"
|
||||
>{{ json }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ defineProps<{
|
||||
canReveal: boolean
|
||||
canDiscard: boolean
|
||||
saving: boolean
|
||||
savedAt?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{ save: []; discard: [] }>()
|
||||
@@ -18,7 +19,7 @@ defineEmits<{ save: []; discard: [] }>()
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 items-center gap-2 md:gap-3">
|
||||
<BuilderStatusChip :status="status" :problems="problems" />
|
||||
<BuilderStatusChip :status="status" :problems="problems" :saved-at="savedAt" />
|
||||
<span class="flex-1 md:hidden" />
|
||||
<UButton
|
||||
color="neutral"
|
||||
@@ -34,6 +35,7 @@ defineEmits<{ save: []; discard: [] }>()
|
||||
:aria-disabled="canReveal ? 'true' : undefined"
|
||||
:loading="saving"
|
||||
class="h-11 px-4 md:h-8"
|
||||
:class="saving ? 'disabled:opacity-40' : ''"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ saving ? t('Saving…') : t('Save') }}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ kind: 'new' | 'required' | 'unique'; onSand?: boolean }>()
|
||||
const props = defineProps<{
|
||||
kind: 'new' | 'changed' | 'cause' | 'required' | 'unique'
|
||||
onSand?: boolean
|
||||
}>()
|
||||
|
||||
const badges = {
|
||||
new: {
|
||||
@@ -12,6 +15,19 @@ const badges = {
|
||||
},
|
||||
class: 'border border-(--rc-sand) bg-(--rc-sand) text-(--rc-on-sand)',
|
||||
},
|
||||
changed: {
|
||||
get label() {
|
||||
return t('Changed')
|
||||
},
|
||||
class: 'border border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
|
||||
},
|
||||
cause: {
|
||||
get label() {
|
||||
return t('Possible cause')
|
||||
},
|
||||
icon: 'i-lucide-triangle-alert',
|
||||
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
|
||||
},
|
||||
required: {
|
||||
get label() {
|
||||
return t('Required')
|
||||
@@ -29,6 +45,7 @@ const badges = {
|
||||
const OUTLINE_ON_SAND = 'border border-(--rc-on-sand) bg-transparent text-(--rc-on-sand)'
|
||||
|
||||
const badge = computed(() => badges[props.kind])
|
||||
const badgeIcon = computed(() => ('icon' in badge.value ? badge.value.icon : undefined))
|
||||
const badgeClass = computed(() =>
|
||||
props.onSand && props.kind !== 'new' ? OUTLINE_ON_SAND : badge.value.class,
|
||||
)
|
||||
@@ -38,8 +55,12 @@ const badgeClass = computed(() =>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:icon="badgeIcon"
|
||||
:class="badgeClass"
|
||||
:ui="{ base: 'h-5 shrink-0 px-1.5 py-0 text-xs font-medium whitespace-nowrap' }"
|
||||
:ui="{
|
||||
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs font-medium whitespace-nowrap',
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</UBadge>
|
||||
|
||||
@@ -4,7 +4,11 @@ import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
|
||||
|
||||
const props = defineProps<{ status: BuilderStatus; problems: number }>()
|
||||
const props = defineProps<{
|
||||
status: BuilderStatus
|
||||
problems: number
|
||||
savedAt?: string
|
||||
}>()
|
||||
|
||||
const text = computed(() => {
|
||||
switch (props.status) {
|
||||
@@ -12,6 +16,8 @@ const text = computed(() => {
|
||||
return t('Saving…')
|
||||
case 'offline':
|
||||
return t('Waiting for connection')
|
||||
case 'saved':
|
||||
return props.savedAt ? t('Saved at {0}', [props.savedAt]) : t('Saved')
|
||||
case 'failed':
|
||||
return t('Save failed')
|
||||
case 'problems':
|
||||
@@ -33,7 +39,7 @@ const text = computed(() => {
|
||||
:class="{
|
||||
'text-warning': status === 'dirty',
|
||||
'text-error': status === 'failed' || status === 'problems',
|
||||
'text-muted': status === 'saving' || status === 'offline',
|
||||
'text-muted': status === 'saving' || status === 'offline' || status === 'saved',
|
||||
}"
|
||||
>
|
||||
<UIcon
|
||||
@@ -43,6 +49,7 @@ const text = computed(() => {
|
||||
/>
|
||||
<UIcon v-else-if="status === 'offline'" name="i-lucide-wifi-off" class="size-3.5" />
|
||||
<UIcon v-else-if="status === 'dirty'" name="i-lucide-circle-dot" class="size-3.5" />
|
||||
<UIcon v-else-if="status === 'saved'" name="i-lucide-check" class="size-3.5" />
|
||||
<UIcon v-else name="i-lucide-circle-alert" class="size-3.5" />
|
||||
<span>{{ text }}</span>
|
||||
</span>
|
||||
|
||||
@@ -19,6 +19,7 @@ const props = defineProps<{
|
||||
keyError?: string
|
||||
disabled: boolean
|
||||
keyFocusToken: number
|
||||
keyLocked?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -29,6 +30,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const tipOpen = ref(false)
|
||||
const slugMode = computed(() => keyDisplayMode(props.keyMode, props.keyValue))
|
||||
const hasError = computed(() => Boolean(props.nameError || props.keyError))
|
||||
|
||||
@@ -62,7 +64,36 @@ watch(
|
||||
/>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
<div :class="keyError ? 'md:w-90' : 'md:w-80'">
|
||||
<div v-if="keyLocked" class="md:w-80">
|
||||
<UTooltip
|
||||
v-model:open="tipOpen"
|
||||
:text="
|
||||
t(
|
||||
'The key is used in URLs and the API, so it can\'t change after the type is created.',
|
||||
)
|
||||
"
|
||||
:content="{ side: 'bottom', align: 'start' }"
|
||||
:ui="{
|
||||
content:
|
||||
'h-auto w-65 items-start bg-inverted px-2.5 py-2 text-xs/4.5 text-inverted shadow-(--shadow-popover) ring-0',
|
||||
text: 'whitespace-normal',
|
||||
}"
|
||||
>
|
||||
<div @focusin="tipOpen = true" @focusout="tipOpen = false">
|
||||
<SlugField
|
||||
:label="t('Key')"
|
||||
name="key"
|
||||
mode="manual"
|
||||
:value="keyValue"
|
||||
:disabled="disabled"
|
||||
dense
|
||||
locked
|
||||
:maxlength="BUILDER_LIMITS.typeKeyMax"
|
||||
/>
|
||||
</div>
|
||||
</UTooltip>
|
||||
</div>
|
||||
<div v-else :class="keyError ? 'md:w-90' : 'md:w-80'">
|
||||
<SlugField
|
||||
:label="t('Key')"
|
||||
name="key"
|
||||
|
||||
@@ -27,6 +27,7 @@ const props = withDefaults(
|
||||
fullBleed?: boolean
|
||||
compactPadding?: boolean
|
||||
bleedOfflineBanner?: boolean
|
||||
flushOfflineBanner?: boolean
|
||||
renderWhileChecking?: boolean
|
||||
noCurrentNav?: boolean
|
||||
offlineMessage?: string
|
||||
@@ -42,6 +43,7 @@ const props = withDefaults(
|
||||
fullBleed: false,
|
||||
compactPadding: false,
|
||||
bleedOfflineBanner: false,
|
||||
flushOfflineBanner: false,
|
||||
renderWhileChecking: false,
|
||||
noCurrentNav: false,
|
||||
},
|
||||
@@ -411,6 +413,7 @@ async function onLogout() {
|
||||
:icon="offlineIcon"
|
||||
:bleed="compactPadding || bleedOfflineBanner"
|
||||
:roomy="!compactPadding && bleedOfflineBanner"
|
||||
:flush="flushOfflineBanner"
|
||||
/>
|
||||
|
||||
<AppBoot v-if="showBoot" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -17,6 +17,7 @@ const props = withDefaults(
|
||||
notice?: DirectoryNotice | null
|
||||
closable?: boolean
|
||||
confirmDisabled?: boolean
|
||||
wide?: boolean
|
||||
}>(),
|
||||
{
|
||||
cancelLabel: '',
|
||||
@@ -25,13 +26,30 @@ const props = withDefaults(
|
||||
notice: null,
|
||||
closable: false,
|
||||
confirmDisabled: false,
|
||||
wide: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ confirm: []; close: [] }>()
|
||||
const emit = defineEmits<{ confirm: []; close: []; cancel: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const titleElement = ref<HTMLElement | null>(null)
|
||||
const contentClass = computed(
|
||||
() =>
|
||||
`${SHEET_CONTENT_CLASS} ${props.wide ? 'sm:top-15 sm:max-w-140' : 'sm:top-35 sm:max-w-115'}`,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.pending,
|
||||
async (value) => {
|
||||
if (value) {
|
||||
await nextTick()
|
||||
titleElement.value?.focus()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const confirmText = computed(() => (props.pending ? props.pendingLabel : props.confirmLabel))
|
||||
const closeButton = computed(() =>
|
||||
props.closable && !props.pending
|
||||
@@ -50,6 +68,7 @@ function onOpenChange(value: boolean) {
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
emit('cancel')
|
||||
onOpenChange(false)
|
||||
}
|
||||
</script>
|
||||
@@ -66,8 +85,11 @@ function onCancel() {
|
||||
'aria-busy': pending ? 'true' : undefined,
|
||||
}"
|
||||
:ui="{
|
||||
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
|
||||
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
content: contentClass,
|
||||
header:
|
||||
props.wide && props.closable
|
||||
? 'min-h-0 px-6 pe-20 pt-5 pb-0 sm:px-6 sm:pe-16'
|
||||
: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'mt-1 text-sm text-muted text-pretty',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
@@ -75,6 +97,9 @@ function onCancel() {
|
||||
}"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template #title>
|
||||
<span ref="titleElement" tabindex="-1" class="outline-none">{{ title }}</span>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UAlert
|
||||
@@ -100,7 +125,10 @@ function onCancel() {
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
class="h-11 justify-center text-sm text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3"
|
||||
class="h-11 justify-center text-sm text-highlighted sm:h-8 sm:w-auto sm:px-3"
|
||||
:class="
|
||||
wide ? 'disabled:opacity-40' : 'disabled:text-dimmed disabled:opacity-100'
|
||||
"
|
||||
:disabled="pending"
|
||||
@click="onCancel"
|
||||
>
|
||||
@@ -112,6 +140,7 @@ function onCancel() {
|
||||
:disabled="pending || confirmDisabled"
|
||||
block
|
||||
class="h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3"
|
||||
:class="wide ? 'disabled:opacity-40' : ''"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ confirmText }}
|
||||
|
||||
@@ -154,6 +154,8 @@ watch(
|
||||
/>
|
||||
</DirectoryField>
|
||||
<SlugField
|
||||
:locked="field.saved"
|
||||
:locked-help="t('Used in the API, so it can\'t change.')"
|
||||
:label="t('Key')"
|
||||
name="field-key"
|
||||
:mode="keyMode"
|
||||
@@ -180,6 +182,7 @@ watch(
|
||||
:model-value="type"
|
||||
:compact="compact"
|
||||
:disabled="disabled"
|
||||
:locked="field.saved"
|
||||
@update:model-value="emit('type', $event)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,17 +4,31 @@ import { t } from '@/i18n'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import FieldRow from '@/components/FieldRow.vue'
|
||||
import { useKeyboardReorder } from '@/composables/useKeyboardReorder'
|
||||
import type { RowMarks } from '@/composables/useSchemaBuilder'
|
||||
import { BUILDER_LIMITS, type BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: BuilderField[]
|
||||
selectedUid: number | null
|
||||
errors: Map<number, FieldErrors>
|
||||
listError?: string
|
||||
disabled: boolean
|
||||
mobile: boolean
|
||||
}>()
|
||||
const NEW_MARKS: RowMarks = { isNew: true, changed: false, cause: false }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
fields: BuilderField[]
|
||||
selectedUid: number | null
|
||||
errors: Map<number, FieldErrors>
|
||||
listError?: string
|
||||
disabled: boolean
|
||||
mobile: boolean
|
||||
marks?: Map<number, RowMarks>
|
||||
removed?: BuilderField[]
|
||||
canUndo?: boolean
|
||||
}>(),
|
||||
{
|
||||
listError: undefined,
|
||||
marks: undefined,
|
||||
removed: () => [],
|
||||
canUndo: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [uid: number]
|
||||
@@ -22,6 +36,7 @@ const emit = defineEmits<{
|
||||
remove: [uid: number]
|
||||
move: [uid: number, delta: number]
|
||||
reorder: [order: number[]]
|
||||
undo: [key: string]
|
||||
}>()
|
||||
|
||||
const list = ref<HTMLElement | null>(null)
|
||||
@@ -120,6 +135,7 @@ function onFocusin(event: FocusEvent) {
|
||||
:disabled="disabled"
|
||||
:tabbable="field.uid === tabbableUid"
|
||||
:show-actions="!mobile"
|
||||
:marks="marks?.get(field.uid) ?? NEW_MARKS"
|
||||
@select="emit('select', field.uid)"
|
||||
@up="onMove(field.uid, -1)"
|
||||
@down="onMove(field.uid, 1)"
|
||||
@@ -141,6 +157,28 @@ function onFocusin(event: FocusEvent) {
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="field in removed"
|
||||
:key="field.key"
|
||||
data-testid="undo-row"
|
||||
class="flex min-h-11 items-center gap-2 border border-dashed border-accented px-3 text-toned"
|
||||
:class="disabled ? 'opacity-60' : ''"
|
||||
>
|
||||
<UIcon name="i-lucide-minus" class="size-3.5 shrink-0" />
|
||||
<span class="flex-1">{{
|
||||
t('{0} will be removed', [field.label.trim() || t('Untitled field')])
|
||||
}}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="undo-action"
|
||||
:disabled="disabled || !canUndo"
|
||||
class="min-h-11 font-medium text-highlighted underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-primary disabled:text-dimmed md:min-h-0"
|
||||
@click="emit('undo', field.key)"
|
||||
>
|
||||
{{ t('Undo') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
color="neutral"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed } from 'vue'
|
||||
import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||
import FieldActions from '@/components/FieldActions.vue'
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import type { RowMarks } from '@/composables/useSchemaBuilder'
|
||||
import { fieldIcon } from '@/lib/schemaFields'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -17,6 +18,7 @@ const props = defineProps<{
|
||||
disabled: boolean
|
||||
tabbable: boolean
|
||||
showActions: boolean
|
||||
marks: RowMarks
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -68,7 +70,7 @@ const rowClass = computed(() => [
|
||||
:aria-current="selected ? 'true' : undefined"
|
||||
:disabled="disabled"
|
||||
:tabindex="tabbable ? 0 : -1"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 self-stretch text-left focus-visible:outline-2 focus-visible:outline-primary"
|
||||
class="flex min-w-0 flex-1 items-center gap-2.5 self-stretch text-left max-md:gap-3 focus-visible:outline-2 focus-visible:outline-primary"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<span
|
||||
@@ -88,7 +90,9 @@ const rowClass = computed(() => [
|
||||
</span>
|
||||
</button>
|
||||
<span class="flex shrink-0 items-center gap-1" data-testid="field-row-badges">
|
||||
<BuilderBadge kind="new" />
|
||||
<BuilderBadge v-if="marks.isNew" kind="new" />
|
||||
<BuilderBadge v-if="marks.changed" kind="changed" />
|
||||
<BuilderBadge v-if="marks.cause" kind="cause" />
|
||||
<BuilderBadge v-if="field.required" kind="required" :on-sand="selected" />
|
||||
<BuilderBadge v-if="field.unique" kind="unique" :on-sand="selected" />
|
||||
<UIcon
|
||||
|
||||
@@ -5,7 +5,12 @@ import { computed } from 'vue'
|
||||
import { FIELD_TYPES, type FieldType } from '@/lib/schemaBuilderModel'
|
||||
import { fieldIcon, fieldTypeLabel } from '@/lib/schemaFields'
|
||||
|
||||
defineProps<{ modelValue: FieldType; compact: boolean; disabled: boolean }>()
|
||||
const props = defineProps<{
|
||||
modelValue: FieldType
|
||||
compact: boolean
|
||||
disabled: boolean
|
||||
locked?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ 'update:modelValue': [type: FieldType] }>()
|
||||
|
||||
@@ -27,7 +32,11 @@ function describe(type: FieldType) {
|
||||
}
|
||||
|
||||
const items = computed(() =>
|
||||
FIELD_TYPES.map((type) => ({ value: type, label: fieldTypeLabel(type) })),
|
||||
FIELD_TYPES.map((type) => ({
|
||||
value: type,
|
||||
label: fieldTypeLabel(type),
|
||||
disabled: props.locked && type !== props.modelValue,
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -42,7 +51,7 @@ const items = computed(() =>
|
||||
orientation="horizontal"
|
||||
:ui="{
|
||||
root: 'w-full',
|
||||
legend: 'mb-1.5 font-medium text-highlighted',
|
||||
legend: 'mb-1.5 w-full font-medium text-highlighted',
|
||||
fieldset: compact ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-3 gap-2',
|
||||
item: 'group min-h-14 rounded-none border-accented p-2.5 text-highlighted has-data-[state=checked]:border-primary has-data-[state=checked]:bg-(--rc-sand) has-data-[state=checked]:text-(--rc-on-sand) max-md:min-h-16',
|
||||
wrapper: 'ms-0 w-full',
|
||||
@@ -50,8 +59,20 @@ const items = computed(() =>
|
||||
}"
|
||||
@update:model-value="$emit('update:modelValue', $event as FieldType)"
|
||||
>
|
||||
<template v-if="locked" #legend>
|
||||
<span class="flex items-center justify-between gap-2">
|
||||
<span>{{ t('Type') }}</span>
|
||||
<span class="flex items-center gap-1 text-xs font-normal text-muted">
|
||||
<UIcon name="i-lucide-lock" class="size-3" />
|
||||
{{ t("Can't change after saving") }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #label="{ item }">
|
||||
<span class="flex items-start gap-2.5 text-left">
|
||||
<span
|
||||
class="flex items-start gap-2.5 text-left"
|
||||
:class="item.disabled ? 'opacity-40' : ''"
|
||||
>
|
||||
<UIcon :name="fieldIcon(item.value)" class="mt-0.5 size-4 shrink-0" />
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="font-medium">{{ item.label }}</span>
|
||||
|
||||
@@ -16,17 +16,32 @@ const BLEED_UI = {
|
||||
title: 'whitespace-normal font-normal text-wrap',
|
||||
}
|
||||
|
||||
const FLUSH_UI = { ...BLEED_UI, root: 'w-auto' }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
message?: string
|
||||
icon?: string
|
||||
bleed?: boolean
|
||||
roomy?: boolean
|
||||
flush?: boolean
|
||||
}>(),
|
||||
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false, roomy: false },
|
||||
{
|
||||
message: DEFAULT_MESSAGE,
|
||||
icon: undefined,
|
||||
bleed: false,
|
||||
roomy: false,
|
||||
flush: false,
|
||||
},
|
||||
)
|
||||
|
||||
const bleedUi = computed(() => (props.roomy ? { ...BLEED_UI, root: ROOMY_BLEED_ROOT } : BLEED_UI))
|
||||
const bannerUi = computed(() => {
|
||||
if (props.flush) {
|
||||
return FLUSH_UI
|
||||
}
|
||||
return props.bleed ? bleedUi.value : undefined
|
||||
})
|
||||
|
||||
const isOffline = ref(!navigator.onLine)
|
||||
|
||||
@@ -51,6 +66,6 @@ onUnmounted(() => {
|
||||
color="neutral"
|
||||
:icon="icon"
|
||||
:title="translate(message)"
|
||||
:ui="bleed ? bleedUi : undefined"
|
||||
:ui="bannerUi"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,7 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ title: string; description: string }>()
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
actionLabel?: string
|
||||
actionIcon?: string
|
||||
}>(),
|
||||
{ actionLabel: '', actionIcon: 'i-lucide-rotate-cw' },
|
||||
)
|
||||
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
@@ -16,18 +24,18 @@ defineEmits<{ retry: [] }>()
|
||||
icon="i-lucide-circle-x"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
:ui="{ actions: 'mt-1.5' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
:icon="actionIcon || undefined"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="$emit('retry')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
{{ actionLabel || t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } 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 FieldList from '@/components/FieldList.vue'
|
||||
import FieldSheet from '@/components/FieldSheet.vue'
|
||||
import RetryAlert from '@/components/RetryAlert.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import SchemaRefusalDialog from '@/components/SchemaRefusalDialog.vue'
|
||||
import SchemaReviewDialog from '@/components/SchemaReviewDialog.vue'
|
||||
import { useSchemaBuilder, type EditSource } from '@/composables/useSchemaBuilder'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
import { HOME_PATH } from '@/lib/routePaths'
|
||||
import { sampleJson } from '@/lib/schemaBuilderSample'
|
||||
import { entriesNotice } from '@/lib/schemaEntries'
|
||||
import { formatClock } from '@/lib/updateFormat'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
|
||||
const props = defineProps<{ edit?: EditSource }>()
|
||||
|
||||
const router = useRouter()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const builder = useSchemaBuilder(props.edit)
|
||||
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 json = computed(() => sampleJson(form.value.key, form.value.fields))
|
||||
const notice = computed(() => entriesNotice(builder.entryTotal))
|
||||
const savedClock = computed(() =>
|
||||
builder.savedAt.value ? formatClock(new Date(builder.savedAt.value)) : '',
|
||||
)
|
||||
const removedFields = computed(() => builder.removed.value.map((entry) => entry.field))
|
||||
const refusalCauses = computed(() => builder.refusedChanges.value)
|
||||
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 selectedMarks = computed(() =>
|
||||
builder.selected.value ? builder.marks.value.get(builder.selected.value.uid) : undefined,
|
||||
)
|
||||
const selectedName = computed(() => builder.selected.value?.label.trim() || t('Untitled field'))
|
||||
const sheetTitle = computed(() => t('Field: {0}', [selectedName.value]))
|
||||
const reviewName = computed(() => builder.baseline.value?.name ?? '')
|
||||
|
||||
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>
|
||||
<SchemaForbidden v-if="builder.failure.value === 'forbidden'" @back="goHome" />
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col max-md:shrink-0 max-md:basis-auto"
|
||||
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"
|
||||
:key-locked="form.keyLocked"
|
||||
: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"
|
||||
:saved-at="savedClock"
|
||||
@save="builder.save"
|
||||
@discard="builder.discard"
|
||||
/>
|
||||
</template>
|
||||
</BuilderTypeBar>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notice"
|
||||
data-testid="entries-notice"
|
||||
class="flex shrink-0 items-center gap-2 bg-(--rc-info-soft) px-4 py-2 text-xs/4.5 md:px-6"
|
||||
>
|
||||
<UIcon name="i-lucide-info" class="size-3.5 shrink-0 text-info" />
|
||||
<span class="text-toned">{{ notice }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
builder.failure.value === 'failed' ||
|
||||
builder.failure.value === 'invalid' ||
|
||||
builder.refusal.value === 'banner'
|
||||
"
|
||||
class="flex flex-col gap-2 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-if="builder.failure.value === 'invalid'"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="t('Check the fields marked in the list.')"
|
||||
/>
|
||||
<RetryAlert
|
||||
v-if="builder.refusal.value === 'banner'"
|
||||
:title="t('Nothing was changed')"
|
||||
:description="
|
||||
t(
|
||||
'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.',
|
||||
)
|
||||
"
|
||||
:action-label="t('Review changes')"
|
||||
action-icon=""
|
||||
@retry="builder.reviewAgain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<div
|
||||
class="flex flex-col gap-2 p-4 md:min-h-0 md:w-95 md:shrink-0 md:overflow-y-auto 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"
|
||||
:marks="builder.marks.value"
|
||||
:removed="removedFields"
|
||||
:can-undo="builder.mayRestore.value"
|
||||
@select="onSelect"
|
||||
@add="onAdd"
|
||||
@remove="builder.removeField"
|
||||
@move="builder.moveField"
|
||||
@reorder="builder.reorderFields"
|
||||
@undo="builder.undoRemoval"
|
||||
>
|
||||
<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-h-0 min-w-0 flex-1 flex-col gap-3.5 overflow-y-auto 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 v-if="selectedMarks?.isNew" kind="new" />
|
||||
<BuilderBadge v-if="selectedMarks?.changed" kind="changed" />
|
||||
</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"
|
||||
:saved-at="savedClock"
|
||||
@save="builder.save"
|
||||
@discard="builder.discard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!isDesktop">
|
||||
<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>
|
||||
|
||||
<SchemaReviewDialog
|
||||
v-if="builder.reviewOpen.value"
|
||||
:name="reviewName"
|
||||
:total="builder.entryTotal"
|
||||
:changes="builder.changes.value"
|
||||
:applying="builder.saving.value"
|
||||
:offline="!builder.online.value"
|
||||
@apply="builder.apply"
|
||||
@close="builder.closeReview"
|
||||
/>
|
||||
<SchemaRefusalDialog
|
||||
v-if="builder.refusal.value === 'dialog'"
|
||||
:causes="refusalCauses"
|
||||
@keep-editing="builder.keepEditing"
|
||||
@discard="builder.discard"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import SchemaChangeRow from '@/components/SchemaChangeRow.vue'
|
||||
import type { FieldChange } from '@/lib/schemaBuilderDiff'
|
||||
|
||||
defineProps<{ changes: FieldChange[]; testId: string; cause?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="m-0 flex list-none flex-col gap-2 p-0" :data-testid="testId">
|
||||
<SchemaChangeRow
|
||||
v-for="change in changes"
|
||||
:key="`${change.kind}:${change.key}`"
|
||||
:change="change"
|
||||
:cause="cause"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
causeText,
|
||||
changeText,
|
||||
changeTitle,
|
||||
type ChangeKind,
|
||||
type FieldChange,
|
||||
} from '@/lib/schemaBuilderDiff'
|
||||
|
||||
const props = defineProps<{ change: FieldChange; cause?: boolean }>()
|
||||
|
||||
const ICONS: Record<ChangeKind, string> = {
|
||||
add: 'i-lucide-plus',
|
||||
change: 'i-lucide-pencil',
|
||||
remove: 'i-lucide-minus',
|
||||
}
|
||||
|
||||
const flagged = computed(() => props.cause || props.change.flag !== null)
|
||||
const badge = computed(() => {
|
||||
if (props.cause) {
|
||||
return t('Possible cause')
|
||||
}
|
||||
if (props.change.flag === 'mayNotFit') {
|
||||
return t('Entries may not fit')
|
||||
}
|
||||
return props.change.flag === 'removesData' ? t('Removes data') : ''
|
||||
})
|
||||
const text = computed(() => (props.cause ? causeText(props.change) : changeText(props.change)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
data-testid="change-row"
|
||||
class="flex gap-2.5 border border-default px-3 py-2.5"
|
||||
:class="flagged ? 'bg-(--rc-warning-soft)' : 'bg-default'"
|
||||
>
|
||||
<UIcon
|
||||
:name="ICONS[change.kind]"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
:class="flagged ? 'text-warning' : 'text-toned'"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-semibold text-highlighted" data-testid="change-title">{{
|
||||
changeTitle(change)
|
||||
}}</span>
|
||||
<UBadge
|
||||
v-if="badge"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
icon="i-lucide-triangle-alert"
|
||||
data-testid="change-flag"
|
||||
class="border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning"
|
||||
:ui="{
|
||||
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs font-medium whitespace-nowrap',
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
>
|
||||
{{ badge }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<span class="text-pretty text-toned" data-testid="change-text">{{ text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import SchemaChangeList from '@/components/SchemaChangeList.vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import type { FieldChange } from '@/lib/schemaBuilderDiff'
|
||||
|
||||
defineProps<{ causes: FieldChange[] }>()
|
||||
|
||||
defineEmits<{ keepEditing: []; discard: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
:title="t('Nothing was changed')"
|
||||
:description="
|
||||
t(
|
||||
'RenewCMS refused the changes because some entries don\'t fit the new fields. It doesn\'t say which entries.',
|
||||
)
|
||||
"
|
||||
:confirm-label="t('Back to editing')"
|
||||
:pending-label="t('Back to editing')"
|
||||
:cancel-label="t('Discard changes')"
|
||||
:notice="{
|
||||
title: t('Your content type is as it was'),
|
||||
description: t('No field was added, changed or removed.'),
|
||||
}"
|
||||
tone="neutral"
|
||||
wide
|
||||
closable
|
||||
@confirm="$emit('keepEditing')"
|
||||
@cancel="$emit('discard')"
|
||||
@close="$emit('keepEditing')"
|
||||
>
|
||||
<template v-if="causes.length > 0">
|
||||
<span class="font-semibold text-highlighted">{{
|
||||
t('The change that can cause this in your edit')
|
||||
}}</span>
|
||||
<SchemaChangeList :changes="causes" test-id="refusal-causes" cause />
|
||||
</template>
|
||||
<p class="text-xs/4.5 text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'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.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import SchemaChangeList from '@/components/SchemaChangeList.vue'
|
||||
import { entriesLine } from '@/lib/schemaEntries'
|
||||
import { summaryLine, type FieldChange } from '@/lib/schemaBuilderDiff'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
total: number
|
||||
changes: FieldChange[]
|
||||
applying: boolean
|
||||
offline: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ apply: []; close: [] }>()
|
||||
|
||||
const summary = computed(() => summaryLine(props.changes))
|
||||
const entries = computed(() => entriesLine(props.name, props.total))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
:title="t('Review changes to {0}', [name])"
|
||||
:description="t('Apply them to save the content type.')"
|
||||
:confirm-label="t('Apply changes')"
|
||||
:pending-label="t('Applying…')"
|
||||
:cancel-label="t('Back to editing')"
|
||||
tone="neutral"
|
||||
wide
|
||||
:pending="applying"
|
||||
:confirm-disabled="offline"
|
||||
@confirm="$emit('apply')"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-md/4.5 font-semibold text-highlighted" data-testid="review-summary">{{
|
||||
summary
|
||||
}}</span>
|
||||
<span class="text-muted">{{ entries }}</span>
|
||||
</div>
|
||||
<SchemaChangeList :changes="changes" test-id="review-changes" />
|
||||
<p v-if="!applying" class="text-xs/4.5 text-muted">
|
||||
{{
|
||||
t(
|
||||
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -99,7 +99,7 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
|
||||
:maxlength="maxlength"
|
||||
:data-autofocus="autofocus ? '' : undefined"
|
||||
:color="error ? 'error' : undefined"
|
||||
:highlight="mode === 'manual' && !error"
|
||||
:highlight="mode === 'manual' && !error && !locked"
|
||||
class="w-full"
|
||||
:ui="inputUi"
|
||||
@update:model-value="emit('update:value', String($event))"
|
||||
|
||||
@@ -17,7 +17,9 @@ const fetchMock = vi.fn()
|
||||
|
||||
function answer(status: number, body: unknown) {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }),
|
||||
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||||
status,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,3 +157,80 @@ describe('createContentType', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateContentType', () => {
|
||||
const body = {
|
||||
name: 'Recipes',
|
||||
icon: null,
|
||||
fields: [],
|
||||
}
|
||||
|
||||
it('PUTs the JSON body to the key and returns the type', async () => {
|
||||
answer(200, recipes)
|
||||
|
||||
const updated = await useContentTypes().updateContentType('recipes', body)
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!
|
||||
expect(url).toBe('/v1/content-type/recipes')
|
||||
expect(init.method).toBe('PUT')
|
||||
expect(JSON.parse(init.body)).toEqual(body)
|
||||
expect(updated.key).toBe('recipes')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[400, 'Bad request body'],
|
||||
[404, 'content_type_not_found'],
|
||||
[409, 'schema_incompatible'],
|
||||
[422, 'invalid_field'],
|
||||
[403, 'Only owners and admins can do this'],
|
||||
[500, 'boom'],
|
||||
])('rejects %i with its plain-text body', async (status, text) => {
|
||||
answer(status, text)
|
||||
|
||||
const failure = await useContentTypes()
|
||||
.updateContentType('recipes', body)
|
||||
.catch((error) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(ApiError)
|
||||
expect(failure.status).toBe(status)
|
||||
expect(failure.body).toBe(text)
|
||||
})
|
||||
|
||||
it('lets a network failure through', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
|
||||
await expect(useContentTypes().updateContentType('recipes', body)).rejects.toBeInstanceOf(
|
||||
NetworkError,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('countEntries', () => {
|
||||
it('reads the total of a five-entry page', async () => {
|
||||
answer(200, { items: [], total: 37, page: 1, size: 5 })
|
||||
|
||||
await expect(useContentTypes().countEntries('events')).resolves.toBe(37)
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe('/v1/content-type/events/entry?page=1&size=5')
|
||||
})
|
||||
|
||||
it.each([[{ items: [] }], [{ total: '37' }], [null]])(
|
||||
'rejects a body without a numeric total: %j',
|
||||
async (page) => {
|
||||
answer(200, page)
|
||||
|
||||
await expect(useContentTypes().countEntries('events')).rejects.toThrow()
|
||||
},
|
||||
)
|
||||
|
||||
it.each([404, 403, 500])('rejects %i with an ApiError', async (status) => {
|
||||
answer(status, 'content_type_not_found')
|
||||
|
||||
await expect(useContentTypes().countEntries('events')).rejects.toMatchObject({ status })
|
||||
})
|
||||
|
||||
it('lets a network failure through', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
|
||||
await expect(useContentTypes().countEntries('events')).rejects.toBeInstanceOf(NetworkError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useEntryCount } from '@/composables/useEntryCount'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
function page(total: number) {
|
||||
return new Response(JSON.stringify({ items: [], total, page: 1, size: 5 }), { status: 200 })
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function mount(key: ReturnType<typeof ref<string | null>>) {
|
||||
return effectScope().run(() => useEntryCount(() => key.value))!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('the entry count of a type', () => {
|
||||
it('stays idle and sends nothing without a key', async () => {
|
||||
const count = mount(ref<string | null>(null))
|
||||
await flush()
|
||||
|
||||
expect(count.status.value).toBe('idle')
|
||||
expect(count.total.value).toBe(0)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads, then reads ready with the total', async () => {
|
||||
fetchMock.mockResolvedValue(page(37))
|
||||
const count = mount(ref<string | null>('events'))
|
||||
|
||||
expect(count.status.value).toBe('loading')
|
||||
await flush()
|
||||
|
||||
expect(count.status.value).toBe('ready')
|
||||
expect(count.total.value).toBe(37)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[404, 'missing'],
|
||||
[403, 'forbidden'],
|
||||
[500, 'failed'],
|
||||
])('reads %i as %s', async (status, expected) => {
|
||||
fetchMock.mockResolvedValue(new Response('x', { status }))
|
||||
const count = mount(ref<string | null>('events'))
|
||||
await flush()
|
||||
|
||||
expect(count.status.value).toBe(expected)
|
||||
})
|
||||
|
||||
it('reads a network failure as failed, and retry loads again', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
const count = mount(ref<string | null>('events'))
|
||||
await flush()
|
||||
expect(count.status.value).toBe('failed')
|
||||
|
||||
fetchMock.mockResolvedValue(page(2))
|
||||
await count.retry()
|
||||
|
||||
expect(count.status.value).toBe('ready')
|
||||
expect(count.total.value).toBe(2)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not retry without a key', async () => {
|
||||
const count = mount(ref<string | null>(null))
|
||||
await count.retry()
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores the answer of a key that is no longer current', async () => {
|
||||
let release: (value: Response) => void = () => {}
|
||||
fetchMock.mockImplementationOnce(() => new Promise<Response>((r) => (release = r)))
|
||||
fetchMock.mockResolvedValueOnce(page(9))
|
||||
const key = ref<string | null>('events')
|
||||
const count = mount(key)
|
||||
|
||||
key.value = 'recipes'
|
||||
await flush()
|
||||
release(page(1000))
|
||||
await flush()
|
||||
|
||||
expect(count.total.value).toBe(9)
|
||||
expect(count.status.value).toBe('ready')
|
||||
})
|
||||
|
||||
it('ignores a late failure once the key is cleared', async () => {
|
||||
let fail: (reason: unknown) => void = () => {}
|
||||
fetchMock.mockImplementationOnce(() => new Promise<Response>((_, r) => (fail = r)))
|
||||
const key = ref<string | null>('events')
|
||||
const count = mount(key)
|
||||
|
||||
key.value = null
|
||||
await nextTick()
|
||||
fail(new TypeError('Failed to fetch'))
|
||||
await flush()
|
||||
|
||||
expect(count.status.value).toBe('idle')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { jsonInit } from '@/lib/directoryRequest'
|
||||
import type { ContentTypePayload } from '@/lib/schemaBuilderPayload'
|
||||
import type { ContentTypePayload, UpdatePayload } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
export interface BuiltinField {
|
||||
key: string
|
||||
@@ -57,5 +57,29 @@ export function useContentTypes() {
|
||||
})
|
||||
}
|
||||
|
||||
return { listContentTypes, listBuiltinTypes, createContentType }
|
||||
function updateContentType(key: string, payload: UpdatePayload): Promise<ContentType> {
|
||||
return request<ContentType>(`/v1/content-type/${encodeURIComponent(key)}`, {
|
||||
...jsonInit('PUT', payload),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
async function countEntries(key: string): Promise<number> {
|
||||
const page = await request<{ total?: unknown }>(
|
||||
`/v1/content-type/${encodeURIComponent(key)}/entry?page=1&size=5`,
|
||||
options,
|
||||
)
|
||||
if (typeof page?.total !== 'number') {
|
||||
throw new Error(`Unexpected response from /v1/content-type/${key}/entry`)
|
||||
}
|
||||
return page.total
|
||||
}
|
||||
|
||||
return {
|
||||
listContentTypes,
|
||||
listBuiltinTypes,
|
||||
createContentType,
|
||||
updateContentType,
|
||||
countEntries,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useContentTypes } from '@/composables/useContentTypes'
|
||||
|
||||
export type EntryCountStatus = 'idle' | 'loading' | 'ready' | 'failed' | 'missing' | 'forbidden'
|
||||
|
||||
export function useEntryCount(key: () => string | null) {
|
||||
const status = ref<EntryCountStatus>('idle')
|
||||
const total = ref(0)
|
||||
let generation = 0
|
||||
|
||||
async function load(target: string) {
|
||||
const current = ++generation
|
||||
status.value = 'loading'
|
||||
try {
|
||||
const counted = await useContentTypes().countEntries(target)
|
||||
if (current === generation) {
|
||||
total.value = counted
|
||||
status.value = 'ready'
|
||||
}
|
||||
} catch (error) {
|
||||
if (current !== generation) {
|
||||
return
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
status.value = 'missing'
|
||||
} else if (error instanceof ApiError && error.status === 403) {
|
||||
status.value = 'forbidden'
|
||||
} else {
|
||||
status.value = 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
const target = key()
|
||||
if (target !== null) {
|
||||
return load(target)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
watch(
|
||||
key,
|
||||
(target) => {
|
||||
if (target === null) {
|
||||
generation++
|
||||
status.value = 'idle'
|
||||
return
|
||||
}
|
||||
load(target)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return { status, total, retry }
|
||||
}
|
||||
@@ -19,7 +19,16 @@ import {
|
||||
type BuilderStatus,
|
||||
type FieldType,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import { buildPayload } from '@/lib/schemaBuilderPayload'
|
||||
import {
|
||||
baselineSignatures,
|
||||
diffFields,
|
||||
flaggedChanges,
|
||||
isFieldChanged,
|
||||
type FieldChange,
|
||||
} from '@/lib/schemaBuilderDiff'
|
||||
import { baselinePayload, formFromType } from '@/lib/schemaBuilderLoad'
|
||||
import { buildPayload, buildUpdatePayload } from '@/lib/schemaBuilderPayload'
|
||||
import { reservedKeys, restoreIndex, type RemovedField } from '@/lib/schemaBuilderRemoval'
|
||||
import { FIELD_ERROR_TOUCH, fieldTouchKey, typeTouchKey } from '@/lib/schemaBuilderTouch'
|
||||
import type { FieldTouch, TypeTouch } from '@/lib/schemaBuilderTouch'
|
||||
import {
|
||||
@@ -34,10 +43,25 @@ import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
const KEY_TAKEN_BODY = 'content_type_key_taken'
|
||||
const INVALID_KEY_BODY = 'invalid_content_type_key'
|
||||
const INVALID_FIELD_BODY = 'invalid_field'
|
||||
const INCOMPATIBLE_BODY = 'schema_incompatible'
|
||||
const TOAST_ID = 'content-type-saved'
|
||||
|
||||
export type SaveFailure = 'none' | 'failed' | 'invalid' | 'forbidden'
|
||||
export type SaveState = 'editing' | 'saving' | 'saved' | 'failed'
|
||||
export type RefusalState = 'none' | 'dialog' | 'banner'
|
||||
|
||||
export interface EditSource {
|
||||
type: ContentType
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface RowMarks {
|
||||
isNew: boolean
|
||||
changed: boolean
|
||||
cause: boolean
|
||||
}
|
||||
|
||||
const CREATE_MARKS: RowMarks = { isNew: true, changed: false, cause: false }
|
||||
|
||||
function signature(form: BuilderForm) {
|
||||
return JSON.stringify({
|
||||
@@ -48,7 +72,7 @@ function signature(form: BuilderForm) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useSchemaBuilder() {
|
||||
export function useSchemaBuilder(source?: EditSource) {
|
||||
const router = useRouter()
|
||||
const store = useContentTypesStore()
|
||||
const toast = useLocalizedToast()
|
||||
@@ -56,7 +80,17 @@ export function useSchemaBuilder() {
|
||||
let nextUid = 1
|
||||
const newUid = () => nextUid++
|
||||
|
||||
const form = ref<BuilderForm>(blankForm(newUid()))
|
||||
const editing = source !== undefined
|
||||
const baseline = ref<ContentType | null>(source?.type ?? null)
|
||||
const entryTotal = source?.total ?? 0
|
||||
const form = ref<BuilderForm>(source ? formFromType(source.type, newUid) : blankForm(newUid()))
|
||||
const removed = ref<RemovedField[]>([])
|
||||
const reviewOpen = ref(false)
|
||||
const refusal = ref<RefusalState>('none')
|
||||
const refusedUids = ref<number[]>([])
|
||||
const refusedChanges = ref<FieldChange[]>([])
|
||||
const savedAt = ref('')
|
||||
const nameServerError = ref('')
|
||||
let initialSignature = signature(form.value)
|
||||
const selectedUid = ref<number | null>(form.value.fields[0]!.uid)
|
||||
const touched = ref(new Set<string>())
|
||||
@@ -71,7 +105,34 @@ export function useSchemaBuilder() {
|
||||
const dirty = computed(
|
||||
() => saveState.value !== 'saved' && signature(form.value) !== initialSignature,
|
||||
)
|
||||
const errors = computed(() => validateForm(form.value))
|
||||
const errors = computed(() => validateForm(form.value, reservedKeys(removed.value)))
|
||||
const baselineFields = computed(() => (baseline.value ? baselinePayload(baseline.value) : []))
|
||||
const baselineKeys = computed(() => baselineFields.value.map((field) => field.key))
|
||||
const savedSignatures = computed(() => baselineSignatures(baselineFields.value))
|
||||
const changes = computed(() => diffFields(baselineFields.value, form.value.fields))
|
||||
const needsReview = computed(() => editing && entryTotal > 0 && changes.value.length > 0)
|
||||
const flaggedUids = computed(
|
||||
() => new Set(flaggedChanges(changes.value).map((change) => change.uid)),
|
||||
)
|
||||
const marks = computed(() => {
|
||||
const result = new Map<number, RowMarks>()
|
||||
for (const field of form.value.fields) {
|
||||
result.set(
|
||||
field.uid,
|
||||
editing
|
||||
? {
|
||||
isNew: !field.saved,
|
||||
changed: isFieldChanged(field, savedSignatures.value),
|
||||
cause:
|
||||
refusedUids.value.includes(field.uid) &&
|
||||
flaggedUids.value.has(field.uid),
|
||||
}
|
||||
: CREATE_MARKS,
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
const mayRestore = computed(() => form.value.fields.length < BUILDER_LIMITS.fieldsMax)
|
||||
const selected = computed(
|
||||
() => form.value.fields.find((field) => field.uid === selectedUid.value) ?? null,
|
||||
)
|
||||
@@ -110,6 +171,7 @@ export function useSchemaBuilder() {
|
||||
if (!isShown(typeTouchKey('key'))) delete type.key
|
||||
if (!isShown(typeTouchKey('fields'))) delete type.fields
|
||||
if (keyServerError.value) type.key = keyServerError.value
|
||||
if (nameServerError.value && !type.name) type.name = nameServerError.value
|
||||
const fields = new Map<number, FieldErrors>()
|
||||
for (const [uid, all] of errors.value.fields) {
|
||||
const pick = shownFieldErrors(uid, all)
|
||||
@@ -120,11 +182,15 @@ export function useSchemaBuilder() {
|
||||
return { type, fields }
|
||||
})
|
||||
|
||||
const hasServerKeyError = computed(
|
||||
() => keyServerError.value !== '' && !errors.value.type.key,
|
||||
const hasServerKeyError = computed(() => keyServerError.value !== '' && !errors.value.type.key)
|
||||
const hasServerNameError = computed(
|
||||
() => nameServerError.value !== '' && !errors.value.type.name,
|
||||
)
|
||||
const problems = computed(
|
||||
() => countProblems(errors.value) + (hasServerKeyError.value ? 1 : 0),
|
||||
() =>
|
||||
countProblems(errors.value) +
|
||||
(hasServerKeyError.value ? 1 : 0) +
|
||||
(hasServerNameError.value ? 1 : 0),
|
||||
)
|
||||
const hasHidden = computed(() => countErrors(errors.value) > countErrors(shown.value))
|
||||
const engaged = computed(() => dirty.value || touched.value.size > 0 || submitted.value)
|
||||
@@ -134,15 +200,15 @@ export function useSchemaBuilder() {
|
||||
if (!online.value) return 'offline'
|
||||
if (failure.value === 'failed') return 'failed'
|
||||
if (problems.value > 0 && engaged.value) return 'problems'
|
||||
return dirty.value ? 'dirty' : 'none'
|
||||
if (dirty.value) return 'dirty'
|
||||
return editing ? 'saved' : 'none'
|
||||
})
|
||||
|
||||
const canSave = computed(
|
||||
() => !saving.value && online.value && dirty.value && problems.value === 0,
|
||||
)
|
||||
const canReveal = computed(
|
||||
() =>
|
||||
!saving.value && online.value && dirty.value && hasHidden.value && !submitted.value,
|
||||
() => !saving.value && online.value && dirty.value && hasHidden.value && !submitted.value,
|
||||
)
|
||||
const canDiscard = computed(
|
||||
() =>
|
||||
@@ -150,7 +216,8 @@ export function useSchemaBuilder() {
|
||||
(dirty.value ||
|
||||
touched.value.size > 0 ||
|
||||
submitted.value ||
|
||||
keyServerError.value !== ''),
|
||||
keyServerError.value !== '' ||
|
||||
nameServerError.value !== ''),
|
||||
)
|
||||
|
||||
function clearFailure() {
|
||||
@@ -167,11 +234,15 @@ export function useSchemaBuilder() {
|
||||
}
|
||||
|
||||
function otherKeys(uid: number) {
|
||||
return form.value.fields.filter((field) => field.uid !== uid).map((field) => field.key)
|
||||
return [
|
||||
...form.value.fields.filter((field) => field.uid !== uid).map((field) => field.key),
|
||||
...reservedKeys(removed.value),
|
||||
]
|
||||
}
|
||||
|
||||
function setName(name: string) {
|
||||
form.value.name = name
|
||||
nameServerError.value = ''
|
||||
touch('name')
|
||||
if (form.value.keyMode === 'auto') {
|
||||
form.value.key = deriveTypeKey(name)
|
||||
@@ -245,7 +316,10 @@ export function useSchemaBuilder() {
|
||||
function resetFieldKey(uid: number) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (field) {
|
||||
patchField(uid, { keyMode: 'auto', key: deriveFieldKey(field.label, otherKeys(uid)) })
|
||||
patchField(uid, {
|
||||
keyMode: 'auto',
|
||||
key: deriveFieldKey(field.label, otherKeys(uid)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +359,10 @@ export function useSchemaBuilder() {
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
form.value.fields.splice(index, 1)
|
||||
const [taken] = form.value.fields.splice(index, 1)
|
||||
if (editing && taken?.saved) {
|
||||
removed.value = [...removed.value, { field: taken }]
|
||||
}
|
||||
if (selectedUid.value === uid) {
|
||||
const next = form.value.fields[index] ?? form.value.fields[index - 1]
|
||||
selectedUid.value = next?.uid ?? null
|
||||
@@ -296,6 +373,20 @@ export function useSchemaBuilder() {
|
||||
edited()
|
||||
}
|
||||
|
||||
function undoRemoval(key: string) {
|
||||
const entry = removed.value.find((candidate) => candidate.field.key === key)
|
||||
if (!entry || !mayRestore.value) {
|
||||
return
|
||||
}
|
||||
const index = restoreIndex(form.value.fields, baselineKeys.value, key)
|
||||
form.value.fields.splice(index, 0, entry.field)
|
||||
removed.value = removed.value.filter((candidate) => candidate !== entry)
|
||||
if (selectedUid.value === null) {
|
||||
selectedUid.value = entry.field.uid
|
||||
}
|
||||
edited()
|
||||
}
|
||||
|
||||
function moveField(uid: number, delta: number) {
|
||||
const index = indexOf(uid)
|
||||
form.value.fields = moveItem(form.value.fields, index, index + delta)
|
||||
@@ -344,14 +435,24 @@ export function useSchemaBuilder() {
|
||||
setOptions(uid, options)
|
||||
}
|
||||
|
||||
function discard() {
|
||||
form.value = blankForm(newUid())
|
||||
function resetEditState() {
|
||||
initialSignature = signature(form.value)
|
||||
selectedUid.value = form.value.fields[0]!.uid
|
||||
selectedUid.value = form.value.fields[0]?.uid ?? null
|
||||
touched.value = new Set()
|
||||
submitted.value = false
|
||||
keyServerError.value = ''
|
||||
nameServerError.value = ''
|
||||
failure.value = 'none'
|
||||
removed.value = []
|
||||
reviewOpen.value = false
|
||||
refusal.value = 'none'
|
||||
refusedUids.value = []
|
||||
refusedChanges.value = []
|
||||
}
|
||||
|
||||
function discard() {
|
||||
form.value = baseline.value ? formFromType(baseline.value, newUid) : blankForm(newUid())
|
||||
resetEditState()
|
||||
saveState.value = 'editing'
|
||||
}
|
||||
|
||||
@@ -370,15 +471,45 @@ export function useSchemaBuilder() {
|
||||
keyFocusToken.value++
|
||||
}
|
||||
|
||||
function onIncompatible() {
|
||||
const flagged = flaggedChanges(changes.value)
|
||||
refusedChanges.value = flagged
|
||||
refusedUids.value = flagged.flatMap((change) => (change.uid === null ? [] : [change.uid]))
|
||||
refusal.value = 'dialog'
|
||||
saveState.value = 'editing'
|
||||
}
|
||||
|
||||
function onInvalidName() {
|
||||
nameServerError.value = t('Give the content type a name.')
|
||||
touch('name')
|
||||
}
|
||||
|
||||
function onMissing() {
|
||||
store.drop(form.value.key)
|
||||
}
|
||||
|
||||
function onRefused(error: unknown) {
|
||||
reviewOpen.value = false
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
const body = error instanceof ApiError ? error.body : undefined
|
||||
if (error instanceof ApiError && error.status === 409 && body === KEY_TAKEN_BODY) {
|
||||
if (
|
||||
editing &&
|
||||
error instanceof ApiError &&
|
||||
error.status === 409 &&
|
||||
body === INCOMPATIBLE_BODY
|
||||
) {
|
||||
onIncompatible()
|
||||
} else if (editing && error instanceof ApiError && error.status === 404) {
|
||||
onMissing()
|
||||
} else if (error instanceof ApiError && error.status === 409 && body === KEY_TAKEN_BODY) {
|
||||
onKeyTaken()
|
||||
} else if (error instanceof ApiError && error.status === 400 && body === INVALID_KEY_BODY) {
|
||||
onInvalidKey()
|
||||
} else if (editing && error instanceof ApiError && error.status === 400) {
|
||||
saveState.value = 'editing'
|
||||
onInvalidName()
|
||||
} else if (
|
||||
error instanceof ApiError &&
|
||||
error.status === 422 &&
|
||||
@@ -413,8 +544,76 @@ export function useSchemaBuilder() {
|
||||
if (!canSave.value) {
|
||||
return
|
||||
}
|
||||
if (needsReview.value) {
|
||||
reviewOpen.value = true
|
||||
return
|
||||
}
|
||||
await apply()
|
||||
}
|
||||
|
||||
function reviewAgain() {
|
||||
if (needsReview.value) {
|
||||
reviewOpen.value = true
|
||||
return
|
||||
}
|
||||
refusal.value = 'none'
|
||||
}
|
||||
|
||||
function closeReview() {
|
||||
if (!saving.value) {
|
||||
reviewOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function keepEditing() {
|
||||
if (refusal.value === 'dialog') {
|
||||
refusal.value = 'banner'
|
||||
}
|
||||
}
|
||||
|
||||
function settleSaved(answered: ContentType) {
|
||||
store.replace(answered)
|
||||
baseline.value = answered
|
||||
form.value = formFromType(
|
||||
answered,
|
||||
newUid,
|
||||
form.value.fields.map((field) => field.uid),
|
||||
)
|
||||
const keep = selectedUid.value
|
||||
resetEditState()
|
||||
if (keep !== null && form.value.fields.some((field) => field.uid === keep)) {
|
||||
selectedUid.value = keep
|
||||
}
|
||||
savedAt.value = answered.updated_at
|
||||
saveState.value = 'saved'
|
||||
announceSaved(answered.name, answered.fields.length)
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (saving.value) {
|
||||
return
|
||||
}
|
||||
failure.value = 'none'
|
||||
saveState.value = 'saving'
|
||||
if (!baseline.value) {
|
||||
await create()
|
||||
return
|
||||
}
|
||||
let answered: ContentType
|
||||
try {
|
||||
answered = await useContentTypes().updateContentType(
|
||||
baseline.value.key,
|
||||
buildUpdatePayload(form.value, baseline.value.icon),
|
||||
)
|
||||
} catch (error) {
|
||||
saveState.value = 'failed'
|
||||
onRefused(error)
|
||||
return
|
||||
}
|
||||
settleSaved(answered)
|
||||
}
|
||||
|
||||
async function create() {
|
||||
let created: ContentType
|
||||
try {
|
||||
created = await useContentTypes().createContentType(buildPayload(form.value))
|
||||
@@ -445,6 +644,22 @@ export function useSchemaBuilder() {
|
||||
keyFocusToken,
|
||||
labelFocusToken,
|
||||
online,
|
||||
editing,
|
||||
entryTotal,
|
||||
baseline,
|
||||
marks,
|
||||
changes,
|
||||
removed,
|
||||
mayRestore,
|
||||
reviewOpen,
|
||||
refusal,
|
||||
refusedChanges,
|
||||
savedAt,
|
||||
undoRemoval,
|
||||
apply,
|
||||
reviewAgain,
|
||||
closeReview,
|
||||
keepEditing,
|
||||
setName,
|
||||
setKey,
|
||||
unlockKey,
|
||||
|
||||
@@ -2409,6 +2409,83 @@ export const messages = {
|
||||
'Yes or no fields have no limits.': 'Les champs oui ou non n’ont pas de limites.',
|
||||
"You're offline. Changes will be saved when you reconnect.":
|
||||
'Vous êtes hors ligne. Les modifications seront enregistrées à la reconnexion.',
|
||||
'{0} entries use this content type. Changes are checked against them when you save.':
|
||||
'{0} entrées utilisent ce type de contenu. Les modifications sont vérifiées par rapport à elles à l’enregistrement.',
|
||||
'1 entry uses this content type. Changes are checked against it when you save.':
|
||||
'1 entrée utilise ce type de contenu. Les modifications sont vérifiées par rapport à elle à l’enregistrement.',
|
||||
'{0} goes from {1} to {2}.': '{0} passe de {1} à {2}.',
|
||||
'{0} has {1} entries. Each one is checked against the new fields when you apply.':
|
||||
'{0} compte {1} entrées. Chacune est vérifiée par rapport aux nouveaux champs à l’application.',
|
||||
'{0} has 1 entry. Each one is checked against the new fields when you apply.':
|
||||
'{0} compte 1 entrée. Elle est vérifiée par rapport aux nouveaux champs à l’application.',
|
||||
'{0} is no longer set.': '{0} n’est plus défini.',
|
||||
'{0} is now {1}.': '{0} vaut maintenant {1}.',
|
||||
'{0} will be removed': '{0} sera supprimé',
|
||||
'Add {0}': 'Ajouter {0}',
|
||||
'Add {0} fields': 'Ajouter {0} champs',
|
||||
'Add 1 field': 'Ajouter 1 champ',
|
||||
'An entry may be longer.': 'Une entrée peut être plus longue.',
|
||||
'An entry may be shorter.': 'Une entrée peut être plus courte.',
|
||||
'An entry may have a higher value.': 'Une entrée peut avoir une valeur plus élevée.',
|
||||
'An entry may have a later date.': 'Une entrée peut avoir une date plus tardive.',
|
||||
'An entry may have a lower value.': 'Une entrée peut avoir une valeur plus basse.',
|
||||
'An entry may have an earlier date.': 'Une entrée peut avoir une date plus ancienne.',
|
||||
'An entry may have no value.': 'Une entrée peut n’avoir aucune valeur.',
|
||||
'An entry may still use it.': 'Une entrée peut encore l’utiliser.',
|
||||
'Apply changes': 'Appliquer les modifications',
|
||||
'Applying…': 'Application…',
|
||||
'Apply them to save the content type.': 'Appliquez-les pour enregistrer le type de contenu.',
|
||||
'Back to editing': 'Retour à l’édition',
|
||||
"Can't change after saving": 'Ne peut plus changer après l’enregistrement',
|
||||
'change {0}': 'modifier {0}',
|
||||
'Change {0}': 'Modifier {0}',
|
||||
Changed: 'Modifié',
|
||||
'Choice added: "{0}".': 'Choix ajouté : « {0} ».',
|
||||
'Choice removed: "{0}".': 'Choix supprimé : « {0} ».',
|
||||
'Choices were reordered.': 'Les choix ont été réordonnés.',
|
||||
"Couldn't load content type": 'Impossible de charger le type de contenu',
|
||||
'default "{0}"': 'valeur par défaut « {0} »',
|
||||
'default on': 'activé par défaut',
|
||||
'Default value is now "{0}".': 'La valeur par défaut est maintenant « {0} ».',
|
||||
'Default value removed.': 'Valeur par défaut supprimée.',
|
||||
'Entries may not fit': 'Des entrées peuvent ne pas convenir',
|
||||
'Entries may share a value.': 'Des entrées peuvent partager une valeur.',
|
||||
'Entries stop returning its values.': 'Les entrées ne renvoient plus ses valeurs.',
|
||||
'Existing entries get the default, so they still fit.':
|
||||
'Les entrées existantes reçoivent la valeur par défaut, elles conviennent donc toujours.',
|
||||
'Existing entries have no value for it.':
|
||||
'Les entrées existantes n’ont aucune valeur pour lui.',
|
||||
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.":
|
||||
'Les modifications signalées peuvent être refusées si des entrées existantes ne conviennent pas. Dans ce cas, rien n’est modifié.',
|
||||
'GET /v1/content-type/{0}/entry failed. Check your connection and try again.':
|
||||
'GET /v1/content-type/{0}/entry a échoué. Vérifiez votre connexion et réessayez.',
|
||||
'Label goes from "{0}" to "{1}".': 'Le libellé passe de « {0} » à « {1} ».',
|
||||
'No field was added, changed or removed.': 'Aucun champ n’a été ajouté, modifié ou supprimé.',
|
||||
'No longer required.': 'N’est plus obligatoire.',
|
||||
'Nothing was changed': 'Rien n’a été modifié',
|
||||
'Now required.': 'Devient obligatoire.',
|
||||
'On by default is now off.': 'Activé par défaut est maintenant désactivé.',
|
||||
'On by default is now on.': 'Activé par défaut est maintenant activé.',
|
||||
optional: 'facultatif',
|
||||
'Possible cause': 'Cause possible',
|
||||
'remove {0}': 'supprimer {0}',
|
||||
'Removes data': 'Supprime des données',
|
||||
"RenewCMS refused the changes because some entries don't fit the new fields. It doesn't say which entries.":
|
||||
'RenewCMS a refusé les modifications parce que certaines entrées ne conviennent pas aux nouveaux champs. Il ne dit pas lesquelles.',
|
||||
'Review changes': 'Vérifier les modifications',
|
||||
'Review changes to {0}': 'Vérifier les modifications de {0}',
|
||||
"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.":
|
||||
'Certaines entrées ne conviennent pas aux nouveaux champs, donc toute la modification a été refusée. RenewCMS ne peut pas dire lesquelles. Les modifications qui peuvent en être la cause sont signalées.',
|
||||
'The change that can cause this in your edit':
|
||||
'La modification qui peut en être la cause dans vos changements',
|
||||
"The key is used in URLs and the API, so it can't change after the type is created.":
|
||||
'La clé est utilisée dans les adresses et l’API, elle ne peut donc plus changer après la création du type.',
|
||||
'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.':
|
||||
'Ce type de modification est refusé quand une entrée dépasse la nouvelle limite, qu’un nouveau champ obligatoire n’a pas de valeur par défaut, qu’un champ devient unique alors que des entrées partagent une valeur, ou qu’un choix supprimé est encore utilisé.',
|
||||
"Used in the API, so it can't change.": 'Utilisée dans l’API, elle ne peut donc pas changer.',
|
||||
'Values must now be unique.': 'Les valeurs doivent maintenant être uniques.',
|
||||
'Values no longer need to be unique.': 'Les valeurs n’ont plus besoin d’être uniques.',
|
||||
'Your content type is as it was': 'Votre type de contenu est tel qu’il était',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,413 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
baselineSignatures,
|
||||
causeText,
|
||||
changeText,
|
||||
changeTitle,
|
||||
diffFields,
|
||||
flaggedChanges,
|
||||
isFieldChanged,
|
||||
summaryLine,
|
||||
} from '@/lib/schemaBuilderDiff'
|
||||
import type { ContentType } from '@/composables/useContentTypes'
|
||||
import { baselinePayload, formFromType } from '@/lib/schemaBuilderLoad'
|
||||
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { eventsFields } from './schemaFixtures'
|
||||
|
||||
const events: ContentType = {
|
||||
id: 1,
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: null,
|
||||
fields: eventsFields,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
|
||||
const baseline = baselinePayload(events)
|
||||
const signatures = baselineSignatures(baseline)
|
||||
|
||||
function loaded() {
|
||||
let next = 1
|
||||
return formFromType(events, () => next++).fields
|
||||
}
|
||||
|
||||
function edit(fields: BuilderField[], key: string, patch: Partial<BuilderField>) {
|
||||
return fields.map((field) => (field.key === key ? { ...field, ...patch } : field))
|
||||
}
|
||||
|
||||
function only(fields: BuilderField[]) {
|
||||
return diffFields(baseline, fields)
|
||||
}
|
||||
|
||||
describe('change detection per saved field', () => {
|
||||
it('marks nothing on the loaded fields', () => {
|
||||
expect(loaded().some((field) => isFieldChanged(field, signatures))).toBe(false)
|
||||
expect(only(loaded())).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Capacity', 'capacity', { max: '2000' }, { max: '5000' }],
|
||||
['Title', 'title', { label: 'Name of event' }, { label: 'Title' }],
|
||||
['Starts on', 'starts_on', { required: false }, { required: true }],
|
||||
[
|
||||
'Category',
|
||||
'category',
|
||||
{ options: ['Talk', 'Workshop', 'Meetup', 'Panel'] },
|
||||
{ options: ['Talk', 'Workshop', 'Meetup'] },
|
||||
],
|
||||
['Online', 'online', { defaultOn: true }, { defaultOn: false }],
|
||||
])(
|
||||
'marks %s changed, then clears it when the value is restored',
|
||||
(_, key, edited, original) => {
|
||||
const changed = edit(loaded(), key, edited)
|
||||
expect(
|
||||
changed
|
||||
.filter((field) => isFieldChanged(field, signatures))
|
||||
.map((field) => field.key),
|
||||
).toEqual([key])
|
||||
|
||||
const restored = edit(changed, key, original)
|
||||
expect(restored.some((field) => isFieldChanged(field, signatures))).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('does not count a reorder as a change of any field', () => {
|
||||
const [first, ...rest] = loaded()
|
||||
const reordered = [...rest, first!]
|
||||
|
||||
expect(reordered.some((field) => isFieldChanged(field, signatures))).toBe(false)
|
||||
expect(only(reordered)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not count surrounding spaces in a label', () => {
|
||||
expect(only(edit(loaded(), 'title', { label: ' Title ' }))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('review rows', () => {
|
||||
function row(fields: BuilderField[]) {
|
||||
const [change] = only(fields)
|
||||
return {
|
||||
title: changeTitle(change!),
|
||||
text: changeText(change!),
|
||||
flag: change!.flag,
|
||||
}
|
||||
}
|
||||
|
||||
const added = (patch: Partial<BuilderField>) => [
|
||||
...loaded(),
|
||||
createField(100, { label: 'New', key: 'new', ...patch }),
|
||||
]
|
||||
|
||||
it.each([
|
||||
[
|
||||
'adds an optional Text',
|
||||
added({ label: 'Speaker', key: 'speaker' }),
|
||||
'Add Speaker',
|
||||
'Text, optional.',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'adds a required Choice with a default',
|
||||
added({
|
||||
label: 'Room',
|
||||
key: 'room',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: ['Main hall', 'Studio'],
|
||||
defaultValue: 'Main hall',
|
||||
}),
|
||||
'Add Room',
|
||||
'Choice, required, default "Main hall". Existing entries get the default, so they still fit.',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'adds a required Number with no default',
|
||||
added({
|
||||
label: 'Seats',
|
||||
key: 'seats',
|
||||
type: 'number',
|
||||
required: true,
|
||||
}),
|
||||
'Add Seats',
|
||||
'Number, required. Existing entries have no value for it.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'lowers the highest value',
|
||||
edit(loaded(), 'capacity', { max: '2000' }),
|
||||
'Change Capacity',
|
||||
'Highest value goes from 5000 to 2000.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'raises the lowest value',
|
||||
edit(loaded(), 'capacity', { min: '10' }),
|
||||
'Change Capacity',
|
||||
'Lowest value goes from 1 to 10.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'sets a first longest',
|
||||
edit(loaded(), 'description', { max: '80' }),
|
||||
'Change Description',
|
||||
'Longest is now 80.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'turns Required on',
|
||||
edit(loaded(), 'ends_on', { required: true }),
|
||||
'Change Ends on',
|
||||
'Now required.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'turns Unique on',
|
||||
edit(loaded(), 'category', { unique: true }),
|
||||
'Change Category',
|
||||
'Values must now be unique.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'removes a choice',
|
||||
edit(loaded(), 'category', { options: ['Talk', 'Workshop'] }),
|
||||
'Change Category',
|
||||
'Choice removed: "Meetup".',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'loosens the highest value',
|
||||
edit(loaded(), 'capacity', { max: '8000' }),
|
||||
'Change Capacity',
|
||||
'Highest value goes from 5000 to 8000.',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'renames a label',
|
||||
edit(loaded(), 'title', { label: 'Name' }),
|
||||
'Change Title',
|
||||
'Label goes from "Title" to "Name".',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'adds a choice',
|
||||
edit(loaded(), 'category', {
|
||||
options: ['Talk', 'Workshop', 'Meetup', 'Panel'],
|
||||
}),
|
||||
'Change Category',
|
||||
'Choice added: "Panel".',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'removes a field',
|
||||
loaded().filter((field) => field.key !== 'description'),
|
||||
'Remove Description',
|
||||
'Entries stop returning its values.',
|
||||
'removesData',
|
||||
],
|
||||
])('%s', (_, fields, title, text, flag) => {
|
||||
expect(row(fields)).toEqual({ title, text, flag })
|
||||
})
|
||||
|
||||
it('describes a default, an unset bound and a reordered choice list', () => {
|
||||
expect(row(edit(loaded(), 'capacity', { defaultValue: '20' })).text).toBe(
|
||||
'Default value is now "20".',
|
||||
)
|
||||
expect(row(edit(loaded(), 'capacity', { max: '' })).text).toBe(
|
||||
'Highest value is no longer set.',
|
||||
)
|
||||
expect(
|
||||
row(
|
||||
edit(loaded(), 'category', {
|
||||
options: ['Meetup', 'Talk', 'Workshop'],
|
||||
}),
|
||||
).text,
|
||||
).toBe('Choices were reordered.')
|
||||
expect(row(edit(loaded(), 'online', { defaultOn: true })).text).toBe(
|
||||
'On by default is now on.',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'loosens the lowest value',
|
||||
'capacity',
|
||||
{ min: '0' },
|
||||
'Lowest value goes from 1 to 0.',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'sets a first lowest value',
|
||||
'description',
|
||||
{ min: '2' },
|
||||
'Shortest is now 2.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'unsets the lowest value',
|
||||
'capacity',
|
||||
{ min: '' },
|
||||
'Lowest value is no longer set.',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'turns Unique off',
|
||||
'title',
|
||||
{ unique: false },
|
||||
'Values no longer need to be unique.',
|
||||
null,
|
||||
],
|
||||
['turns Required off', 'starts_on', { required: false }, 'No longer required.', null],
|
||||
['turns the default off', 'online', { defaultOn: false }, '', null],
|
||||
])('%s', (_, key, patch, text, flag) => {
|
||||
const fields = edit(loaded(), key, patch)
|
||||
if (text === '') {
|
||||
expect(only(fields)).toEqual([])
|
||||
return
|
||||
}
|
||||
expect(row(fields)).toMatchObject({ text, flag })
|
||||
})
|
||||
|
||||
it('says the default was removed', () => {
|
||||
const withDefault = { ...eventsFields[4]!, default: 20 }
|
||||
const form = formFromType({ ...events, fields: [withDefault] }, () => 1).fields
|
||||
const [removed] = diffFields(
|
||||
baselinePayload({ ...events, fields: [withDefault] }),
|
||||
edit(form, 'capacity', { defaultValue: '' }),
|
||||
)
|
||||
|
||||
expect(changeText(removed!)).toBe('Default value removed.')
|
||||
expect(removed!.flag).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'starts_on',
|
||||
{ max: '2027-01-01' },
|
||||
'Latest date is now 2027-01-01. An entry may have a later date.',
|
||||
],
|
||||
[
|
||||
'starts_on',
|
||||
{ min: '2027-01-01' },
|
||||
'Earliest date is now 2027-01-01. An entry may have an earlier date.',
|
||||
],
|
||||
['description', { max: '80' }, 'Longest is now 80. An entry may be longer.'],
|
||||
['description', { min: '3' }, 'Shortest is now 3. An entry may be shorter.'],
|
||||
[
|
||||
'capacity',
|
||||
{ min: '10' },
|
||||
'Lowest value goes from 1 to 10. An entry may have a lower value.',
|
||||
],
|
||||
[
|
||||
'capacity',
|
||||
{ max: '2000' },
|
||||
'Highest value goes from 5000 to 2000. An entry may have a higher value.',
|
||||
],
|
||||
])('explains the tighter bound of %s with its hint', (key, patch, cause) => {
|
||||
const [change] = flaggedChanges(only(edit(loaded(), key, patch)))
|
||||
|
||||
expect(causeText(change!)).toBe(cause)
|
||||
})
|
||||
|
||||
it('reports a choice removed together with its hint', () => {
|
||||
const [change] = flaggedChanges(
|
||||
only(edit(loaded(), 'category', { options: ['Talk', 'Workshop'] })),
|
||||
)
|
||||
|
||||
expect(causeText(change!)).toBe('Choice removed: "Meetup". An entry may still use it.')
|
||||
})
|
||||
|
||||
it('summarises a lone removal and one added field alongside a change', () => {
|
||||
const base = loaded().filter((field) => field.key !== 'description')
|
||||
|
||||
expect(summaryLine(only(base))).toBe('Remove 1')
|
||||
expect(summaryLine(only(edit(base, 'capacity', { max: '2000' })))).toBe(
|
||||
'Change 1, remove 1',
|
||||
)
|
||||
})
|
||||
|
||||
it('describes an added field with a boolean or text default', () => {
|
||||
const added = (patch: Partial<BuilderField>) =>
|
||||
createField(100, { label: 'New', key: 'new', ...patch })
|
||||
|
||||
expect(row([...loaded(), added({ type: 'boolean', defaultOn: true })]).text).toBe(
|
||||
'Yes or no, optional, default on.',
|
||||
)
|
||||
expect(row([...loaded(), added({ defaultValue: 'x' })]).text).toBe(
|
||||
'Text, optional, default "x".',
|
||||
)
|
||||
})
|
||||
|
||||
it('joins several notes of one field and flags it when one of them is flagged', () => {
|
||||
const [change] = only(edit(loaded(), 'capacity', { max: '2000', label: 'Seats' }))
|
||||
|
||||
expect(changeText(change!)).toBe(
|
||||
'Label goes from "Capacity" to "Seats". Highest value goes from 5000 to 2000.',
|
||||
)
|
||||
expect(change!.flag).toBe('mayNotFit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('order of the review and the summary', () => {
|
||||
const added = [
|
||||
createField(100, { label: 'Speaker', key: 'speaker' }),
|
||||
createField(101, { label: 'Room', key: 'room' }),
|
||||
]
|
||||
const fields = edit(
|
||||
[...loaded().filter((field) => field.key !== 'description'), ...added],
|
||||
'capacity',
|
||||
{ max: '2000' },
|
||||
)
|
||||
|
||||
it('lists additions, changes then removals', () => {
|
||||
expect(only(fields).map(changeTitle)).toEqual([
|
||||
'Add Speaker',
|
||||
'Add Room',
|
||||
'Change Capacity',
|
||||
'Remove Description',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['2 added, 1 changed, 1 removed', 'Add 2 fields, change 1, remove 1'],
|
||||
['1 added', 'Add 1 field'],
|
||||
['1 changed, 2 removed', 'Change 1, remove 2'],
|
||||
['1 changed', 'Change 1'],
|
||||
])('writes the summary for %s', (changes, summary) => {
|
||||
const base = loaded()
|
||||
const wanted: Record<string, BuilderField[]> = {
|
||||
'2 added, 1 changed, 1 removed': fields,
|
||||
'1 added': [...base, added[0]!],
|
||||
'1 changed, 2 removed': edit(
|
||||
base.filter((field) => field.key !== 'description' && field.key !== 'ends_on'),
|
||||
'capacity',
|
||||
{ max: '2000' },
|
||||
),
|
||||
'1 changed': edit(base, 'capacity', { max: '2000' }),
|
||||
}
|
||||
|
||||
expect(summaryLine(only(wanted[changes]!))).toBe(summary)
|
||||
})
|
||||
|
||||
it('keeps only the flagged additions and changes for the refusal', () => {
|
||||
const flagged = flaggedChanges(only(fields))
|
||||
|
||||
expect(flagged.map(changeTitle)).toEqual(['Change Capacity'])
|
||||
expect(causeText(flagged[0]!)).toBe(
|
||||
'Highest value goes from 5000 to 2000. An entry may have a higher value.',
|
||||
)
|
||||
})
|
||||
|
||||
it('names the cause of a field added with no default without a hint', () => {
|
||||
const seats = createField(100, {
|
||||
label: 'Seats',
|
||||
key: 'seats',
|
||||
type: 'number',
|
||||
required: true,
|
||||
})
|
||||
const [change] = flaggedChanges(only([...loaded(), seats]))
|
||||
|
||||
expect(causeText(change!)).toBe('Number, required. Existing entries have no value for it.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentType } from '@/composables/useContentTypes'
|
||||
import { baselinePayload, fieldFromApi, formFromType } from '@/lib/schemaBuilderLoad'
|
||||
import { buildUpdatePayload, payloadField } from '@/lib/schemaBuilderPayload'
|
||||
import { apiField, eventsFields } from './schemaFixtures'
|
||||
|
||||
const events: ContentType = {
|
||||
id: 1,
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: 'calendar',
|
||||
fields: eventsFields,
|
||||
created_at: '2026-09-01T09:30:00Z',
|
||||
updated_at: '2026-10-15T09:30:00Z',
|
||||
}
|
||||
|
||||
function counter() {
|
||||
let next = 1
|
||||
return () => next++
|
||||
}
|
||||
|
||||
describe('form from a content type', () => {
|
||||
it('locks the key, marks every field saved and keeps the saved order', () => {
|
||||
const form = formFromType(events, counter())
|
||||
|
||||
expect(form.name).toBe('Events')
|
||||
expect(form.key).toBe('events')
|
||||
expect(form.keyLocked).toBe(true)
|
||||
expect(form.fields.map((field) => field.key)).toEqual(
|
||||
eventsFields.map((field) => field.key),
|
||||
)
|
||||
expect(form.fields.every((field) => field.saved && field.keyMode === 'manual')).toBe(true)
|
||||
})
|
||||
|
||||
it('reuses the given uids by position, then draws new ones', () => {
|
||||
const form = formFromType(events, counter(), [10, 11])
|
||||
|
||||
expect(form.fields.map((field) => field.uid)).toEqual([10, 11, 1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
it('maps each API field to its builder fields', () => {
|
||||
const [title, startsOn, , online, capacity, category] = formFromType(
|
||||
events,
|
||||
counter(),
|
||||
).fields
|
||||
|
||||
expect(title).toMatchObject({
|
||||
required: true,
|
||||
unique: true,
|
||||
max: '80',
|
||||
min: '',
|
||||
})
|
||||
expect(startsOn).toMatchObject({ type: 'date', required: true })
|
||||
expect(online).toMatchObject({
|
||||
type: 'boolean',
|
||||
defaultOn: false,
|
||||
defaultValue: '',
|
||||
})
|
||||
expect(capacity).toMatchObject({ min: '1', max: '5000' })
|
||||
expect(category).toMatchObject({
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a number default', apiField('N', 'n', 'number', { default: 0, options: [] }), '0'],
|
||||
['a text default', apiField('T', 't', 'text', { default: 'x', options: [] }), 'x'],
|
||||
[
|
||||
'a date default as a plain day',
|
||||
apiField('D', 'd', 'date', {
|
||||
default: '2026-11-21T00:00:00Z',
|
||||
options: [],
|
||||
}),
|
||||
'2026-11-21',
|
||||
],
|
||||
])('reads %s', (_, source, expected) => {
|
||||
expect(fieldFromApi(source, 1).defaultValue).toBe(expected)
|
||||
})
|
||||
|
||||
it('turns a true boolean default on and anything else off', () => {
|
||||
expect(fieldFromApi(apiField('B', 'b', 'boolean', { default: true }), 1).defaultOn).toBe(
|
||||
true,
|
||||
)
|
||||
expect(fieldFromApi(apiField('B', 'b', 'boolean', { default: false }), 1).defaultOn).toBe(
|
||||
false,
|
||||
)
|
||||
expect(fieldFromApi(apiField('B', 'b', 'boolean'), 1).defaultOn).toBe(false)
|
||||
})
|
||||
|
||||
it('reads date bounds as plain days and Choice options as text', () => {
|
||||
const date = fieldFromApi(
|
||||
apiField('D', 'd', 'date', {
|
||||
min: '2026-01-01T00:00:00Z',
|
||||
max: '2027-12-31',
|
||||
options: [],
|
||||
}),
|
||||
1,
|
||||
)
|
||||
|
||||
expect(date).toMatchObject({ min: '2026-01-01', max: '2027-12-31' })
|
||||
expect(fieldFromApi(apiField('C', 'c', 'select', { options: null }), 1).options).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the round trip', () => {
|
||||
it('sends back the loaded fields with no spurious difference', () => {
|
||||
const form = formFromType(events, counter())
|
||||
|
||||
const payload = buildUpdatePayload(form, events.icon)
|
||||
|
||||
expect(payload.fields).toEqual(
|
||||
eventsFields.map((field) => ({
|
||||
...field,
|
||||
default: field.type === 'boolean' ? null : field.default,
|
||||
})),
|
||||
)
|
||||
expect(payload.fields).toEqual(baselinePayload(events))
|
||||
})
|
||||
|
||||
it('has the same payload for a field and for its reloaded copy', () => {
|
||||
const field = fieldFromApi(eventsFields[4]!, 1)
|
||||
|
||||
expect(payloadField(field)).toEqual(payloadField(fieldFromApi(eventsFields[4]!, 2)))
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createField, type BuilderForm } from '@/lib/schemaBuilderModel'
|
||||
import { buildPayload } from '@/lib/schemaBuilderPayload'
|
||||
import { buildPayload, buildUpdatePayload } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
const EVENTS: BuilderForm = {
|
||||
name: ' Events ',
|
||||
key: 'events',
|
||||
keyMode: 'auto',
|
||||
keyLocked: false,
|
||||
fields: [
|
||||
createField(1, { label: 'Title', key: 'title', required: true, unique: true, max: '80' }),
|
||||
createField(1, {
|
||||
label: 'Title',
|
||||
key: 'title',
|
||||
required: true,
|
||||
unique: true,
|
||||
max: '80',
|
||||
}),
|
||||
createField(2, {
|
||||
label: 'Starts on',
|
||||
key: 'starts_on',
|
||||
@@ -16,7 +23,12 @@ const EVENTS: BuilderForm = {
|
||||
min: '2026-01-01',
|
||||
max: '2027-12-31',
|
||||
}),
|
||||
createField(3, { label: 'Online', key: 'online', type: 'boolean', defaultOn: true }),
|
||||
createField(3, {
|
||||
label: 'Online',
|
||||
key: 'online',
|
||||
type: 'boolean',
|
||||
defaultOn: true,
|
||||
}),
|
||||
createField(4, {
|
||||
label: 'Capacity',
|
||||
key: 'capacity',
|
||||
@@ -32,12 +44,24 @@ const EVENTS: BuilderForm = {
|
||||
defaultValue: 'Talk',
|
||||
options: [' Talk', 'Workshop ', 'Meetup'],
|
||||
}),
|
||||
createField(6, { label: 'Description', key: 'description', type: 'richtext', max: '5000' }),
|
||||
createField(6, {
|
||||
label: 'Description',
|
||||
key: 'description',
|
||||
type: 'richtext',
|
||||
max: '5000',
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
function field(key: string, rest: object) {
|
||||
return { key, label: key, type: 'text', required: false, unique: false, ...rest }
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
type: 'text',
|
||||
required: false,
|
||||
unique: false,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
describe('create payload', () => {
|
||||
@@ -146,7 +170,14 @@ describe('create payload', () => {
|
||||
it('does not send the default of a unique field', () => {
|
||||
const body = buildPayload({
|
||||
...EVENTS,
|
||||
fields: [createField(1, { label: 'T', key: 't', unique: true, defaultValue: 'Draft' })],
|
||||
fields: [
|
||||
createField(1, {
|
||||
label: 'T',
|
||||
key: 't',
|
||||
unique: true,
|
||||
defaultValue: 'Draft',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(body.fields[0]!.default).toBeNull()
|
||||
@@ -156,8 +187,18 @@ describe('create payload', () => {
|
||||
const body = buildPayload({
|
||||
...EVENTS,
|
||||
fields: [
|
||||
createField(1, { label: 'R', key: 'r', type: 'richtext', unique: true }),
|
||||
createField(2, { label: 'B', key: 'b', type: 'boolean', unique: true }),
|
||||
createField(1, {
|
||||
label: 'R',
|
||||
key: 'r',
|
||||
type: 'richtext',
|
||||
unique: true,
|
||||
}),
|
||||
createField(2, {
|
||||
label: 'B',
|
||||
key: 'b',
|
||||
type: 'boolean',
|
||||
unique: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -168,8 +209,20 @@ describe('create payload', () => {
|
||||
const body = buildPayload({
|
||||
...EVENTS,
|
||||
fields: [
|
||||
createField(1, { label: 'B', key: 'b', type: 'boolean', min: '1', max: '2' }),
|
||||
createField(2, { label: 'S', key: 's', type: 'select', min: '1', options: ['A'] }),
|
||||
createField(1, {
|
||||
label: 'B',
|
||||
key: 'b',
|
||||
type: 'boolean',
|
||||
min: '1',
|
||||
max: '2',
|
||||
}),
|
||||
createField(2, {
|
||||
label: 'S',
|
||||
key: 's',
|
||||
type: 'select',
|
||||
min: '1',
|
||||
options: ['A'],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -188,3 +241,18 @@ describe('create payload', () => {
|
||||
expect(body.fields[0]!.min).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildUpdatePayload', () => {
|
||||
it('sends exactly name, icon and fields, with no key and the name trimmed', () => {
|
||||
const payload = buildUpdatePayload(EVENTS, 'calendar')
|
||||
|
||||
expect(Object.keys(payload)).toEqual(['name', 'icon', 'fields'])
|
||||
expect(payload.name).toBe('Events')
|
||||
expect(payload.icon).toBe('calendar')
|
||||
expect(payload.fields).toEqual(buildPayload(EVENTS).fields)
|
||||
})
|
||||
|
||||
it('keeps a null icon null', () => {
|
||||
expect(buildUpdatePayload(EVENTS, null).icon).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createField } from '@/lib/schemaBuilderModel'
|
||||
import { reservedKeys, restoreIndex } from '@/lib/schemaBuilderRemoval'
|
||||
|
||||
const keys = ['title', 'starts_on', 'ends_on', 'online', 'capacity', 'category', 'description']
|
||||
const saved = (key: string) => createField(keys.indexOf(key), { key, saved: true })
|
||||
|
||||
describe('where an undo puts a field back', () => {
|
||||
it('restores the last field at the end', () => {
|
||||
const current = keys.slice(0, 6).map(saved)
|
||||
|
||||
expect(restoreIndex(current, keys, 'description')).toBe(6)
|
||||
})
|
||||
|
||||
it('restores a middle field after the saved field that came before it', () => {
|
||||
const current = ['title', 'starts_on', 'online', 'capacity', 'category', 'description'].map(
|
||||
saved,
|
||||
)
|
||||
|
||||
expect(restoreIndex(current, keys, 'ends_on')).toBe(2)
|
||||
})
|
||||
|
||||
it('restores the first field at the top', () => {
|
||||
const current = keys.slice(1).map(saved)
|
||||
|
||||
expect(restoreIndex(current, keys, 'title')).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores fields added since the last save when looking for the slot', () => {
|
||||
const current = [saved('title'), createField(99, { key: 'speaker' }), saved('online')]
|
||||
|
||||
expect(restoreIndex(current, keys, 'ends_on')).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the keys of the removed fields reserved', () => {
|
||||
expect(
|
||||
reservedKeys([{ field: saved('description') }, { field: saved('ends_on') }]),
|
||||
).toEqual(['description', 'ends_on'])
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ function form(overrides: Partial<BuilderForm> = {}): BuilderForm {
|
||||
name: 'Events',
|
||||
key: 'events',
|
||||
keyMode: 'auto',
|
||||
keyLocked: false,
|
||||
fields: [starterField(1)],
|
||||
...overrides,
|
||||
}
|
||||
@@ -109,8 +110,16 @@ describe('field validation', () => {
|
||||
})
|
||||
|
||||
it('flags only the second of two equal keys', () => {
|
||||
const first = createField(1, { label: 'A', key: 'title', keyMode: 'manual' })
|
||||
const second = createField(2, { label: 'B', key: 'title', keyMode: 'manual' })
|
||||
const first = createField(1, {
|
||||
label: 'A',
|
||||
key: 'title',
|
||||
keyMode: 'manual',
|
||||
})
|
||||
const second = createField(2, {
|
||||
label: 'B',
|
||||
key: 'title',
|
||||
keyMode: 'manual',
|
||||
})
|
||||
|
||||
const errors = validateForm(form({ fields: [first, second] }))
|
||||
|
||||
@@ -162,7 +171,11 @@ describe('field validation', () => {
|
||||
],
|
||||
[
|
||||
'date',
|
||||
{ min: '2026-01-01', max: '2027-12-31', defaultValue: '2028-01-01' },
|
||||
{
|
||||
min: '2026-01-01',
|
||||
max: '2027-12-31',
|
||||
defaultValue: '2028-01-01',
|
||||
},
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
@@ -238,7 +251,11 @@ describe('problem counting', () => {
|
||||
})
|
||||
|
||||
it('counts one field with a bad key as one problem', () => {
|
||||
const bad = createField(1, { label: 'S', key: 'start-date', keyMode: 'manual' })
|
||||
const bad = createField(1, {
|
||||
label: 'S',
|
||||
key: 'start-date',
|
||||
keyMode: 'manual',
|
||||
})
|
||||
|
||||
expect(countProblems(validateForm(form({ fields: [bad] })))).toBe(1)
|
||||
})
|
||||
@@ -247,3 +264,45 @@ describe('problem counting', () => {
|
||||
expect(countProblems(validateForm(form()))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation of a saved type', () => {
|
||||
const saved = (key: string, overrides: Partial<BuilderField> = {}) =>
|
||||
createField(1, {
|
||||
label: key,
|
||||
key,
|
||||
keyMode: 'manual',
|
||||
saved: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('shows no key error for a locked type key', () => {
|
||||
expect(validateType(form({ key: 'E', keyLocked: true })).key).toBeUndefined()
|
||||
expect(Object.keys(validateType(form({ key: 'E', keyLocked: true })))).toEqual([])
|
||||
})
|
||||
|
||||
it('never shows a key error on a saved field', () => {
|
||||
const odd = saved('Old-Key')
|
||||
|
||||
expect(validateForm(form({ fields: [odd] })).fields.size).toBe(0)
|
||||
})
|
||||
|
||||
it('flags a new field whose key is a saved key, whatever the order', () => {
|
||||
const copy = createField(2, { label: 'Title', key: 'title' })
|
||||
const errors = validateForm(form({ fields: [copy, saved('title')] }))
|
||||
|
||||
expect(errors.fields.get(2)?.key).toBe('Another field already uses this key.')
|
||||
expect(errors.fields.has(1)).toBe(false)
|
||||
})
|
||||
|
||||
it('flags a new field that takes the key of a removed field', () => {
|
||||
const fresh = createField(2, {
|
||||
label: 'Description',
|
||||
key: 'description',
|
||||
})
|
||||
|
||||
expect(validateForm(form({ fields: [fresh] }), ['description']).fields.get(2)?.key).toBe(
|
||||
'Another field already uses this key.',
|
||||
)
|
||||
expect(validateForm(form({ fields: [fresh] })).fields.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { entriesNotice } from '@/lib/schemaEntries'
|
||||
|
||||
describe('the entries notice', () => {
|
||||
it.each([
|
||||
[-3, ''],
|
||||
[0, ''],
|
||||
[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.',
|
||||
],
|
||||
])('reads for %s entries', (total, copy) => {
|
||||
expect(entriesNotice(total)).toBe(copy)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
export function apiField(label: string, key: string, type: string, rest: object = {}) {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
type,
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: null,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
export const eventsFields = [
|
||||
apiField('Title', 'title', 'text', {
|
||||
required: true,
|
||||
unique: true,
|
||||
max: 80,
|
||||
options: [],
|
||||
}),
|
||||
apiField('Starts on', 'starts_on', 'date', { required: true, options: [] }),
|
||||
apiField('Ends on', 'ends_on', 'date', { options: [] }),
|
||||
apiField('Online', 'online', 'boolean', { default: false, options: [] }),
|
||||
apiField('Capacity', 'capacity', 'number', {
|
||||
min: 1,
|
||||
max: 5000,
|
||||
options: [],
|
||||
}),
|
||||
apiField('Category', 'category', 'select', {
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
}),
|
||||
apiField('Description', 'description', 'richtext', { options: [] }),
|
||||
]
|
||||
|
||||
export const recipesFields = [
|
||||
apiField('Title', 'title', 'text', { required: true, options: [] }),
|
||||
apiField('Servings', 'servings', 'number', { min: 1, options: [] }),
|
||||
apiField('Vegetarian', 'vegetarian', 'boolean', { options: [] }),
|
||||
apiField('Method', 'method', 'richtext', { options: [] }),
|
||||
]
|
||||
@@ -0,0 +1,263 @@
|
||||
import { t } from '@/i18n'
|
||||
import { fieldTypeLabel } from '@/lib/schemaFields'
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
export type ChangeKind = 'add' | 'change' | 'remove'
|
||||
export type ChangeFlag = 'mayNotFit' | 'removesData'
|
||||
|
||||
export interface ChangeNote {
|
||||
text: string
|
||||
hint: string
|
||||
flagged: boolean
|
||||
}
|
||||
|
||||
export interface FieldChange {
|
||||
kind: ChangeKind
|
||||
uid: number | null
|
||||
key: string
|
||||
label: string
|
||||
notes: ChangeNote[]
|
||||
flag: ChangeFlag | null
|
||||
}
|
||||
|
||||
type Bound = number | string | null
|
||||
|
||||
export function fieldSignature(field: PayloadField) {
|
||||
return JSON.stringify(field)
|
||||
}
|
||||
|
||||
export function isFieldChanged(field: BuilderField, baseline: ReadonlyMap<string, string>) {
|
||||
return field.saved && fieldSignature(payloadField(field)) !== baseline.get(field.key)
|
||||
}
|
||||
|
||||
export function baselineSignatures(fields: readonly PayloadField[]) {
|
||||
return new Map(fields.map((field) => [field.key, fieldSignature(field)]))
|
||||
}
|
||||
|
||||
function note(text: string, hint = '', flagged = false): ChangeNote {
|
||||
return { text, hint, flagged }
|
||||
}
|
||||
|
||||
function boundNames(type: string) {
|
||||
if (type === 'number') {
|
||||
return [t('Lowest value'), t('Highest value')] as const
|
||||
}
|
||||
if (type === 'date') {
|
||||
return [t('Earliest date'), t('Latest date')] as const
|
||||
}
|
||||
return [t('Shortest'), t('Longest')] as const
|
||||
}
|
||||
|
||||
function tightenHint(type: string, which: 'min' | 'max') {
|
||||
if (type === 'number') {
|
||||
return which === 'max'
|
||||
? t('An entry may have a higher value.')
|
||||
: t('An entry may have a lower value.')
|
||||
}
|
||||
if (type === 'date') {
|
||||
return which === 'max'
|
||||
? t('An entry may have a later date.')
|
||||
: t('An entry may have an earlier date.')
|
||||
}
|
||||
return which === 'max' ? t('An entry may be longer.') : t('An entry may be shorter.')
|
||||
}
|
||||
|
||||
function isTighter(which: 'min' | 'max', before: Bound, after: Bound) {
|
||||
if (after === null) {
|
||||
return false
|
||||
}
|
||||
if (before === null) {
|
||||
return true
|
||||
}
|
||||
return which === 'max' ? after < before : after > before
|
||||
}
|
||||
|
||||
function boundNote(type: string, which: 'min' | 'max', before: Bound, after: Bound) {
|
||||
if (before === after) {
|
||||
return []
|
||||
}
|
||||
const name = boundNames(type)[which === 'min' ? 0 : 1]
|
||||
if (after === null) {
|
||||
return [note(t('{0} is no longer set.', [name]))]
|
||||
}
|
||||
const tighter = isTighter(which, before, after)
|
||||
const hint = tighter ? tightenHint(type, which) : ''
|
||||
const text =
|
||||
before === null
|
||||
? t('{0} is now {1}.', [name, String(after)])
|
||||
: t('{0} goes from {1} to {2}.', [name, String(before), String(after)])
|
||||
return [note(text, hint, tighter)]
|
||||
}
|
||||
|
||||
function optionNotes(before: readonly string[], after: readonly string[]) {
|
||||
const removed = before.filter((option) => !after.includes(option))
|
||||
const added = after.filter((option) => !before.includes(option))
|
||||
const notes = [
|
||||
...removed.map((option) =>
|
||||
note(t('Choice removed: "{0}".', [option]), t('An entry may still use it.'), true),
|
||||
),
|
||||
...added.map((option) => note(t('Choice added: "{0}".', [option]))),
|
||||
]
|
||||
if (notes.length === 0 && before.join('\n') !== after.join('\n')) {
|
||||
notes.push(note(t('Choices were reordered.')))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
function defaultNotes(before: PayloadField, after: PayloadField) {
|
||||
if (before.default === after.default) {
|
||||
return []
|
||||
}
|
||||
if (after.type === 'boolean') {
|
||||
return [
|
||||
note(
|
||||
after.default === true
|
||||
? t('On by default is now on.')
|
||||
: t('On by default is now off.'),
|
||||
),
|
||||
]
|
||||
}
|
||||
if (after.default === null) {
|
||||
return [note(t('Default value removed.'))]
|
||||
}
|
||||
return [note(t('Default value is now "{0}".', [String(after.default)]))]
|
||||
}
|
||||
|
||||
function changeNotes(before: PayloadField, after: PayloadField) {
|
||||
const notes: ChangeNote[] = []
|
||||
if (before.label !== after.label) {
|
||||
notes.push(note(t('Label goes from "{0}" to "{1}".', [before.label, after.label])))
|
||||
}
|
||||
if (before.required !== after.required) {
|
||||
notes.push(
|
||||
after.required
|
||||
? note(t('Now required.'), t('An entry may have no value.'), true)
|
||||
: note(t('No longer required.')),
|
||||
)
|
||||
}
|
||||
if (before.unique !== after.unique) {
|
||||
notes.push(
|
||||
after.unique
|
||||
? note(t('Values must now be unique.'), t('Entries may share a value.'), true)
|
||||
: note(t('Values no longer need to be unique.')),
|
||||
)
|
||||
}
|
||||
notes.push(...boundNote(after.type, 'min', before.min, after.min))
|
||||
notes.push(...boundNote(after.type, 'max', before.max, after.max))
|
||||
notes.push(...defaultNotes(before, after))
|
||||
notes.push(...optionNotes(before.options, after.options))
|
||||
return notes
|
||||
}
|
||||
|
||||
function addNote(field: PayloadField) {
|
||||
const parts = [fieldTypeLabel(field.type), field.required ? t('required') : t('optional')]
|
||||
if (field.default !== null) {
|
||||
parts.push(
|
||||
field.type === 'boolean'
|
||||
? t('default on')
|
||||
: t('default "{0}"', [String(field.default)]),
|
||||
)
|
||||
}
|
||||
const summary = `${parts.join(', ')}.`
|
||||
if (!field.required) {
|
||||
return note(summary)
|
||||
}
|
||||
return field.default === null
|
||||
? note(`${summary} ${t('Existing entries have no value for it.')}`, '', true)
|
||||
: note(`${summary} ${t('Existing entries get the default, so they still fit.')}`)
|
||||
}
|
||||
|
||||
function flagOf(notes: readonly ChangeNote[]): ChangeFlag | null {
|
||||
return notes.some((entry) => entry.flagged) ? 'mayNotFit' : null
|
||||
}
|
||||
|
||||
export function diffFields(
|
||||
baseline: readonly PayloadField[],
|
||||
fields: readonly BuilderField[],
|
||||
): FieldChange[] {
|
||||
const before = new Map(baseline.map((field) => [field.key, field]))
|
||||
const additions: FieldChange[] = []
|
||||
const changes: FieldChange[] = []
|
||||
for (const field of fields) {
|
||||
const after = payloadField(field)
|
||||
const previous = field.saved ? before.get(field.key) : undefined
|
||||
if (!field.saved || !previous) {
|
||||
const notes = [addNote(after)]
|
||||
additions.push({
|
||||
kind: 'add',
|
||||
uid: field.uid,
|
||||
key: after.key,
|
||||
label: after.label,
|
||||
notes,
|
||||
flag: flagOf(notes),
|
||||
})
|
||||
} else if (fieldSignature(previous) !== fieldSignature(after)) {
|
||||
const notes = changeNotes(previous, after)
|
||||
changes.push({
|
||||
kind: 'change',
|
||||
uid: field.uid,
|
||||
key: previous.key,
|
||||
label: previous.label,
|
||||
notes,
|
||||
flag: flagOf(notes),
|
||||
})
|
||||
}
|
||||
}
|
||||
const kept = new Set(fields.filter((field) => field.saved).map((field) => field.key))
|
||||
const removals = baseline
|
||||
.filter((field) => !kept.has(field.key))
|
||||
.map(
|
||||
(field): FieldChange => ({
|
||||
kind: 'remove',
|
||||
uid: null,
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
notes: [note(t('Entries stop returning its values.'))],
|
||||
flag: 'removesData',
|
||||
}),
|
||||
)
|
||||
return [...additions, ...changes, ...removals]
|
||||
}
|
||||
|
||||
export function changeTitle(change: FieldChange) {
|
||||
if (change.kind === 'add') {
|
||||
return t('Add {0}', [change.label])
|
||||
}
|
||||
return change.kind === 'change'
|
||||
? t('Change {0}', [change.label])
|
||||
: t('Remove {0}', [change.label])
|
||||
}
|
||||
|
||||
export function changeText(change: FieldChange) {
|
||||
return change.notes.map((entry) => entry.text).join(' ')
|
||||
}
|
||||
|
||||
export function causeText(change: FieldChange) {
|
||||
return change.notes
|
||||
.filter((entry) => entry.flagged)
|
||||
.map((entry) => (entry.hint ? `${entry.text} ${entry.hint}` : entry.text))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function flaggedChanges(changes: readonly FieldChange[]) {
|
||||
return changes.filter((change) => change.flag === 'mayNotFit')
|
||||
}
|
||||
|
||||
export function summaryLine(changes: readonly FieldChange[]) {
|
||||
const count = (kind: ChangeKind) => changes.filter((change) => change.kind === kind).length
|
||||
const added = count('add')
|
||||
const changed = count('change')
|
||||
const removed = count('remove')
|
||||
const parts: string[] = []
|
||||
if (added > 0) {
|
||||
parts.push(added === 1 ? t('Add 1 field') : t('Add {0} fields', [added]))
|
||||
}
|
||||
if (changed > 0) {
|
||||
parts.push(parts.length === 0 ? t('Change {0}', [changed]) : t('change {0}', [changed]))
|
||||
}
|
||||
if (removed > 0) {
|
||||
parts.push(parts.length === 0 ? t('Remove {0}', [removed]) : t('remove {0}', [removed]))
|
||||
}
|
||||
return parts.join(', ')
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { BuiltinField, ContentType } from '@/composables/useContentTypes'
|
||||
import {
|
||||
FIELD_TYPES,
|
||||
createField,
|
||||
type BuilderField,
|
||||
type BuilderForm,
|
||||
type FieldType,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
const DATE_LENGTH = 10
|
||||
|
||||
function asText(value: unknown) {
|
||||
return value === null || value === undefined ? '' : String(value)
|
||||
}
|
||||
|
||||
function asDate(value: unknown) {
|
||||
return asText(value).slice(0, DATE_LENGTH)
|
||||
}
|
||||
|
||||
function asType(type: string): FieldType {
|
||||
return (FIELD_TYPES as readonly string[]).includes(type) ? (type as FieldType) : 'text'
|
||||
}
|
||||
|
||||
function asOptions(options: unknown[] | null) {
|
||||
return (options ?? []).map((option) => String(option))
|
||||
}
|
||||
|
||||
export function fieldFromApi(source: BuiltinField, uid: number): BuilderField {
|
||||
const type = asType(source.type)
|
||||
const isDate = type === 'date'
|
||||
const bound = isDate ? asDate : asText
|
||||
return createField(uid, {
|
||||
label: source.label,
|
||||
key: source.key,
|
||||
keyMode: 'manual',
|
||||
type,
|
||||
required: source.required,
|
||||
unique: source.unique,
|
||||
defaultValue: type === 'boolean' ? '' : bound(source.default),
|
||||
defaultOn: type === 'boolean' && source.default === true,
|
||||
min: bound(source.min),
|
||||
max: bound(source.max),
|
||||
options: type === 'select' ? asOptions(source.options) : [],
|
||||
saved: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function formFromType(
|
||||
type: ContentType,
|
||||
newUid: () => number,
|
||||
uids: readonly number[] = [],
|
||||
): BuilderForm {
|
||||
return {
|
||||
name: type.name,
|
||||
key: type.key,
|
||||
keyMode: 'manual',
|
||||
keyLocked: true,
|
||||
fields: type.fields.map((field, index) => fieldFromApi(field, uids[index] ?? newUid())),
|
||||
}
|
||||
}
|
||||
|
||||
export function baselinePayload(type: ContentType): PayloadField[] {
|
||||
return type.fields.map((field) => payloadField(fieldFromApi(field, 0)))
|
||||
}
|
||||
@@ -3,7 +3,14 @@ export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'se
|
||||
export type FieldType = (typeof FIELD_TYPES)[number]
|
||||
export type KeyMode = 'auto' | 'manual'
|
||||
export type KeyDisplayMode = KeyMode | 'empty'
|
||||
export type BuilderStatus = 'saving' | 'offline' | 'failed' | 'problems' | 'dirty' | 'none'
|
||||
export type BuilderStatus =
|
||||
| 'saving'
|
||||
| 'offline'
|
||||
| 'failed'
|
||||
| 'problems'
|
||||
| 'dirty'
|
||||
| 'saved'
|
||||
| 'none'
|
||||
|
||||
export const BUILDER_LIMITS = {
|
||||
nameMax: 100,
|
||||
@@ -32,12 +39,14 @@ export interface BuilderField {
|
||||
min: string
|
||||
max: string
|
||||
options: string[]
|
||||
saved: boolean
|
||||
}
|
||||
|
||||
export interface BuilderForm {
|
||||
name: string
|
||||
key: string
|
||||
keyMode: KeyMode
|
||||
keyLocked: boolean
|
||||
fields: BuilderField[]
|
||||
}
|
||||
|
||||
@@ -62,6 +71,7 @@ export function createField(uid: number, overrides: Partial<BuilderField> = {}):
|
||||
min: '',
|
||||
max: '',
|
||||
options: [],
|
||||
saved: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -71,7 +81,13 @@ export function starterField(uid: number): BuilderField {
|
||||
}
|
||||
|
||||
export function blankForm(uid: number): BuilderForm {
|
||||
return { name: '', key: '', keyMode: 'auto', fields: [starterField(uid)] }
|
||||
return {
|
||||
name: '',
|
||||
key: '',
|
||||
keyMode: 'auto',
|
||||
keyLocked: false,
|
||||
fields: [starterField(uid)],
|
||||
}
|
||||
}
|
||||
|
||||
export function changeFieldType(field: BuilderField, type: FieldType): BuilderField {
|
||||
|
||||
@@ -43,6 +43,12 @@ function defaultFor(field: BuilderField) {
|
||||
return field.type === 'number' ? Number(given) : given
|
||||
}
|
||||
|
||||
export interface UpdatePayload {
|
||||
name: string
|
||||
icon: string | null
|
||||
fields: PayloadField[]
|
||||
}
|
||||
|
||||
export function payloadField(field: BuilderField): PayloadField {
|
||||
return {
|
||||
key: field.key,
|
||||
@@ -64,3 +70,11 @@ export function buildPayload(form: BuilderForm): ContentTypePayload {
|
||||
fields: form.fields.map(payloadField),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUpdatePayload(form: BuilderForm, icon: string | null): UpdatePayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
icon,
|
||||
fields: form.fields.map(payloadField),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
|
||||
export interface RemovedField {
|
||||
field: BuilderField
|
||||
}
|
||||
|
||||
export function restoreIndex(
|
||||
current: readonly BuilderField[],
|
||||
baselineKeys: readonly string[],
|
||||
removedKey: string,
|
||||
) {
|
||||
const target = baselineKeys.indexOf(removedKey)
|
||||
let index = 0
|
||||
current.forEach((field, position) => {
|
||||
const place = field.saved ? baselineKeys.indexOf(field.key) : -1
|
||||
if (place !== -1 && place < target) {
|
||||
index = position + 1
|
||||
}
|
||||
})
|
||||
return index
|
||||
}
|
||||
|
||||
export function reservedKeys(removed: readonly RemovedField[]) {
|
||||
return removed.map((entry) => entry.field.key)
|
||||
}
|
||||
@@ -51,10 +51,12 @@ export function validateType(form: BuilderForm): TypeErrors {
|
||||
} else if (form.name.length > BUILDER_LIMITS.nameMax) {
|
||||
errors.name = t('Use 100 characters or fewer.')
|
||||
}
|
||||
if (RESERVED_TYPE_KEYS.includes(form.key)) {
|
||||
errors.key = t('The key "{0}" is reserved. Choose a different key.', [form.key])
|
||||
} else if (!isValidTypeKey(form.key)) {
|
||||
errors.key = t('Use 2 to 40 lowercase letters, digits or hyphens.')
|
||||
if (!form.keyLocked) {
|
||||
if (RESERVED_TYPE_KEYS.includes(form.key)) {
|
||||
errors.key = t('The key "{0}" is reserved. Choose a different key.', [form.key])
|
||||
} else if (!isValidTypeKey(form.key)) {
|
||||
errors.key = t('Use 2 to 40 lowercase letters, digits or hyphens.')
|
||||
}
|
||||
}
|
||||
if (form.fields.length === 0) {
|
||||
errors.fields = t('A content type needs at least one field.')
|
||||
@@ -164,6 +166,9 @@ function choiceErrors(field: BuilderField, errors: FieldErrors) {
|
||||
}
|
||||
|
||||
function keyError(field: BuilderField, earlierKeys: readonly string[]) {
|
||||
if (field.saved) {
|
||||
return undefined
|
||||
}
|
||||
if (field.keyMode === 'auto' && field.label.trim() === '') {
|
||||
return undefined
|
||||
}
|
||||
@@ -201,11 +206,12 @@ export function hasFieldErrors(errors: FieldErrors) {
|
||||
return Object.keys(errors).length > 0
|
||||
}
|
||||
|
||||
export function validateForm(form: BuilderForm): FormErrors {
|
||||
export function validateForm(form: BuilderForm, reserved: readonly string[] = []): FormErrors {
|
||||
const fields = new Map<number, FieldErrors>()
|
||||
const savedKeys = form.fields.filter((field) => field.saved).map((field) => field.key)
|
||||
const earlierKeys: string[] = []
|
||||
for (const field of form.fields) {
|
||||
const errors = validateField(field, earlierKeys)
|
||||
const errors = validateField(field, [...earlierKeys, ...savedKeys, ...reserved])
|
||||
if (hasFieldErrors(errors)) {
|
||||
fields.set(field.uid, errors)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
|
||||
export function entriesNotice(total: number) {
|
||||
if (total <= 0) {
|
||||
return ''
|
||||
}
|
||||
return total === 1
|
||||
? t('1 entry uses this content type. Changes are checked against it when you save.')
|
||||
: t('{0} entries use this content type. Changes are checked against them when you save.', [
|
||||
formatNumber(total),
|
||||
])
|
||||
}
|
||||
|
||||
export function entriesLine(name: string, total: number) {
|
||||
return total === 1
|
||||
? t('{0} has 1 entry. Each one is checked against the new fields when you apply.', [name])
|
||||
: t('{0} has {1} entries. Each one is checked against the new fields when you apply.', [
|
||||
name,
|
||||
formatNumber(total),
|
||||
])
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function customRow(type: ContentType): SchemaRow {
|
||||
fieldCount: type.fields.length,
|
||||
updated: formatAbsoluteDateFromIso(type.updated_at),
|
||||
builtin: false,
|
||||
to: `/schema/${type.key}`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,30 +164,36 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
expect(resolved.meta.title).toBe('Schema')
|
||||
})
|
||||
|
||||
it('The schema type route redirects an unverified session to the pending page', async () => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: false })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
it.each(['articles', 'events'])(
|
||||
'The schema type route redirects an unverified session on /schema/%s to the pending page',
|
||||
async (key) => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: false })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
|
||||
await router.push('/schema/articles')
|
||||
mountApp()
|
||||
await router.push(`/schema/${key}`)
|
||||
mountApp()
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
|
||||
})
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
|
||||
},
|
||||
)
|
||||
|
||||
it('The schema type route redirects a signed-out visitor to login with the original path', async () => {
|
||||
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
it.each(['articles', 'events'])(
|
||||
'The schema type route redirects a signed-out visitor on /schema/%s to login with the original path',
|
||||
async (key) => {
|
||||
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
|
||||
await router.push('/schema/articles')
|
||||
mountApp()
|
||||
await flushPromises()
|
||||
await router.push(`/schema/${key}`)
|
||||
mountApp()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/login?redirect=/schema/articles&reason=expired',
|
||||
)
|
||||
})
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
`/login?redirect=/schema/${key}&reason=expired`,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('The new content type route is registered before the type key route, verified-only, titled Schema', () => {
|
||||
const resolved = router.resolve('/schema/new')
|
||||
|
||||
@@ -205,7 +205,12 @@ describe('content types store', () => {
|
||||
await store.load()
|
||||
|
||||
store.add(agenda)
|
||||
store.add({ ...events, id: 4, key: 'press', name: 'Press mentions' })
|
||||
store.add({
|
||||
...events,
|
||||
id: 4,
|
||||
key: 'press',
|
||||
name: 'Press mentions',
|
||||
})
|
||||
store.add({ ...events, id: 5, key: 'zoo', name: 'Zoo' })
|
||||
|
||||
expect(store.items.map((item) => item.name)).toEqual([
|
||||
@@ -280,3 +285,92 @@ describe('content types store', () => {
|
||||
expect(store.items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('content types store: replace, drop and reset', () => {
|
||||
const recipes = { ...events, id: 2, key: 'recipes', name: 'Recipes' }
|
||||
const pressMentions = {
|
||||
...events,
|
||||
id: 3,
|
||||
key: 'press-mentions',
|
||||
name: 'Press mentions',
|
||||
}
|
||||
|
||||
async function loadedStore() {
|
||||
listContentTypes.mockResolvedValue([events, pressMentions, recipes])
|
||||
listBuiltinTypes.mockResolvedValue([{ key: 'articles', name: 'Articles', fields: [] }])
|
||||
const store = useContentTypesStore()
|
||||
await store.load({ builtin: true })
|
||||
listContentTypes.mockClear()
|
||||
return store
|
||||
}
|
||||
|
||||
it('swaps the type by key in place when the name did not change', async () => {
|
||||
const store = await loadedStore()
|
||||
|
||||
store.replace({ ...events, fields: [{ key: 'title' }] as never })
|
||||
|
||||
expect(store.items.map((item) => item.key)).toEqual(['events', 'press-mentions', 'recipes'])
|
||||
expect(store.items[0]!.fields).toHaveLength(1)
|
||||
expect(listContentTypes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the list ordered by name when the name changed', async () => {
|
||||
const store = await loadedStore()
|
||||
|
||||
store.replace({ ...events, name: 'Seminars' })
|
||||
|
||||
expect(store.items.map((item) => item.name)).toEqual([
|
||||
'Press mentions',
|
||||
'Recipes',
|
||||
'Seminars',
|
||||
])
|
||||
store.replace({ ...recipes, name: 'Aardvarks' })
|
||||
expect(store.items.map((item) => item.name)).toEqual([
|
||||
'Aardvarks',
|
||||
'Press mentions',
|
||||
'Seminars',
|
||||
])
|
||||
})
|
||||
|
||||
it('does nothing before the list is loaded', () => {
|
||||
const store = useContentTypesStore()
|
||||
|
||||
store.replace(events)
|
||||
|
||||
expect(store.items).toEqual([])
|
||||
expect(listContentTypes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a type it does not hold and never touches the built-in types', async () => {
|
||||
const store = await loadedStore()
|
||||
const builtin = [...store.builtinItems]
|
||||
|
||||
store.replace({ ...events, key: 'unknown', name: 'Unknown' })
|
||||
store.replace({ ...events, name: 'Events!' })
|
||||
|
||||
expect(store.items).toHaveLength(3)
|
||||
expect(store.builtinItems).toEqual(builtin)
|
||||
})
|
||||
|
||||
it('drops a type by key', async () => {
|
||||
const store = await loadedStore()
|
||||
|
||||
store.drop('recipes')
|
||||
|
||||
expect(store.items.map((item) => item.key)).toEqual(['events', 'press-mentions'])
|
||||
})
|
||||
|
||||
it('reset clears both lists so the next session loads them again', async () => {
|
||||
const store = await loadedStore()
|
||||
|
||||
store.reset()
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.builtinItems).toEqual([])
|
||||
expect(store.status).toBe('idle')
|
||||
expect(store.builtinStatus).toBe('idle')
|
||||
|
||||
await store.load({ builtin: true })
|
||||
expect(listContentTypes).toHaveBeenCalledTimes(1)
|
||||
expect(listBuiltinTypes).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -84,9 +84,43 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
|
||||
list.splice(index === -1 ? list.length : index, 0, item)
|
||||
}
|
||||
|
||||
function replace(
|
||||
item: T,
|
||||
matches: (existing: T) => boolean,
|
||||
keepsSlot: (existing: T) => boolean,
|
||||
before: (existing: T) => boolean,
|
||||
) {
|
||||
if (status.value === 'loading') {
|
||||
stale = true
|
||||
return
|
||||
}
|
||||
if (status.value !== 'ready') {
|
||||
return
|
||||
}
|
||||
const list = items.value as T[]
|
||||
const index = list.findIndex(matches)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
if (keepsSlot(list[index]!)) {
|
||||
list.splice(index, 1, item)
|
||||
return
|
||||
}
|
||||
list.splice(index, 1)
|
||||
const target = list.findIndex(before)
|
||||
list.splice(target === -1 ? list.length : target, 0, item)
|
||||
}
|
||||
|
||||
function drop(matches: (existing: T) => boolean) {
|
||||
if (status.value !== 'ready') {
|
||||
return
|
||||
}
|
||||
items.value = (items.value as T[]).filter((existing) => !matches(existing))
|
||||
}
|
||||
|
||||
const loading = computed(() => status.value === 'idle' || status.value === 'loading')
|
||||
|
||||
return { items, status, loading, load, retry, reset, insert }
|
||||
return { items, status, loading, load, retry, reset, insert, replace, drop }
|
||||
}
|
||||
|
||||
export const useContentTypesStore = defineStore('contentTypes', () => {
|
||||
@@ -118,6 +152,20 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
|
||||
custom.insert(type, (existing) => existing.name.toLowerCase() > name)
|
||||
}
|
||||
|
||||
function replace(type: ContentType) {
|
||||
const name = type.name.toLowerCase()
|
||||
custom.replace(
|
||||
type,
|
||||
(existing) => existing.key === type.key,
|
||||
(existing) => existing.name.toLowerCase() === name,
|
||||
(existing) => existing.name.toLowerCase() > name,
|
||||
)
|
||||
}
|
||||
|
||||
function drop(key: string) {
|
||||
custom.drop((existing) => existing.key === key)
|
||||
}
|
||||
|
||||
function retryCustom() {
|
||||
return custom.retry()
|
||||
}
|
||||
@@ -142,6 +190,8 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
|
||||
forbidden,
|
||||
load,
|
||||
add,
|
||||
replace,
|
||||
drop,
|
||||
retryCustom,
|
||||
retryBuiltin,
|
||||
reset,
|
||||
|
||||
@@ -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))()
|
||||
}
|
||||
|
||||
@@ -71,6 +71,12 @@ const extraSources: Record<string, Source[]> = {
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '3 Review and delete',
|
||||
states: ['Review before saving', 'Applying', 'Refused (schema_incompatible)'],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
sites: [
|
||||
{
|
||||
|
||||
+348
-30
@@ -6445,20 +6445,6 @@ const editorSeoStates: Record<string, VisualState> = {
|
||||
},
|
||||
}
|
||||
|
||||
const schemaCustomTypes = [
|
||||
['events', 'Events', 7, '2026-10-15T09:30:00Z'],
|
||||
['recipes', 'Recipes', 4, '2026-10-02T09:30:00Z'],
|
||||
['press-mentions', 'Press mentions', 5, '2026-09-18T09:30:00Z'],
|
||||
].map(([key, name, fieldCount, updatedAt], index) => ({
|
||||
id: index + 1,
|
||||
key,
|
||||
name,
|
||||
icon: 'files',
|
||||
fields: Array.from({ length: fieldCount as number }, (_, field) => ({ key: `field_${field}` })),
|
||||
created_at: '2026-09-01T09:30:00Z',
|
||||
updated_at: updatedAt,
|
||||
}))
|
||||
|
||||
function schemaField(label: string, key: string, type: string, rest: object = {}) {
|
||||
return {
|
||||
key,
|
||||
@@ -6474,6 +6460,56 @@ function schemaField(label: string, key: string, type: string, rest: object = {}
|
||||
}
|
||||
}
|
||||
|
||||
const schemaEventsFields = [
|
||||
schemaField('Title', 'title', 'text', {
|
||||
required: true,
|
||||
unique: true,
|
||||
max: 80,
|
||||
options: [],
|
||||
}),
|
||||
schemaField('Starts on', 'starts_on', 'date', {
|
||||
required: true,
|
||||
options: [],
|
||||
}),
|
||||
schemaField('Ends on', 'ends_on', 'date', { options: [] }),
|
||||
schemaField('Online', 'online', 'boolean', { default: false, options: [] }),
|
||||
schemaField('Capacity', 'capacity', 'number', {
|
||||
min: 1,
|
||||
max: 5000,
|
||||
options: [],
|
||||
}),
|
||||
schemaField('Category', 'category', 'select', {
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
}),
|
||||
schemaField('Description', 'description', 'richtext', { options: [] }),
|
||||
]
|
||||
|
||||
const schemaRecipesFields = [
|
||||
schemaField('Title', 'title', 'text', { required: true, options: [] }),
|
||||
schemaField('Servings', 'servings', 'number', { min: 1, options: [] }),
|
||||
schemaField('Vegetarian', 'vegetarian', 'boolean', { options: [] }),
|
||||
schemaField('Method', 'method', 'richtext', { options: [] }),
|
||||
]
|
||||
|
||||
const schemaCustomTypes = [
|
||||
['events', 'Events', schemaEventsFields, '2026-10-15T09:30:00Z'],
|
||||
['recipes', 'Recipes', schemaRecipesFields, '2026-10-02T09:30:00Z'],
|
||||
[
|
||||
'press-mentions',
|
||||
'Press mentions',
|
||||
Array.from({ length: 5 }, (_, field) => ({ key: `field_${field}` })),
|
||||
'2026-09-18T09:30:00Z',
|
||||
],
|
||||
].map(([key, name, fields, updatedAt], index) => ({
|
||||
id: index + 1,
|
||||
key,
|
||||
name,
|
||||
icon: 'files',
|
||||
fields,
|
||||
created_at: '2026-09-01T09:30:00Z',
|
||||
updated_at: updatedAt,
|
||||
}))
|
||||
|
||||
const schemaArticleFields = [
|
||||
schemaField('Title', 'title', 'text', { required: true, max: 200 }),
|
||||
schemaField('Slug', 'slug', 'text', { required: true, unique: true }),
|
||||
@@ -6502,7 +6538,11 @@ const schemaBuiltinTypes = [
|
||||
function schemaSession(role: string): ApiMock {
|
||||
return {
|
||||
status: 200,
|
||||
body: { ...(verifiedSession.body as object), display_name: 'Marta Alves', role },
|
||||
body: {
|
||||
...(verifiedSession.body as object),
|
||||
display_name: 'Marta Alves',
|
||||
role,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6511,16 +6551,119 @@ function schemaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiM
|
||||
'GET /v1/auth/me': schemaSession('owner'),
|
||||
'GET /v1/content-type': { status: 200, body: schemaCustomTypes },
|
||||
'GET /v1/builtin-type': { status: 200, body: schemaBuiltinTypes },
|
||||
'GET /v1/content-type/events/entry': {
|
||||
status: 200,
|
||||
body: schemaEntryPage(37),
|
||||
},
|
||||
'GET /v1/content-type/recipes/entry': {
|
||||
status: 200,
|
||||
body: schemaEntryPage(0),
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function schemaEntryPage(total: number) {
|
||||
return { items: [], total, page: 1, size: 1 }
|
||||
}
|
||||
|
||||
function schemaPut(response: ApiMock): Record<string, ApiMock> {
|
||||
return { 'PUT /v1/content-type/events': response }
|
||||
}
|
||||
|
||||
async function schemaSettle(page: Page) {
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('*').forEach((element) => {
|
||||
element.scrollTop = 0
|
||||
})
|
||||
})
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function schemaFocusRow(page: Page, label: string) {
|
||||
await schemaOpenRow(page, label)
|
||||
await schemaCloseSheet(page)
|
||||
}
|
||||
|
||||
async function schemaAddField(page: Page, label: string) {
|
||||
await page.getByRole('button', { name: 'Add field' }).dispatchEvent('click')
|
||||
await page.locator('input[name="label"]').fill(label)
|
||||
}
|
||||
|
||||
async function schemaAddChoice(page: Page, text: string) {
|
||||
const input = page.getByPlaceholder('Add a choice')
|
||||
await input.fill(text)
|
||||
await input.press('Enter')
|
||||
}
|
||||
|
||||
async function schemaUnsavedEdits(page: Page) {
|
||||
await schemaOpenRow(page, 'Capacity')
|
||||
await page.locator('input[name="max"]').fill('2000')
|
||||
await schemaCloseSheet(page)
|
||||
await schemaAddField(page, 'Speaker')
|
||||
await schemaCloseSheet(page)
|
||||
await schemaAddField(page, 'Room')
|
||||
await page.locator('label:has([role="radio"][value="select"])').click()
|
||||
await page.getByRole('switch', { name: 'Required' }).click()
|
||||
await schemaAddChoice(page, 'Main hall')
|
||||
await schemaAddChoice(page, 'Studio')
|
||||
await page.locator('[data-testid="field-inspector"] button[role="combobox"]').click()
|
||||
await page.getByRole('option', { name: 'Main hall' }).click()
|
||||
await schemaCloseSheet(page)
|
||||
await schemaOpenRow(page, 'Description')
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
await page.getByText('Description will be removed').waitFor()
|
||||
if (!schemaIsMobile(page)) {
|
||||
await schemaOpenRow(page, 'Capacity')
|
||||
}
|
||||
await schemaSettle(page)
|
||||
}
|
||||
|
||||
async function schemaApplyEdits(page: Page) {
|
||||
await schemaUnsavedEdits(page)
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function schemaApply(page: Page) {
|
||||
await schemaUnsavedEdits(page)
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Apply changes' }).click()
|
||||
}
|
||||
|
||||
async function schemaFieldErrors(page: Page) {
|
||||
await schemaOpenRow(page, 'Ends on')
|
||||
await page.locator('input[name="label"]').fill('')
|
||||
await schemaCloseSheet(page)
|
||||
await schemaOpenRow(page, 'Capacity')
|
||||
await page.locator('input[name="min"]').fill('6000')
|
||||
await schemaCloseSheet(page)
|
||||
await schemaCategoryChoices(page)
|
||||
if (!schemaIsMobile(page)) {
|
||||
await schemaOpenRow(page, 'Capacity')
|
||||
} else {
|
||||
await schemaCloseSheet(page)
|
||||
}
|
||||
await schemaSettle(page)
|
||||
}
|
||||
|
||||
async function schemaCategoryChoices(page: Page) {
|
||||
await schemaOpenRow(page, 'Category')
|
||||
await page.locator('[data-testid="choice-row"] input').nth(2).fill('')
|
||||
await schemaAddChoice(page, 'Talk')
|
||||
await page.getByText("Choices can't be blank.").first().waitFor()
|
||||
}
|
||||
|
||||
function schemaIsMobile(page: Page) {
|
||||
return (page.viewportSize()?.width ?? 0) < 768
|
||||
}
|
||||
|
||||
async function schemaOpenRow(page: Page, label: string) {
|
||||
await page.locator('[data-field-row]', { hasText: label }).first().click()
|
||||
await page.locator('[data-field-row]', { hasText: label }).first().dispatchEvent('click')
|
||||
}
|
||||
|
||||
async function schemaCloseSheet(page: Page) {
|
||||
@@ -6550,12 +6693,38 @@ const schemaStates: Record<string, VisualState> = {
|
||||
'GET /v1/auth/me': schemaSession('user'),
|
||||
}),
|
||||
},
|
||||
'Built-in type: read-only view': { path: '/schema/articles', api: schemaApi() },
|
||||
'Built-in type: read-only view': {
|
||||
path: '/schema/articles',
|
||||
api: schemaApi(),
|
||||
},
|
||||
'New type': { path: '/schema/new', api: schemaApi() },
|
||||
'Editing an existing type': { path: '/schema/events', skip: 'pending' },
|
||||
'Locked key: tooltip': { path: '/schema/events', skip: 'pending' },
|
||||
'Unsaved changes': { path: '/schema/events', skip: 'pending' },
|
||||
'Field errors': { path: '/schema/events', skip: 'pending' },
|
||||
'Editing an existing type': { path: '/schema/events', api: schemaApi() },
|
||||
'Locked key: tooltip': {
|
||||
path: '/schema/events',
|
||||
api: schemaApi(),
|
||||
steps: async (page) => {
|
||||
await schemaFocusRow(page, 'Starts on')
|
||||
if (!schemaIsMobile(page)) {
|
||||
await page.locator('input[name="key"]').hover()
|
||||
await page.getByText('The key is used in URLs and the API').first().waitFor()
|
||||
}
|
||||
},
|
||||
},
|
||||
'Unsaved changes': {
|
||||
path: '/schema/events',
|
||||
api: schemaApi(),
|
||||
steps: schemaUnsavedEdits,
|
||||
},
|
||||
'Field errors': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 11 },
|
||||
reason: 'the board list header shrinks when the list overflows and shifts rows, the app scrolls instead',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi(),
|
||||
steps: schemaFieldErrors,
|
||||
},
|
||||
'Type-level errors': {
|
||||
path: '/schema/new',
|
||||
api: schemaApi(),
|
||||
@@ -6571,16 +6740,105 @@ const schemaStates: Record<string, VisualState> = {
|
||||
},
|
||||
},
|
||||
'Field key error': { path: '/schema/new', skip: 'pending' },
|
||||
'Choice list editor': { path: '/schema/events', skip: 'pending' },
|
||||
'Keyboard reorder': { path: '/schema/events', skip: 'pending' },
|
||||
'API preview open': { path: '/schema/events', skip: 'pending' },
|
||||
Saving: { path: '/schema/events', skip: 'pending' },
|
||||
Saved: { path: '/schema/events', skip: 'pending' },
|
||||
'Save failed': { path: '/schema/events', skip: 'pending' },
|
||||
'Choice list editor': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 25 },
|
||||
reason: 'the accepted spec requires a Move up, Move down and Remove row in the mobile field sheet that the board does not draw',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi(),
|
||||
steps: async (page) => {
|
||||
await schemaOpenRow(page, 'Category')
|
||||
await schemaAddChoice(page, 'x')
|
||||
await page.locator('[data-testid="choice-row"] input').nth(3).fill('')
|
||||
await schemaAddChoice(page, 'Talk')
|
||||
await page.getByText('Two choices are the same.').first().waitFor()
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
'Keyboard reorder': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 15, mobile: 14 },
|
||||
reason: 'the design board draws a miniature of the board and overlays in the desktop frame, and the board list header shrinks when the list overflows',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi(),
|
||||
steps: async (page) => {
|
||||
await schemaFocusRow(page, 'Ends on')
|
||||
await page.getByRole('button', { name: 'Move Ends on' }).focus()
|
||||
await page.keyboard.press('Space')
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await page.keyboard.press('Space')
|
||||
await page.getByText('Unsaved changes').first().waitFor()
|
||||
await page.getByRole('button', { name: 'Move Ends on' }).focus()
|
||||
await page.keyboard.press('Space')
|
||||
await page.getByText('Moving Ends on. Position 2 of 7.').first().waitFor()
|
||||
},
|
||||
},
|
||||
'API preview open': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 11, mobile: 10 },
|
||||
reason: 'the design board draws a miniature of the board and overlays in the desktop frame',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi(),
|
||||
steps: async (page) => {
|
||||
if (schemaIsMobile(page)) {
|
||||
await page.locator('[data-testid="api-row"]').click()
|
||||
await page.getByRole('dialog').waitFor()
|
||||
} else {
|
||||
await page.locator('[data-testid="api-preview-toggle"]').click()
|
||||
await page.locator('[data-testid="api-json"]').waitFor()
|
||||
}
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
Saving: {
|
||||
exception: {
|
||||
maxPercent: { desktop: 27, mobile: 17 },
|
||||
reason: 'the board draws Saving with 37 entries and no review, the app always reviews first so the setup has no entries and no info bar; the design board draws a miniature of the board and overlays in the desktop frame',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi({
|
||||
'GET /v1/content-type/events/entry': {
|
||||
status: 200,
|
||||
body: schemaEntryPage(0),
|
||||
},
|
||||
...schemaPut({ status: 200, hang: true }),
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await schemaUnsavedEdits(page)
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await page.getByText('Saving…').first().waitFor()
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
Saved: { path: '/schema/events', skip: 'clock-sensitive' },
|
||||
'Save failed': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 12, mobile: 10 },
|
||||
reason: 'the design board draws a miniature of the board and overlays in the desktop frame, and the board list header shrinks when the list overflows',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi(schemaPut({ status: 500 })),
|
||||
steps: async (page) => {
|
||||
await schemaApply(page)
|
||||
await page.getByText("Couldn't save the content type").waitFor()
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
'Key already taken': {
|
||||
path: '/schema/new',
|
||||
api: schemaApi({
|
||||
'POST /v1/content-type': { status: 409, body: 'content_type_key_taken' },
|
||||
'POST /v1/content-type': {
|
||||
status: 409,
|
||||
body: 'content_type_key_taken',
|
||||
},
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await page.getByPlaceholder('For example, Events').fill('Events')
|
||||
@@ -6599,8 +6857,68 @@ const schemaStates: Record<string, VisualState> = {
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Schema incompatible': { path: '/schema/events', skip: 'pending' },
|
||||
Offline: { path: '/schema/events', skip: 'pending' },
|
||||
'Schema incompatible': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 13, mobile: 10 },
|
||||
reason: 'the design board draws a miniature of the board and overlays in the desktop frame, and the board list header shrinks when the list overflows',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi(schemaPut({ status: 409, body: 'schema_incompatible' })),
|
||||
steps: async (page) => {
|
||||
await schemaApply(page)
|
||||
await page.getByRole('button', { name: 'Back to editing' }).click()
|
||||
await page.getByRole('button', { name: 'Review changes' }).waitFor()
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
Offline: {
|
||||
path: '/schema/events',
|
||||
api: schemaApi(),
|
||||
steps: async (page) => {
|
||||
await schemaUnsavedEdits(page)
|
||||
await goOffline(page)
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
'Review before saving': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 15, mobile: 10 },
|
||||
reason: 'the design board draws a miniature of the board and overlays in the desktop frame, and the board list header shrinks when the list overflows',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi(),
|
||||
steps: schemaApplyEdits,
|
||||
},
|
||||
Applying: {
|
||||
exception: {
|
||||
maxPercent: { desktop: 42, mobile: 10 },
|
||||
reason: 'the design board draws a miniature of the board and overlays in the desktop frame and the overlay of another viewport',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi(schemaPut({ status: 200, hang: true })),
|
||||
steps: async (page) => {
|
||||
await schemaApply(page)
|
||||
await page.getByRole('button', { name: 'Applying…' }).waitFor()
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
'Refused (schema_incompatible)': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 43, mobile: 10 },
|
||||
reason: 'the design board draws a miniature of the board and overlays in the desktop frame and the overlay of another viewport',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi(schemaPut({ status: 409, body: 'schema_incompatible' })),
|
||||
steps: async (page) => {
|
||||
await schemaApply(page)
|
||||
await page.getByText('Your content type is as it was').waitFor()
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
|
||||
Reference in new issue
Block a user