354 lines
11 KiB
Vue
354 lines
11 KiB
Vue
<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 RelationPicker from '@/components/RelationPicker.vue'
|
|
import SlugField from '@/components/SlugField.vue'
|
|
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
|
import {
|
|
BUILDER_LIMITS,
|
|
STARTER_KEY,
|
|
keyDisplayMode,
|
|
supportsLimits,
|
|
supportsUnique,
|
|
trimmedOptions,
|
|
type BuilderField,
|
|
type FieldType,
|
|
} from '@/lib/schemaBuilderModel'
|
|
import type { RelationKind } from '@/lib/relationField'
|
|
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
|
|
ownerName?: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
label: [value: string]
|
|
key: [value: string]
|
|
unlock: []
|
|
reset: []
|
|
type: [type: FieldType]
|
|
relationType: [key: string]
|
|
relationKind: [kind: RelationKind]
|
|
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 spec = computed(() => fieldTypeSpec(type.value))
|
|
const hasLimits = computed(() => supportsLimits(type.value))
|
|
const canUnique = computed(() => supportsUnique(type.value))
|
|
const showUnique = computed(() => canUnique.value || spec.value.showsUnsupportedOptions)
|
|
const showRuleGrid = computed(
|
|
() => hasLimits.value || spec.value.supportsDefault || spec.value.showsUnsupportedOptions,
|
|
)
|
|
const holdsRelation = computed(() => spec.value.holdsRelation)
|
|
const hint = computed(() => spec.value.builderHint?.() ?? '')
|
|
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: 'w-full flex-row-reverse items-center justify-between gap-4 border-b border-default py-3',
|
|
wrapper: 'ms-0 flex min-w-0 flex-col gap-0.5',
|
|
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
|
|
:locked="field.saved"
|
|
:locked-help="t('Used in the API, so it can\'t change.')"
|
|
: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"
|
|
:locked="field.saved"
|
|
@update:model-value="emit('type', $event)"
|
|
/>
|
|
|
|
<RelationPicker
|
|
v-if="holdsRelation"
|
|
:label="field.label"
|
|
:owner-name="ownerName ?? ''"
|
|
:type-key="field.relationType"
|
|
:kind="field.relationKind"
|
|
:locked="field.saved"
|
|
:disabled="disabled"
|
|
:compact="compact"
|
|
:error="errors.relation"
|
|
@type="emit('relationType', $event)"
|
|
@kind="emit('relationKind', $event)"
|
|
/>
|
|
|
|
<div class="flex min-w-0 flex-col border-t border-default">
|
|
<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
|
|
v-if="showUnique"
|
|
: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))"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="showRuleGrid" class="grid items-start gap-3.5" :class="gridClass">
|
|
<template v-if="hasLimits">
|
|
<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>
|
|
</template>
|
|
<DirectoryField
|
|
v-if="type !== 'boolean' && spec.supportsDefault"
|
|
: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
|
|
v-if="!hasLimits && spec.showsUnsupportedOptions"
|
|
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>
|
|
|
|
<div
|
|
v-if="hint"
|
|
data-testid="builder-hint"
|
|
class="flex gap-2 bg-(--rc-info-soft) px-3 py-2.5 text-info"
|
|
>
|
|
<span class="flex pt-0.5"><UIcon name="i-lucide-info" class="size-4" /></span>
|
|
<span class="text-pretty text-toned">{{ hint }}</span>
|
|
</div>
|
|
|
|
<ChoiceList
|
|
v-if="spec.supportsChoices"
|
|
: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>
|