- string mode: split on the separator (comma or whitespace), normalised, stored joined by a comma or one space; null stores an empty string - customTags false refuses a tag outside options (taglist.not_allowed) - taglist checked and hydrated on relation forms and settings forms; settings bind taglist columns; required checked on the tags - admin: TaglistField string mode and closed option lists, preview renders the tags read-only as a comma-separated list; rebuilt dist - docs: Taglist section covers both modes, separator and customTags
136 lines
5.1 KiB
Vue
136 lines
5.1 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
|
|
import { t } from '../../app/i18n'
|
|
import { toggleOn } from './control'
|
|
import { isPluginField, rendererForField } from './registry'
|
|
|
|
// One field of the preview screen (UI-SPEC S3, D-11): a label and its value
|
|
// as a dt/dd pair. Values are text, never disabled inputs, so they keep full
|
|
// contrast and are read in order. Controls that already have a read-only
|
|
// mode (datepicker, fileupload, permissioneditor, money, taglist), partials and plugin
|
|
// field types (their element gets `disabled`) are rendered through their own
|
|
// component with the field marked read-only.
|
|
const props = defineProps<{
|
|
field: FormField
|
|
value: unknown
|
|
labels?: RelationOption[]
|
|
source?: ControllerParams | null
|
|
recordId?: number | null
|
|
idPrefix?: string
|
|
}>()
|
|
|
|
/** Types rendered by their own control in its read-only mode. */
|
|
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater', 'money', 'taglist'])
|
|
|
|
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
|
|
const labelId = computed(() => `${controlId.value}-label`)
|
|
|
|
const kind = computed(() => {
|
|
const type = props.field.type
|
|
if (ownControl.has(type) || isPluginField(props.field)) {
|
|
return 'control'
|
|
}
|
|
if (type === 'checkbox' || type === 'switch') {
|
|
return 'toggle'
|
|
}
|
|
if (type === 'relation') {
|
|
return props.field.multiple ? 'chips' : 'relation'
|
|
}
|
|
return type === 'textarea' ? 'textarea' : 'text'
|
|
})
|
|
|
|
/** The field as its own control sees it on preview: always read-only. */
|
|
const readOnlyField = computed<FormField>(() => ({ ...props.field, readOnly: true }))
|
|
const control = computed(() => rendererForField(props.field))
|
|
|
|
function isEmpty(value: unknown): boolean {
|
|
return value === null || value === undefined || value === ''
|
|
}
|
|
|
|
const text = computed(() => {
|
|
const value = props.value
|
|
if (isEmpty(value) || typeof value === 'object') {
|
|
return ''
|
|
}
|
|
if (props.field.type === 'dropdown') {
|
|
const option = (props.field.options ?? []).find((item) => String(item.value) === String(value))
|
|
return option ? option.label : String(value)
|
|
}
|
|
return String(value)
|
|
})
|
|
|
|
const relationLabel = computed(() => props.labels?.[0]?.label ?? '')
|
|
const chips = computed(() => (Array.isArray(props.value) ? (props.labels ?? []) : []))
|
|
|
|
function initials(label: string): string {
|
|
const parts = label.trim().split(/\s+/).filter(Boolean)
|
|
return parts
|
|
.slice(0, 2)
|
|
.map((part) => part.charAt(0).toUpperCase())
|
|
.join('')
|
|
}
|
|
|
|
const box = 'flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5 [overflow-wrap:anywhere]'
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex min-w-0 flex-col gap-1.5" :data-preview-field="field.name">
|
|
<dt :id="labelId" class="font-semibold">{{ field.label || field.name }}</dt>
|
|
<dd class="m-0 min-w-0">
|
|
<component
|
|
:is="control"
|
|
v-if="kind === 'control'"
|
|
:field="readOnlyField"
|
|
:model-value="value"
|
|
:control-id="controlId"
|
|
:labels="labels"
|
|
:source="source"
|
|
:record-id="recordId"
|
|
/>
|
|
<template v-else-if="kind === 'toggle'">
|
|
<span
|
|
v-if="toggleOn(value)"
|
|
data-switch="true"
|
|
class="inline-flex h-6 items-center rounded-pill bg-ok-bg px-2.5 text-[12px] font-semibold text-ok-text"
|
|
>{{ t('backend::lang.list.column_switch_true') }}</span
|
|
>
|
|
<span
|
|
v-else
|
|
data-switch="false"
|
|
class="inline-flex h-6 items-center rounded-pill border border-border-strong px-2.5 text-[12px] font-semibold text-muted"
|
|
>{{ t('backend::lang.list.column_switch_false') }}</span
|
|
>
|
|
</template>
|
|
<div v-else-if="kind === 'chips'" data-preview-value :class="box" class="flex-wrap gap-1.5 p-1.5">
|
|
<span
|
|
v-for="chip in chips"
|
|
:key="String(chip.value)"
|
|
data-chip
|
|
class="inline-flex min-h-[30px] items-center gap-1.5 rounded-pill bg-surface pr-3 pl-1"
|
|
>
|
|
<span
|
|
class="flex size-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-on-primary"
|
|
aria-hidden="true"
|
|
>{{ initials(chip.label) }}</span
|
|
>
|
|
<span class="font-semibold">{{ chip.label }}</span>
|
|
</span>
|
|
<span v-if="chips.length === 0" class="px-2 text-muted" data-empty>{{ t('backend::lang.list.empty_value') }}</span>
|
|
</div>
|
|
<div v-else-if="kind === 'relation'" data-preview-value :class="box">
|
|
<span v-if="relationLabel !== ''">{{ relationLabel }}</span>
|
|
<span v-else class="text-muted" data-empty>{{ field.emptyOption || t('backend::lang.list.empty_value') }}</span>
|
|
</div>
|
|
<div
|
|
v-else
|
|
data-preview-value
|
|
:class="[box, kind === 'textarea' ? 'items-start! py-3 whitespace-pre-wrap' : '']"
|
|
>
|
|
<span v-if="text !== ''">{{ text }}</span>
|
|
<span v-else class="text-muted" data-empty>{{ t('backend::lang.list.empty_value') }}</span>
|
|
</div>
|
|
</dd>
|
|
</div>
|
|
</template>
|