feat: schema builder's create flow

This commit is contained in:
Florian Sylvain committed 2026-10-20 00:29:01 +02:00
1 parent f514146fed
commit 083cc9e23e
71 files changed
+7223 -61

No files matched your search

+97
View File
@@ -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>
+48
View File
@@ -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>
+2 -1
View File
@@ -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
+42
View File
@@ -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>
+46
View File
@@ -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>
+41
View File
@@ -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>
+49
View File
@@ -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>
+93
View File
@@ -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>
+175
View File
@@ -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>
+6 -3
View File
@@ -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',
},
{
+3 -2
View File
@@ -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>
+4 -22
View File
@@ -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>
+44
View File
@@ -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>
+310
View File
@@ -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>
+156
View File
@@ -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>
+111
View File
@@ -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>
+63
View File
@@ -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>
+66
View File
@@ -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>
+34
View File
@@ -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>
+24 -1
View File
@@ -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()
})
})