Files
summercms/admin/src/components/form/PreviewField.vue
Jakub Zych cf63fb3152 feat(18.1-01): task 2 taglist string mode, customTags and all save sites
- 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
2026-10-14 15:52:52 +02:00

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>