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

+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>