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
@@ -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>
|
||||
Reference in new issue
Block a user