feat: schema builder's create flow
This commit is contained in:
1 parent
f514146fed
commit
083cc9e23e
71 files changed
+7223
-61
No files matched your search
@@ -398,3 +398,13 @@ body {
|
||||
var(--ui-bg-accented) 8px 16px
|
||||
);
|
||||
}
|
||||
|
||||
.no-spin {
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.no-spin::-webkit-inner-spin-button,
|
||||
.no-spin::-webkit-outer-spin-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useJsonCopy } from '@/composables/useJsonCopy'
|
||||
import { previewRoutes } from '@/lib/schemaBuilderSample'
|
||||
|
||||
const props = defineProps<{ keyValue: string; json: string }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { copied, copy, label: copyLabel, icon: copyIcon } = useJsonCopy()
|
||||
|
||||
const routes = computed(() => previewRoutes(props.keyValue))
|
||||
const summary = computed(() => `${routes.value[0]!.method} ${routes.value[0]!.path}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:aria-label="t('API preview')"
|
||||
class="flex shrink-0 flex-col"
|
||||
data-testid="api-preview"
|
||||
>
|
||||
<div
|
||||
class="flex h-11 items-center gap-2.5 border-t border-default bg-default px-3 text-highlighted md:px-6"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:icon="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
||||
:aria-expanded="open"
|
||||
data-testid="api-preview-toggle"
|
||||
class="h-11 min-w-0 flex-1 justify-start gap-2.5 px-0 font-semibold hover:bg-transparent"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span>{{ t('API preview') }}</span>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-left font-mono text-xs font-normal text-muted"
|
||||
>{{ summary }}</span
|
||||
>
|
||||
</UButton>
|
||||
<UButton
|
||||
:icon="copyIcon"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
data-testid="api-copy"
|
||||
class="h-7 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0"
|
||||
:class="copied ? 'text-success' : ''"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="copy(json)"
|
||||
>
|
||||
<span aria-live="polite">{{ copyLabel }}</span>
|
||||
</UButton>
|
||||
</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">{{
|
||||
t('Where entries of this type live')
|
||||
}}</span>
|
||||
<ul
|
||||
class="m-0 flex list-none flex-col gap-0.5 border border-default bg-elevated px-3 py-2.5"
|
||||
>
|
||||
<li
|
||||
v-for="(route, index) in routes"
|
||||
:key="index"
|
||||
data-testid="api-route"
|
||||
class="truncate font-mono text-xs/5 text-highlighted"
|
||||
>
|
||||
<span class="inline-block w-14 text-muted">{{ route.method }}</span
|
||||
>{{ route.path }}
|
||||
</li>
|
||||
</ul>
|
||||
<span class="text-xs/4.5 text-pretty text-muted">{{
|
||||
t(
|
||||
'Updates as you edit. Fields you add or remove here appear after you save.',
|
||||
)
|
||||
}}</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">{{
|
||||
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"
|
||||
>{{ json }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UCollapsible>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useJsonCopy } from '@/composables/useJsonCopy'
|
||||
import { previewRoutes } from '@/lib/schemaBuilderSample'
|
||||
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||
|
||||
const props = defineProps<{ keyValue: string; json: string }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { copy, label: copyLabel, icon: copyIcon } = useJsonCopy()
|
||||
|
||||
const first = computed(() => previewRoutes(props.keyValue)[0]!)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="t('API preview')"
|
||||
:description="t('Where entries of this type live')"
|
||||
:ui="BOTTOM_SHEET_UI"
|
||||
>
|
||||
<template #body>
|
||||
<span class="text-xs text-muted" data-testid="api-sheet-route">{{
|
||||
`${first.method} ${first.path}`
|
||||
}}</span>
|
||||
<pre
|
||||
tabindex="0"
|
||||
data-testid="api-sheet-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"
|
||||
>{{ json }}</pre
|
||||
>
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton
|
||||
:icon="copyIcon"
|
||||
data-testid="api-sheet-copy"
|
||||
class="h-11 flex-1 justify-center"
|
||||
@click="copy(json)"
|
||||
>
|
||||
<span aria-live="polite">{{ copyLabel }}</span>
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -5,7 +5,8 @@ import { computed, nextTick, ref, useId } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import type { Byline } from '@/composables/useArticles'
|
||||
import { insertionTarget, moveItem } from '@/lib/articleMeta'
|
||||
import { moveItem } from '@/lib/arrayMove'
|
||||
import { insertionTarget } from '@/lib/articleMeta'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 64rem)'
|
||||
const DESKTOP_AVATAR = 22
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import BuilderStatusChip from '@/components/BuilderStatusChip.vue'
|
||||
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
|
||||
|
||||
defineProps<{
|
||||
status: BuilderStatus
|
||||
problems: number
|
||||
canSave: boolean
|
||||
canReveal: boolean
|
||||
canDiscard: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ save: []; discard: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 items-center gap-2 md:gap-3">
|
||||
<BuilderStatusChip :status="status" :problems="problems" />
|
||||
<span class="flex-1 md:hidden" />
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="!canDiscard"
|
||||
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
|
||||
@click="$emit('discard')"
|
||||
>
|
||||
{{ t('Discard') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
:disabled="!canSave && !canReveal"
|
||||
:aria-disabled="canReveal ? 'true' : undefined"
|
||||
:loading="saving"
|
||||
class="h-11 px-4 md:h-8"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ saving ? t('Saving…') : t('Save') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ kind: 'new' | 'required' | 'unique'; onSand?: boolean }>()
|
||||
|
||||
const badges = {
|
||||
new: {
|
||||
get label() {
|
||||
return t('New')
|
||||
},
|
||||
class: 'border border-(--rc-sand) bg-(--rc-sand) text-(--rc-on-sand)',
|
||||
},
|
||||
required: {
|
||||
get label() {
|
||||
return t('Required')
|
||||
},
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
},
|
||||
unique: {
|
||||
get label() {
|
||||
return t('Unique')
|
||||
},
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
},
|
||||
} as const
|
||||
|
||||
const OUTLINE_ON_SAND = 'border border-(--rc-on-sand) bg-transparent text-(--rc-on-sand)'
|
||||
|
||||
const badge = computed(() => badges[props.kind])
|
||||
const badgeClass = computed(() =>
|
||||
props.onSand && props.kind !== 'new' ? OUTLINE_ON_SAND : badge.value.class,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:class="badgeClass"
|
||||
:ui="{ base: 'h-5 shrink-0 px-1.5 py-0 text-xs font-medium whitespace-nowrap' }"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import FieldInspector from '@/components/FieldInspector.vue'
|
||||
import type { useSchemaBuilder } from '@/composables/useSchemaBuilder'
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
type Builder = ReturnType<typeof useSchemaBuilder>
|
||||
|
||||
const props = defineProps<{
|
||||
builder: Builder
|
||||
field: BuilderField
|
||||
errors: FieldErrors
|
||||
compact: boolean
|
||||
}>()
|
||||
|
||||
const uid = () => props.field.uid
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FieldInspector
|
||||
:field="field"
|
||||
:errors="errors"
|
||||
:disabled="builder.saving.value"
|
||||
:compact="compact"
|
||||
:focus-token="builder.labelFocusToken.value"
|
||||
@label="builder.setLabel(uid(), $event)"
|
||||
@key="builder.setFieldKey(uid(), $event)"
|
||||
@unlock="builder.unlockFieldKey(uid())"
|
||||
@reset="builder.resetFieldKey(uid())"
|
||||
@type="builder.setFieldType(uid(), $event)"
|
||||
@required="builder.patchField(uid(), { required: $event })"
|
||||
@unique="builder.patchField(uid(), { unique: $event })"
|
||||
@limit="(which, value) => builder.setLimit(uid(), which, value)"
|
||||
@default="builder.setDefault(uid(), $event)"
|
||||
@default-on="builder.patchField(uid(), { defaultOn: $event })"
|
||||
@add-choice="builder.addChoice(uid(), $event)"
|
||||
@remove-choice="builder.removeChoice(uid(), $event)"
|
||||
@rename-choice="(index, text) => builder.renameChoice(uid(), index, text)"
|
||||
@reorder-choices="builder.reorderChoices(uid(), $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
|
||||
|
||||
const props = defineProps<{ status: BuilderStatus; problems: number }>()
|
||||
|
||||
const text = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'saving':
|
||||
return t('Saving…')
|
||||
case 'offline':
|
||||
return t('Waiting for connection')
|
||||
case 'failed':
|
||||
return t('Save failed')
|
||||
case 'problems':
|
||||
return props.problems === 1
|
||||
? t('1 problem to fix')
|
||||
: t('{0} problems to fix', [props.problems])
|
||||
default:
|
||||
return t('Unsaved changes')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="status !== 'none'"
|
||||
role="status"
|
||||
data-testid="builder-chip"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 text-xs whitespace-nowrap"
|
||||
:class="{
|
||||
'text-warning': status === 'dirty',
|
||||
'text-error': status === 'failed' || status === 'problems',
|
||||
'text-muted': status === 'saving' || status === 'offline',
|
||||
}"
|
||||
>
|
||||
<UIcon
|
||||
v-if="status === 'saving'"
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-3.5 animate-spin"
|
||||
/>
|
||||
<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 name="i-lucide-circle-alert" class="size-3.5" />
|
||||
<span>{{ text }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import {
|
||||
BUILDER_LIMITS,
|
||||
PREVIEW_KEY_FALLBACK,
|
||||
keyDisplayMode,
|
||||
type KeyMode,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
keyValue: string
|
||||
keyMode: KeyMode
|
||||
nameError?: string
|
||||
keyError?: string
|
||||
disabled: boolean
|
||||
keyFocusToken: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:key': [value: string]
|
||||
unlock: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const slugMode = computed(() => keyDisplayMode(props.keyMode, props.keyValue))
|
||||
const hasError = computed(() => Boolean(props.nameError || props.keyError))
|
||||
|
||||
watch(
|
||||
() => props.keyFocusToken,
|
||||
async () => {
|
||||
await nextTick()
|
||||
root.value?.querySelector<HTMLInputElement>('input[name="key"]')?.focus()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="flex min-w-0 flex-col gap-3 transition-opacity md:flex-row md:gap-4"
|
||||
:class="[hasError ? 'md:items-start' : 'md:items-end', disabled ? 'opacity-60' : '']"
|
||||
>
|
||||
<div class="md:w-80">
|
||||
<DirectoryField :label="t('Name')" name="name" required :error="nameError">
|
||||
<UInput
|
||||
:model-value="name"
|
||||
:placeholder="t('For example, Events')"
|
||||
:maxlength="BUILDER_LIMITS.nameMax"
|
||||
:disabled="disabled"
|
||||
:color="nameError ? 'error' : undefined"
|
||||
required
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('update:name', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
<div :class="keyError ? 'md:w-90' : 'md:w-80'">
|
||||
<SlugField
|
||||
:label="t('Key')"
|
||||
name="key"
|
||||
:mode="slugMode"
|
||||
:value="keyValue"
|
||||
:error="keyError"
|
||||
:disabled="disabled"
|
||||
dense
|
||||
reset-in-label
|
||||
:help="
|
||||
t(
|
||||
'Used in URLs and the API. Lowercase letters, digits and hyphens. It can\'t change after you save.',
|
||||
)
|
||||
"
|
||||
:edit-label="t('Edit key')"
|
||||
:placeholder="PREVIEW_KEY_FALLBACK"
|
||||
:maxlength="BUILDER_LIMITS.typeKeyMax"
|
||||
@update:value="emit('update:key', $event)"
|
||||
@unlock="emit('unlock')"
|
||||
@reset="emit('reset')"
|
||||
/>
|
||||
</div>
|
||||
<span class="hidden flex-1 md:block" />
|
||||
<div v-if="$slots.actions" :class="hasError ? 'md:mt-5.5' : ''">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useKeyboardReorder } from '@/composables/useKeyboardReorder'
|
||||
import { BUILDER_LIMITS } from '@/lib/schemaBuilderModel'
|
||||
|
||||
const props = defineProps<{
|
||||
options: string[]
|
||||
rowErrors?: Record<number, string>
|
||||
listError?: string
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [text: string]
|
||||
remove: [index: number]
|
||||
rename: [index: number, text: string]
|
||||
reorder: [options: string[]]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const draft = ref('')
|
||||
const addInput = ref<{ inputRef?: HTMLInputElement | null } | null>(null)
|
||||
|
||||
interface ChoiceRow {
|
||||
index: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const reorder = useKeyboardReorder<ChoiceRow>({
|
||||
items: () => props.options.map((text, index) => ({ index, text })),
|
||||
name: (row) => row.text.trim() || t('Choice'),
|
||||
commit: (next) =>
|
||||
emit(
|
||||
'reorder',
|
||||
next.map((row) => row.text),
|
||||
),
|
||||
root: () => root.value,
|
||||
})
|
||||
|
||||
const rows = computed(() => reorder.shown.value)
|
||||
const full = computed(() => props.options.length >= BUILDER_LIMITS.choicesMax)
|
||||
const countText = computed(() => t('{0} of {1}', [props.options.length, BUILDER_LIMITS.choicesMax]))
|
||||
const canAdd = computed(() => !props.disabled && !full.value && draft.value.trim() !== '')
|
||||
|
||||
async function add() {
|
||||
if (!canAdd.value) {
|
||||
return
|
||||
}
|
||||
emit('add', draft.value)
|
||||
draft.value = ''
|
||||
await nextTick()
|
||||
addInput.value?.inputRef?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="flex flex-col gap-2" data-testid="choice-list">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-highlighted"
|
||||
>{{ t('Choices') }}<span aria-hidden="true"> *</span></span
|
||||
>
|
||||
<span class="font-mono text-xs text-muted" data-testid="choice-count">{{
|
||||
countText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="choice-live"
|
||||
:class="
|
||||
reorder.active.value
|
||||
? 'bg-inverted px-2.5 py-2 text-xs/4.5 text-inverted'
|
||||
: 'sr-only'
|
||||
"
|
||||
>
|
||||
{{ reorder.announcement.value }}
|
||||
</div>
|
||||
|
||||
<ul class="m-0 flex list-none flex-col gap-2 p-0">
|
||||
<li
|
||||
v-for="(row, index) in rows"
|
||||
:key="row.index"
|
||||
data-testid="choice-row"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
:class="reorder.grabbed.value === index ? 'outline-2 outline-primary' : ''"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:data-handle="index"
|
||||
:aria-label="t('Move {0}', [row.text.trim() || t('Choice')])"
|
||||
:aria-pressed="reorder.grabbed.value === index"
|
||||
:disabled="disabled"
|
||||
class="flex size-7 shrink-0 items-center justify-center text-muted focus-visible:outline-2 focus-visible:outline-primary max-md:size-11"
|
||||
@keydown="reorder.onKeydown($event, index)"
|
||||
>
|
||||
<UIcon name="i-lucide-grip-vertical" class="size-4" />
|
||||
</button>
|
||||
<UInput
|
||||
:model-value="row.text"
|
||||
:placeholder="t('Choice')"
|
||||
:disabled="disabled"
|
||||
:color="rowErrors?.[row.index] ? 'error' : undefined"
|
||||
:aria-label="t('Choice {0}', [index + 1])"
|
||||
:aria-invalid="rowErrors?.[row.index] ? 'true' : undefined"
|
||||
:aria-describedby="
|
||||
rowErrors?.[row.index] ? `choice-error-${row.index}` : undefined
|
||||
"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('rename', row.index, String($event))"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('Remove {0}', [row.text.trim() || t('Choice')])"
|
||||
class="size-7 justify-center max-md:size-11"
|
||||
@click="emit('remove', row.index)"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="rowErrors?.[row.index]"
|
||||
:id="`choice-error-${row.index}`"
|
||||
role="alert"
|
||||
class="ml-8 flex items-start gap-1.5 text-xs/4.5 text-error max-md:ml-12"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{{ rowErrors[row.index] }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="listError" role="alert" class="flex items-start gap-1.5 text-xs/4.5 text-error">
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{{ listError }}</span>
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2 pl-8 max-md:pl-12">
|
||||
<UInput
|
||||
ref="addInput"
|
||||
v-model="draft"
|
||||
:placeholder="t('Add a choice')"
|
||||
:aria-label="t('Add a choice')"
|
||||
:disabled="disabled || full"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 border-dashed text-lg md:h-8 md:text-sm' }"
|
||||
@keydown.enter.prevent="add"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="!canAdd"
|
||||
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
|
||||
@click="add"
|
||||
>
|
||||
{{ t('Add') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<p class="text-xs/4.5 text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,6 +16,7 @@ import ErrorView from '@/components/ErrorView.vue'
|
||||
import OfflineBanner from '@/components/OfflineBanner.vue'
|
||||
import UpdatePill from '@/components/UpdatePill.vue'
|
||||
import UserMenu from '@/components/UserMenu.vue'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -67,7 +68,9 @@ const update = useUpdateStore()
|
||||
const api = useApi()
|
||||
const contentTypes = useContentTypesStore()
|
||||
|
||||
const wantsBuiltin = computed(() => session.canManageContent && /^\/schema(\/|$)/.test(route.path))
|
||||
const wantsBuiltin = computed(
|
||||
() => session.canManageContent && route.meta.needsBuiltinTypes === true,
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => session.status === 'ready' && session.session?.verified === true, wantsBuiltin],
|
||||
@@ -115,7 +118,7 @@ const schemaItem = computed(() =>
|
||||
get label() {
|
||||
return t('Schema')
|
||||
},
|
||||
to: '/schema',
|
||||
to: SCHEMA_PATH,
|
||||
icon: 'i-lucide-shapes',
|
||||
},
|
||||
]
|
||||
@@ -128,7 +131,7 @@ const navItems = computed(() =>
|
||||
get label() {
|
||||
return t('Home')
|
||||
},
|
||||
to: '/home',
|
||||
to: HOME_PATH,
|
||||
icon: 'i-lucide-home',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,11 +5,12 @@ withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
optional?: boolean
|
||||
required?: boolean
|
||||
error?: string
|
||||
help?: string
|
||||
name?: string
|
||||
}>(),
|
||||
{ label: '', optional: false, error: '', help: '', name: undefined },
|
||||
{ label: '', optional: false, required: false, error: '', help: '', name: undefined },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -29,7 +30,7 @@ withDefaults(
|
||||
}"
|
||||
>
|
||||
<template v-if="label" #label>
|
||||
{{ label }}
|
||||
{{ label }}<span v-if="required" aria-hidden="true"> *</span>
|
||||
<span v-if="optional" class="font-normal text-muted"> {{ t('(optional)') }}</span>
|
||||
</template>
|
||||
<template v-if="$slots.hint" #hint>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import RetryAlert from '@/components/RetryAlert.vue'
|
||||
|
||||
defineProps<{ title: string; description?: string }>()
|
||||
|
||||
@@ -7,28 +8,9 @@ defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
<RetryAlert
|
||||
:title="title"
|
||||
:description="description ?? t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
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') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
@retry="$emit('retry')"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ first: boolean; last: boolean; disabled: boolean }>()
|
||||
|
||||
defineEmits<{ up: []; down: []; remove: [] }>()
|
||||
|
||||
const BUTTON_CLASS = 'size-7 justify-center max-md:size-11'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 gap-0.5">
|
||||
<UButton
|
||||
icon="i-lucide-chevron-up"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
data-action="up"
|
||||
:aria-label="t('Move up')"
|
||||
:disabled="disabled || first"
|
||||
:class="BUTTON_CLASS"
|
||||
@click.stop="$emit('up')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-chevron-down"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
data-action="down"
|
||||
:aria-label="t('Move down')"
|
||||
:disabled="disabled || last"
|
||||
:class="BUTTON_CLASS"
|
||||
@click.stop="$emit('down')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
data-action="remove"
|
||||
:aria-label="t('Remove')"
|
||||
:disabled="disabled"
|
||||
:class="BUTTON_CLASS"
|
||||
@click.stop="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import AppSwitch from '@/components/AppSwitch.vue'
|
||||
import ChoiceList from '@/components/ChoiceList.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import FieldTypePicker from '@/components/FieldTypePicker.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import {
|
||||
BUILDER_LIMITS,
|
||||
STARTER_KEY,
|
||||
keyDisplayMode,
|
||||
supportsLimits,
|
||||
supportsUnique,
|
||||
trimmedOptions,
|
||||
type BuilderField,
|
||||
type FieldType,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
const NO_DEFAULT = 'none'
|
||||
const CHOICE_PREFIX = 'choice:'
|
||||
|
||||
const props = defineProps<{
|
||||
field: BuilderField
|
||||
errors: FieldErrors
|
||||
disabled: boolean
|
||||
compact: boolean
|
||||
focusToken: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
label: [value: string]
|
||||
key: [value: string]
|
||||
unlock: []
|
||||
reset: []
|
||||
type: [type: FieldType]
|
||||
required: [value: boolean]
|
||||
unique: [value: boolean]
|
||||
limit: [which: 'min' | 'max', value: string]
|
||||
default: [value: string]
|
||||
defaultOn: [value: boolean]
|
||||
addChoice: [text: string]
|
||||
removeChoice: [index: number]
|
||||
renameChoice: [index: number, text: string]
|
||||
reorderChoices: [options: string[]]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const type = computed(() => props.field.type)
|
||||
const hasLimits = computed(() => supportsLimits(type.value))
|
||||
const canUnique = computed(() => supportsUnique(type.value))
|
||||
const keyMode = computed(() => keyDisplayMode(props.field.keyMode, props.field.key))
|
||||
const gridClass = computed(() => (props.compact ? 'grid-cols-1' : 'md:grid-cols-2'))
|
||||
|
||||
const limitLabels = computed(() => {
|
||||
if (type.value === 'number') {
|
||||
return [t('Lowest value'), t('Highest value')] as const
|
||||
}
|
||||
if (type.value === 'date') {
|
||||
return [t('Earliest date'), t('Latest date')] as const
|
||||
}
|
||||
return [t('Shortest'), t('Longest')] as const
|
||||
})
|
||||
const limitInput = computed(() => {
|
||||
if (type.value === 'date') {
|
||||
return 'date'
|
||||
}
|
||||
return 'number'
|
||||
})
|
||||
const isTextual = computed(() => type.value === 'text' || type.value === 'richtext')
|
||||
const limitSuffix = computed(() => (isTextual.value ? t('characters') : ''))
|
||||
const maxHelp = computed(() =>
|
||||
isTextual.value
|
||||
? t('Counted in characters. Entries show a counter.')
|
||||
: t('Entries outside the range are refused.'),
|
||||
)
|
||||
const noLimitsText = computed(() =>
|
||||
type.value === 'boolean'
|
||||
? t('Yes or no fields have no limits.')
|
||||
: t('Limits come from the choices below.'),
|
||||
)
|
||||
|
||||
const uniqueHelp = computed(() => {
|
||||
if (canUnique.value) {
|
||||
return t('Each entry needs a different value.')
|
||||
}
|
||||
return type.value === 'richtext'
|
||||
? t('Not available for rich text.')
|
||||
: t('Not available for yes or no fields.')
|
||||
})
|
||||
const defaultHelp = computed(() =>
|
||||
props.field.unique
|
||||
? t("A field with unique values can't have a default.")
|
||||
: t('Used when an entry is created and this field is left out.'),
|
||||
)
|
||||
const defaultOnHelp = computed(() =>
|
||||
props.field.defaultOn
|
||||
? t('New entries start with this switch on when the field is left out.')
|
||||
: t('New entries start with this switch off when the field is left out.'),
|
||||
)
|
||||
|
||||
const choiceItems = computed(() => [
|
||||
{ label: t('None'), value: NO_DEFAULT },
|
||||
...Array.from(new Set(trimmedOptions(props.field).filter((option) => option !== ''))).map(
|
||||
(option) => ({ label: option, value: `${CHOICE_PREFIX}${option}` }),
|
||||
),
|
||||
])
|
||||
const choiceValue = computed(() =>
|
||||
props.field.defaultValue === '' ? NO_DEFAULT : `${CHOICE_PREFIX}${props.field.defaultValue}`,
|
||||
)
|
||||
|
||||
function onChoice(value: unknown) {
|
||||
const text = String(value)
|
||||
emit('default', text === NO_DEFAULT ? '' : text.slice(CHOICE_PREFIX.length))
|
||||
}
|
||||
|
||||
const SWITCH_UI = {
|
||||
root: 'items-start gap-3',
|
||||
wrapper: 'ms-0',
|
||||
label: 'font-medium text-highlighted',
|
||||
description: 'mt-0 text-xs/4.5 text-pretty text-muted',
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.focusToken,
|
||||
async () => {
|
||||
await nextTick()
|
||||
root.value?.querySelector<HTMLInputElement>('input[name="label"]')?.focus()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="flex min-w-0 flex-col gap-3.5 transition-opacity max-md:gap-4"
|
||||
:class="disabled ? 'opacity-60' : ''"
|
||||
data-testid="field-inspector"
|
||||
>
|
||||
<div class="grid gap-3.5" :class="gridClass">
|
||||
<DirectoryField :label="t('Label')" name="label" required :error="errors.label">
|
||||
<UInput
|
||||
:model-value="field.label"
|
||||
:placeholder="t('For example, Title')"
|
||||
:disabled="disabled"
|
||||
:color="errors.label ? 'error' : undefined"
|
||||
required
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('label', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<SlugField
|
||||
:label="t('Key')"
|
||||
name="field-key"
|
||||
:mode="keyMode"
|
||||
:value="field.key"
|
||||
:error="errors.key"
|
||||
:disabled="disabled"
|
||||
dense
|
||||
:placeholder="STARTER_KEY"
|
||||
:maxlength="BUILDER_LIMITS.fieldKeyMax"
|
||||
:help="
|
||||
t(
|
||||
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
|
||||
)
|
||||
"
|
||||
:edit-label="t('Edit key')"
|
||||
:reset-label="t('Reset to match the label')"
|
||||
@update:value="emit('key', $event)"
|
||||
@unlock="emit('unlock')"
|
||||
@reset="emit('reset')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FieldTypePicker
|
||||
:model-value="type"
|
||||
:compact="compact"
|
||||
:disabled="disabled"
|
||||
@update:model-value="emit('type', $event)"
|
||||
/>
|
||||
|
||||
<div class="grid items-start gap-3.5" :class="gridClass">
|
||||
<div class="flex min-w-0 flex-col gap-3.5">
|
||||
<AppSwitch
|
||||
:model-value="field.required"
|
||||
:label="t('Required')"
|
||||
:description="t('An entry can\'t be saved with this field empty.')"
|
||||
:disabled="disabled"
|
||||
:ui="SWITCH_UI"
|
||||
@update:model-value="emit('required', Boolean($event))"
|
||||
/>
|
||||
<AppSwitch
|
||||
:model-value="canUnique && field.unique"
|
||||
:label="t('Unique values')"
|
||||
:description="uniqueHelp"
|
||||
:disabled="disabled || !canUnique"
|
||||
:ui="SWITCH_UI"
|
||||
@update:model-value="emit('unique', Boolean($event))"
|
||||
/>
|
||||
<AppSwitch
|
||||
v-if="type === 'boolean'"
|
||||
:model-value="field.defaultOn"
|
||||
:label="t('On by default')"
|
||||
:description="defaultOnHelp"
|
||||
:disabled="disabled"
|
||||
:ui="SWITCH_UI"
|
||||
@update:model-value="emit('defaultOn', Boolean($event))"
|
||||
/>
|
||||
<DirectoryField
|
||||
v-else
|
||||
:label="t('Default value')"
|
||||
name="default"
|
||||
:error="errors.default"
|
||||
:help="defaultHelp"
|
||||
>
|
||||
<USelect
|
||||
v-if="type === 'select'"
|
||||
:model-value="choiceValue"
|
||||
:items="choiceItems"
|
||||
:placeholder="t('None')"
|
||||
:disabled="disabled || field.unique"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="onChoice"
|
||||
/>
|
||||
<UTextarea
|
||||
v-else-if="type === 'richtext'"
|
||||
:model-value="field.defaultValue"
|
||||
:placeholder="t('No default')"
|
||||
:disabled="disabled || field.unique"
|
||||
:rows="3"
|
||||
class="w-full"
|
||||
@update:model-value="emit('default', String($event))"
|
||||
/>
|
||||
<UInput
|
||||
v-else
|
||||
:model-value="field.defaultValue"
|
||||
:type="type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'"
|
||||
:placeholder="t('No default')"
|
||||
:disabled="disabled || field.unique"
|
||||
:color="errors.default ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('default', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
|
||||
<div v-if="hasLimits" class="flex min-w-0 flex-col gap-3.5">
|
||||
<DirectoryField :label="limitLabels[0]" name="min" :error="errors.min">
|
||||
<UInput
|
||||
:model-value="field.min"
|
||||
:type="limitInput"
|
||||
:placeholder="t('No minimum')"
|
||||
:disabled="disabled"
|
||||
:color="errors.min ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{ base: 'no-spin h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('limit', 'min', String($event))"
|
||||
>
|
||||
<template v-if="limitSuffix" #trailing>
|
||||
<span class="text-xs text-muted">{{ limitSuffix }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</DirectoryField>
|
||||
<DirectoryField
|
||||
:label="limitLabels[1]"
|
||||
name="max"
|
||||
:error="errors.max"
|
||||
:help="errors.min ? '' : maxHelp"
|
||||
>
|
||||
<UInput
|
||||
:model-value="field.max"
|
||||
:type="limitInput"
|
||||
:placeholder="t('No maximum')"
|
||||
:disabled="disabled"
|
||||
:color="errors.max ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{ base: 'no-spin h-11 text-lg md:h-8 md:text-sm' }"
|
||||
@update:model-value="emit('limit', 'max', String($event))"
|
||||
>
|
||||
<template v-if="limitSuffix" #trailing>
|
||||
<span class="text-xs text-muted">{{ limitSuffix }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</DirectoryField>
|
||||
</div>
|
||||
<div v-else class="flex min-w-0 flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">{{ t('Limits') }}</span>
|
||||
<span class="text-xs/4.5 text-muted">{{ noLimitsText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChoiceList
|
||||
v-if="type === 'select'"
|
||||
:options="field.options"
|
||||
:row-errors="errors.choices"
|
||||
:list-error="errors.options"
|
||||
:disabled="disabled"
|
||||
@add="emit('addChoice', $event)"
|
||||
@remove="emit('removeChoice', $event)"
|
||||
@rename="(index, text) => emit('renameChoice', index, text)"
|
||||
@reorder="emit('reorderChoices', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import FieldRow from '@/components/FieldRow.vue'
|
||||
import { useKeyboardReorder } from '@/composables/useKeyboardReorder'
|
||||
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 emit = defineEmits<{
|
||||
select: [uid: number]
|
||||
add: []
|
||||
remove: [uid: number]
|
||||
move: [uid: number, delta: number]
|
||||
reorder: [order: number[]]
|
||||
}>()
|
||||
|
||||
const list = ref<HTMLElement | null>(null)
|
||||
const focusedUid = ref<number | null>(null)
|
||||
|
||||
const reorder = useKeyboardReorder<BuilderField>({
|
||||
items: () => props.fields,
|
||||
name: (field) => field.label.trim() || t('Untitled field'),
|
||||
commit: (next) =>
|
||||
emit(
|
||||
'reorder',
|
||||
next.map((field) => field.uid),
|
||||
),
|
||||
root: () => list.value,
|
||||
})
|
||||
|
||||
const rows = computed(() => reorder.shown.value)
|
||||
const tabbableUid = computed(
|
||||
() => focusedUid.value ?? props.selectedUid ?? props.fields[0]?.uid ?? null,
|
||||
)
|
||||
const full = computed(() => props.fields.length >= BUILDER_LIMITS.fieldsMax)
|
||||
const countText = computed(() => t('{0} of {1}', [props.fields.length, BUILDER_LIMITS.fieldsMax]))
|
||||
|
||||
async function onMove(uid: number, delta: number) {
|
||||
const action = delta < 0 ? 'up' : 'down'
|
||||
emit('move', uid, delta)
|
||||
await nextTick()
|
||||
const row = list.value?.querySelector(`li[data-uid="${uid}"]`)
|
||||
const same = row?.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)
|
||||
const other = row?.querySelector<HTMLButtonElement>(
|
||||
`[data-action="${action === 'up' ? 'down' : 'up'}"]`,
|
||||
)
|
||||
;(same && !same.disabled ? same : other)?.focus()
|
||||
}
|
||||
|
||||
function onRowKeydown(event: KeyboardEvent) {
|
||||
if (reorder.active.value || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) {
|
||||
return
|
||||
}
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.matches('[data-field-row]')) {
|
||||
return
|
||||
}
|
||||
const buttons = Array.from(list.value?.querySelectorAll<HTMLElement>('[data-field-row]') ?? [])
|
||||
const next = buttons[buttons.indexOf(target) + (event.key === 'ArrowUp' ? -1 : 1)]
|
||||
if (next) {
|
||||
event.preventDefault()
|
||||
next.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function onFocusin(event: FocusEvent) {
|
||||
const uid = (event.target as HTMLElement).closest('li[data-uid]')?.getAttribute('data-uid')
|
||||
focusedUid.value = uid ? Number(uid) : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="flex h-7 items-center justify-between">
|
||||
<h2 class="font-semibold text-highlighted">{{ t('Fields') }}</h2>
|
||||
<span class="font-mono text-xs text-muted" data-testid="field-count">{{
|
||||
countText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="reorder-live"
|
||||
:class="
|
||||
reorder.active.value
|
||||
? 'bg-inverted px-2.5 py-2 text-xs/4.5 text-inverted'
|
||||
: 'sr-only'
|
||||
"
|
||||
>
|
||||
{{ reorder.announcement.value }}
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="rows.length"
|
||||
ref="list"
|
||||
class="m-0 flex list-none flex-col gap-2 p-0"
|
||||
@keydown="onRowKeydown"
|
||||
@focusin="onFocusin"
|
||||
>
|
||||
<FieldRow
|
||||
v-for="(field, index) in rows"
|
||||
:key="field.uid"
|
||||
:field="field"
|
||||
:index="index"
|
||||
:total="rows.length"
|
||||
:selected="!mobile && field.uid === selectedUid"
|
||||
:invalid="errors.has(field.uid)"
|
||||
:grabbed="reorder.grabbed.value === index"
|
||||
:disabled="disabled"
|
||||
:tabbable="field.uid === tabbableUid"
|
||||
:show-actions="!mobile"
|
||||
@select="emit('select', field.uid)"
|
||||
@up="onMove(field.uid, -1)"
|
||||
@down="onMove(field.uid, 1)"
|
||||
@remove="emit('remove', field.uid)"
|
||||
@grip-keydown="reorder.onKeydown($event, index)"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
v-else
|
||||
ref="list"
|
||||
data-testid="field-empty"
|
||||
class="flex flex-col items-center gap-2 border border-dashed px-4 py-8 text-center"
|
||||
:class="listError ? 'border-error' : 'border-accented'"
|
||||
>
|
||||
<UIcon name="i-lucide-rows-3" class="size-5 text-muted" />
|
||||
<span class="font-semibold">{{ t('No fields yet') }}</span>
|
||||
<span :class="listError ? 'text-error' : 'text-muted'">{{
|
||||
listError || t('Add the first field.')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="disabled || full"
|
||||
class="h-11 w-full justify-center border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
|
||||
@click="emit('add')"
|
||||
>
|
||||
{{ t('Add field') }}
|
||||
</UButton>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||
import FieldActions from '@/components/FieldActions.vue'
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { fieldIcon } from '@/lib/schemaFields'
|
||||
|
||||
const props = defineProps<{
|
||||
field: BuilderField
|
||||
index: number
|
||||
total: number
|
||||
selected: boolean
|
||||
invalid: boolean
|
||||
grabbed: boolean
|
||||
disabled: boolean
|
||||
tabbable: boolean
|
||||
showActions: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: []
|
||||
up: []
|
||||
down: []
|
||||
remove: []
|
||||
'grip-keydown': [event: KeyboardEvent]
|
||||
}>()
|
||||
|
||||
const name = computed(() => props.field.label.trim() || t('Untitled field'))
|
||||
const key = computed(() => props.field.key || '–')
|
||||
|
||||
const rowClass = computed(() => [
|
||||
props.invalid ? 'border-error' : props.grabbed ? 'border-primary' : 'border-default',
|
||||
props.grabbed ? 'shadow-(--shadow-popover) outline-2 -outline-offset-1 outline-primary' : '',
|
||||
props.selected ? 'bg-(--rc-sand) text-(--rc-on-sand)' : 'bg-default text-highlighted',
|
||||
props.disabled ? 'opacity-60' : '',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
data-testid="field-row"
|
||||
:data-uid="field.uid"
|
||||
class="flex min-h-14 items-center gap-0.5 border py-1.5 pr-2 pl-1 max-md:min-h-16 max-md:gap-1"
|
||||
:class="rowClass"
|
||||
@click="$emit('select')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-grip
|
||||
:data-handle="index"
|
||||
:aria-label="t('Move {0}', [name])"
|
||||
:aria-pressed="grabbed"
|
||||
:disabled="disabled"
|
||||
:tabindex="tabbable ? 0 : -1"
|
||||
class="flex h-8 w-6 shrink-0 items-center justify-center focus-visible:outline-2 focus-visible:outline-primary max-md:h-11 max-md:w-9"
|
||||
:class="selected ? 'text-(--rc-on-sand)' : 'text-muted'"
|
||||
@keydown="$emit('grip-keydown', $event)"
|
||||
@click.stop
|
||||
>
|
||||
<UIcon name="i-lucide-grip-vertical" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-field-row
|
||||
:data-uid="field.uid"
|
||||
: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"
|
||||
@click.stop="$emit('select')"
|
||||
>
|
||||
<span
|
||||
class="flex size-7 shrink-0 items-center justify-center text-highlighted"
|
||||
:class="selected ? 'bg-default' : 'bg-elevated'"
|
||||
>
|
||||
<UIcon :name="fieldIcon(field.type)" class="size-4" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate font-medium" data-testid="field-row-label">{{ name }}</span>
|
||||
<span
|
||||
class="truncate font-mono text-xs"
|
||||
:class="selected ? 'text-(--rc-on-sand)' : 'text-muted'"
|
||||
data-testid="field-row-key"
|
||||
>{{ key }}</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<span class="flex shrink-0 items-center gap-1" data-testid="field-row-badges">
|
||||
<BuilderBadge kind="new" />
|
||||
<BuilderBadge v-if="field.required" kind="required" :on-sand="selected" />
|
||||
<BuilderBadge v-if="field.unique" kind="unique" :on-sand="selected" />
|
||||
<UIcon
|
||||
v-if="invalid"
|
||||
name="i-lucide-circle-alert"
|
||||
data-testid="field-row-alert"
|
||||
class="size-4 text-error"
|
||||
/>
|
||||
</span>
|
||||
<FieldActions
|
||||
v-if="selected && showActions"
|
||||
:first="index === 0"
|
||||
:last="index === total - 1"
|
||||
:disabled="disabled"
|
||||
@up="$emit('up')"
|
||||
@down="$emit('down')"
|
||||
@remove="$emit('remove')"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import FieldActions from '@/components/FieldActions.vue'
|
||||
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||
|
||||
defineProps<{ title: string; first: boolean; last: boolean; disabled: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ up: []; down: []; remove: []; closed: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const titleElement = ref<HTMLElement | null>(null)
|
||||
|
||||
function focusTitle(event: Event) {
|
||||
event.preventDefault()
|
||||
titleElement.value?.focus()
|
||||
}
|
||||
|
||||
function returnFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
emit('closed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="title"
|
||||
:description="title"
|
||||
:content="{ onOpenAutoFocus: focusTitle, onCloseAutoFocus: returnFocus }"
|
||||
:ui="BOTTOM_SHEET_UI"
|
||||
>
|
||||
<template #title>
|
||||
<span
|
||||
ref="titleElement"
|
||||
tabindex="-1"
|
||||
data-testid="field-sheet-title"
|
||||
class="outline-none"
|
||||
>{{ title }}</span
|
||||
>
|
||||
</template>
|
||||
<template #body>
|
||||
<FieldActions
|
||||
:first="first"
|
||||
:last="last"
|
||||
:disabled="disabled"
|
||||
class="self-end"
|
||||
@up="emit('up')"
|
||||
@down="emit('down')"
|
||||
@remove="emit('remove')"
|
||||
/>
|
||||
<slot />
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton class="h-11 flex-1 justify-center" @click="open = false">
|
||||
{{ t('Done') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
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 }>()
|
||||
|
||||
defineEmits<{ 'update:modelValue': [type: FieldType] }>()
|
||||
|
||||
function describe(type: FieldType) {
|
||||
switch (type) {
|
||||
case 'richtext':
|
||||
return t('Long text with formatting, written in Markdown')
|
||||
case 'number':
|
||||
return t('Whole or decimal numbers')
|
||||
case 'boolean':
|
||||
return t('An on or off switch')
|
||||
case 'date':
|
||||
return t('A calendar day, without a time')
|
||||
case 'select':
|
||||
return t('One option from a list you define')
|
||||
default:
|
||||
return t('Short text, like a title or a name')
|
||||
}
|
||||
}
|
||||
|
||||
const items = computed(() =>
|
||||
FIELD_TYPES.map((type) => ({ value: type, label: fieldTypeLabel(type) })),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<URadioGroup
|
||||
:model-value="modelValue"
|
||||
:items="items"
|
||||
:disabled="disabled"
|
||||
:legend="t('Type')"
|
||||
variant="card"
|
||||
indicator="hidden"
|
||||
orientation="horizontal"
|
||||
:ui="{
|
||||
root: 'w-full',
|
||||
legend: 'mb-1.5 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',
|
||||
label: 'w-full text-base font-medium text-inherit',
|
||||
}"
|
||||
@update:model-value="$emit('update:modelValue', $event as FieldType)"
|
||||
>
|
||||
<template #label="{ item }">
|
||||
<span class="flex items-start gap-2.5 text-left">
|
||||
<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>
|
||||
<span
|
||||
class="text-xs/4 font-normal text-pretty text-muted group-has-data-[state=checked]:text-(--rc-on-sand)"
|
||||
>{{ describe(item.value) }}</span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</URadioGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ title: string; description: string }>()
|
||||
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
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') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
</template>
|
||||
@@ -16,6 +16,9 @@ const props = withDefaults(
|
||||
help?: string
|
||||
manualHelp?: string
|
||||
locked?: boolean
|
||||
disabled?: boolean
|
||||
dense?: boolean
|
||||
resetInLabel?: boolean
|
||||
lockedHelp?: string
|
||||
editLabel?: string
|
||||
resetLabel?: string
|
||||
@@ -30,6 +33,9 @@ const props = withDefaults(
|
||||
help: '',
|
||||
manualHelp: '',
|
||||
locked: false,
|
||||
disabled: false,
|
||||
dense: false,
|
||||
resetInLabel: false,
|
||||
lockedHelp: '',
|
||||
editLabel: '',
|
||||
resetLabel: '',
|
||||
@@ -50,7 +56,7 @@ const emit = defineEmits<{
|
||||
const readonly = computed(() => props.locked || props.mode === 'empty' || props.mode === 'auto')
|
||||
const showAuto = computed(() => props.mode === 'auto' && !props.locked)
|
||||
const inputUi = computed(() => ({
|
||||
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5 pe-28 md:pe-24' : ''}`,
|
||||
base: `h-11 font-mono text-base text-ellipsis ${props.dense ? 'md:h-8' : 'md:h-10'} md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5 pe-28 md:pe-24' : ''}`,
|
||||
}))
|
||||
const showReset = computed(
|
||||
() => !props.locked && (props.mode === 'manual' || (props.mode === 'saved' && props.error)),
|
||||
@@ -72,9 +78,23 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<DirectoryField :label="label" :name="name" :error="error" :help="shownHelp">
|
||||
<template v-if="resetInLabel && showReset" #hint>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
:disabled="disabled"
|
||||
class="gap-1 p-0 font-sans text-xs font-medium text-highlighted underline underline-offset-3 max-md:hidden"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
{{ resetText }}
|
||||
</UButton>
|
||||
</template>
|
||||
<UInput
|
||||
:model-value="value"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholderText"
|
||||
:maxlength="maxlength"
|
||||
:data-autofocus="autofocus ? '' : undefined"
|
||||
@@ -103,6 +123,7 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="pencilLabel"
|
||||
:disabled="disabled"
|
||||
class="size-7 justify-center text-highlighted max-md:size-11"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('unlock')"
|
||||
@@ -127,8 +148,10 @@ const placeholderText = computed(() => props.placeholder || t('Generated from th
|
||||
v-if="showReset"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
:disabled="disabled"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
class="min-h-11 gap-1 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||
:class="resetInLabel ? 'md:hidden' : ''"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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 ApiPreview from '../ApiPreview.vue'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
const writeText = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
writeText.mockReset().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
})
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
const JSON_TEXT = '{\n "id": 42\n}'
|
||||
|
||||
const toggle = () => document.body.querySelector<HTMLElement>('[data-testid="api-preview-toggle"]')!
|
||||
|
||||
describe('ApiPreview', () => {
|
||||
it('is collapsed, then opens and closes with the toggle', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.body.querySelector('[data-testid="api-json"]')).toBeNull()
|
||||
|
||||
toggle().click()
|
||||
await flushPromises()
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('true')
|
||||
expect(document.body.querySelector('[data-testid="api-json"]')!.textContent).toBe(JSON_TEXT)
|
||||
expect(holder.wrapper!.findComponent(ApiPreview).emitted('update:open')![0]).toEqual([true])
|
||||
})
|
||||
|
||||
it('shows the first route in the bar and falls back to events', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: '', json: JSON_TEXT })
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="api-preview"]'))).toContain(
|
||||
'GET /v1/content-type/events/entry',
|
||||
)
|
||||
})
|
||||
|
||||
it('copies the JSON and reads Copied', async () => {
|
||||
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
|
||||
|
||||
document.body.querySelector<HTMLElement>('[data-testid="api-copy"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(JSON_TEXT)
|
||||
expect(text(document.body.querySelector('[data-testid="api-copy"]'))).toBe('Copied')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
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 ChoiceList from '../ChoiceList.vue'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return { options: ['Talk', 'Workshop', 'Meetup'], disabled: false, ...extra }
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(ChoiceList).emitted(name) ?? []
|
||||
}
|
||||
|
||||
const addInput = () =>
|
||||
document.body.querySelector<HTMLInputElement>('input[aria-label="Add a choice"]')!
|
||||
|
||||
async function type(input: HTMLInputElement, value: string) {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function addButton() {
|
||||
return [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(candidate) => text(candidate) === 'Add',
|
||||
)!
|
||||
}
|
||||
|
||||
describe('ChoiceList', () => {
|
||||
it('shows the count, the rows and the help', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="choice-count"]'))).toBe('3 of 50')
|
||||
expect(document.body.querySelectorAll('[data-testid="choice-row"]')).toHaveLength(3)
|
||||
expect(text(document.body)).toContain('Add 1 to 50 choices.')
|
||||
})
|
||||
|
||||
it('emits add from the button and from Enter, and ignores a blank draft', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
expect(addButton().disabled).toBe(true)
|
||||
|
||||
await type(addInput(), 'Panel')
|
||||
addButton().click()
|
||||
await flushPromises()
|
||||
await type(addInput(), 'Social')
|
||||
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await flushPromises()
|
||||
await type(addInput(), ' ')
|
||||
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('add')).toEqual([['Panel'], ['Social']])
|
||||
expect(addInput().value).toBe(' ')
|
||||
})
|
||||
|
||||
it('emits rename and remove for a row', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
|
||||
await type(
|
||||
document.body.querySelectorAll<HTMLInputElement>(
|
||||
'[data-testid="choice-row"] input',
|
||||
)[1]!,
|
||||
'Seminar',
|
||||
)
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Remove Talk"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('rename')).toEqual([[1, 'Seminar']])
|
||||
expect(emitted('remove')).toEqual([[0]])
|
||||
})
|
||||
|
||||
it('shows a row error with an alert icon and ties it to the input', async () => {
|
||||
await mountInApp(ChoiceList, props({ rowErrors: { 1: 'Two choices are the same.' } }))
|
||||
|
||||
const row = document.body.querySelectorAll('[data-testid="choice-row"]')[1]!
|
||||
expect(text(row.querySelector('[role="alert"]'))).toBe('Two choices are the same.')
|
||||
expect(row.querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
const input = row.querySelector('input')!
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(input.getAttribute('aria-describedby')).toBe('choice-error-1')
|
||||
})
|
||||
|
||||
it('shows a list error and disables Add at 50 choices', async () => {
|
||||
await mountInApp(
|
||||
ChoiceList,
|
||||
props({ options: Array.from({ length: 50 }, (_, index) => `C${index}`) }),
|
||||
)
|
||||
await type(addInput(), 'more')
|
||||
|
||||
expect(addButton().disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the list error', async () => {
|
||||
await mountInApp(ChoiceList, props({ options: [], listError: 'Add at least one choice.' }))
|
||||
|
||||
expect(text(document.body.querySelector('p[role="alert"]'))).toBe(
|
||||
'Add at least one choice.',
|
||||
)
|
||||
})
|
||||
|
||||
it('emits the new order on drop only', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
const grip = () => document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
const key = async (name: string) => {
|
||||
grip().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
await key(' ')
|
||||
await key('ArrowDown')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
await key(' ')
|
||||
|
||||
expect(emitted('reorder')).toEqual([[['Workshop', 'Talk', 'Meetup']]])
|
||||
})
|
||||
|
||||
it('keeps each error with its choice while a row is being moved', async () => {
|
||||
await mountInApp(
|
||||
ChoiceList,
|
||||
props({
|
||||
options: ['Talk', 'Talk', 'Meetup'],
|
||||
rowErrors: { 1: 'Two choices are the same.' },
|
||||
}),
|
||||
)
|
||||
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const rows = [...document.body.querySelectorAll('[data-testid="choice-row"]')]
|
||||
expect(rows.map((row) => text(row.querySelector('[role="alert"]')))).toEqual([
|
||||
'Two choices are the same.',
|
||||
'',
|
||||
'',
|
||||
])
|
||||
})
|
||||
|
||||
it('emits the data index for rename and remove while a row is being moved', async () => {
|
||||
await mountInApp(ChoiceList, props())
|
||||
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
grip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const removers = [...document.body.querySelectorAll<HTMLButtonElement>('button')].filter(
|
||||
(candidate) => candidate.getAttribute('aria-label')?.startsWith('Remove '),
|
||||
)
|
||||
removers[0]!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('remove')).toEqual([[1]])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
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 FieldInspector from '../FieldInspector.vue'
|
||||
import { createField } from '@/lib/schemaBuilderModel'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
field: createField(1, { label: 'Title', key: 'title' }),
|
||||
errors: {},
|
||||
disabled: false,
|
||||
compact: false,
|
||||
focusToken: 0,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(FieldInspector).emitted(name) ?? []
|
||||
}
|
||||
|
||||
async function type(selector: string, value: string) {
|
||||
const input = document.body.querySelector<HTMLInputElement>(selector)!
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('FieldInspector', () => {
|
||||
it('emits label, key, type, limit and default edits', async () => {
|
||||
await mountInApp(
|
||||
FieldInspector,
|
||||
props({ field: createField(1, { label: 'T', key: 't', keyMode: 'manual' }) }),
|
||||
)
|
||||
|
||||
await type('input[name="label"]', 'Headline')
|
||||
await type('input[name="field-key"]', 'headline')
|
||||
await type('input[name="max"]', '80')
|
||||
await type('input[name="default"]', 'Draft')
|
||||
document.body.querySelector<HTMLElement>('[role="radio"][value="number"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('label')).toEqual([['Headline']])
|
||||
expect(emitted('key')).toEqual([['headline']])
|
||||
expect(emitted('limit')).toEqual([['max', '80']])
|
||||
expect(emitted('default')).toEqual([['Draft']])
|
||||
expect(emitted('type')).toEqual([['number']])
|
||||
})
|
||||
|
||||
it('emits the Required switch', async () => {
|
||||
await mountInApp(FieldInspector, props())
|
||||
|
||||
const label = [...document.body.querySelectorAll('label')].find((node) =>
|
||||
text(node).startsWith('Required'),
|
||||
)!
|
||||
document.getElementById(label.getAttribute('for')!)!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('required')).toEqual([[true]])
|
||||
})
|
||||
|
||||
it('dims and disables every control while disabled', async () => {
|
||||
await mountInApp(FieldInspector, props({ disabled: true }))
|
||||
|
||||
const root = document.body.querySelector('[data-testid="field-inspector"]')!
|
||||
expect(root.className).toContain('opacity-60')
|
||||
expect(
|
||||
[...root.querySelectorAll<HTMLInputElement>('input')].every((input) => input.disabled),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the errors under their controls', async () => {
|
||||
await mountInApp(
|
||||
FieldInspector,
|
||||
props({
|
||||
field: createField(1, { type: 'number', min: '6', max: '5', defaultValue: '0' }),
|
||||
errors: {
|
||||
label: 'Give the field a label.',
|
||||
key: 'Bad key.',
|
||||
min: 'Order.',
|
||||
default: 'Outside.',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const alerts = [...document.body.querySelectorAll('[role="alert"]')].map(text)
|
||||
expect(alerts).toEqual(['Give the field a label.', 'Bad key.', 'Outside.', 'Order.'])
|
||||
})
|
||||
|
||||
it('lays the controls out in one column when compact', async () => {
|
||||
await mountInApp(FieldInspector, props({ compact: true }))
|
||||
|
||||
expect(document.body.querySelector('.grid-cols-1')).not.toBeNull()
|
||||
expect(document.body.querySelector('.grid-cols-2')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
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 FieldList from '../FieldList.vue'
|
||||
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { mountInApp, text, unmountApp, holder } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
const FIELDS: BuilderField[] = [
|
||||
createField(1, { label: 'Title', key: 'title' }),
|
||||
createField(2, { label: 'Starts on', key: 'starts_on', type: 'date' }),
|
||||
createField(3, { label: 'Ends on', key: 'ends_on', type: 'date' }),
|
||||
]
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
fields: FIELDS,
|
||||
selectedUid: 2,
|
||||
errors: new Map(),
|
||||
disabled: false,
|
||||
mobile: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return holder.wrapper!.findComponent(FieldList).emitted(name) ?? []
|
||||
}
|
||||
|
||||
function grip(label: string) {
|
||||
return document.body.querySelector<HTMLElement>(`[aria-label="Move ${label}"]`)!
|
||||
}
|
||||
|
||||
async function press(target: Element, key: string) {
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function labels() {
|
||||
return [...document.body.querySelectorAll('[data-testid="field-row-label"]')].map(text)
|
||||
}
|
||||
|
||||
describe('FieldList', () => {
|
||||
it('lists the rows and counts them against 50', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
|
||||
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('3 of 50')
|
||||
})
|
||||
|
||||
it('emits select, add, move and remove', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
document.body.querySelector<HTMLElement>('[data-field-row][data-uid="3"]')!.click()
|
||||
;(document.body.querySelector('[data-action="up"]') as HTMLElement).click()
|
||||
;(document.body.querySelector('[data-action="remove"]') as HTMLElement).click()
|
||||
const add = [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'Add field',
|
||||
)!
|
||||
add.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted('select')).toEqual([[3]])
|
||||
expect(emitted('move')).toEqual([[2, -1]])
|
||||
expect(emitted('remove')).toEqual([[2]])
|
||||
expect(emitted('add')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('disables Add field at 50 rows and when disabled', async () => {
|
||||
const many = Array.from({ length: 50 }, (_, index) =>
|
||||
createField(index + 1, { label: 'F' }),
|
||||
)
|
||||
await mountInApp(FieldList, props({ fields: many, selectedUid: 1 }))
|
||||
|
||||
const add = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(candidate) => text(candidate) === 'Add field',
|
||||
)!
|
||||
expect(add.disabled).toBe(true)
|
||||
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('50 of 50')
|
||||
})
|
||||
|
||||
it('shows the empty state with the error message in the error colour', async () => {
|
||||
await mountInApp(FieldList, props({ fields: [], selectedUid: null, listError: 'Nope.' }))
|
||||
|
||||
const empty = document.body.querySelector('[data-testid="field-empty"]')!
|
||||
expect(text(empty)).toBe('No fields yet Nope.')
|
||||
expect(empty.querySelector('.text-error')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('emits the new order once, on drop, and nothing on cancel', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
await press(grip('Starts on'), 'Escape')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
expect(emitted('reorder')).toHaveLength(0)
|
||||
await press(grip('Starts on'), ' ')
|
||||
|
||||
expect(emitted('reorder')).toEqual([[[2, 1, 3]]])
|
||||
})
|
||||
|
||||
it('moves the focus between rows with the arrow keys', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
const rows = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')]
|
||||
rows[0]!.focus()
|
||||
|
||||
await press(rows[0]!, 'ArrowDown')
|
||||
expect(document.activeElement).toBe(rows[1])
|
||||
await press(rows[1]!, 'ArrowUp')
|
||||
expect(document.activeElement).toBe(rows[0])
|
||||
})
|
||||
|
||||
it('keeps one roving tab stop for the rows', async () => {
|
||||
await mountInApp(FieldList, props())
|
||||
|
||||
const stops = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')].filter(
|
||||
(row) => row.tabIndex === 0,
|
||||
)
|
||||
expect(stops.map((row) => row.getAttribute('data-uid'))).toEqual(['2'])
|
||||
})
|
||||
|
||||
it('hides the row actions on mobile', async () => {
|
||||
await mountInApp(FieldList, props({ mobile: true }))
|
||||
|
||||
expect(document.body.querySelector('[data-action]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -97,3 +97,61 @@ describe('listBuiltinTypes', () => {
|
||||
expect(failure.status).toBe(status)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createContentType', () => {
|
||||
const payload = {
|
||||
key: 'recipes',
|
||||
name: 'Recipes',
|
||||
fields: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('posts the JSON body to /v1/content-type and returns the created type', async () => {
|
||||
answer(201, recipes)
|
||||
|
||||
await expect(useContentTypes().createContentType(payload)).resolves.toEqual(recipes)
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!
|
||||
expect(url).toBe('/v1/content-type')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
expect(JSON.parse(init.body)).toEqual(payload)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[400, 'invalid_content_type_key'],
|
||||
[409, 'content_type_key_taken'],
|
||||
[422, 'invalid_field'],
|
||||
[403, 'Only owners and admins can do this'],
|
||||
[500, 'boom'],
|
||||
])('rejects %i with its plain-text body', async (status, body) => {
|
||||
answer(status, body)
|
||||
|
||||
const failure = await useContentTypes()
|
||||
.createContentType(payload)
|
||||
.catch((error) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(ApiError)
|
||||
expect(failure.status).toBe(status)
|
||||
expect(failure.body).toBe(body)
|
||||
})
|
||||
|
||||
it('lets a network failure through', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
|
||||
await expect(useContentTypes().createContentType(payload)).rejects.toBeInstanceOf(
|
||||
NetworkError,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useContentTypes } from '@/composables/useContentTypes'
|
||||
import { useSchemaGate, type SchemaGateScope } from '@/composables/useSchemaGate'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
vi.mock('@/composables/useContentTypes', () => ({ useContentTypes: vi.fn() }))
|
||||
|
||||
const listContentTypes = vi.fn()
|
||||
const listBuiltinTypes = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
listContentTypes.mockReset()
|
||||
listBuiltinTypes.mockReset()
|
||||
vi.mocked(useContentTypes).mockReturnValue({ listContentTypes, listBuiltinTypes })
|
||||
const session = useSessionStore()
|
||||
session.$patch({ status: 'ready', session: { role: 'owner' } })
|
||||
})
|
||||
|
||||
async function gate(scope: SchemaGateScope) {
|
||||
const store = useContentTypesStore()
|
||||
await store.load({ builtin: true })
|
||||
return useSchemaGate(scope)
|
||||
}
|
||||
|
||||
describe('schema gate forbidden', () => {
|
||||
it.each([
|
||||
['custom', true],
|
||||
['builtin', false],
|
||||
['all', true],
|
||||
] as const)('scope %s with custom 403 is %s', async (scope, expected) => {
|
||||
listContentTypes.mockRejectedValue(new ApiError(403))
|
||||
listBuiltinTypes.mockResolvedValue([])
|
||||
|
||||
expect((await gate(scope)).forbidden.value).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['custom', false],
|
||||
['builtin', true],
|
||||
['all', true],
|
||||
] as const)('scope %s with builtin 403 is %s', async (scope, expected) => {
|
||||
listContentTypes.mockResolvedValue([])
|
||||
listBuiltinTypes.mockRejectedValue(new ApiError(403))
|
||||
|
||||
expect((await gate(scope)).forbidden.value).toBe(expected)
|
||||
})
|
||||
|
||||
it('is forbidden for a session that cannot manage content, whatever the scope', async () => {
|
||||
listContentTypes.mockResolvedValue([])
|
||||
listBuiltinTypes.mockResolvedValue([])
|
||||
useSessionStore().$patch({ session: { role: 'user' } })
|
||||
|
||||
expect((await gate('custom')).forbidden.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { jsonInit } from '@/lib/directoryRequest'
|
||||
import type { ContentTypePayload } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
export interface BuiltinField {
|
||||
key: string
|
||||
@@ -48,5 +50,12 @@ export function useContentTypes() {
|
||||
return requestList<BuiltinType>('/v1/builtin-type')
|
||||
}
|
||||
|
||||
return { listContentTypes, listBuiltinTypes }
|
||||
function createContentType(payload: ContentTypePayload): Promise<ContentType> {
|
||||
return request<ContentType>('/v1/content-type', {
|
||||
...jsonInit('POST', payload),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
return { listContentTypes, listBuiltinTypes, createContentType }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { computed } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
export function useJsonCopy() {
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
const label = computed(() => (copied.value ? t('Copied') : t('Copy JSON')))
|
||||
const icon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
|
||||
return { copied, copy, label, icon }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { computed, nextTick, ref, shallowRef } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { moveItem } from '@/lib/arrayMove'
|
||||
|
||||
interface KeyboardReorderOptions<T> {
|
||||
items: () => readonly T[]
|
||||
name: (item: T) => string
|
||||
commit: (next: T[]) => void
|
||||
root: () => HTMLElement | null
|
||||
}
|
||||
|
||||
export function useKeyboardReorder<T>(options: KeyboardReorderOptions<T>) {
|
||||
const working = shallowRef<T[] | null>(null)
|
||||
const grabbed = ref<number | null>(null)
|
||||
const origin = ref(0)
|
||||
const announcement = ref('')
|
||||
|
||||
const shown = computed<readonly T[]>(() => working.value ?? options.items())
|
||||
const active = computed(() => grabbed.value !== null)
|
||||
|
||||
function focusHandle(index: number) {
|
||||
nextTick(() =>
|
||||
options.root()?.querySelector<HTMLElement>(`[data-handle="${index}"]`)?.focus(),
|
||||
)
|
||||
}
|
||||
|
||||
function movingText(item: T, position: number, total: number) {
|
||||
return t(
|
||||
'Moving {0}. Position {1} of {2}. Press Up or Down to move, Space to drop, Esc to cancel.',
|
||||
[options.name(item), position, total],
|
||||
)
|
||||
}
|
||||
|
||||
function grab(index: number) {
|
||||
const items = [...options.items()]
|
||||
const item = items[index]
|
||||
if (item === undefined) {
|
||||
return
|
||||
}
|
||||
working.value = items
|
||||
grabbed.value = index
|
||||
origin.value = index
|
||||
announcement.value = movingText(item, index + 1, items.length)
|
||||
}
|
||||
|
||||
function move(delta: number) {
|
||||
const current = grabbed.value
|
||||
const items = working.value
|
||||
if (current === null || !items) {
|
||||
return
|
||||
}
|
||||
const target = current + delta
|
||||
if (target < 0 || target >= items.length) {
|
||||
return
|
||||
}
|
||||
working.value = moveItem(items, current, target)
|
||||
grabbed.value = target
|
||||
announcement.value = movingText(items[current]!, target + 1, items.length)
|
||||
focusHandle(target)
|
||||
}
|
||||
|
||||
function release() {
|
||||
working.value = null
|
||||
grabbed.value = null
|
||||
}
|
||||
|
||||
function drop() {
|
||||
const current = grabbed.value
|
||||
const items = working.value
|
||||
if (current === null || !items) {
|
||||
return
|
||||
}
|
||||
announcement.value = t('{0} dropped. Position {1} of {2}.', [
|
||||
options.name(items[current]!),
|
||||
current + 1,
|
||||
items.length,
|
||||
])
|
||||
release()
|
||||
if (current !== origin.value) {
|
||||
options.commit(items)
|
||||
}
|
||||
focusHandle(current)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
const current = grabbed.value
|
||||
const items = working.value
|
||||
if (current === null || !items) {
|
||||
return
|
||||
}
|
||||
const index = origin.value
|
||||
announcement.value = t('Reorder cancelled. {0} is back at position {1} of {2}.', [
|
||||
options.name(items[current]!),
|
||||
index + 1,
|
||||
items.length,
|
||||
])
|
||||
release()
|
||||
focusHandle(index)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent, index: number) {
|
||||
if (grabbed.value === null) {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
grab(index)
|
||||
}
|
||||
return
|
||||
}
|
||||
const actions: Record<string, () => void> = {
|
||||
ArrowUp: () => move(-1),
|
||||
ArrowDown: () => move(1),
|
||||
' ': drop,
|
||||
Enter: drop,
|
||||
Escape: cancel,
|
||||
}
|
||||
const action = actions[event.key]
|
||||
if (action) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
return { shown, grabbed, active, announcement, onKeydown }
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
|
||||
import { moveItem } from '@/lib/arrayMove'
|
||||
import { SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { deriveFieldKey, deriveTypeKey } from '@/lib/schemaBuilderKeys'
|
||||
import {
|
||||
BUILDER_LIMITS,
|
||||
blankForm,
|
||||
changeFieldType,
|
||||
clearStaleDefault,
|
||||
createField,
|
||||
type BuilderField,
|
||||
type BuilderForm,
|
||||
type BuilderStatus,
|
||||
type FieldType,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import { buildPayload } from '@/lib/schemaBuilderPayload'
|
||||
import { FIELD_ERROR_TOUCH, fieldTouchKey, typeTouchKey } from '@/lib/schemaBuilderTouch'
|
||||
import type { FieldTouch, TypeTouch } from '@/lib/schemaBuilderTouch'
|
||||
import {
|
||||
countErrors,
|
||||
countProblems,
|
||||
validateForm,
|
||||
type FieldErrors,
|
||||
type FormErrors,
|
||||
} from '@/lib/schemaBuilderValidation'
|
||||
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 TOAST_ID = 'content-type-saved'
|
||||
|
||||
export type SaveFailure = 'none' | 'failed' | 'invalid' | 'forbidden'
|
||||
export type SaveState = 'editing' | 'saving' | 'saved' | 'failed'
|
||||
|
||||
function signature(form: BuilderForm) {
|
||||
return JSON.stringify({
|
||||
name: form.name,
|
||||
key: form.key,
|
||||
keyMode: form.keyMode,
|
||||
fields: form.fields.map((field) => ({ ...field, uid: 0 })),
|
||||
})
|
||||
}
|
||||
|
||||
export function useSchemaBuilder() {
|
||||
const router = useRouter()
|
||||
const store = useContentTypesStore()
|
||||
const toast = useLocalizedToast()
|
||||
const online = useOnline()
|
||||
let nextUid = 1
|
||||
const newUid = () => nextUid++
|
||||
|
||||
const form = ref<BuilderForm>(blankForm(newUid()))
|
||||
let initialSignature = signature(form.value)
|
||||
const selectedUid = ref<number | null>(form.value.fields[0]!.uid)
|
||||
const touched = ref(new Set<string>())
|
||||
const submitted = ref(false)
|
||||
const saveState = ref<SaveState>('editing')
|
||||
const failure = ref<SaveFailure>('none')
|
||||
const keyServerError = ref('')
|
||||
const keyFocusToken = ref(0)
|
||||
const labelFocusToken = ref(0)
|
||||
|
||||
const saving = computed(() => saveState.value === 'saving')
|
||||
const dirty = computed(
|
||||
() => saveState.value !== 'saved' && signature(form.value) !== initialSignature,
|
||||
)
|
||||
const errors = computed(() => validateForm(form.value))
|
||||
const selected = computed(
|
||||
() => form.value.fields.find((field) => field.uid === selectedUid.value) ?? null,
|
||||
)
|
||||
|
||||
function touchKey(key: string) {
|
||||
if (!touched.value.has(key)) {
|
||||
touched.value = new Set(touched.value).add(key)
|
||||
}
|
||||
}
|
||||
|
||||
function touch(name: TypeTouch) {
|
||||
touchKey(typeTouchKey(name))
|
||||
}
|
||||
|
||||
function touchField(uid: number, name: FieldTouch) {
|
||||
touchKey(fieldTouchKey(uid, name))
|
||||
}
|
||||
|
||||
function isShown(key: string) {
|
||||
return submitted.value || touched.value.has(key)
|
||||
}
|
||||
|
||||
function shownFieldErrors(uid: number, all: FieldErrors) {
|
||||
const pick: Record<string, unknown> = {}
|
||||
for (const [errorKey, touchName] of FIELD_ERROR_TOUCH) {
|
||||
if (all[errorKey] && isShown(fieldTouchKey(uid, touchName))) {
|
||||
pick[errorKey] = all[errorKey]
|
||||
}
|
||||
}
|
||||
return pick as FieldErrors
|
||||
}
|
||||
|
||||
const shown = computed<FormErrors>(() => {
|
||||
const type = { ...errors.value.type }
|
||||
if (!isShown(typeTouchKey('name'))) delete type.name
|
||||
if (!isShown(typeTouchKey('key'))) delete type.key
|
||||
if (!isShown(typeTouchKey('fields'))) delete type.fields
|
||||
if (keyServerError.value) type.key = keyServerError.value
|
||||
const fields = new Map<number, FieldErrors>()
|
||||
for (const [uid, all] of errors.value.fields) {
|
||||
const pick = shownFieldErrors(uid, all)
|
||||
if (Object.keys(pick).length > 0) {
|
||||
fields.set(uid, pick)
|
||||
}
|
||||
}
|
||||
return { type, fields }
|
||||
})
|
||||
|
||||
const hasServerKeyError = computed(
|
||||
() => keyServerError.value !== '' && !errors.value.type.key,
|
||||
)
|
||||
const problems = computed(
|
||||
() => countProblems(errors.value) + (hasServerKeyError.value ? 1 : 0),
|
||||
)
|
||||
const hasHidden = computed(() => countErrors(errors.value) > countErrors(shown.value))
|
||||
const engaged = computed(() => dirty.value || touched.value.size > 0 || submitted.value)
|
||||
|
||||
const status = computed<BuilderStatus>(() => {
|
||||
if (saving.value) return 'saving'
|
||||
if (!online.value) return 'offline'
|
||||
if (failure.value === 'failed') return 'failed'
|
||||
if (problems.value > 0 && engaged.value) return 'problems'
|
||||
return dirty.value ? 'dirty' : '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,
|
||||
)
|
||||
const canDiscard = computed(
|
||||
() =>
|
||||
!saving.value &&
|
||||
(dirty.value ||
|
||||
touched.value.size > 0 ||
|
||||
submitted.value ||
|
||||
keyServerError.value !== ''),
|
||||
)
|
||||
|
||||
function clearFailure() {
|
||||
if (failure.value !== 'none' && failure.value !== 'forbidden') {
|
||||
failure.value = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
function edited() {
|
||||
if (saveState.value === 'saved' || saveState.value === 'failed') {
|
||||
saveState.value = 'editing'
|
||||
}
|
||||
clearFailure()
|
||||
}
|
||||
|
||||
function otherKeys(uid: number) {
|
||||
return form.value.fields.filter((field) => field.uid !== uid).map((field) => field.key)
|
||||
}
|
||||
|
||||
function setName(name: string) {
|
||||
form.value.name = name
|
||||
touch('name')
|
||||
if (form.value.keyMode === 'auto') {
|
||||
form.value.key = deriveTypeKey(name)
|
||||
keyServerError.value = ''
|
||||
}
|
||||
edited()
|
||||
}
|
||||
|
||||
function setKey(key: string) {
|
||||
form.value.key = key
|
||||
form.value.keyMode = 'manual'
|
||||
keyServerError.value = ''
|
||||
touch('key')
|
||||
edited()
|
||||
}
|
||||
|
||||
function unlockKey() {
|
||||
form.value.keyMode = 'manual'
|
||||
}
|
||||
|
||||
function resetKey() {
|
||||
form.value.keyMode = 'auto'
|
||||
form.value.key = deriveTypeKey(form.value.name)
|
||||
keyServerError.value = ''
|
||||
edited()
|
||||
}
|
||||
|
||||
function indexOf(uid: number) {
|
||||
return form.value.fields.findIndex((field) => field.uid === uid)
|
||||
}
|
||||
|
||||
function replaceField(uid: number, next: BuilderField) {
|
||||
const index = indexOf(uid)
|
||||
if (index !== -1) {
|
||||
form.value.fields.splice(index, 1, next)
|
||||
}
|
||||
}
|
||||
|
||||
function patchField(uid: number, patch: Partial<BuilderField>, touchName?: FieldTouch) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (!field) {
|
||||
return
|
||||
}
|
||||
replaceField(uid, clearStaleDefault({ ...field, ...patch }))
|
||||
if (touchName) {
|
||||
touchField(uid, touchName)
|
||||
}
|
||||
edited()
|
||||
}
|
||||
|
||||
function setLabel(uid: number, label: string) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (!field) {
|
||||
return
|
||||
}
|
||||
const patch: Partial<BuilderField> = { label }
|
||||
if (field.keyMode === 'auto') {
|
||||
patch.key = deriveFieldKey(label, otherKeys(uid))
|
||||
}
|
||||
patchField(uid, patch, 'label')
|
||||
}
|
||||
|
||||
function setFieldKey(uid: number, key: string) {
|
||||
patchField(uid, { key, keyMode: 'manual' }, 'key')
|
||||
}
|
||||
|
||||
function unlockFieldKey(uid: number) {
|
||||
patchField(uid, { keyMode: 'manual' })
|
||||
}
|
||||
|
||||
function resetFieldKey(uid: number) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (field) {
|
||||
patchField(uid, { keyMode: 'auto', key: deriveFieldKey(field.label, otherKeys(uid)) })
|
||||
}
|
||||
}
|
||||
|
||||
function setFieldType(uid: number, type: FieldType) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (field) {
|
||||
replaceField(uid, changeFieldType(field, type))
|
||||
edited()
|
||||
}
|
||||
}
|
||||
|
||||
function setLimit(uid: number, which: 'min' | 'max', value: string) {
|
||||
patchField(uid, { [which]: value }, which)
|
||||
}
|
||||
|
||||
function setDefault(uid: number, value: string) {
|
||||
patchField(uid, { defaultValue: value }, 'default')
|
||||
}
|
||||
|
||||
function selectField(uid: number) {
|
||||
selectedUid.value = uid
|
||||
}
|
||||
|
||||
function addField() {
|
||||
if (form.value.fields.length >= BUILDER_LIMITS.fieldsMax) {
|
||||
return
|
||||
}
|
||||
const field = createField(newUid())
|
||||
form.value.fields.push(field)
|
||||
selectedUid.value = field.uid
|
||||
labelFocusToken.value++
|
||||
edited()
|
||||
}
|
||||
|
||||
function removeField(uid: number) {
|
||||
const index = indexOf(uid)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
form.value.fields.splice(index, 1)
|
||||
if (selectedUid.value === uid) {
|
||||
const next = form.value.fields[index] ?? form.value.fields[index - 1]
|
||||
selectedUid.value = next?.uid ?? null
|
||||
}
|
||||
if (form.value.fields.length === 0) {
|
||||
touch('fields')
|
||||
}
|
||||
edited()
|
||||
}
|
||||
|
||||
function moveField(uid: number, delta: number) {
|
||||
const index = indexOf(uid)
|
||||
form.value.fields = moveItem(form.value.fields, index, index + delta)
|
||||
edited()
|
||||
}
|
||||
|
||||
function reorderFields(order: number[]) {
|
||||
const byUid = new Map(form.value.fields.map((field) => [field.uid, field]))
|
||||
form.value.fields = order.map((uid) => byUid.get(uid)!)
|
||||
edited()
|
||||
}
|
||||
|
||||
function setOptions(uid: number, options: string[]) {
|
||||
patchField(uid, { options }, 'choices')
|
||||
}
|
||||
|
||||
function addChoice(uid: number, text: string) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (!field || text.trim() === '' || field.options.length >= BUILDER_LIMITS.choicesMax) {
|
||||
return
|
||||
}
|
||||
setOptions(uid, [...field.options, text])
|
||||
}
|
||||
|
||||
function removeChoice(uid: number, index: number) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (field) {
|
||||
setOptions(
|
||||
uid,
|
||||
field.options.filter((_, position) => position !== index),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function renameChoice(uid: number, index: number, text: string) {
|
||||
const field = form.value.fields[indexOf(uid)]
|
||||
if (field) {
|
||||
setOptions(
|
||||
uid,
|
||||
field.options.map((option, position) => (position === index ? text : option)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function reorderChoices(uid: number, options: string[]) {
|
||||
setOptions(uid, options)
|
||||
}
|
||||
|
||||
function discard() {
|
||||
form.value = blankForm(newUid())
|
||||
initialSignature = signature(form.value)
|
||||
selectedUid.value = form.value.fields[0]!.uid
|
||||
touched.value = new Set()
|
||||
submitted.value = false
|
||||
keyServerError.value = ''
|
||||
failure.value = 'none'
|
||||
saveState.value = 'editing'
|
||||
}
|
||||
|
||||
function onKeyTaken() {
|
||||
form.value.keyMode = 'manual'
|
||||
keyServerError.value = t(
|
||||
'The key "{0}" is already used by another content type. Choose a different key.',
|
||||
[form.value.key],
|
||||
)
|
||||
keyFocusToken.value++
|
||||
}
|
||||
|
||||
function onInvalidKey() {
|
||||
form.value.keyMode = 'manual'
|
||||
keyServerError.value = t('Use 2 to 40 lowercase letters, digits or hyphens.')
|
||||
keyFocusToken.value++
|
||||
}
|
||||
|
||||
function onRefused(error: unknown) {
|
||||
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) {
|
||||
onKeyTaken()
|
||||
} else if (error instanceof ApiError && error.status === 400 && body === INVALID_KEY_BODY) {
|
||||
onInvalidKey()
|
||||
} else if (
|
||||
error instanceof ApiError &&
|
||||
error.status === 422 &&
|
||||
body === INVALID_FIELD_BODY
|
||||
) {
|
||||
failure.value = 'invalid'
|
||||
} else if (error instanceof ApiError && error.status === 403) {
|
||||
failure.value = 'forbidden'
|
||||
} else {
|
||||
failure.value = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
function announceSaved(name: string, count: number) {
|
||||
toast.add(() => ({
|
||||
id: TOAST_ID,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: t('Content type saved'),
|
||||
description:
|
||||
count === 1
|
||||
? t('{0} now has 1 field.', [name])
|
||||
: t('{0} now has {1} fields.', [name, count]),
|
||||
}))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value && !canReveal.value) {
|
||||
return
|
||||
}
|
||||
submitted.value = true
|
||||
if (!canSave.value) {
|
||||
return
|
||||
}
|
||||
failure.value = 'none'
|
||||
saveState.value = 'saving'
|
||||
let created: ContentType
|
||||
try {
|
||||
created = await useContentTypes().createContentType(buildPayload(form.value))
|
||||
} catch (error) {
|
||||
saveState.value = 'failed'
|
||||
onRefused(error)
|
||||
return
|
||||
}
|
||||
saveState.value = 'saved'
|
||||
store.add(created)
|
||||
announceSaved(created.name, created.fields.length)
|
||||
await router.replace(SCHEMA_PATH).catch(() => undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
selectedUid,
|
||||
selected,
|
||||
dirty,
|
||||
shown,
|
||||
problems,
|
||||
status,
|
||||
canSave,
|
||||
canReveal,
|
||||
canDiscard,
|
||||
saving,
|
||||
failure,
|
||||
keyFocusToken,
|
||||
labelFocusToken,
|
||||
online,
|
||||
setName,
|
||||
setKey,
|
||||
unlockKey,
|
||||
resetKey,
|
||||
setLabel,
|
||||
setFieldKey,
|
||||
unlockFieldKey,
|
||||
resetFieldKey,
|
||||
setFieldType,
|
||||
setLimit,
|
||||
setDefault,
|
||||
patchField,
|
||||
selectField,
|
||||
addField,
|
||||
removeField,
|
||||
moveField,
|
||||
reorderFields,
|
||||
addChoice,
|
||||
removeChoice,
|
||||
renameChoice,
|
||||
reorderChoices,
|
||||
touch,
|
||||
discard,
|
||||
save,
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,26 @@ import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
export type SchemaGateScope = 'all' | 'builtin'
|
||||
export type SchemaGateScope = 'all' | 'builtin' | 'custom'
|
||||
|
||||
export function useSchemaGate(scope: SchemaGateScope) {
|
||||
const session = useSessionStore()
|
||||
const store = useContentTypesStore()
|
||||
const skeleton = useDelayedSkeleton()
|
||||
const withCustom = scope === 'all'
|
||||
const withCustom = scope !== 'builtin'
|
||||
const withBuiltin = scope !== 'custom'
|
||||
|
||||
const sessionReady = computed(() => session.status === 'ready')
|
||||
const forbidden = computed(
|
||||
() => (sessionReady.value && !session.canManageContent) || store.forbidden,
|
||||
() =>
|
||||
(sessionReady.value && !session.canManageContent) ||
|
||||
(withCustom && store.status === 'forbidden') ||
|
||||
(withBuiltin && store.builtinStatus === 'forbidden'),
|
||||
)
|
||||
const loading = computed(
|
||||
() => !forbidden.value && ((withCustom && store.customLoading) || store.builtinLoading),
|
||||
() =>
|
||||
!forbidden.value &&
|
||||
((withCustom && store.customLoading) || (withBuiltin && store.builtinLoading)),
|
||||
)
|
||||
const showSkeleton = computed(() => loading.value && skeleton.visible.value)
|
||||
const customFailed = computed(() => withCustom && store.error)
|
||||
|
||||
@@ -2298,6 +2298,117 @@ export const messages = {
|
||||
'Il a peut-être été supprimé, ou l’adresse est mal saisie.',
|
||||
'GET /v1/builtin-type failed. Check your connection and try again.':
|
||||
'GET /v1/builtin-type a échoué. Vérifiez votre connexion et réessayez.',
|
||||
'{0} now has 1 field.': '{0} a maintenant 1 champ.',
|
||||
'{0} now has {1} fields.': '{0} a maintenant {1} champs.',
|
||||
'{0} problems to fix': '{0} problèmes à corriger',
|
||||
'1 problem to fix': '1 problème à corriger',
|
||||
'A calendar day, without a time': 'Un jour du calendrier, sans heure',
|
||||
'A content type can have up to 50 fields.': 'Un type de contenu peut avoir jusqu’à 50 champs.',
|
||||
'A content type needs at least one field.': 'Un type de contenu a besoin d’au moins un champ.',
|
||||
Add: 'Ajouter',
|
||||
'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.':
|
||||
'Ajoutez de 1 à 50 choix. Chacun doit avoir un nom différent. Supprimer un choix utilisé par des entrées sera refusé à l’enregistrement.',
|
||||
'Add a choice': 'Ajouter un choix',
|
||||
'Add a field, then choose its type and rules here.':
|
||||
'Ajoutez un champ, puis choisissez ici son type et ses règles.',
|
||||
'Add at least one choice.': 'Ajoutez au moins un choix.',
|
||||
'Add field': 'Ajouter un champ',
|
||||
'Add the first field.': 'Ajoutez le premier champ.',
|
||||
"A field with unique values can't have a default.":
|
||||
'Un champ à valeurs uniques ne peut pas avoir de valeur par défaut.',
|
||||
"An entry can't be saved with this field empty.":
|
||||
'Une entrée ne peut pas être enregistrée si ce champ est vide.',
|
||||
'An on or off switch': 'Un interrupteur activé ou désactivé',
|
||||
'Another field already uses this key.': 'Un autre champ utilise déjà cette clé.',
|
||||
'API preview': 'Aperçu de l’API',
|
||||
"A sample entry. Values come from each field's type and default.":
|
||||
'Un exemple d’entrée. Les valeurs viennent du type et de la valeur par défaut de chaque champ.',
|
||||
characters: 'caractères',
|
||||
'Check the fields marked in the list.': 'Vérifiez les champs marqués dans la liste.',
|
||||
'Choice {0}': 'Choix {0}',
|
||||
Choices: 'Choix',
|
||||
"Choices can't be blank.": 'Les choix ne peuvent pas être vides.',
|
||||
'Content type saved': 'Type de contenu enregistré',
|
||||
"Couldn't save the content type": 'Impossible d’enregistrer le type de contenu',
|
||||
'Counted in characters. Entries show a counter.':
|
||||
'Compté en caractères. Les entrées affichent un compteur.',
|
||||
'Default value': 'Valeur par défaut',
|
||||
Discard: 'Abandonner',
|
||||
'Each entry needs a different value.': 'Chaque entrée doit avoir une valeur différente.',
|
||||
'Earliest date': 'Date la plus proche',
|
||||
"Earliest date can't be after the latest date.":
|
||||
'La date la plus proche ne peut pas être après la date la plus éloignée.',
|
||||
'Enter a number.': 'Saisissez un nombre.',
|
||||
'Entries outside the range are refused.': 'Les entrées hors de cette plage sont refusées.',
|
||||
'Field: {0}': 'Champ : {0}',
|
||||
'Field settings': 'Réglages du champ',
|
||||
'For example, Events': 'Par exemple, Événements',
|
||||
'For example, Title': 'Par exemple, Titre',
|
||||
'Give the content type a name.': 'Donnez un nom au type de contenu.',
|
||||
'Use 100 characters or fewer.': 'Utilisez 100 caractères maximum.',
|
||||
'Give the field a label.': 'Donnez un libellé au champ.',
|
||||
'Highest value': 'Valeur la plus haute',
|
||||
'Latest date': 'Date la plus éloignée',
|
||||
Limits: 'Limites',
|
||||
'Limits come from the choices below.': 'Les limites viennent des choix ci-dessous.',
|
||||
Longest: 'Plus long',
|
||||
'Long text with formatting, written in Markdown':
|
||||
'Texte long avec mise en forme, écrit en Markdown',
|
||||
'Lowest value': 'Valeur la plus basse',
|
||||
"Lowest value can't be above the highest value.":
|
||||
'La valeur la plus basse ne peut pas dépasser la valeur la plus haute.',
|
||||
'Move {0}': 'Déplacer {0}',
|
||||
'Move down': 'Descendre',
|
||||
'Move up': 'Monter',
|
||||
'Moving {0}. Position {1} of {2}. Press Up or Down to move, Space to drop, Esc to cancel.':
|
||||
'Déplacement de {0}. Position {1} sur {2}. Appuyez sur Haut ou Bas pour déplacer, sur Espace pour déposer, sur Échap pour annuler.',
|
||||
New: 'Nouveau',
|
||||
'New entries start with this switch off when the field is left out.':
|
||||
'Les nouvelles entrées démarrent avec cet interrupteur désactivé quand le champ est omis.',
|
||||
'New entries start with this switch on when the field is left out.':
|
||||
'Les nouvelles entrées démarrent avec cet interrupteur activé quand le champ est omis.',
|
||||
'No default': 'Aucune valeur par défaut',
|
||||
'No fields yet': 'Aucun champ pour l’instant',
|
||||
'No maximum': 'Aucun maximum',
|
||||
'No minimum': 'Aucun minimum',
|
||||
'Not available for rich text.': 'Indisponible pour le texte enrichi.',
|
||||
'Not available for yes or no fields.': 'Indisponible pour les champs oui ou non.',
|
||||
'On by default': 'Activé par défaut',
|
||||
'One option from a list you define': 'Une option parmi une liste que vous définissez',
|
||||
'Reset to match the label': 'Rétablir d’après le libellé',
|
||||
Shortest: 'Plus court',
|
||||
"Shortest can't be longer than longest.":
|
||||
'Le plus court ne peut pas être plus long que le plus long.',
|
||||
'Short text, like a title or a name': 'Texte court, comme un titre ou un nom',
|
||||
'Something went wrong on our side. Your changes are still here. Try again in a moment.':
|
||||
'Un problème est survenu de notre côté. Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||
'Start with a letter. Use lowercase letters, digits and underscores.':
|
||||
'Commencez par une lettre. Utilisez des minuscules, des chiffres et des tirets bas.',
|
||||
'The default value is outside the limits.': 'La valeur par défaut est hors des limites.',
|
||||
'The key "{0}" is already used by another content type. Choose a different key.':
|
||||
'La clé « {0} » est déjà utilisée par un autre type de contenu. Choisissez une autre clé.',
|
||||
'The key "{0}" is reserved. Choose a different key.':
|
||||
'La clé « {0} » est réservée. Choisissez une autre clé.',
|
||||
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.':
|
||||
'Le nom utilisé dans l’API. Commence par une lettre. Minuscules, chiffres et tirets bas.',
|
||||
'Two choices are the same.': 'Deux choix sont identiques.',
|
||||
Unique: 'Unique',
|
||||
'Unique values': 'Valeurs uniques',
|
||||
'Untitled field': 'Champ sans titre',
|
||||
'Updates as you edit. Fields you add or remove here appear after you save.':
|
||||
'Se met à jour pendant la saisie. Les champs que vous ajoutez ou supprimez ici apparaissent après l’enregistrement.',
|
||||
'Use 2 to 40 lowercase letters, digits or hyphens.':
|
||||
'Utilisez de 2 à 40 minuscules, chiffres ou tirets.',
|
||||
'Use a whole number, 0 or more.': 'Utilisez un nombre entier, 0 ou plus.',
|
||||
"Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save.":
|
||||
'Utilisée dans les adresses et l’API. Minuscules, chiffres et tirets. Elle ne peut plus changer après l’enregistrement.',
|
||||
'Used when an entry is created and this field is left out.':
|
||||
'Utilisée quand une entrée est créée et que ce champ est omis.',
|
||||
'Where entries of this type live': 'Où se trouvent les entrées de ce type',
|
||||
'Whole or decimal numbers': 'Nombres entiers ou décimaux',
|
||||
'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.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { moveItem } from '@/lib/arrayMove'
|
||||
|
||||
describe('moveItem', () => {
|
||||
it('moves an item forward and backward without touching the input', () => {
|
||||
const items = ['a', 'b', 'c']
|
||||
expect(moveItem(items, 0, 2)).toEqual(['b', 'c', 'a'])
|
||||
expect(moveItem(items, 2, 0)).toEqual(['c', 'a', 'b'])
|
||||
expect(items).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, 1],
|
||||
[-1, 0],
|
||||
[0, -1],
|
||||
[3, 0],
|
||||
[0, 3],
|
||||
])('returns the same array for %i to %i', (from, to) => {
|
||||
const items = ['a', 'b', 'c']
|
||||
expect(moveItem(items, from, to)).toBe(items)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveFieldKey, deriveTypeKey } from '@/lib/schemaBuilderKeys'
|
||||
|
||||
describe('type key from the name', () => {
|
||||
it.each([
|
||||
['Events', 'events'],
|
||||
['Press mentions', 'press-mentions'],
|
||||
['Café & Bar', 'cafe-bar'],
|
||||
[' Job openings! ', 'job-openings'],
|
||||
['2026 Awards', '2026-awards'],
|
||||
['!!!', ''],
|
||||
['', ''],
|
||||
])('%j gives %j', (name, key) => {
|
||||
expect(deriveTypeKey(name)).toBe(key)
|
||||
})
|
||||
|
||||
it('keeps the first 40 characters of a long name', () => {
|
||||
const name = 'abcdefghij'.repeat(6)
|
||||
|
||||
expect(deriveTypeKey(name)).toBe(name.slice(0, 40))
|
||||
})
|
||||
|
||||
it('drops a hyphen left at the end by the cut', () => {
|
||||
const name = `${'a'.repeat(39)} bbbbbbbbbb`
|
||||
|
||||
expect(deriveTypeKey(name)).toBe('a'.repeat(39))
|
||||
})
|
||||
})
|
||||
|
||||
describe('field key from the label', () => {
|
||||
it.each([
|
||||
['Title', 'title'],
|
||||
['Starts on', 'starts_on'],
|
||||
['Éléments', 'elements'],
|
||||
['Ends on (date)', 'ends_on_date'],
|
||||
['2nd room', 'nd_room'],
|
||||
['!!!', ''],
|
||||
['', ''],
|
||||
])('%j gives %j', (label, key) => {
|
||||
expect(deriveFieldKey(label, [])).toBe(key)
|
||||
})
|
||||
|
||||
it('adds _2 when the key already exists, then _3', () => {
|
||||
expect(deriveFieldKey('Title', ['title'])).toBe('title_2')
|
||||
expect(deriveFieldKey('Title', ['title', 'title_2'])).toBe('title_3')
|
||||
})
|
||||
|
||||
it('keeps the first 40 characters of a long label', () => {
|
||||
const label = 'abcdefghij'.repeat(6)
|
||||
|
||||
expect(deriveFieldKey(label, [])).toBe(label.slice(0, 40))
|
||||
})
|
||||
|
||||
it('keeps the suffixed key within 40 characters', () => {
|
||||
const long = 'a'.repeat(40)
|
||||
|
||||
const key = deriveFieldKey(long, [long])
|
||||
|
||||
expect(key).toBe(`${'a'.repeat(38)}_2`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
blankForm,
|
||||
changeFieldType,
|
||||
clearStaleDefault,
|
||||
createField,
|
||||
effectiveDefault,
|
||||
isUnique,
|
||||
keyDisplayMode,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
|
||||
describe('changing the type of a field', () => {
|
||||
const number = createField(7, {
|
||||
label: 'Capacity',
|
||||
key: 'capacity',
|
||||
keyMode: 'manual',
|
||||
type: 'number',
|
||||
required: true,
|
||||
unique: true,
|
||||
defaultValue: '20',
|
||||
min: '1',
|
||||
max: '5000',
|
||||
})
|
||||
|
||||
it('keeps the label, key and Required and resets the rest', () => {
|
||||
const next = changeFieldType(number, 'boolean')
|
||||
|
||||
expect(next).toEqual(
|
||||
createField(7, {
|
||||
label: 'Capacity',
|
||||
key: 'capacity',
|
||||
keyMode: 'manual',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('starts a Choice field with no choice', () => {
|
||||
expect(changeFieldType(changeFieldType(number, 'boolean'), 'select').options).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the same field for the same type', () => {
|
||||
expect(changeFieldType(number, 'number')).toBe(number)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blank form', () => {
|
||||
it('holds the starter field Title', () => {
|
||||
const form = blankForm(1)
|
||||
|
||||
expect([form.name, form.key, form.keyMode]).toEqual(['', '', 'auto'])
|
||||
expect(form.fields).toHaveLength(1)
|
||||
expect([form.fields[0]!.label, form.fields[0]!.key, form.fields[0]!.type]).toEqual([
|
||||
'Title',
|
||||
'title',
|
||||
'text',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stale default', () => {
|
||||
it('clears a Choice default that is no longer a choice', () => {
|
||||
const field = createField(1, { type: 'select', options: ['A'], defaultValue: 'B' })
|
||||
|
||||
expect(clearStaleDefault(field).defaultValue).toBe('')
|
||||
})
|
||||
|
||||
it('keeps a default that is still a choice', () => {
|
||||
const field = createField(1, { type: 'select', options: ['A'], defaultValue: 'A' })
|
||||
|
||||
expect(clearStaleDefault(field)).toBe(field)
|
||||
})
|
||||
})
|
||||
|
||||
describe('key display mode', () => {
|
||||
it.each([
|
||||
['manual', '', 'manual'],
|
||||
['manual', 'title', 'manual'],
|
||||
['auto', 'title', 'auto'],
|
||||
['auto', '', 'empty'],
|
||||
] as const)('%s with key %j is %s', (mode, key, expected) => {
|
||||
expect(keyDisplayMode(mode, key)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('effective default', () => {
|
||||
it('is the typed default for a plain field', () => {
|
||||
expect(effectiveDefault(createField(1, { defaultValue: 'x' }))).toBe('x')
|
||||
})
|
||||
|
||||
it('is empty for a unique field that can be unique', () => {
|
||||
const field = createField(1, { defaultValue: 'x', unique: true })
|
||||
expect(isUnique(field)).toBe(true)
|
||||
expect(effectiveDefault(field)).toBe('')
|
||||
})
|
||||
|
||||
it('ignores unique on a type that cannot be unique', () => {
|
||||
const field = createField(1, { type: 'richtext', defaultValue: 'x', unique: true })
|
||||
expect(isUnique(field)).toBe(false)
|
||||
expect(effectiveDefault(field)).toBe('x')
|
||||
})
|
||||
|
||||
it('keeps a select default only while it is one of the choices', () => {
|
||||
const field = createField(1, { type: 'select', options: [' Talk '], defaultValue: 'Talk' })
|
||||
expect(effectiveDefault(field)).toBe('Talk')
|
||||
expect(effectiveDefault({ ...field, defaultValue: 'Other' })).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createField, type BuilderForm } from '@/lib/schemaBuilderModel'
|
||||
import { buildPayload } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
const EVENTS: BuilderForm = {
|
||||
name: ' Events ',
|
||||
key: 'events',
|
||||
keyMode: 'auto',
|
||||
fields: [
|
||||
createField(1, { label: 'Title', key: 'title', required: true, unique: true, max: '80' }),
|
||||
createField(2, {
|
||||
label: 'Starts on',
|
||||
key: 'starts_on',
|
||||
type: 'date',
|
||||
required: true,
|
||||
min: '2026-01-01',
|
||||
max: '2027-12-31',
|
||||
}),
|
||||
createField(3, { label: 'Online', key: 'online', type: 'boolean', defaultOn: true }),
|
||||
createField(4, {
|
||||
label: 'Capacity',
|
||||
key: 'capacity',
|
||||
type: 'number',
|
||||
defaultValue: '20',
|
||||
min: '1',
|
||||
max: '5000',
|
||||
}),
|
||||
createField(5, {
|
||||
label: 'Category',
|
||||
key: 'category',
|
||||
type: 'select',
|
||||
defaultValue: 'Talk',
|
||||
options: [' Talk', 'Workshop ', 'Meetup'],
|
||||
}),
|
||||
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 }
|
||||
}
|
||||
|
||||
describe('create payload', () => {
|
||||
it('builds the exact body for the six-field Events form', () => {
|
||||
expect(buildPayload(EVENTS)).toEqual({
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
fields: [
|
||||
{
|
||||
...field('title', {}),
|
||||
label: 'Title',
|
||||
required: true,
|
||||
unique: true,
|
||||
default: null,
|
||||
min: null,
|
||||
max: 80,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'starts_on',
|
||||
label: 'Starts on',
|
||||
type: 'date',
|
||||
required: true,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: '2026-01-01',
|
||||
max: '2027-12-31',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'online',
|
||||
label: 'Online',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: true,
|
||||
min: null,
|
||||
max: null,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'capacity',
|
||||
label: 'Capacity',
|
||||
type: 'number',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: 20,
|
||||
min: 1,
|
||||
max: 5000,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
type: 'select',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: 'Talk',
|
||||
min: null,
|
||||
max: null,
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
type: 'richtext',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: 5000,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('has no icon key and exactly the nine field keys', () => {
|
||||
const body = buildPayload(EVENTS)
|
||||
|
||||
expect(Object.keys(body).sort()).toEqual(['fields', 'key', 'name'])
|
||||
for (const sent of body.fields) {
|
||||
expect(Object.keys(sent).sort()).toEqual([
|
||||
'default',
|
||||
'key',
|
||||
'label',
|
||||
'max',
|
||||
'min',
|
||||
'options',
|
||||
'required',
|
||||
'type',
|
||||
'unique',
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('sends null for an off switch', () => {
|
||||
const body = buildPayload({
|
||||
...EVENTS,
|
||||
fields: [createField(1, { label: 'On', key: 'on', type: 'boolean' })],
|
||||
})
|
||||
|
||||
expect(body.fields[0]!.default).toBeNull()
|
||||
})
|
||||
|
||||
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' })],
|
||||
})
|
||||
|
||||
expect(body.fields[0]!.default).toBeNull()
|
||||
})
|
||||
|
||||
it('sends no unique flag for rich text and yes or no fields', () => {
|
||||
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 }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(body.fields.map((sent) => sent.unique)).toEqual([false, false])
|
||||
})
|
||||
|
||||
it('sends no limits for yes or no and choice fields', () => {
|
||||
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'] }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(body.fields.map((sent) => [sent.min, sent.max])).toEqual([
|
||||
[null, null],
|
||||
[null, null],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a limit of zero', () => {
|
||||
const body = buildPayload({
|
||||
...EVENTS,
|
||||
fields: [createField(1, { label: 'T', key: 't', min: '0' })],
|
||||
})
|
||||
|
||||
expect(body.fields[0]!.min).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { previewRoutes, sampleEntry, sampleJson } from '@/lib/schemaBuilderSample'
|
||||
|
||||
function valueOf(overrides: Partial<BuilderField>) {
|
||||
const field = createField(1, { label: 'F', key: 'f', ...overrides })
|
||||
return sampleEntry('events', [field]).data.f
|
||||
}
|
||||
|
||||
describe('sample values', () => {
|
||||
it.each([
|
||||
['text', {}, 'Example text'],
|
||||
['text', { defaultValue: 'Draft' }, 'Draft'],
|
||||
['richtext', {}, 'Markdown text'],
|
||||
['number', {}, 0],
|
||||
['number', { min: '1' }, 1],
|
||||
['number', { min: '1', defaultValue: '20' }, 20],
|
||||
['boolean', {}, false],
|
||||
['boolean', { defaultOn: true }, true],
|
||||
['date', {}, '2026-11-21'],
|
||||
['select', { options: ['Talk', 'Workshop'] }, 'Talk'],
|
||||
['select', { options: ['Talk', 'Workshop'], defaultValue: 'Workshop' }, 'Workshop'],
|
||||
['select', {}, null],
|
||||
] as const)('%s with %j holds %j', (type, settings, expected) => {
|
||||
expect(valueOf({ type, ...settings } as Partial<BuilderField>)).toBe(expected)
|
||||
})
|
||||
|
||||
it('ignores the default of a unique field', () => {
|
||||
expect(valueOf({ unique: true, defaultValue: 'Draft' })).toBe('Example text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sample entry', () => {
|
||||
const fields = [
|
||||
createField(1, { label: 'Title', key: 'title' }),
|
||||
createField(2, { label: 'Bad', key: 'start-date', keyMode: 'manual' }),
|
||||
createField(3, { label: 'Online', key: 'online', type: 'boolean' }),
|
||||
]
|
||||
|
||||
it('wraps the data in the fixed envelope, in field order, skipping invalid keys', () => {
|
||||
expect(sampleEntry('events', fields)).toEqual({
|
||||
id: 42,
|
||||
content_type: 'events',
|
||||
data: { title: 'Example text', online: false },
|
||||
created_by: 3,
|
||||
updated_by: 3,
|
||||
created_at: '2026-10-15T11:02:00Z',
|
||||
updated_at: '2026-10-15T11:02:00Z',
|
||||
})
|
||||
expect(Object.keys(sampleEntry('events', fields).data)).toEqual(['title', 'online'])
|
||||
})
|
||||
|
||||
it('indents by two spaces', () => {
|
||||
expect(sampleJson('events', fields.slice(0, 1))).toBe(
|
||||
JSON.stringify(sampleEntry('events', fields.slice(0, 1)), null, 2),
|
||||
)
|
||||
expect(sampleJson('events', fields.slice(0, 1))).toContain('\n "id": 42,')
|
||||
})
|
||||
|
||||
it('falls back to the key events while the key is empty', () => {
|
||||
expect(sampleEntry('', []).content_type).toBe('events')
|
||||
expect(previewRoutes('')[0]!.path).toBe('/v1/content-type/events/entry')
|
||||
})
|
||||
|
||||
it('lists the five routes of the type', () => {
|
||||
expect(previewRoutes('recipes')).toEqual([
|
||||
{ method: 'GET', path: '/v1/content-type/recipes/entry' },
|
||||
{ method: 'POST', path: '/v1/content-type/recipes/entry' },
|
||||
{ method: 'GET', path: '/v1/content-type/recipes/entry/{id}' },
|
||||
{ method: 'PUT', path: '/v1/content-type/recipes/entry/{id}' },
|
||||
{ method: 'DELETE', path: '/v1/content-type/recipes/entry/{id}' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FIELD_ERROR_TOUCH, fieldTouchKey, typeTouchKey } from '@/lib/schemaBuilderTouch'
|
||||
|
||||
describe('touch keys', () => {
|
||||
it('keeps type and field keys apart', () => {
|
||||
expect(typeTouchKey('key')).toBe('type:key')
|
||||
expect(fieldTouchKey(3, 'key')).toBe('field:3:key')
|
||||
expect(fieldTouchKey(3, 'key')).not.toBe(fieldTouchKey(33, 'key'))
|
||||
expect(fieldTouchKey(1, 'label')).not.toBe(fieldTouchKey(1, 'key'))
|
||||
})
|
||||
|
||||
it('maps both choice errors to the choices touch', () => {
|
||||
const choices = FIELD_ERROR_TOUCH.filter(([, touch]) => touch === 'choices').map(
|
||||
([error]) => error,
|
||||
)
|
||||
expect(choices).toEqual(['options', 'choices'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createField,
|
||||
starterField,
|
||||
type BuilderField,
|
||||
type BuilderForm,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import {
|
||||
countProblems,
|
||||
validateField,
|
||||
validateForm,
|
||||
validateType,
|
||||
} from '@/lib/schemaBuilderValidation'
|
||||
|
||||
const KEY_MESSAGE = 'Use 2 to 40 lowercase letters, digits or hyphens.'
|
||||
|
||||
function form(overrides: Partial<BuilderForm> = {}): BuilderForm {
|
||||
return {
|
||||
name: 'Events',
|
||||
key: 'events',
|
||||
keyMode: 'auto',
|
||||
fields: [starterField(1)],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function field(overrides: Partial<BuilderField> = {}) {
|
||||
return createField(2, { label: 'Thing', key: 'thing', ...overrides })
|
||||
}
|
||||
|
||||
describe('type validation', () => {
|
||||
it.each([
|
||||
['', 'Give the content type a name.'],
|
||||
[' ', 'Give the content type a name.'],
|
||||
['a'.repeat(101), 'Use 100 characters or fewer.'],
|
||||
['a'.repeat(100), undefined],
|
||||
['Events', undefined],
|
||||
])('name %j', (name, message) => {
|
||||
expect(validateType(form({ name })).name).toBe(message)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['e', KEY_MESSAGE],
|
||||
['Events', KEY_MESSAGE],
|
||||
['my_type', KEY_MESSAGE],
|
||||
['-events', KEY_MESSAGE],
|
||||
['events-', KEY_MESSAGE],
|
||||
['events--2', KEY_MESSAGE],
|
||||
['', KEY_MESSAGE],
|
||||
['a'.repeat(41), KEY_MESSAGE],
|
||||
['new', 'The key "new" is reserved. Choose a different key.'],
|
||||
['ab', undefined],
|
||||
['press-mentions', undefined],
|
||||
['2026-awards', undefined],
|
||||
['a'.repeat(40), undefined],
|
||||
])('key %j', (key, message) => {
|
||||
expect(validateType(form({ key })).key).toBe(message)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, 'A content type needs at least one field.'],
|
||||
[1, undefined],
|
||||
[50, undefined],
|
||||
[51, 'A content type can have up to 50 fields.'],
|
||||
])('%i fields', (count, message) => {
|
||||
const fields = Array.from({ length: count }, (_, index) => starterField(index))
|
||||
|
||||
expect(validateType(form({ fields })).fields).toBe(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('field validation', () => {
|
||||
const KEY_RULE = 'Start with a letter. Use lowercase letters, digits and underscores.'
|
||||
|
||||
it.each([
|
||||
['', 'Give the field a label.'],
|
||||
[' ', 'Give the field a label.'],
|
||||
['Title', undefined],
|
||||
])('label %j', (label, message) => {
|
||||
expect(validateField(field({ label }), []).label).toBe(message)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Title', KEY_RULE],
|
||||
['1st', KEY_RULE],
|
||||
['a-b', KEY_RULE],
|
||||
['a b', KEY_RULE],
|
||||
['', KEY_RULE],
|
||||
['_a', KEY_RULE],
|
||||
['a'.repeat(41), KEY_RULE],
|
||||
['starts_on', undefined],
|
||||
['a', undefined],
|
||||
['a'.repeat(40), undefined],
|
||||
])('manual key %j', (key, message) => {
|
||||
expect(validateField(field({ key, keyMode: 'manual' }), []).key).toBe(message)
|
||||
})
|
||||
|
||||
it('leaves the key error to the label while an automatic key follows a blank label', () => {
|
||||
const errors = validateField(field({ label: '', key: '' }), [])
|
||||
|
||||
expect(errors.key).toBeUndefined()
|
||||
expect(errors.label).toBe('Give the field a label.')
|
||||
})
|
||||
|
||||
it('flags a key already used by an earlier field', () => {
|
||||
expect(validateField(field({ key: 'title' }), ['title']).key).toBe(
|
||||
'Another field already uses this key.',
|
||||
)
|
||||
})
|
||||
|
||||
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 errors = validateForm(form({ fields: [first, second] }))
|
||||
|
||||
expect([...errors.fields.keys()]).toEqual([2])
|
||||
expect(errors.fields.get(2)!.key).toBe('Another field already uses this key.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'number',
|
||||
{ min: '6000', max: '5000' },
|
||||
'min',
|
||||
"Lowest value can't be above the highest value.",
|
||||
],
|
||||
['text', { min: '10', max: '3' }, 'min', "Shortest can't be longer than longest."],
|
||||
[
|
||||
'date',
|
||||
{ min: '2027-12-31', max: '2026-01-01' },
|
||||
'min',
|
||||
"Earliest date can't be after the latest date.",
|
||||
],
|
||||
['text', { min: '2.5' }, 'min', 'Use a whole number, 0 or more.'],
|
||||
['text', { min: '-1' }, 'min', 'Use a whole number, 0 or more.'],
|
||||
['richtext', { max: 'abc' }, 'max', 'Use a whole number, 0 or more.'],
|
||||
[
|
||||
'number',
|
||||
{ min: '1', max: '5000', defaultValue: '0' },
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
[
|
||||
'number',
|
||||
{ max: '10', defaultValue: '11' },
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
['number', { defaultValue: 'x' }, 'default', 'Enter a number.'],
|
||||
[
|
||||
'text',
|
||||
{ max: '3', defaultValue: 'abcd' },
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
[
|
||||
'text',
|
||||
{ min: '3', defaultValue: 'ab' },
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
[
|
||||
'date',
|
||||
{ min: '2026-01-01', max: '2027-12-31', defaultValue: '2028-01-01' },
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
['select', { options: [] }, 'options', 'Add at least one choice.'],
|
||||
['number', { min: '1', max: '5000' }, 'min', undefined],
|
||||
['number', { min: '5', max: '5' }, 'min', undefined],
|
||||
['text', { min: '0' }, 'min', undefined],
|
||||
['number', { min: '1', max: '5000', defaultValue: '20' }, 'default', undefined],
|
||||
['text', { max: '3', defaultValue: 'abc' }, 'default', undefined],
|
||||
] as const)('%s with %j reports %s as %j', (type, settings, where, message) => {
|
||||
const errors = validateField(field({ type, ...settings } as Partial<BuilderField>), [])
|
||||
|
||||
expect(errors[where]).toBe(message)
|
||||
})
|
||||
|
||||
it('skips the default check while unique values drop the default', () => {
|
||||
const errors = validateField(field({ max: '3', defaultValue: 'abcd', unique: true }), [])
|
||||
|
||||
expect(errors.default).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flags a blank choice and a duplicate once per row, after trimming, case-sensitively', () => {
|
||||
const errors = validateField(
|
||||
field({
|
||||
type: 'select',
|
||||
options: ['Talk', 'Workshop', 'Meetup', ' ', ' Talk ', 'talk'],
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
expect(errors.choices).toEqual({
|
||||
3: "Choices can't be blank.",
|
||||
4: 'Two choices are the same.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('problem counting', () => {
|
||||
it('counts the three type-level inputs', () => {
|
||||
const errors = validateForm(form({ name: '', key: 'e', fields: [] }))
|
||||
|
||||
expect(countProblems(errors)).toBe(3)
|
||||
})
|
||||
|
||||
it('counts a field once whatever the number of rules it breaks', () => {
|
||||
const noLabel = createField(1, { label: '', key: '' })
|
||||
const inverted = createField(2, {
|
||||
label: 'N',
|
||||
key: 'n',
|
||||
type: 'number',
|
||||
min: '6',
|
||||
max: '5',
|
||||
})
|
||||
const choices = createField(3, {
|
||||
label: 'C',
|
||||
key: 'c',
|
||||
type: 'select',
|
||||
options: ['A', '', 'A'],
|
||||
})
|
||||
|
||||
expect(countProblems(validateForm(form({ fields: [noLabel, inverted, choices] })))).toBe(3)
|
||||
})
|
||||
|
||||
it('counts one choice list with a blank and a duplicate as one problem', () => {
|
||||
const choices = createField(1, {
|
||||
label: 'C',
|
||||
key: 'c',
|
||||
type: 'select',
|
||||
options: ['A', '', 'A'],
|
||||
})
|
||||
|
||||
expect(countProblems(validateForm(form({ fields: [choices] })))).toBe(1)
|
||||
})
|
||||
|
||||
it('counts one field with a bad key as one problem', () => {
|
||||
const bad = createField(1, { label: 'S', key: 'start-date', keyMode: 'manual' })
|
||||
|
||||
expect(countProblems(validateForm(form({ fields: [bad] })))).toBe(1)
|
||||
})
|
||||
|
||||
it('counts nothing for a valid form', () => {
|
||||
expect(countProblems(validateForm(form()))).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
export function moveItem<T>(items: T[], from: number, to: number) {
|
||||
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
|
||||
return items
|
||||
}
|
||||
const next = [...items]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(to, 0, moved!)
|
||||
return next
|
||||
}
|
||||
@@ -14,16 +14,6 @@ export function metaSummary(authors: Byline[], siteName: string | null) {
|
||||
return siteName ? t('{0} · {1}', [siteName, summary]) : summary
|
||||
}
|
||||
|
||||
export function moveItem<T>(items: T[], from: number, to: number) {
|
||||
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
|
||||
return items
|
||||
}
|
||||
const next = [...items]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(to, 0, moved!)
|
||||
return next
|
||||
}
|
||||
|
||||
export function insertionTarget(from: number, gap: number) {
|
||||
return gap > from ? gap - 1 : gap
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const HOME_PATH = '/home'
|
||||
export const SCHEMA_PATH = '/schema'
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BUILDER_LIMITS } from '@/lib/schemaBuilderModel'
|
||||
|
||||
function plain(text: string) {
|
||||
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
|
||||
}
|
||||
|
||||
export function deriveTypeKey(name: string) {
|
||||
const slug = plain(name)
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return slug.slice(0, BUILDER_LIMITS.typeKeyMax).replace(/-+$/, '')
|
||||
}
|
||||
|
||||
function fieldKeyBase(label: string) {
|
||||
return plain(label)
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^[^a-z]+/, '')
|
||||
.slice(0, BUILDER_LIMITS.fieldKeyMax)
|
||||
.replace(/_+$/, '')
|
||||
}
|
||||
|
||||
export function deriveFieldKey(label: string, taken: readonly string[]) {
|
||||
const base = fieldKeyBase(label)
|
||||
if (!base || !taken.includes(base)) {
|
||||
return base
|
||||
}
|
||||
for (let attempt = 2; ; attempt++) {
|
||||
const suffix = `_${attempt}`
|
||||
const candidate =
|
||||
base.slice(0, BUILDER_LIMITS.fieldKeyMax - suffix.length).replace(/_+$/, '') + suffix
|
||||
if (!taken.includes(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const
|
||||
|
||||
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 const BUILDER_LIMITS = {
|
||||
nameMax: 100,
|
||||
typeKeyMin: 2,
|
||||
typeKeyMax: 40,
|
||||
fieldsMax: 50,
|
||||
fieldKeyMax: 40,
|
||||
choicesMax: 50,
|
||||
}
|
||||
|
||||
export const RESERVED_TYPE_KEYS = ['new']
|
||||
export const PREVIEW_KEY_FALLBACK = 'events'
|
||||
export const STARTER_LABEL = 'Title'
|
||||
export const STARTER_KEY = 'title'
|
||||
|
||||
export interface BuilderField {
|
||||
uid: number
|
||||
label: string
|
||||
key: string
|
||||
keyMode: KeyMode
|
||||
type: FieldType
|
||||
required: boolean
|
||||
unique: boolean
|
||||
defaultValue: string
|
||||
defaultOn: boolean
|
||||
min: string
|
||||
max: string
|
||||
options: string[]
|
||||
}
|
||||
|
||||
export interface BuilderForm {
|
||||
name: string
|
||||
key: string
|
||||
keyMode: KeyMode
|
||||
fields: BuilderField[]
|
||||
}
|
||||
|
||||
export function keyDisplayMode(mode: KeyMode, key: string): KeyDisplayMode {
|
||||
if (mode === 'manual') {
|
||||
return 'manual'
|
||||
}
|
||||
return key ? 'auto' : 'empty'
|
||||
}
|
||||
|
||||
export function createField(uid: number, overrides: Partial<BuilderField> = {}): BuilderField {
|
||||
return {
|
||||
uid,
|
||||
label: '',
|
||||
key: '',
|
||||
keyMode: 'auto',
|
||||
type: 'text',
|
||||
required: false,
|
||||
unique: false,
|
||||
defaultValue: '',
|
||||
defaultOn: false,
|
||||
min: '',
|
||||
max: '',
|
||||
options: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function starterField(uid: number): BuilderField {
|
||||
return createField(uid, { label: STARTER_LABEL, key: STARTER_KEY })
|
||||
}
|
||||
|
||||
export function blankForm(uid: number): BuilderForm {
|
||||
return { name: '', key: '', keyMode: 'auto', fields: [starterField(uid)] }
|
||||
}
|
||||
|
||||
export function changeFieldType(field: BuilderField, type: FieldType): BuilderField {
|
||||
if (field.type === type) {
|
||||
return field
|
||||
}
|
||||
return createField(field.uid, {
|
||||
label: field.label,
|
||||
key: field.key,
|
||||
keyMode: field.keyMode,
|
||||
required: field.required,
|
||||
type,
|
||||
})
|
||||
}
|
||||
|
||||
export function supportsUnique(type: FieldType) {
|
||||
return type !== 'richtext' && type !== 'boolean'
|
||||
}
|
||||
|
||||
export function supportsLimits(type: FieldType) {
|
||||
return type === 'text' || type === 'richtext' || type === 'number' || type === 'date'
|
||||
}
|
||||
|
||||
export function hasDefaultValue(field: BuilderField) {
|
||||
return !field.unique && field.defaultValue !== ''
|
||||
}
|
||||
|
||||
export function trimmedOptions(field: BuilderField) {
|
||||
return field.options.map((option) => option.trim())
|
||||
}
|
||||
|
||||
export function clearStaleDefault(field: BuilderField): BuilderField {
|
||||
if (field.type !== 'select' || field.defaultValue === '') {
|
||||
return field
|
||||
}
|
||||
return trimmedOptions(field).includes(field.defaultValue)
|
||||
? field
|
||||
: { ...field, defaultValue: '' }
|
||||
}
|
||||
|
||||
export function isUnique(field: BuilderField) {
|
||||
return field.unique && supportsUnique(field.type)
|
||||
}
|
||||
|
||||
export function effectiveDefault(field: BuilderField) {
|
||||
if (isUnique(field) || field.defaultValue === '') {
|
||||
return ''
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
return trimmedOptions(field).includes(field.defaultValue) ? field.defaultValue : ''
|
||||
}
|
||||
return field.defaultValue
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
effectiveDefault,
|
||||
isUnique,
|
||||
supportsLimits,
|
||||
trimmedOptions,
|
||||
type BuilderField,
|
||||
type BuilderForm,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
|
||||
export interface PayloadField {
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
unique: boolean
|
||||
default: string | number | boolean | null
|
||||
min: number | string | null
|
||||
max: number | string | null
|
||||
options: string[]
|
||||
}
|
||||
|
||||
export interface ContentTypePayload {
|
||||
key: string
|
||||
name: string
|
||||
fields: PayloadField[]
|
||||
}
|
||||
|
||||
function limit(field: BuilderField, text: string) {
|
||||
if (!supportsLimits(field.type) || text.trim() === '') {
|
||||
return null
|
||||
}
|
||||
return field.type === 'date' ? text : Number(text)
|
||||
}
|
||||
|
||||
function defaultFor(field: BuilderField) {
|
||||
if (field.type === 'boolean') {
|
||||
return field.defaultOn ? true : null
|
||||
}
|
||||
const given = effectiveDefault(field)
|
||||
if (given === '') {
|
||||
return null
|
||||
}
|
||||
return field.type === 'number' ? Number(given) : given
|
||||
}
|
||||
|
||||
export function payloadField(field: BuilderField): PayloadField {
|
||||
return {
|
||||
key: field.key,
|
||||
label: field.label.trim(),
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
unique: isUnique(field),
|
||||
default: defaultFor(field),
|
||||
min: limit(field, field.min),
|
||||
max: limit(field, field.max),
|
||||
options: field.type === 'select' ? trimmedOptions(field) : [],
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPayload(form: BuilderForm): ContentTypePayload {
|
||||
return {
|
||||
key: form.key,
|
||||
name: form.name.trim(),
|
||||
fields: form.fields.map(payloadField),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
PREVIEW_KEY_FALLBACK,
|
||||
effectiveDefault,
|
||||
trimmedOptions,
|
||||
type BuilderField,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import { isValidFieldKey } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
const SAMPLE_TEXT = 'Example text'
|
||||
const SAMPLE_MARKDOWN = 'Markdown text'
|
||||
const SAMPLE_DATE = '2026-11-21'
|
||||
const SAMPLE_ID = 42
|
||||
const SAMPLE_USER = 3
|
||||
const SAMPLE_MOMENT = '2026-10-15T11:02:00Z'
|
||||
const INDENT = 2
|
||||
|
||||
export function previewKey(key: string) {
|
||||
return key === '' ? PREVIEW_KEY_FALLBACK : key
|
||||
}
|
||||
|
||||
export function previewRoutes(key: string) {
|
||||
const base = `/v1/content-type/${previewKey(key)}/entry`
|
||||
const item = `${base}/{id}`
|
||||
return [
|
||||
{ method: 'GET', path: base },
|
||||
{ method: 'POST', path: base },
|
||||
{ method: 'GET', path: item },
|
||||
{ method: 'PUT', path: item },
|
||||
{ method: 'DELETE', path: item },
|
||||
]
|
||||
}
|
||||
|
||||
export function sampleValue(field: BuilderField): string | number | boolean | null {
|
||||
const given = effectiveDefault(field)
|
||||
switch (field.type) {
|
||||
case 'richtext':
|
||||
return given === '' ? SAMPLE_MARKDOWN : given
|
||||
case 'number': {
|
||||
const fromDefault = Number(given)
|
||||
const fromMin = Number(field.min)
|
||||
if (given !== '' && Number.isFinite(fromDefault)) {
|
||||
return fromDefault
|
||||
}
|
||||
return field.min.trim() !== '' && Number.isFinite(fromMin) ? fromMin : 0
|
||||
}
|
||||
case 'boolean':
|
||||
return field.defaultOn
|
||||
case 'date':
|
||||
return given === '' ? SAMPLE_DATE : given
|
||||
case 'select': {
|
||||
const choices = trimmedOptions(field).filter((option) => option !== '')
|
||||
return given !== '' ? given : (choices[0] ?? null)
|
||||
}
|
||||
default:
|
||||
return given === '' ? SAMPLE_TEXT : given
|
||||
}
|
||||
}
|
||||
|
||||
export function sampleEntry(key: string, fields: readonly BuilderField[]) {
|
||||
const data: Record<string, string | number | boolean | null> = {}
|
||||
for (const field of fields) {
|
||||
if (isValidFieldKey(field.key)) {
|
||||
data[field.key] = sampleValue(field)
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: SAMPLE_ID,
|
||||
content_type: previewKey(key),
|
||||
data,
|
||||
created_by: SAMPLE_USER,
|
||||
updated_by: SAMPLE_USER,
|
||||
created_at: SAMPLE_MOMENT,
|
||||
updated_at: SAMPLE_MOMENT,
|
||||
}
|
||||
}
|
||||
|
||||
export function sampleJson(key: string, fields: readonly BuilderField[]) {
|
||||
return JSON.stringify(sampleEntry(key, fields), null, INDENT)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
|
||||
|
||||
export type TypeTouch = 'name' | 'key' | 'fields'
|
||||
export type FieldTouch = 'label' | 'key' | 'min' | 'max' | 'default' | 'choices'
|
||||
|
||||
export function typeTouchKey(name: TypeTouch) {
|
||||
return `type:${name}`
|
||||
}
|
||||
|
||||
export function fieldTouchKey(uid: number, name: FieldTouch) {
|
||||
return `field:${uid}:${name}`
|
||||
}
|
||||
|
||||
export const FIELD_ERROR_TOUCH: readonly (readonly [keyof FieldErrors, FieldTouch])[] = [
|
||||
['label', 'label'],
|
||||
['key', 'key'],
|
||||
['min', 'min'],
|
||||
['max', 'max'],
|
||||
['default', 'default'],
|
||||
['options', 'choices'],
|
||||
['choices', 'choices'],
|
||||
]
|
||||
@@ -0,0 +1,227 @@
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
BUILDER_LIMITS,
|
||||
RESERVED_TYPE_KEYS,
|
||||
trimmedOptions,
|
||||
type BuilderField,
|
||||
type BuilderForm,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
|
||||
const TYPE_KEY_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
const FIELD_KEY_PATTERN = /^[a-z][a-z0-9_]*$/
|
||||
const WHOLE_PATTERN = /^\d+$/
|
||||
|
||||
export interface TypeErrors {
|
||||
name?: string
|
||||
key?: string
|
||||
fields?: string
|
||||
}
|
||||
|
||||
export interface FieldErrors {
|
||||
label?: string
|
||||
key?: string
|
||||
min?: string
|
||||
max?: string
|
||||
default?: string
|
||||
options?: string
|
||||
choices?: Record<number, string>
|
||||
}
|
||||
|
||||
export interface FormErrors {
|
||||
type: TypeErrors
|
||||
fields: Map<number, FieldErrors>
|
||||
}
|
||||
|
||||
export function isValidTypeKey(key: string) {
|
||||
return (
|
||||
key.length >= BUILDER_LIMITS.typeKeyMin &&
|
||||
key.length <= BUILDER_LIMITS.typeKeyMax &&
|
||||
TYPE_KEY_PATTERN.test(key)
|
||||
)
|
||||
}
|
||||
|
||||
export function isValidFieldKey(key: string) {
|
||||
return key.length <= BUILDER_LIMITS.fieldKeyMax && FIELD_KEY_PATTERN.test(key)
|
||||
}
|
||||
|
||||
export function validateType(form: BuilderForm): TypeErrors {
|
||||
const errors: TypeErrors = {}
|
||||
if (form.name.trim() === '') {
|
||||
errors.name = t('Give the content type a name.')
|
||||
} 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.fields.length === 0) {
|
||||
errors.fields = t('A content type needs at least one field.')
|
||||
} else if (form.fields.length > BUILDER_LIMITS.fieldsMax) {
|
||||
errors.fields = t('A content type can have up to 50 fields.')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
function parseWhole(text: string) {
|
||||
return WHOLE_PATTERN.test(text.trim()) ? Number(text.trim()) : null
|
||||
}
|
||||
|
||||
function parseNumber(text: string) {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : Number.NaN
|
||||
}
|
||||
|
||||
function textLimits(field: BuilderField, errors: FieldErrors) {
|
||||
let min: number | null = null
|
||||
let max: number | null = null
|
||||
if (field.min.trim() !== '') {
|
||||
min = parseWhole(field.min)
|
||||
if (min === null) {
|
||||
errors.min = t('Use a whole number, 0 or more.')
|
||||
}
|
||||
}
|
||||
if (field.max.trim() !== '') {
|
||||
max = parseWhole(field.max)
|
||||
if (max === null) {
|
||||
errors.max = t('Use a whole number, 0 or more.')
|
||||
}
|
||||
}
|
||||
if (min !== null && max !== null && min > max) {
|
||||
errors.min = t("Shortest can't be longer than longest.")
|
||||
}
|
||||
if (field.defaultValue !== '' && !field.unique) {
|
||||
const length = Array.from(field.defaultValue).length
|
||||
if ((min !== null && length < min) || (max !== null && length > max)) {
|
||||
errors.default = t('The default value is outside the limits.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function numberLimits(field: BuilderField, errors: FieldErrors) {
|
||||
const min = parseNumber(field.min)
|
||||
const max = parseNumber(field.max)
|
||||
if (Number.isNaN(min)) {
|
||||
errors.min = t('Enter a number.')
|
||||
}
|
||||
if (Number.isNaN(max)) {
|
||||
errors.max = t('Enter a number.')
|
||||
}
|
||||
if (min !== null && max !== null && min > max) {
|
||||
errors.min = t("Lowest value can't be above the highest value.")
|
||||
}
|
||||
if (field.defaultValue !== '' && !field.unique) {
|
||||
const value = parseNumber(field.defaultValue)
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
errors.default = t('Enter a number.')
|
||||
} else if (
|
||||
(min !== null && !Number.isNaN(min) && value < min) ||
|
||||
(max !== null && !Number.isNaN(max) && value > max)
|
||||
) {
|
||||
errors.default = t('The default value is outside the limits.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dateLimits(field: BuilderField, errors: FieldErrors) {
|
||||
const min = field.min
|
||||
const max = field.max
|
||||
if (min !== '' && max !== '' && min > max) {
|
||||
errors.min = t("Earliest date can't be after the latest date.")
|
||||
}
|
||||
if (field.defaultValue !== '' && !field.unique) {
|
||||
if ((min !== '' && field.defaultValue < min) || (max !== '' && field.defaultValue > max)) {
|
||||
errors.default = t('The default value is outside the limits.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function choiceErrors(field: BuilderField, errors: FieldErrors) {
|
||||
const options = trimmedOptions(field)
|
||||
if (options.length === 0) {
|
||||
errors.options = t('Add at least one choice.')
|
||||
return
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const rows: Record<number, string> = {}
|
||||
options.forEach((option, index) => {
|
||||
if (option === '') {
|
||||
rows[index] = t("Choices can't be blank.")
|
||||
} else if (seen.has(option)) {
|
||||
rows[index] = t('Two choices are the same.')
|
||||
} else {
|
||||
seen.add(option)
|
||||
}
|
||||
})
|
||||
if (Object.keys(rows).length > 0) {
|
||||
errors.choices = rows
|
||||
}
|
||||
}
|
||||
|
||||
function keyError(field: BuilderField, earlierKeys: readonly string[]) {
|
||||
if (field.keyMode === 'auto' && field.label.trim() === '') {
|
||||
return undefined
|
||||
}
|
||||
if (!isValidFieldKey(field.key)) {
|
||||
return t('Start with a letter. Use lowercase letters, digits and underscores.')
|
||||
}
|
||||
if (earlierKeys.includes(field.key)) {
|
||||
return t('Another field already uses this key.')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function validateField(field: BuilderField, earlierKeys: readonly string[]): FieldErrors {
|
||||
const errors: FieldErrors = {}
|
||||
if (field.label.trim() === '') {
|
||||
errors.label = t('Give the field a label.')
|
||||
}
|
||||
const key = keyError(field, earlierKeys)
|
||||
if (key) {
|
||||
errors.key = key
|
||||
}
|
||||
if (field.type === 'text' || field.type === 'richtext') {
|
||||
textLimits(field, errors)
|
||||
} else if (field.type === 'number') {
|
||||
numberLimits(field, errors)
|
||||
} else if (field.type === 'date') {
|
||||
dateLimits(field, errors)
|
||||
} else if (field.type === 'select') {
|
||||
choiceErrors(field, errors)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function hasFieldErrors(errors: FieldErrors) {
|
||||
return Object.keys(errors).length > 0
|
||||
}
|
||||
|
||||
export function validateForm(form: BuilderForm): FormErrors {
|
||||
const fields = new Map<number, FieldErrors>()
|
||||
const earlierKeys: string[] = []
|
||||
for (const field of form.fields) {
|
||||
const errors = validateField(field, earlierKeys)
|
||||
if (hasFieldErrors(errors)) {
|
||||
fields.set(field.uid, errors)
|
||||
}
|
||||
earlierKeys.push(field.key)
|
||||
}
|
||||
return { type: validateType(form), fields }
|
||||
}
|
||||
|
||||
export function countErrors(errors: FormErrors) {
|
||||
let total = Object.keys(errors.type).length
|
||||
for (const fieldErrors of errors.fields.values()) {
|
||||
total += Object.keys(fieldErrors).length
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
export function countProblems(errors: FormErrors) {
|
||||
return Object.keys(errors.type).length + errors.fields.size
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const BOTTOM_SHEET_UI = {
|
||||
content:
|
||||
'h-11/12 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4',
|
||||
title: 'text-lg font-semibold text-highlighted',
|
||||
description: 'sr-only',
|
||||
close: 'top-4.5 end-2 size-11 justify-center p-0',
|
||||
body: 'flex flex-col gap-4 p-4 sm:p-4',
|
||||
footer: 'border-t border-default px-4 py-3 sm:px-4',
|
||||
}
|
||||
@@ -189,6 +189,44 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('The new content type route is registered before the type key route, verified-only, titled Schema', () => {
|
||||
const resolved = router.resolve('/schema/new')
|
||||
|
||||
expect(resolved.matched).toHaveLength(1)
|
||||
expect(resolved.matched[0]!.path).toBe('/schema/new')
|
||||
expect(resolved.params.key).toBeUndefined()
|
||||
expect(resolved.meta.auth).toBe('verified')
|
||||
expect(resolved.meta.title).toBe('Schema')
|
||||
expect(resolved.meta.needsBuiltinTypes).not.toBe(true)
|
||||
expect(router.resolve('/schema/articles').meta.needsBuiltinTypes).toBe(true)
|
||||
expect(router.resolve('/schema').meta.needsBuiltinTypes).toBe(true)
|
||||
})
|
||||
|
||||
it('The new content type route redirects an unverified session to the pending page', async () => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: false })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
|
||||
await router.push('/schema/new')
|
||||
mountApp()
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
|
||||
})
|
||||
|
||||
it('The new content 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()
|
||||
|
||||
await router.push('/schema/new')
|
||||
mountApp()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/login?redirect=/schema/new&reason=expired',
|
||||
)
|
||||
})
|
||||
|
||||
it('A session-only route admits an unverified session', async () => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: false })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
|
||||
@@ -90,12 +90,17 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/schema',
|
||||
component: () => import('@/views/SchemaView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
|
||||
},
|
||||
{
|
||||
path: '/schema/new',
|
||||
component: () => import('@/views/SchemaBuilderView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema' },
|
||||
},
|
||||
{
|
||||
path: '/schema/:key',
|
||||
component: () => import('@/views/SchemaTypeView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema' },
|
||||
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
|
||||
},
|
||||
{
|
||||
path: '/activity',
|
||||
|
||||
@@ -194,4 +194,89 @@ describe('content types store', () => {
|
||||
expect(store.status).toBe('idle')
|
||||
expect(store.builtinStatus).toBe('idle')
|
||||
})
|
||||
|
||||
describe('add', () => {
|
||||
const recipes = { ...events, id: 2, key: 'recipes', name: 'Recipes' }
|
||||
const agenda = { ...events, id: 3, key: 'agenda', name: 'agenda' }
|
||||
|
||||
it('inserts the type in name order, ignoring case, once the list is ready', async () => {
|
||||
listContentTypes.mockResolvedValue([events, recipes])
|
||||
const store = useContentTypesStore()
|
||||
await store.load()
|
||||
|
||||
store.add(agenda)
|
||||
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([
|
||||
'agenda',
|
||||
'Events',
|
||||
'Press mentions',
|
||||
'Recipes',
|
||||
'Zoo',
|
||||
])
|
||||
})
|
||||
|
||||
it('does nothing while the list is not loaded, so the next load returns it', async () => {
|
||||
listContentTypes.mockResolvedValue([events, agenda])
|
||||
const store = useContentTypesStore()
|
||||
|
||||
store.add(agenda)
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.status).toBe('idle')
|
||||
|
||||
await store.load()
|
||||
expect(store.items).toEqual([events, agenda])
|
||||
})
|
||||
|
||||
it('leaves the built-in items alone and sends no request', async () => {
|
||||
listContentTypes.mockResolvedValue([events])
|
||||
listBuiltinTypes.mockResolvedValue([{ key: 'articles', name: 'Articles', fields: [] }])
|
||||
const store = useContentTypesStore()
|
||||
await store.load({ builtin: true })
|
||||
listContentTypes.mockClear()
|
||||
listBuiltinTypes.mockClear()
|
||||
|
||||
store.add(recipes)
|
||||
|
||||
expect(store.builtinItems).toHaveLength(1)
|
||||
expect(listContentTypes).not.toHaveBeenCalled()
|
||||
expect(listBuiltinTypes).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a type created while the custom list is still loading', async () => {
|
||||
const first = deferred()
|
||||
listContentTypes.mockReturnValueOnce(first.promise).mockResolvedValueOnce([events])
|
||||
const store = useContentTypesStore()
|
||||
|
||||
const loaded = store.load()
|
||||
store.add(events)
|
||||
first.resolve([])
|
||||
await loaded
|
||||
|
||||
expect(listContentTypes).toHaveBeenCalledTimes(2)
|
||||
expect(store.items).toEqual([events])
|
||||
expect(store.customLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refetch when nothing was created during the load', async () => {
|
||||
listContentTypes.mockResolvedValue([events])
|
||||
const store = useContentTypesStore()
|
||||
|
||||
await store.load()
|
||||
|
||||
expect(listContentTypes).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores an add before any load started', async () => {
|
||||
listContentTypes.mockResolvedValue([])
|
||||
const store = useContentTypesStore()
|
||||
|
||||
store.add(events)
|
||||
await store.load()
|
||||
|
||||
expect(listContentTypes).toHaveBeenCalledTimes(1)
|
||||
expect(store.items).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -14,13 +14,18 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
|
||||
const status = ref<ContentTypesStatus>('idle')
|
||||
let pending: Promise<void> | null = null
|
||||
let generation = 0
|
||||
let stale = false
|
||||
|
||||
async function run(token: number) {
|
||||
try {
|
||||
const listed = await fetcher()
|
||||
if (token !== generation) {
|
||||
return
|
||||
}
|
||||
let listed: T[]
|
||||
do {
|
||||
stale = false
|
||||
listed = await fetcher()
|
||||
if (token !== generation) {
|
||||
return
|
||||
}
|
||||
} while (stale)
|
||||
items.value = listed
|
||||
status.value = 'ready'
|
||||
} catch (error) {
|
||||
@@ -52,6 +57,7 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
|
||||
|
||||
function retry() {
|
||||
generation++
|
||||
stale = false
|
||||
pending = null
|
||||
status.value = 'idle'
|
||||
return load()
|
||||
@@ -59,14 +65,28 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
|
||||
|
||||
function reset() {
|
||||
generation++
|
||||
stale = false
|
||||
pending = null
|
||||
items.value = []
|
||||
status.value = 'idle'
|
||||
}
|
||||
|
||||
function insert(item: T, 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(before)
|
||||
list.splice(index === -1 ? list.length : index, 0, item)
|
||||
}
|
||||
|
||||
const loading = computed(() => status.value === 'idle' || status.value === 'loading')
|
||||
|
||||
return { items, status, loading, load, retry, reset }
|
||||
return { items, status, loading, load, retry, reset, insert }
|
||||
}
|
||||
|
||||
export const useContentTypesStore = defineStore('contentTypes', () => {
|
||||
@@ -93,6 +113,11 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
|
||||
return Promise.all(requests).then(() => undefined)
|
||||
}
|
||||
|
||||
function add(type: ContentType) {
|
||||
const name = type.name.toLowerCase()
|
||||
custom.insert(type, (existing) => existing.name.toLowerCase() > name)
|
||||
}
|
||||
|
||||
function retryCustom() {
|
||||
return custom.retry()
|
||||
}
|
||||
@@ -116,6 +141,7 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
|
||||
error,
|
||||
forbidden,
|
||||
load,
|
||||
add,
|
||||
retryCustom,
|
||||
retryBuiltin,
|
||||
reset,
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<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 DashboardShell from '@/components/DashboardShell.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 { 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 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>
|
||||
<DashboardShell
|
||||
full-bleed
|
||||
:mobile-title="mobileTitle"
|
||||
:back-to="SCHEMA_PATH"
|
||||
:back-label="t('Schema')"
|
||||
:offline-message="t(OFFLINE_COPY)"
|
||||
offline-icon="i-lucide-wifi-off"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="breadcrumb"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
:ui="{ link: 'text-sm font-medium' }"
|
||||
/>
|
||||
</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>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -11,8 +11,7 @@ import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import SchemaTable from '@/components/SchemaTable.vue'
|
||||
import SchemaTypeNotFound from '@/components/SchemaTypeNotFound.vue'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
|
||||
const LIST_PATH = '/schema'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -23,12 +22,12 @@ 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 breadcrumb = computed(() => [
|
||||
{ label: t('Schema'), to: LIST_PATH },
|
||||
{ label: t('Schema'), to: SCHEMA_PATH },
|
||||
...(type.value ? [{ label: type.value.name }] : []),
|
||||
])
|
||||
|
||||
function goHome() {
|
||||
router.push('/home')
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,7 +36,7 @@ function goHome() {
|
||||
compact-padding
|
||||
:full-bleed="forbidden || (sessionReady && !loading && !failed && !type)"
|
||||
:mobile-title="mobileTitle"
|
||||
:back-to="LIST_PATH"
|
||||
:back-to="SCHEMA_PATH"
|
||||
:back-label="t('Schema')"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { button, mountAt, posts, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
all,
|
||||
byLabel,
|
||||
chip,
|
||||
click,
|
||||
discardButton,
|
||||
el,
|
||||
keyField,
|
||||
keyInput,
|
||||
main,
|
||||
nameInput,
|
||||
rows,
|
||||
saveButton,
|
||||
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 KEY_MESSAGE = 'Use 2 to 40 lowercase letters, digits or hyphens.'
|
||||
|
||||
function errorsOf(container: Element | null) {
|
||||
return [...(container?.querySelectorAll('[role="alert"]') ?? [])].map(text)
|
||||
}
|
||||
|
||||
function nameField() {
|
||||
return nameInput()!.closest<HTMLElement>('[data-orientation]')!
|
||||
}
|
||||
|
||||
async function unlockKey() {
|
||||
await click(keyField().querySelector('button[aria-label="Edit key"]'))
|
||||
}
|
||||
|
||||
describe('Type-level rules (design states 6 and 14)', () => {
|
||||
it.each([
|
||||
['', 'Give the content type a name.'],
|
||||
[' ', 'Give the content type a name.'],
|
||||
])(
|
||||
'shows the name message for %j with an alert icon and sends nothing',
|
||||
async (value, message) => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await typeInto(nameInput(), value)
|
||||
await click(saveButton())
|
||||
|
||||
expect(errorsOf(nameField())).toEqual([message])
|
||||
expect(nameField().querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(posts).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['e', KEY_MESSAGE],
|
||||
['Events', KEY_MESSAGE],
|
||||
['my_type', KEY_MESSAGE],
|
||||
['-events', KEY_MESSAGE],
|
||||
['events--2', KEY_MESSAGE],
|
||||
['new', 'The key "new" is reserved. Choose a different key.'],
|
||||
])('shows the key message for %j and sends nothing', async (value, message) => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await unlockKey()
|
||||
|
||||
await typeInto(keyInput(), value)
|
||||
await click(saveButton())
|
||||
|
||||
expect(errorsOf(keyField())).toEqual([message])
|
||||
expect(keyField().querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(posts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(['ab', 'press-mentions', '2026-awards', 'a'.repeat(40)])(
|
||||
'sends the accepted key %j',
|
||||
async (key) => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'X')
|
||||
await unlockKey()
|
||||
await typeInto(keyInput(), key)
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.body.key).toBe(key)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Type-level errors together (design state 6)', () => {
|
||||
async function reachState6() {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'x')
|
||||
await unlockKey()
|
||||
await typeInto(keyInput(), 'e')
|
||||
await typeInto(nameInput(), '')
|
||||
await click(byLabel('Remove'))
|
||||
}
|
||||
|
||||
it('shows the three messages, the empty list, the chip, and disables Save only', async () => {
|
||||
await reachState6()
|
||||
|
||||
expect(errorsOf(nameField())).toEqual(['Give the content type a name.'])
|
||||
expect(errorsOf(keyField())).toEqual([KEY_MESSAGE])
|
||||
const empty = el('[data-testid="field-empty"]')!
|
||||
expect(text(empty)).toContain('No fields yet')
|
||||
expect(text(empty)).toContain('A content type needs at least one field.')
|
||||
expect(empty.querySelector('.text-error')).not.toBeNull()
|
||||
expect(chip()).toBe('3 problems to fix')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(discardButton()!.disabled).toBe(false)
|
||||
expect(text(el('[data-testid="inspector-pane"]'))).toContain('Field settings')
|
||||
expect(text(el('[data-testid="inspector-pane"]'))).toContain(
|
||||
'Add a field, then choose its type and rules here.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reveals every error at once when Save is activated, then enables Save once fixed', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(byLabel('Remove'))
|
||||
await click(button('Add field'))
|
||||
expect(chip()).toBe('3 problems to fix')
|
||||
expect(errorsOf(nameField())).toEqual([])
|
||||
expect(errorsOf(keyField())).toEqual([])
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
expect(saveButton()!.getAttribute('aria-disabled')).toBe('true')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(chip()).toBe('3 problems to fix')
|
||||
expect(errorsOf(nameField())).toEqual(['Give the content type a name.'])
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(posts).toHaveLength(0)
|
||||
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'Title')
|
||||
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('When errors are shown', () => {
|
||||
it('shows none on a pristine form', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(all('[role="alert"]')).toHaveLength(0)
|
||||
expect(all('[aria-invalid="true"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows an error for the input being edited and for no other', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(button('Add field'))
|
||||
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await typeInto(nameInput(), '')
|
||||
|
||||
expect(errorsOf(main())).toEqual(['Give the content type a name.'])
|
||||
})
|
||||
|
||||
it('ties each error to its input with aria-invalid and aria-describedby', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'x')
|
||||
await typeInto(nameInput(), '')
|
||||
|
||||
const input = nameInput()!
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true')
|
||||
const describedBy = input.getAttribute('aria-describedby')!
|
||||
expect(describedBy).not.toBe('')
|
||||
expect(text(document.getElementById(describedBy.split(' ')[0]!))).toBe(
|
||||
'Give the content type a name.',
|
||||
)
|
||||
})
|
||||
|
||||
it('marks a row with an error with an alert icon, not only a border', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(button('Add field'))
|
||||
await typeInto(el<HTMLInputElement>('input[name="label"]'), 'x')
|
||||
await typeInto(el<HTMLInputElement>('input[name="label"]'), '')
|
||||
|
||||
const invalid = rows().filter((row) => row.querySelector('[data-testid="field-row-alert"]'))
|
||||
expect(invalid).toHaveLength(1)
|
||||
expect(invalid[0]!.className).toContain('border-error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,727 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { button, failWithBody, mountAt, posts, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
addChoice,
|
||||
addChoiceInput,
|
||||
addField,
|
||||
all,
|
||||
byLabel,
|
||||
choiceValues,
|
||||
chip,
|
||||
click,
|
||||
defaultInput,
|
||||
el,
|
||||
fieldKeyInput,
|
||||
inspector,
|
||||
labelInput,
|
||||
limit,
|
||||
nameInput,
|
||||
pickTile,
|
||||
press,
|
||||
rowLabels,
|
||||
rows,
|
||||
saveButton,
|
||||
selectRow,
|
||||
switchOf,
|
||||
tile,
|
||||
toggle,
|
||||
typeInto,
|
||||
} 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)
|
||||
|
||||
function fieldRoot(input: HTMLElement | null) {
|
||||
return input!.closest<HTMLElement>('[data-orientation]')!
|
||||
}
|
||||
|
||||
function errorsIn(container: Element | null) {
|
||||
return [...(container?.querySelectorAll('[role="alert"]') ?? [])].map(text)
|
||||
}
|
||||
|
||||
async function unlockFieldKey() {
|
||||
await click(fieldRoot(fieldKeyInput()).querySelector('button[aria-label="Edit key"]'))
|
||||
}
|
||||
|
||||
const KEY_RULE = 'Start with a letter. Use lowercase letters, digits and underscores.'
|
||||
|
||||
describe('Field list rows', () => {
|
||||
it('shows a grip, the type icon box, the label and the mono key of each row', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await pickTile('Date')
|
||||
await addField('Online')
|
||||
await pickTile('Yes or no')
|
||||
|
||||
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Online'])
|
||||
const second = rows()[1]!
|
||||
expect(second.querySelector('[data-grip]')!.getAttribute('aria-label')).toBe(
|
||||
'Move Starts on',
|
||||
)
|
||||
expect(second.querySelector('[data-field-row] svg')).not.toBeNull()
|
||||
expect(text(second.querySelector('[data-testid="field-row-key"]'))).toBe('starts_on')
|
||||
})
|
||||
|
||||
it('shows the badges on an unselected row and the actions on the selected one only', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await toggle('Required')
|
||||
await toggle('Unique values')
|
||||
await addField('Second')
|
||||
|
||||
const first = rows()[0]!
|
||||
expect(text(first.querySelector('[data-testid="field-row-badges"]'))).toBe(
|
||||
'New Required Unique',
|
||||
)
|
||||
expect(first.querySelector('[data-action]')).toBeNull()
|
||||
const selected = rows()[1]!
|
||||
expect(selected.className).toContain('bg-(--rc-sand)')
|
||||
expect(
|
||||
[...selected.querySelectorAll('[data-action]')].map((node) =>
|
||||
node.getAttribute('aria-label'),
|
||||
),
|
||||
).toEqual(['Move up', 'Move down', 'Remove'])
|
||||
})
|
||||
|
||||
it('reads Untitled field and a dash while a field has no label or key', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await click(button('Add field'))
|
||||
|
||||
const added = rows()[1]!
|
||||
expect(text(added.querySelector('[data-testid="field-row-label"]'))).toBe('Untitled field')
|
||||
expect(text(added.querySelector('[data-testid="field-row-key"]'))).toBe('–')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adding a field', () => {
|
||||
it('appends a selected Text row, focuses the label and counts 2 of 50', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await click(button('Add field'))
|
||||
|
||||
expect(rows()).toHaveLength(2)
|
||||
expect(rows()[1]!.querySelector('[data-field-row]')!.getAttribute('aria-current')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
|
||||
'Text',
|
||||
)
|
||||
expect(labelInput()!.value).toBe('')
|
||||
expect(document.activeElement).toBe(labelInput())
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('2 of 50')
|
||||
})
|
||||
|
||||
it('disables Add field at 50 fields', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
for (let count = 1; count < 50; count++) {
|
||||
await click(button('Add field'))
|
||||
}
|
||||
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('50 of 50')
|
||||
expect((button('Add field') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Field key from the label', () => {
|
||||
it.each([
|
||||
['Title', 'title_2'],
|
||||
['Starts on', 'starts_on'],
|
||||
['Éléments', 'elements'],
|
||||
['Ends on (date)', 'ends_on_date'],
|
||||
['2nd room', 'nd_room'],
|
||||
['a'.repeat(60), 'a'.repeat(40)],
|
||||
])('label %j gives the key %j with the Auto badge', async (label, key) => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await addField(label)
|
||||
|
||||
expect(fieldKeyInput()!.value).toBe(key)
|
||||
expect(fieldKeyInput()!.readOnly).toBe(true)
|
||||
expect(text(fieldRoot(fieldKeyInput()))).toContain('Auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Field key rules (design state 7)', () => {
|
||||
it.each(['Title', '1st', 'a-b', 'a b', ''])('shows the key message for %j', async (key) => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Thing')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), key)
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([KEY_RULE])
|
||||
})
|
||||
|
||||
it.each(['starts_on', 'a', 'a'.repeat(40)])('accepts %j', async (key) => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Thing')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), key)
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([])
|
||||
expect(fieldKeyInput()!.maxLength).toBe(40)
|
||||
})
|
||||
|
||||
it('flags a key already used by an earlier field under the second field', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await addField('Second')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), 'title')
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([
|
||||
'Another field already uses this key.',
|
||||
])
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
})
|
||||
|
||||
it('shows one error with the alert icon, the chip and a disabled Save for a hyphenated key', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await addField('Start date')
|
||||
await unlockFieldKey()
|
||||
|
||||
await typeInto(fieldKeyInput(), 'start-date')
|
||||
|
||||
expect(errorsIn(fieldRoot(fieldKeyInput()))).toEqual([KEY_RULE])
|
||||
expect(fieldRoot(fieldKeyInput()).querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
const TILES = [
|
||||
['Text', 'Short text, like a title or a name', 'type'],
|
||||
['Rich text', 'Long text with formatting, written in Markdown', 'pilcrow'],
|
||||
['Number', 'Whole or decimal numbers', 'hash'],
|
||||
['Yes or no', 'An on or off switch', 'toggle-left'],
|
||||
['Date', 'A calendar day, without a time', 'calendar'],
|
||||
['Choice', 'One option from a list you define', 'list'],
|
||||
] as const
|
||||
|
||||
describe('Type tiles', () => {
|
||||
it('shows six radio tiles with an icon, a name and a description', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(el('[role="radiogroup"]')).not.toBeNull()
|
||||
for (const [name, description, icon] of TILES) {
|
||||
const radio = tile(name)
|
||||
const label = radio.closest('label')!
|
||||
expect(text(label)).toBe(`${name} ${description}`)
|
||||
expect(label.querySelector('svg')).not.toBeNull()
|
||||
expect(icon).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('marks the selected tile with the sand background', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await pickTile('Date')
|
||||
|
||||
const label = tile('Date').closest('label')!
|
||||
expect(tile('Date').getAttribute('aria-checked')).toBe('true')
|
||||
expect(label.className).toContain('has-data-[state=checked]:bg-(--rc-sand)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The inspector follows the type', () => {
|
||||
it.each([
|
||||
[
|
||||
'Text',
|
||||
'Shortest',
|
||||
'Longest',
|
||||
true,
|
||||
'characters',
|
||||
'text',
|
||||
'Counted in characters. Entries show a counter.',
|
||||
],
|
||||
[
|
||||
'Rich text',
|
||||
'Shortest',
|
||||
'Longest',
|
||||
false,
|
||||
'characters',
|
||||
'textarea',
|
||||
'Counted in characters. Entries show a counter.',
|
||||
],
|
||||
[
|
||||
'Number',
|
||||
'Lowest value',
|
||||
'Highest value',
|
||||
true,
|
||||
'',
|
||||
'number',
|
||||
'Entries outside the range are refused.',
|
||||
],
|
||||
[
|
||||
'Date',
|
||||
'Earliest date',
|
||||
'Latest date',
|
||||
true,
|
||||
'',
|
||||
'date',
|
||||
'Entries outside the range are refused.',
|
||||
],
|
||||
])('%s', async (name, low, high, unique, suffix, control, help) => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile(name)
|
||||
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain(low)
|
||||
expect(copy).toContain(high)
|
||||
expect(copy).toContain(help)
|
||||
expect(copy.includes('characters')).toBe(suffix === 'characters')
|
||||
expect(switchOf('Unique values').disabled).toBe(!unique)
|
||||
expect(copy.includes('Not available for rich text.')).toBe(name === 'Rich text')
|
||||
const field =
|
||||
control === 'textarea'
|
||||
? el<HTMLTextAreaElement>('textarea')
|
||||
: (defaultInput() as HTMLInputElement)
|
||||
expect(field!.placeholder).toBe('No default')
|
||||
if (control !== 'textarea') {
|
||||
expect(defaultInput()!.type).toBe(control)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows no limits and a disabled Unique switch for Yes or no', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Yes or no')
|
||||
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain('Yes or no fields have no limits.')
|
||||
expect(copy).toContain('Not available for yes or no fields.')
|
||||
expect(switchOf('Unique values').disabled).toBe(true)
|
||||
expect(copy).toContain('On by default')
|
||||
expect(copy).toContain('New entries start with this switch off when the field is left out.')
|
||||
expect(limit('min')).toBeNull()
|
||||
expect(defaultInput()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the choice list and no limits for Choice', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain('Limits come from the choices below.')
|
||||
expect(copy).toContain('Choices')
|
||||
expect(el('[data-testid="choice-list"]')).not.toBeNull()
|
||||
expect(switchOf('Unique values').disabled).toBe(false)
|
||||
expect(limit('min')).toBeNull()
|
||||
expect(el('button[role="combobox"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Changing the type of a new field', () => {
|
||||
it('keeps the label, key and Required, resets the rest, and starts a Choice with no choice', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Capacity')
|
||||
await pickTile('Number')
|
||||
await toggle('Required')
|
||||
await toggle('Unique values')
|
||||
await typeInto(limit('min'), '1')
|
||||
await typeInto(limit('max'), '5000')
|
||||
await typeInto(defaultInput(), '20')
|
||||
expect(defaultInput()!.disabled).toBe(true)
|
||||
|
||||
await pickTile('Yes or no')
|
||||
|
||||
expect(labelInput()!.value).toBe('Capacity')
|
||||
expect(fieldKeyInput()!.value).toBe('capacity')
|
||||
expect(switchOf('Required').getAttribute('aria-checked')).toBe('true')
|
||||
expect(switchOf('Unique values').getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
await pickTile('Choice')
|
||||
|
||||
expect(choiceValues()).toEqual([])
|
||||
expect(switchOf('Required').getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
await pickTile('Number')
|
||||
|
||||
expect(limit('min')!.value).toBe('')
|
||||
expect(limit('max')!.value).toBe('')
|
||||
expect(defaultInput()!.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Field rules', () => {
|
||||
async function ready(type: string) {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await addField('Thing')
|
||||
await pickTile(type)
|
||||
}
|
||||
|
||||
it('flags an empty label or spaces', async () => {
|
||||
await ready('Text')
|
||||
|
||||
await typeInto(labelInput(), ' ')
|
||||
|
||||
expect(errorsIn(fieldRoot(labelInput()))).toEqual(['Give the field a label.'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Number', { min: '6000', max: '5000' }, "Lowest value can't be above the highest value."],
|
||||
['Text', { min: '10', max: '3' }, "Shortest can't be longer than longest."],
|
||||
[
|
||||
'Date',
|
||||
{ min: '2027-12-31', max: '2026-01-01' },
|
||||
"Earliest date can't be after the latest date.",
|
||||
],
|
||||
['Text', { min: '2.5' }, 'Use a whole number, 0 or more.'],
|
||||
['Text', { min: '-1' }, 'Use a whole number, 0 or more.'],
|
||||
])('flags %s limits %j under the limits', async (type, values, message) => {
|
||||
await ready(type)
|
||||
|
||||
for (const [which, value] of Object.entries(values)) {
|
||||
await typeInto(limit(which as 'min' | 'max'), value)
|
||||
}
|
||||
|
||||
expect(errorsIn(fieldRoot(limit('min')))).toEqual([message])
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Number', { min: '1', max: '5000', default: '0' }],
|
||||
['Text', { max: '3', default: 'abcd' }],
|
||||
])('flags a default outside the limits for %s', async (type, values) => {
|
||||
await ready(type)
|
||||
|
||||
for (const [which, value] of Object.entries(values)) {
|
||||
await typeInto(
|
||||
which === 'default' ? defaultInput() : limit(which as 'min' | 'max'),
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
expect(errorsIn(fieldRoot(defaultInput()))).toEqual([
|
||||
'The default value is outside the limits.',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Number', { min: '1', max: '5000' }],
|
||||
['Number', { min: '5', max: '5' }],
|
||||
['Text', { min: '0' }],
|
||||
])('accepts %s limits %j', async (type, values) => {
|
||||
await ready(type)
|
||||
|
||||
for (const [which, value] of Object.entries(values)) {
|
||||
await typeInto(limit(which as 'min' | 'max'), value)
|
||||
}
|
||||
|
||||
expect(errorsIn(inspector())).toEqual([])
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
})
|
||||
|
||||
it('flags a Choice field with no choice under the choice list', async () => {
|
||||
await ready('Choice')
|
||||
await addChoice('Talk')
|
||||
await click(byLabel('Remove Talk'))
|
||||
|
||||
expect(errorsIn(el('[data-testid="choice-list"]'))).toEqual(['Add at least one choice.'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unique and default', () => {
|
||||
it('disables the default and drops it from the body while Unique is on, then brings it back', async () => {
|
||||
await mountAt('/schema/new', { create: failWithBody(500, 'boom') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await typeInto(defaultInput(), 'Draft')
|
||||
|
||||
await toggle('Unique values')
|
||||
|
||||
expect(defaultInput()!.disabled).toBe(true)
|
||||
expect(text(inspector())).toContain("A field with unique values can't have a default.")
|
||||
await click(saveButton())
|
||||
expect(posts[0]!.body.fields).toMatchObject([{ unique: true, default: null }])
|
||||
|
||||
await toggle('Unique values')
|
||||
expect(defaultInput()!.disabled).toBe(false)
|
||||
expect(defaultInput()!.value).toBe('Draft')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Choice list editor (design state 8)', () => {
|
||||
async function withChoices() {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
await addChoice('Talk')
|
||||
await addChoice('Workshop')
|
||||
await addChoice('Meetup')
|
||||
}
|
||||
|
||||
it('shows the required marker, the count, the rows, the add input and the help', async () => {
|
||||
await withChoices()
|
||||
|
||||
const list = el('[data-testid="choice-list"]')!
|
||||
expect(text(list)).toContain('Choices *')
|
||||
expect(text(el('[data-testid="choice-count"]'))).toBe('3 of 50')
|
||||
expect(choiceValues()).toEqual(['Talk', 'Workshop', 'Meetup'])
|
||||
expect(all<HTMLInputElement>('[data-testid="choice-row"] input')[0]!.placeholder).toBe(
|
||||
'Choice',
|
||||
)
|
||||
expect(addChoiceInput()!.placeholder).toBe('Add a choice')
|
||||
expect(button('Add')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(text(list)).toContain(
|
||||
'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.',
|
||||
)
|
||||
})
|
||||
|
||||
it('appends with the button or Enter, empties the input and keeps the focus', async () => {
|
||||
await withChoices()
|
||||
|
||||
await typeInto(addChoiceInput(), 'Panel')
|
||||
await click(button('Add'))
|
||||
expect(choiceValues()).toEqual(['Talk', 'Workshop', 'Meetup', 'Panel'])
|
||||
expect(addChoiceInput()!.value).toBe('')
|
||||
expect(document.activeElement).toBe(addChoiceInput())
|
||||
|
||||
await typeInto(addChoiceInput(), 'Social')
|
||||
await press(addChoiceInput(), 'Enter')
|
||||
expect(choiceValues().at(-1)).toBe('Social')
|
||||
expect(document.activeElement).toBe(addChoiceInput())
|
||||
})
|
||||
|
||||
it('removes a choice', async () => {
|
||||
await withChoices()
|
||||
|
||||
await click(byLabel('Remove Workshop'))
|
||||
|
||||
expect(choiceValues()).toEqual(['Talk', 'Meetup'])
|
||||
})
|
||||
|
||||
it('lists the choices in the default select and clears the default on remove or rename', async () => {
|
||||
await withChoices()
|
||||
const openSelect = () => press(el('button[role="combobox"]'), 'ArrowDown')
|
||||
await openSelect()
|
||||
const options = all('[role="option"]').map(text)
|
||||
expect(options).toEqual(['None', 'Talk', 'Workshop', 'Meetup'])
|
||||
await press(
|
||||
all('[role="option"]').find((option) => text(option) === 'Workshop'),
|
||||
'Enter',
|
||||
)
|
||||
expect(text(el('button[role="combobox"]'))).toContain('Workshop')
|
||||
|
||||
await typeInto(all<HTMLInputElement>('[data-testid="choice-row"] input')[1], 'Workshops')
|
||||
expect(text(el('button[role="combobox"]'))).toContain('None')
|
||||
|
||||
await openSelect()
|
||||
await press(
|
||||
all('[role="option"]').find((option) => text(option) === 'Meetup'),
|
||||
'Enter',
|
||||
)
|
||||
await click(byLabel('Remove Meetup'))
|
||||
expect(text(el('button[role="combobox"]'))).toContain('None')
|
||||
})
|
||||
|
||||
it('flags a blank row and a duplicate row with an alert icon, as one problem, and caps at 50', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await pickTile('Choice')
|
||||
for (const value of ['Talk', 'Workshop', 'Meetup', 'x', 'y']) {
|
||||
await addChoice(value)
|
||||
}
|
||||
const inputs = () => all<HTMLInputElement>('[data-testid="choice-row"] input')
|
||||
await typeInto(inputs()[3], ' ')
|
||||
await typeInto(inputs()[4], ' Talk ')
|
||||
|
||||
const rowsWithErrors = all('[data-testid="choice-row"]').map((row) => errorsIn(row))
|
||||
expect(rowsWithErrors[3]).toEqual(["Choices can't be blank."])
|
||||
expect(rowsWithErrors[4]).toEqual(['Two choices are the same.'])
|
||||
expect(all('[data-testid="choice-row"] [role="alert"] svg')).toHaveLength(2)
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
expect(inputs()[0]!.getAttribute('aria-invalid')).not.toBe('true')
|
||||
expect(inputs()[4]!.getAttribute('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('treats choices as different when they differ by case', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await pickTile('Choice')
|
||||
await addChoice('Talk')
|
||||
await addChoice('talk')
|
||||
|
||||
expect(errorsIn(el('[data-testid="choice-list"]'))).toEqual([])
|
||||
})
|
||||
|
||||
it('disables Add at 50 choices', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
for (let index = 0; index < 50; index++) {
|
||||
await addChoice(`C${index}`)
|
||||
}
|
||||
|
||||
expect(text(el('[data-testid="choice-count"]'))).toBe('50 of 50')
|
||||
await typeInto(addChoiceInput(), 'more')
|
||||
expect((button('Add') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reordering choices', () => {
|
||||
it('moves with the keyboard grip and announces each step', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await pickTile('Choice')
|
||||
for (const value of ['Talk', 'Workshop', 'Meetup']) {
|
||||
await addChoice(value)
|
||||
}
|
||||
const grip = () => byLabel('Move Workshop')!
|
||||
|
||||
await press(grip(), ' ')
|
||||
expect(text(el('[data-testid="choice-live"]'))).toBe(
|
||||
'Moving Workshop. Position 2 of 3. Press Up or Down to move, Space to drop, Esc to cancel.',
|
||||
)
|
||||
await press(grip(), 'ArrowUp')
|
||||
expect(text(el('[data-testid="choice-live"]'))).toContain(
|
||||
'Moving Workshop. Position 1 of 3.',
|
||||
)
|
||||
await press(grip(), ' ')
|
||||
|
||||
expect(choiceValues()).toEqual(['Workshop', 'Talk', 'Meetup'])
|
||||
expect(text(el('[data-testid="choice-live"]'))).toBe('Workshop dropped. Position 1 of 3.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Removing a field', () => {
|
||||
it('selects the next row, then the previous at the end, and counts one less', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await addField('Ends on')
|
||||
await selectRow('Starts on')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
expect(rowLabels()).toEqual(['Title', 'Ends on'])
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Ends on')
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('2 of 50')
|
||||
expect(text(document.body)).not.toContain('Undo')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Title')
|
||||
})
|
||||
|
||||
it('shows the empty state and Field settings when the list is empty', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
|
||||
expect(text(el('[data-testid="field-empty"]'))).toContain('No fields yet')
|
||||
expect(text(el('[data-testid="inspector-pane"]'))).toContain('Field settings')
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('0 of 50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Moving fields with the buttons', () => {
|
||||
async function three() {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await addField('Ends on')
|
||||
await selectRow('Starts on')
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Move up', ['Starts on', 'Title', 'Ends on']],
|
||||
['Move down', ['Title', 'Ends on', 'Starts on']],
|
||||
])('%s reorders and keeps the selection', async (label, order) => {
|
||||
await three()
|
||||
|
||||
await click(byLabel(label))
|
||||
|
||||
expect(rowLabels()).toEqual(order)
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Starts on')
|
||||
})
|
||||
|
||||
it('keeps the focus on the pressed button while it stays enabled', async () => {
|
||||
await three()
|
||||
await addField('Fourth')
|
||||
await selectRow('Starts on')
|
||||
|
||||
await click(byLabel('Move down'))
|
||||
|
||||
expect(rowLabels()).toEqual(['Title', 'Ends on', 'Starts on', 'Fourth'])
|
||||
expect(document.activeElement).toBe(byLabel('Move down'))
|
||||
})
|
||||
|
||||
it('moves the focus to the other button when the pressed one becomes disabled', async () => {
|
||||
await three()
|
||||
|
||||
await click(byLabel('Move up'))
|
||||
|
||||
expect((byLabel('Move up') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(document.activeElement).toBe(byLabel('Move down'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Title', 'Move up'],
|
||||
['Ends on', 'Move down'],
|
||||
])('disables the end button for %s: %s', async (row, label) => {
|
||||
await three()
|
||||
|
||||
await selectRow(row)
|
||||
|
||||
expect((byLabel(label) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard reorder (design state 9)', () => {
|
||||
async function three() {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
await addField('Ends on')
|
||||
}
|
||||
const grip = (label: string) => byLabel(`Move ${label}`)!
|
||||
|
||||
it('picks up with Space, moves with Up, drops with Space and announces each step', async () => {
|
||||
await three()
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toBe(
|
||||
'Moving Starts on. Position 2 of 3. Press Up or Down to move, Space to drop, Esc to cancel.',
|
||||
)
|
||||
expect(grip('Starts on').closest('li')!.className).toContain('outline-primary')
|
||||
expect(el('[data-testid="reorder-live"]')!.getAttribute('aria-live')).toBe('polite')
|
||||
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
expect(rowLabels()).toEqual(['Starts on', 'Title', 'Ends on'])
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toContain(
|
||||
'Moving Starts on. Position 1 of 3.',
|
||||
)
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
expect(rowLabels()).toEqual(['Starts on', 'Title', 'Ends on'])
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toBe('Starts on dropped. Position 1 of 3.')
|
||||
})
|
||||
|
||||
it('restores the order on Esc', async () => {
|
||||
await three()
|
||||
|
||||
await press(grip('Starts on'), ' ')
|
||||
await press(grip('Starts on'), 'ArrowUp')
|
||||
await press(grip('Starts on'), 'Escape')
|
||||
|
||||
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Ends on'])
|
||||
expect(text(el('[data-testid="reorder-live"]'))).toContain('Reorder cancelled.')
|
||||
})
|
||||
|
||||
it('has no pointer drag on rows', async () => {
|
||||
await three()
|
||||
|
||||
for (const row of rows()) {
|
||||
expect(row.getAttribute('draggable')).toBeNull()
|
||||
expect(row.querySelector('[draggable]')).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
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 { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { button, mountAt, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
addField,
|
||||
all,
|
||||
byLabel,
|
||||
click,
|
||||
el,
|
||||
nameInput,
|
||||
pickTile,
|
||||
press,
|
||||
typeInto,
|
||||
} from './builderKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
const writeText = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
writeText.mockReset().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
const toggle = () => el('[data-testid="api-preview-toggle"]')!
|
||||
const json = () => el('[data-testid="api-json"]')!.textContent!
|
||||
|
||||
async function openPreview() {
|
||||
await click(toggle())
|
||||
}
|
||||
|
||||
describe('API preview (design state 10)', () => {
|
||||
it('shows the collapsed bar with the first route and Copy JSON, key falling back to events', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
const bar = text(el('[data-testid="api-preview"]'))
|
||||
expect(bar).toContain('API preview')
|
||||
expect(bar).toContain('GET /v1/content-type/events/entry')
|
||||
expect(bar).toContain('Copy JSON')
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('opens on the five routes, the sample entry and the hints', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Press mentions')
|
||||
|
||||
await openPreview()
|
||||
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('true')
|
||||
const panel = text(el('[data-testid="api-preview"]'))
|
||||
expect(panel).toContain('Where entries of this type live')
|
||||
expect(all('[data-testid="api-route"]').map(text)).toEqual([
|
||||
'GET /v1/content-type/press-mentions/entry',
|
||||
'POST /v1/content-type/press-mentions/entry',
|
||||
'GET /v1/content-type/press-mentions/entry/{id}',
|
||||
'PUT /v1/content-type/press-mentions/entry/{id}',
|
||||
'DELETE /v1/content-type/press-mentions/entry/{id}',
|
||||
])
|
||||
expect(panel).toContain("A sample entry. Values come from each field's type and default.")
|
||||
expect(panel).toContain(
|
||||
'Updates as you edit. Fields you add or remove here appear after you save.',
|
||||
)
|
||||
expect(JSON.parse(json())).toEqual({
|
||||
id: 42,
|
||||
content_type: 'press-mentions',
|
||||
data: { title: 'Example text' },
|
||||
created_by: 3,
|
||||
updated_by: 3,
|
||||
created_at: '2026-10-15T11:02:00Z',
|
||||
updated_at: '2026-10-15T11:02:00Z',
|
||||
})
|
||||
expect(json()).toContain('\n "id": 42,')
|
||||
})
|
||||
|
||||
it('updates on every edit, reorder and removal', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await openPreview()
|
||||
|
||||
await addField('Online')
|
||||
await pickTile('Yes or no')
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title', 'online'])
|
||||
expect(JSON.parse(json()).data.online).toBe(false)
|
||||
|
||||
await press(byLabel('Move Online'), ' ')
|
||||
await press(byLabel('Move Online'), 'ArrowUp')
|
||||
await press(byLabel('Move Online'), ' ')
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['online', 'title'])
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title'])
|
||||
})
|
||||
|
||||
it('skips a field whose key is invalid', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await openPreview()
|
||||
|
||||
await click(button('Add field'))
|
||||
|
||||
expect(Object.keys(JSON.parse(json()).data)).toEqual(['title'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy JSON', () => {
|
||||
it('writes the exact sample, reads Copied for 2 seconds, then Copy JSON again', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await openPreview()
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
await click(el('[data-testid="api-copy"]'))
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(json())
|
||||
expect(text(el('[data-testid="api-copy"]'))).toBe('Copied')
|
||||
|
||||
vi.advanceTimersByTime(1999)
|
||||
await flushPromises()
|
||||
expect(text(el('[data-testid="api-copy"]'))).toBe('Copied')
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(text(el('[data-testid="api-copy"]'))).toBe('Copy JSON')
|
||||
})
|
||||
})
|
||||
|
||||
describe('API preview on mobile', () => {
|
||||
it('opens a sheet with the route, the JSON and a full-width Copy JSON that copies', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(el('[data-testid="api-row"]'))
|
||||
|
||||
expect(text(el('[role="dialog"]'))).toContain('API preview')
|
||||
expect(text(el('[data-testid="api-sheet-route"]'))).toBe(
|
||||
'GET /v1/content-type/events/entry',
|
||||
)
|
||||
const shown = el('[data-testid="api-sheet-json"]')!.textContent!
|
||||
expect(JSON.parse(shown).content_type).toBe('events')
|
||||
const copy = el('[data-testid="api-sheet-copy"]')!
|
||||
expect(copy.className).toContain('flex-1')
|
||||
expect(text(copy)).toBe('Copy JSON')
|
||||
|
||||
await click(copy)
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(shown)
|
||||
expect(text(copy)).toBe('Copied')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,487 @@
|
||||
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 { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import {
|
||||
button,
|
||||
calls,
|
||||
createdType,
|
||||
failWithBody,
|
||||
mountAt,
|
||||
networkFailure,
|
||||
pending,
|
||||
posts,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './schemaTestKit'
|
||||
import {
|
||||
addField,
|
||||
all,
|
||||
buildEvents,
|
||||
byLabel,
|
||||
chip,
|
||||
click,
|
||||
discardButton,
|
||||
el,
|
||||
keyField,
|
||||
keyInput,
|
||||
labelInput,
|
||||
main,
|
||||
nameInput,
|
||||
rowLabels,
|
||||
saveButton,
|
||||
typeInto,
|
||||
} 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 EVENTS_BODY = {
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
fields: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
default: null,
|
||||
min: null,
|
||||
max: 80,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'starts_on',
|
||||
label: 'Starts on',
|
||||
type: 'date',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: '2026-01-01',
|
||||
max: '2027-12-31',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
type: 'select',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('Saving a valid type', () => {
|
||||
it('posts once the exact body, in list order, with no icon', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await buildEvents()
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.url).toBe('/v1/content-type')
|
||||
expect(posts[0]!.body).toEqual(EVENTS_BODY)
|
||||
expect('icon' in posts[0]!.body).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving (design state 11, create)', () => {
|
||||
async function startSaving() {
|
||||
const gate = pending()
|
||||
await mountAt('/schema/new', { create: () => gate.promise })
|
||||
await buildEvents()
|
||||
await click(saveButton())
|
||||
return gate
|
||||
}
|
||||
|
||||
it('shows the chip with a loader, dims and disables the form, and sends one request only', 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(keyInput()!.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('Remove') as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
saving.click()
|
||||
await flushPromises()
|
||||
expect(posts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves the form editable again once the request settles', async () => {
|
||||
const gate = await startSaving()
|
||||
gate.resolve(createdType(posts[0]!.body))
|
||||
await flushPromises()
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Double activation while saving', () => {
|
||||
it('sends one POST however often Save is activated programmatically', async () => {
|
||||
const gate = pending()
|
||||
const mounted = await mountAt('/schema/new', { create: () => gate.promise })
|
||||
await buildEvents()
|
||||
await click(saveButton())
|
||||
const actions = mounted.findComponent(BuilderActions)
|
||||
|
||||
actions.vm.$emit('save')
|
||||
actions.vm.$emit('save')
|
||||
await flushPromises()
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect((button('Saving…') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(actions.props('canSave')).toBe(false)
|
||||
expect(actions.props('canReveal')).toBe(false)
|
||||
|
||||
gate.resolve(createdType(posts[0]!.body))
|
||||
await flushPromises()
|
||||
expect(posts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created type reaching the store', () => {
|
||||
it('inserts the new type before the first later name and needs no second list request', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Newsletters')
|
||||
|
||||
await click(saveButton())
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
await flushPromises()
|
||||
|
||||
expect(useContentTypesStore().items.map((type) => type.name)).toEqual([
|
||||
'Events',
|
||||
'Newsletters',
|
||||
'Recipes',
|
||||
'Press mentions',
|
||||
])
|
||||
expect(el('nav a[href="/content/newsletters"]')).not.toBeNull()
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saved (design state 12, create)', () => {
|
||||
it('toasts, adds the type to the store and the sidebar, replaces the URL and does not prompt', async () => {
|
||||
await mountAt('/schema/new', {
|
||||
create: (body) => ({ ...createdType(body), fields: new Array(6) }),
|
||||
})
|
||||
await buildEvents()
|
||||
|
||||
await click(saveButton())
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain('Content type saved')
|
||||
expect(text(document.body)).toContain('Events now has 6 fields.')
|
||||
expect(text(document.body)).not.toContain('Leave without saving?')
|
||||
expect(el('nav a[href="/content/events"]')).not.toBeNull()
|
||||
const listed = all('[data-testid="schema-row"]').map((row) => text(row))
|
||||
expect(listed.some((row) => row.includes('Events'))).toBe(true)
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
router.back()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).not.toBe('/schema/new')
|
||||
})
|
||||
|
||||
it('reads "1 field" for a single field', async () => {
|
||||
await mountAt('/schema/new', {
|
||||
create: (body) => ({ ...createdType(body), fields: new Array(1) }),
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain('Events now has 1 field.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation failing after a created type', () => {
|
||||
async function saveWithBrokenNavigation() {
|
||||
vi.spyOn(router, 'replace').mockRejectedValue(new Error('chunk load failed'))
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await click(saveButton())
|
||||
}
|
||||
|
||||
it('keeps the type, shows no failure and blocks a second POST', async () => {
|
||||
await saveWithBrokenNavigation()
|
||||
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(text(document.body)).not.toContain(SAVE_FAILED_TITLE)
|
||||
expect(chip()).toBe('')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(el('nav a[href="/content/events"]')).not.toBeNull()
|
||||
expect(text(document.body)).toContain('Content type saved')
|
||||
})
|
||||
|
||||
it('treats the form as editable again after the next edit', async () => {
|
||||
await saveWithBrokenNavigation()
|
||||
|
||||
await typeInto(nameInput(), 'Events 2')
|
||||
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A failed save then an edit', () => {
|
||||
it('returns to editing so Save works again', async () => {
|
||||
let attempts = 0
|
||||
await mountAt('/schema/new', {
|
||||
create: (body) => {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
throw new Error('boom')
|
||||
}
|
||||
return createdType(body)
|
||||
},
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await click(saveButton())
|
||||
expect(chip()).toBe('Save failed')
|
||||
|
||||
await typeInto(nameInput(), 'Events 2')
|
||||
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Key already taken (design state 14)', () => {
|
||||
async function reachTaken() {
|
||||
await mountAt('/schema/new', {
|
||||
create: failWithBody(409, 'content_type_key_taken'),
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await click(saveButton())
|
||||
}
|
||||
|
||||
it('shows the message under Key, makes it editable with focus, and counts one problem', async () => {
|
||||
await reachTaken()
|
||||
|
||||
expect(text(keyField().querySelector('[role="alert"]'))).toBe(
|
||||
'The key "events" is already used by another content type. Choose a different key.',
|
||||
)
|
||||
expect(keyInput()!.readOnly).toBe(false)
|
||||
expect(document.activeElement).toBe(keyInput())
|
||||
expect(text(keyField())).not.toContain('Auto')
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
})
|
||||
|
||||
it('clears the error and enables Save when the key is edited', async () => {
|
||||
await reachTaken()
|
||||
|
||||
await typeInto(keyInput(), 'events-2')
|
||||
|
||||
expect(keyField().querySelector('[role="alert"]')).toBeNull()
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The server refuses a key or a field', () => {
|
||||
it('shows the key message under Key for 400 invalid_content_type_key', async () => {
|
||||
await mountAt('/schema/new', {
|
||||
create: failWithBody(400, 'invalid_content_type_key'),
|
||||
})
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(keyField().querySelector('[role="alert"]'))).toBe(
|
||||
'Use 2 to 40 lowercase letters, digits or hyphens.',
|
||||
)
|
||||
expect(chip()).toBe('1 problem to fix')
|
||||
})
|
||||
|
||||
it('shows the banner for 422 invalid_field and keeps the edits and Save', async () => {
|
||||
await mountAt('/schema/new', { create: failWithBody(422, 'invalid_field') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).toContain('Check the fields marked in the list.')
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the access page for 403', async () => {
|
||||
await mountAt('/schema/new', { create: failWithBody(403, 'Only owners and admins') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
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 mountAt('/schema/new', { create: failWithBody(401, '') })
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).not.toContain(SAVE_FAILED_TITLE)
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Save failed (design state 13)', () => {
|
||||
it.each([
|
||||
['answers 500', failWithBody(500, 'boom')],
|
||||
['fails (network)', networkFailure],
|
||||
])(
|
||||
'shows the alert, keeps every edit and retries with the same body when the POST %s',
|
||||
async (_, failure) => {
|
||||
let respond = failure
|
||||
await mountAt('/schema/new', { create: (body) => respond() ?? body })
|
||||
await buildEvents()
|
||||
|
||||
await click(saveButton())
|
||||
|
||||
expect(text(main())).toContain(SAVE_FAILED_TITLE)
|
||||
expect(text(main())).toContain(SAVE_FAILED_TEXT)
|
||||
expect(chip()).toBe('Save failed')
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Category'])
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
|
||||
respond = () => undefined
|
||||
const first = posts[0]!.body
|
||||
await click(button('Try again'))
|
||||
|
||||
expect(posts).toHaveLength(2)
|
||||
expect(posts[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 edits, and sends nothing on reconnect', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
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')
|
||||
|
||||
setOnline(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).not.toContain("You're offline.")
|
||||
expect(chip()).toBe('Unsaved changes')
|
||||
expect(saveButton()!.disabled).toBe(false)
|
||||
expect(posts).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Discard', () => {
|
||||
it('resets to the blank form, sends nothing and keeps the URL', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await buildEvents()
|
||||
|
||||
await click(discardButton())
|
||||
|
||||
expect(nameInput()!.value).toBe('')
|
||||
expect(keyInput()!.value).toBe('')
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
expect(chip()).toBe('')
|
||||
expect(discardButton()!.disabled).toBe(true)
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(posts).toHaveLength(0)
|
||||
expect(router.currentRoute.value.path).toBe('/schema/new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Leaving with unsaved edits', () => {
|
||||
const DIALOG_TITLE = 'Leave without saving?'
|
||||
|
||||
it('navigates without a prompt from a pristine form', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await router.push('/schema')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
expect(text(document.body)).not.toContain(DIALOG_TITLE)
|
||||
})
|
||||
|
||||
it('asks first when something was edited, and stays on Keep editing', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
const leaving = router.push('/schema')
|
||||
await flushPromises()
|
||||
|
||||
expect(text(document.body)).toContain(DIALOG_TITLE)
|
||||
expect(button('Keep editing')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(button('Discard changes')).toBeInstanceOf(HTMLButtonElement)
|
||||
await click(button('Keep editing'))
|
||||
await leaving
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema/new')
|
||||
expect(nameInput()!.value).toBe('Events')
|
||||
})
|
||||
|
||||
it('leaves on Discard changes', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await addField('Starts on')
|
||||
|
||||
const leaving = router.push('/schema')
|
||||
await flushPromises()
|
||||
await click(button('Discard changes'))
|
||||
await leaving
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { button, mountAt, text, unmountAll } from './schemaTestKit'
|
||||
import { byLabel, click, el, labelInput, rowLabels, rows, 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 dialog = () => el('[role="dialog"]')
|
||||
|
||||
describe('Selecting a field on mobile', () => {
|
||||
it('opens a sheet titled with the field, with the inspector, the move and remove buttons and Done', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(rows()[0]!.querySelector('[data-field-row]'))
|
||||
|
||||
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Title')
|
||||
expect(labelInput()!.value).toBe('Title')
|
||||
expect(byLabel('Move up')).not.toBeNull()
|
||||
expect(byLabel('Move down')).not.toBeNull()
|
||||
expect(byLabel('Remove')).not.toBeNull()
|
||||
expect(button('Done')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(document.activeElement).toBe(el('[data-testid="field-sheet-title"]'))
|
||||
})
|
||||
|
||||
it('closes with Done and returns the focus to the row', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
const row = rows()[0]!.querySelector<HTMLElement>('[data-field-row]')!
|
||||
await click(row)
|
||||
|
||||
await click(button('Done'))
|
||||
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(document.activeElement).toBe(row)
|
||||
})
|
||||
|
||||
it('updates the title with the label and removes the field from the sheet', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
await click(rows()[0]!.querySelector('[data-field-row]'))
|
||||
|
||||
await typeInto(labelInput(), 'Headline')
|
||||
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Headline')
|
||||
|
||||
await click(byLabel('Remove'))
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
expect(rowLabels()).toEqual([])
|
||||
})
|
||||
|
||||
it('opens the sheet on a new field with the label focused', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(button('Add field'))
|
||||
|
||||
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Untitled field')
|
||||
expect(rowLabels()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
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 { button, calls, failWith, mountAt, text, unmountAll } from './schemaTestKit'
|
||||
import {
|
||||
all,
|
||||
chip,
|
||||
click,
|
||||
discardButton,
|
||||
el,
|
||||
inspector,
|
||||
keyField,
|
||||
keyInput,
|
||||
labelInput,
|
||||
main,
|
||||
nameInput,
|
||||
rowLabels,
|
||||
saveButton,
|
||||
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 NOT_FOUND_TITLE = 'Content type not found'
|
||||
const ACCESS_TITLE = "You don't have access to Schema"
|
||||
|
||||
function newTypeButtons() {
|
||||
return all('button').filter((candidate) => text(candidate) === 'New content type')
|
||||
}
|
||||
|
||||
describe('Reaching the builder', () => {
|
||||
it('opens from the header button', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
await click(newTypeButtons()[0])
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
|
||||
})
|
||||
|
||||
it('opens from the empty-state button', async () => {
|
||||
await mountAt('/schema', { content: () => [] })
|
||||
|
||||
await click(newTypeButtons().at(-1))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
||||
expect(nameInput()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens from the mobile floating button', async () => {
|
||||
await mountAt('/schema', { width: MOBILE_WIDTH })
|
||||
|
||||
await click(newTypeButtons().find((candidate) => candidate.className.includes('fixed')))
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
||||
expect(nameInput()).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each(['/schema/events', '/schema/unknown'])('still says not found at %s', async (path) => {
|
||||
await mountAt(path)
|
||||
|
||||
expect(text(main())).toContain(NOT_FOUND_TITLE)
|
||||
expect(nameInput()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles and sessions', () => {
|
||||
it.each(['owner', 'admin'])('shows the builder to a verified %s', async (role) => {
|
||||
await mountAt('/schema/new', { role })
|
||||
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(text(main())).not.toContain(ACCESS_TITLE)
|
||||
})
|
||||
|
||||
it('tells a verified user who is neither to go home, leaving the URL', async () => {
|
||||
await mountAt('/schema/new', { role: 'user' })
|
||||
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(router.currentRoute.value.path).toBe('/schema/new')
|
||||
expect(nameInput()).toBeNull()
|
||||
expect(el('[data-testid="field-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the same page when the content types answer 403', async () => {
|
||||
await mountAt('/schema/new', { content: failWith(403) })
|
||||
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The builder needs no built-in list', () => {
|
||||
it('calls the custom list once, never the built-in one, and shows no skeleton', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
expect(calls).not.toContain('/v1/builtin-type')
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(el('[data-testid="schema-skeleton-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the builder while the custom list is still loading', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((done) => (release = done))
|
||||
await mountAt('/schema/new', { content: () => gate.then(() => []) })
|
||||
|
||||
expect(nameInput()).not.toBeNull()
|
||||
release()
|
||||
await flushPromises()
|
||||
})
|
||||
})
|
||||
|
||||
describe('New type, desktop (design state 1)', () => {
|
||||
it('shows the breadcrumb, Name, Key and the list header', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(text(el('nav[aria-label="breadcrumb"]'))).toBe('Schema New content type')
|
||||
expect(el('nav[aria-label="breadcrumb"] a[href="/schema"]')).not.toBeNull()
|
||||
expect(all('nav[aria-label="breadcrumb"] a')).toHaveLength(1)
|
||||
|
||||
expect(nameInput()!.value).toBe('')
|
||||
expect(nameInput()!.placeholder).toBe('For example, Events')
|
||||
expect(nameInput()!.required).toBe(true)
|
||||
expect(nameInput()!.maxLength).toBe(100)
|
||||
expect(keyInput()!.value).toBe('')
|
||||
expect(keyInput()!.placeholder).toBe('events')
|
||||
expect(keyInput()!.readOnly).toBe(true)
|
||||
expect(text(main())).toContain(
|
||||
"Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save.",
|
||||
)
|
||||
expect(text(main())).toContain('Fields')
|
||||
expect(text(el('[data-testid="field-count"]'))).toBe('1 of 50')
|
||||
})
|
||||
|
||||
it('shows the starter row and the inspector of Title', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
const row = el('[data-testid="field-row"]')!
|
||||
expect(text(row.querySelector('[data-testid="field-row-key"]'))).toBe('title')
|
||||
expect(row.querySelector('[data-field-row]')!.getAttribute('aria-current')).toBe('true')
|
||||
expect(text(row.querySelector('[data-testid="field-row-badges"]'))).toBe('New')
|
||||
expect(button('Add field')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Title')
|
||||
expect(labelInput()!.value).toBe('Title')
|
||||
expect(el<HTMLInputElement>('input[name="field-key"]')!.value).toBe('title')
|
||||
const copy = text(inspector())
|
||||
expect(copy).toContain('Auto')
|
||||
expect(copy).toContain(
|
||||
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
|
||||
)
|
||||
expect(all('[role="radio"]')).toHaveLength(6)
|
||||
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
|
||||
'Text',
|
||||
)
|
||||
expect(copy).toContain("An entry can't be saved with this field empty.")
|
||||
expect(copy).toContain('Unique values')
|
||||
expect(copy).toContain('Used when an entry is created and this field is left out.')
|
||||
expect(copy).toContain('Shortest')
|
||||
expect(copy).toContain('Longest')
|
||||
expect(copy).toContain('Counted in characters. Entries show a counter.')
|
||||
expect(all('[role="switch"]').map((toggle) => toggle.getAttribute('aria-checked'))).toEqual(
|
||||
['false', 'false'],
|
||||
)
|
||||
})
|
||||
|
||||
it('disables Save and Discard, shows no chip, no entries bar and a collapsed API bar', async () => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
expect(saveButton()!.disabled).toBe(true)
|
||||
expect(discardButton()!.disabled).toBe(true)
|
||||
expect(chip()).toBe('')
|
||||
expect(text(main())).not.toContain('entries use this content type')
|
||||
expect(el('[data-testid="api-preview-toggle"]')!.getAttribute('aria-expanded')).toBe(
|
||||
'false',
|
||||
)
|
||||
expect(el('[data-testid="api-json"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('New type, mobile (design state 1)', () => {
|
||||
it('shows the topbar, stacked Name and Key, the list and the API preview row', async () => {
|
||||
await mountAt('/schema/new', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(el('a[href="/schema"][aria-label="Schema"]')).not.toBeNull()
|
||||
expect(text(el('[data-slot="title"]'))).toBe('New content type')
|
||||
expect(nameInput()).not.toBeNull()
|
||||
expect(keyInput()).not.toBeNull()
|
||||
expect(rowLabels()).toEqual(['Title'])
|
||||
expect(text(el('[data-testid="api-row"]'))).toBe('API preview')
|
||||
expect(el('[data-testid="api-row"] svg')).not.toBeNull()
|
||||
expect(inspector()).toBeNull()
|
||||
expect(el('[data-testid="inspector-pane"]')).toBeNull()
|
||||
expect(discardButton()).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(saveButton()).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(el('[data-testid="api-preview"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The key follows the name until it is edited', () => {
|
||||
it.each([
|
||||
['Events', 'events', 'auto'],
|
||||
['Press mentions', 'press-mentions', 'auto'],
|
||||
['Café & Bar', 'cafe-bar', 'auto'],
|
||||
[' Job openings!', 'job-openings', 'auto'],
|
||||
['2026 Awards', '2026-awards', 'auto'],
|
||||
['!!!', '', 'empty'],
|
||||
['x'.repeat(60), 'x'.repeat(40), 'auto'],
|
||||
])('%j gives the key %j', async (name, key, mode) => {
|
||||
await mountAt('/schema/new')
|
||||
|
||||
await typeInto(nameInput(), name)
|
||||
|
||||
expect(keyInput()!.value).toBe(key)
|
||||
expect(keyInput()!.readOnly).toBe(true)
|
||||
expect(text(keyField()).includes('Auto')).toBe(mode === 'auto')
|
||||
})
|
||||
|
||||
it('unlocks with the pencil, keeps a typed key, and resets to the name', async () => {
|
||||
await mountAt('/schema/new')
|
||||
await typeInto(nameInput(), 'Events')
|
||||
|
||||
await click(keyField().querySelector('button[aria-label="Edit key"]'))
|
||||
|
||||
expect(keyInput()!.readOnly).toBe(false)
|
||||
expect(text(keyField())).not.toContain('Auto')
|
||||
expect(button('Reset to match the name')).toBeInstanceOf(HTMLButtonElement)
|
||||
|
||||
await typeInto(keyInput(), 'agenda')
|
||||
await typeInto(nameInput(), 'Events and talks')
|
||||
expect(keyInput()!.value).toBe('agenda')
|
||||
|
||||
await click(button('Reset to match the name'))
|
||||
|
||||
expect(keyInput()!.value).toBe('events-and-talks')
|
||||
expect(keyInput()!.readOnly).toBe(true)
|
||||
expect(text(keyField())).toContain('Auto')
|
||||
})
|
||||
})
|
||||
@@ -178,7 +178,7 @@ describe('Schema built-in view: loading', () => {
|
||||
})
|
||||
|
||||
describe('Schema built-in view: unknown key', () => {
|
||||
it.each(['/schema/unknown', '/schema/events', '/schema/new'])(
|
||||
it.each(['/schema/unknown', '/schema/events'])(
|
||||
'shows the not-found page at %s without redirecting',
|
||||
async (path) => {
|
||||
await mountAt(path)
|
||||
@@ -204,7 +204,7 @@ describe('Schema built-in view: error', () => {
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/builtin-type failed. Check your connection and try again.',
|
||||
)
|
||||
expect(button('Try again')).toBeDefined()
|
||||
expect(button('Try again')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
|
||||
|
||||
respond = () => builtinTypes
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { button, text } from './schemaTestKit'
|
||||
|
||||
export function el<T extends HTMLElement = HTMLElement>(selector: string) {
|
||||
return document.body.querySelector<T>(selector)
|
||||
}
|
||||
|
||||
export function all<T extends HTMLElement = HTMLElement>(selector: string) {
|
||||
return [...document.body.querySelectorAll<T>(selector)]
|
||||
}
|
||||
|
||||
export async function typeInto(
|
||||
input: HTMLInputElement | HTMLTextAreaElement | null | undefined,
|
||||
value: string,
|
||||
) {
|
||||
input!.value = value
|
||||
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function press(target: Element | null | undefined, key: string) {
|
||||
target!.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function click(target: Element | null | undefined) {
|
||||
;(target as HTMLElement).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export const nameInput = () => el<HTMLInputElement>('input[name="name"]')
|
||||
export const keyInput = () => el<HTMLInputElement>('input[name="key"]')
|
||||
export const keyField = () => keyInput()!.closest<HTMLElement>('[data-orientation]')!
|
||||
export const labelInput = () => el<HTMLInputElement>('input[name="label"]')
|
||||
export const fieldKeyInput = () => el<HTMLInputElement>('input[name="field-key"]')
|
||||
export const saveButton = () => button('Save') as HTMLButtonElement | undefined
|
||||
export const discardButton = () => button('Discard') as HTMLButtonElement | undefined
|
||||
export const byLabel = (label: string) => el<HTMLButtonElement>(`[aria-label="${label}"]`)
|
||||
export const rows = () => all('[data-testid="field-row"]')
|
||||
export const rowLabels = () =>
|
||||
rows().map((row) => text(row.querySelector('[data-testid="field-row-label"]')))
|
||||
export const chip = () => text(el('[data-testid="builder-chip"]'))
|
||||
export const main = () => el('#dashboard-content')!
|
||||
export const inspector = () => el('[data-testid="field-inspector"]')
|
||||
export const live = () => text(el('[data-testid="reorder-live"]'))
|
||||
|
||||
export async function addField(label: string) {
|
||||
await click(button('Add field'))
|
||||
await typeInto(labelInput(), label)
|
||||
}
|
||||
|
||||
export async function selectRow(label: string) {
|
||||
const row = rows().find(
|
||||
(candidate) => text(candidate.querySelector('[data-testid="field-row-label"]')) === label,
|
||||
)
|
||||
await click(row!.querySelector('[data-field-row]'))
|
||||
}
|
||||
|
||||
export function tile(name: string) {
|
||||
return all('[role="radio"]').find((radio) =>
|
||||
text(radio.closest('label')).startsWith(name),
|
||||
) as HTMLButtonElement
|
||||
}
|
||||
|
||||
export async function pickTile(name: string) {
|
||||
await click(tile(name))
|
||||
}
|
||||
|
||||
export function switchOf(label: string) {
|
||||
const found = all('[data-testid="field-inspector"] label').find((candidate) =>
|
||||
text(candidate).startsWith(label),
|
||||
)
|
||||
return document.getElementById(found!.getAttribute('for')!) as HTMLButtonElement
|
||||
}
|
||||
|
||||
export async function toggle(label: string) {
|
||||
await click(switchOf(label))
|
||||
}
|
||||
|
||||
export const limit = (which: 'min' | 'max') => el<HTMLInputElement>(`input[name="${which}"]`)
|
||||
export const defaultInput = () => el<HTMLInputElement>('input[name="default"]')
|
||||
export const addChoiceInput = () => el<HTMLInputElement>('input[aria-label="Add a choice"]')
|
||||
|
||||
export async function addChoice(value: string) {
|
||||
await typeInto(addChoiceInput(), value)
|
||||
await click(button('Add'))
|
||||
}
|
||||
|
||||
export function choiceValues() {
|
||||
return all<HTMLInputElement>('[data-testid="choice-row"] input').map((input) => input.value)
|
||||
}
|
||||
|
||||
export async function buildEvents() {
|
||||
await typeInto(nameInput(), 'Events')
|
||||
await toggle('Required')
|
||||
await toggle('Unique values')
|
||||
await typeInto(limit('max'), '80')
|
||||
await addField('Starts on')
|
||||
await pickTile('Date')
|
||||
await typeInto(limit('min'), '2026-01-01')
|
||||
await typeInto(limit('max'), '2027-12-31')
|
||||
await addField('Category')
|
||||
await pickTile('Choice')
|
||||
await addChoice('Talk')
|
||||
await addChoice('Workshop')
|
||||
await addChoice('Meetup')
|
||||
}
|
||||
@@ -76,6 +76,16 @@ export const builtinTypes = [
|
||||
fields: key === 'articles' ? articleFields : new Array(count),
|
||||
}))
|
||||
|
||||
export function createdType(body: Record<string, unknown>) {
|
||||
return {
|
||||
id: 4,
|
||||
icon: null,
|
||||
created_at: '2026-10-19T09:30:00Z',
|
||||
updated_at: '2026-10-19T09:30:00Z',
|
||||
...body,
|
||||
}
|
||||
}
|
||||
|
||||
export function manyTypes(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...customTypes[0]!,
|
||||
@@ -92,12 +102,24 @@ export function failWith(status: number): Responder {
|
||||
}
|
||||
}
|
||||
|
||||
export function failWithBody(status: number, body: string): Responder {
|
||||
return () => {
|
||||
throw new ApiError(status, undefined, body)
|
||||
}
|
||||
}
|
||||
|
||||
export const networkFailure: Responder = () => {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
export interface PostedRequest {
|
||||
url: string
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SchemaMount {
|
||||
role?: string
|
||||
create?: (body: PostedRequest['body']) => unknown
|
||||
width?: number
|
||||
content?: Responder
|
||||
builtin?: Responder
|
||||
@@ -107,9 +129,11 @@ export interface SchemaMount {
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
export const calls: string[] = []
|
||||
export const posts: PostedRequest[] = []
|
||||
|
||||
export async function mountAt(path: string, options: SchemaMount = {}, shellOnly = false) {
|
||||
calls.length = 0
|
||||
posts.length = 0
|
||||
localStorage.clear()
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
const pinia = createPinia()
|
||||
@@ -130,10 +154,15 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
role: (options.role ?? 'owner') as never,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const request = vi.fn(async (url: string) => {
|
||||
const request = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'POST') {
|
||||
const body = JSON.parse(String(init.body)) as PostedRequest['body']
|
||||
posts.push({ url, body })
|
||||
return (options.create ?? (() => createdType(body)))(body)
|
||||
}
|
||||
calls.push(url)
|
||||
if (url === '/v1/content-type') {
|
||||
return (options.content ?? (() => customTypes))()
|
||||
return (options.content ?? (() => [...customTypes]))()
|
||||
}
|
||||
if (url === '/v1/builtin-type') {
|
||||
return (options.builtin ?? (() => builtinTypes))()
|
||||
|
||||
@@ -44,7 +44,34 @@ const PAGINATION_STATES = [
|
||||
'Long labels on mobile',
|
||||
]
|
||||
|
||||
const schemaBoard = 'RenewCMS Screens Schema.dc.html'
|
||||
|
||||
const extraSources: Record<string, Source[]> = {
|
||||
schema: [
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '2 Schema builder',
|
||||
states: [
|
||||
'New type',
|
||||
'Editing an existing type',
|
||||
'Locked key: tooltip',
|
||||
'Unsaved changes',
|
||||
'Field errors',
|
||||
'Type-level errors',
|
||||
'Field key error',
|
||||
'Choice list editor',
|
||||
'Keyboard reorder',
|
||||
'API preview open',
|
||||
'Saving',
|
||||
'Saved',
|
||||
'Save failed',
|
||||
'Key already taken',
|
||||
'Schema incompatible',
|
||||
'Offline',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
sites: [
|
||||
{
|
||||
board: officeBoard,
|
||||
|
||||
@@ -6515,6 +6515,21 @@ function schemaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiM
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
async function schemaCloseSheet(page: Page) {
|
||||
if (schemaIsMobile(page)) {
|
||||
await page.getByRole('button', { name: 'Done' }).click()
|
||||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||||
}
|
||||
}
|
||||
|
||||
const schemaStates: Record<string, VisualState> = {
|
||||
Populated: { path: '/schema', api: schemaApi() },
|
||||
Loading: {
|
||||
@@ -6536,6 +6551,56 @@ const schemaStates: Record<string, VisualState> = {
|
||||
}),
|
||||
},
|
||||
'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' },
|
||||
'Type-level errors': {
|
||||
path: '/schema/new',
|
||||
api: schemaApi(),
|
||||
steps: async (page) => {
|
||||
await page.getByPlaceholder('For example, Events').fill('x')
|
||||
await page.getByRole('button', { name: 'Edit key' }).first().click()
|
||||
await page.locator('input[name="key"]').fill('e')
|
||||
await page.getByPlaceholder('For example, Events').fill('')
|
||||
await schemaOpenRow(page, 'Title')
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
await page.getByText('3 problems to fix').first().waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'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' },
|
||||
'Key already taken': {
|
||||
path: '/schema/new',
|
||||
api: schemaApi({
|
||||
'POST /v1/content-type': { status: 409, body: 'content_type_key_taken' },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await page.getByPlaceholder('For example, Events').fill('Events')
|
||||
await schemaOpenRow(page, 'Title')
|
||||
await page.getByRole('switch', { name: 'Required' }).click()
|
||||
await schemaCloseSheet(page)
|
||||
await page.getByRole('button', { name: 'Add field' }).click()
|
||||
await page.locator('input[name="label"]').fill('Starts on')
|
||||
await page.locator('label:has([role="radio"][value="date"])').click()
|
||||
await schemaCloseSheet(page)
|
||||
if (!schemaIsMobile(page)) {
|
||||
await schemaOpenRow(page, 'Title')
|
||||
}
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await page.getByText('is already used by another content type').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Schema incompatible': { path: '/schema/events', skip: 'pending' },
|
||||
Offline: { path: '/schema/events', skip: 'pending' },
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
|
||||
Reference in new issue
Block a user