feat: relation field and picker

This commit is contained in:
Florian Sylvain committed 2026-10-23 00:13:42 +02:00
1 parent b45c99be3b
commit 37a77c9c22
92 files changed
+6826 -80

No files matched your search

+4
View File
@@ -29,6 +29,7 @@ const props = defineProps<{
field: BuilderField
errors: FieldErrors
compact: boolean
ownerName?: string
}>()
const uid = () => props.field.uid
@@ -40,12 +41,15 @@ const uid = () => props.field.uid
:errors="errors"
:disabled="saving"
:compact="compact"
:owner-name="ownerName"
:focus-token="focusToken"
@label="actions.setLabel(uid(), $event)"
@key="actions.setFieldKey(uid(), $event)"
@unlock="actions.unlockFieldKey(uid())"
@reset="actions.resetFieldKey(uid())"
@type="actions.setFieldType(uid(), $event)"
@relation-type="actions.patchField(uid(), { relationType: $event }, 'relation')"
@relation-kind="actions.patchField(uid(), { relationKind: $event })"
@required="actions.patchField(uid(), { required: $event })"
@unique="actions.patchField(uid(), { unique: $event })"
@limit="(which, value) => actions.setLimit(uid(), which, value)"
+15
View File
@@ -11,6 +11,7 @@ const props = defineProps<{
name: string
keyValue: string
total: number
referrer: string
pending: boolean
offline: boolean
}>()
@@ -43,6 +44,20 @@ const notice = computed(() => {
),
}
}
if (props.stage === 'referenced') {
return {
title: t("You can't delete {0} yet", [props.name]),
description: props.referrer
? t('{0} has a field that points to {1}. Remove that field first.', [
props.referrer,
props.name,
])
: t(
'Another content type has a field that points to {0}. Remove that field first.',
[props.name],
),
}
}
if (props.stage === 'failed') {
return {
title: t("Couldn't delete the content type"),
+27 -1
View File
@@ -11,7 +11,9 @@ import {
thumbnailColumn,
} from '@/lib/entryCells'
import { t } from '@/i18n'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { mediaOf } from '@/lib/mediaField'
import { summariesOf } from '@/lib/relationField'
import { entryPath } from '@/lib/routePaths'
import { computed } from 'vue'
@@ -26,11 +28,21 @@ const props = defineProps<{
const shown = computed(() => cardFields(props.fields))
const lead = computed(() => thumbnailColumn(props.columns))
const relationColumns = computed(() =>
props.columns.filter(
(field) => field !== shown.value.first && fieldTypeSpec(field.type).cell === 'relation',
),
)
function textOf(field: BuiltinField | undefined, entry: ContentEntry) {
return field
? plainCellText(
describeCell(field, entry.data[field.key], mediaOf(entry, field.key) ?? undefined),
describeCell(
field,
entry.data[field.key],
mediaOf(entry, field.key) ?? undefined,
summariesOf(entry, field.key),
),
)
: ''
}
@@ -80,6 +92,20 @@ function nameOf(entry: ContentEntry) {
<span class="truncate text-md/5.25 font-medium text-highlighted">{{
titleOf(entry)
}}</span>
<span
v-for="field in relationColumns"
:key="field.key"
data-testid="entry-card-relation"
class="flex flex-wrap items-center gap-1.5"
>
<span class="text-xs text-muted">{{ field.label }}</span>
<FieldCell
:field="field"
:value="entry.data[field.key]"
:relations="summariesOf(entry, field.key)"
compact
/>
</span>
<span class="flex items-center gap-2 text-xs text-muted">
<template v-if="shown.second && !secondIsChoice">
<span class="shrink-0">{{ textOf(shown.second, entry) }}</span>
+2
View File
@@ -111,6 +111,8 @@ function backToList() {
:fields="page.fields.value"
:values="page.form.values.value"
:images="page.form.images.value"
:relations="page.form.relations.value"
:type-name="page.typeName.value"
:issues="page.form.issues.value"
:first-issue="page.form.firstIssue.value"
:focus-token="page.focusToken.value"
+27 -1
View File
@@ -4,10 +4,12 @@ import BuilderBadge from '@/components/BuilderBadge.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import EntryControl from '@/components/EntryControl.vue'
import MediaFieldControl from '@/components/MediaFieldControl.vue'
import RelationField from '@/components/RelationField.vue'
import type { BuiltinField } from '@/lib/contentTypes'
import type { FieldIssue } from '@/lib/entryForm'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { isMediaId, type MediaSummary } from '@/lib/mediaField'
import { holdsManyRelations, type RelationSummary } from '@/lib/relationField'
const props = defineProps<{
field: BuiltinField
@@ -16,14 +18,24 @@ const props = defineProps<{
isDefault: boolean
disabled: boolean
image?: MediaSummary | null
relations?: RelationSummary[]
typeName: string
}>()
const emit = defineEmits<{ update: [key: string, value: unknown, image?: MediaSummary | null] }>()
const emit = defineEmits<{
update: [
key: string,
value: unknown,
image?: MediaSummary | null,
relations?: RelationSummary[],
]
}>()
const editor = computed(() => fieldTypeSpec(props.field.type).editor)
const counter = computed(() => editor.value.counter(props.field, props.value))
const help = computed(() => editor.value.help(props.field))
const isMedia = computed(() => editor.value.control === 'media')
const isRelation = computed(() => editor.value.control === 'relation')
const mediaId = computed(() => (isMediaId(props.value) ? props.value : null))
</script>
@@ -40,6 +52,20 @@ const mediaId = computed(() => (isMediaId(props.value) ? props.value : null))
:error="issue?.message"
@change="(id, picked) => emit('update', field.key, id, picked)"
/>
<RelationField
v-else-if="isRelation"
:label="field.label"
:name="field.key"
:required="field.required"
:many="holdsManyRelations(field)"
:target="field.relation?.content_type ?? ''"
:type-name="typeName"
:value="value"
:summaries="relations"
:disabled="disabled"
:error="issue?.message"
@change="(next, summaries) => emit('update', field.key, next, undefined, summaries)"
/>
<DirectoryField
v-else
:label="field.label"
+14 -2
View File
@@ -6,6 +6,7 @@ import EntryField from '@/components/EntryField.vue'
import type { BuiltinField } from '@/lib/contentTypes'
import type { EntryIssues, EntryValues } from '@/lib/entryForm'
import type { MediaMap, MediaSummary } from '@/lib/mediaField'
import type { RelationSummaries, RelationSummary } from '@/lib/relationField'
import { groupFields } from '@/lib/entryLayout'
const FOCUSABLE =
@@ -15,6 +16,8 @@ const props = defineProps<{
fields: BuiltinField[]
values: EntryValues
images: MediaMap
relations: RelationSummaries
typeName: string
issues: EntryIssues
firstIssue: string | null
focusToken: number
@@ -28,7 +31,12 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
update: [key: string, value: unknown, image?: MediaSummary | null]
update: [
key: string,
value: unknown,
image?: MediaSummary | null,
relations?: RelationSummary[],
]
submit: []
}>()
@@ -73,10 +81,14 @@ watch(() => props.focusToken, focusFirstIssue)
:field="field"
:value="values[field.key]"
:image="images[field.key] ?? null"
:relations="relations[field.key] ?? []"
:type-name="typeName"
:issue="issues[field.key]"
:is-default="isDefault(field)"
:disabled="locked"
@update="(key, value, image) => emit('update', key, value, image)"
@update="
(key, value, image, summaries) => emit('update', key, value, image, summaries)
"
/>
</div>
+7 -2
View File
@@ -20,12 +20,17 @@ const props = defineProps<{
defineEmits<{ retry: []; reload: [] }>()
const NAMED_CONTROLS = ['media', 'relation']
const uniqueField = computed(() =>
props.fields.find((field) => props.issues[field.key]?.rule === 'unique'),
props.fields.find(
(field) =>
props.issues[field.key]?.rule === 'unique' && fieldTypeSpec(field.type).supportsUnique,
),
)
const changedLine = computed(() => {
const changed = props.fields.find((field) => field.key === props.schemaField)
return changed && fieldTypeSpec(changed.type).editor.control === 'media'
return changed && NAMED_CONTROLS.includes(fieldTypeSpec(changed.type).editor.control)
? mediaChangedLine(changed.label)
: schemaChangedLine(props.schemaField)
})
+9 -1
View File
@@ -9,11 +9,13 @@ import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { mediaOf } from '@/lib/mediaField'
import { summariesOf } from '@/lib/relationField'
import { entryPath } from '@/lib/routePaths'
const ID_WIDTH = 'w-16.5'
const NARROW_WIDTH = 'w-23'
const FIELD_WIDTH = 'w-33'
const WIDE_WIDTH = 'w-50'
const CREATED_WIDTH = 'w-30.5'
const UPDATED_WIDTH = 'w-40.5'
const UPDATED_BY_WIDTH = 'w-37'
@@ -37,7 +39,11 @@ function widthOf(field: BuiltinField, index: number) {
if (index === 0) {
return ''
}
return fieldTypeSpec(field.type).narrowColumn ? NARROW_WIDTH : FIELD_WIDTH
const spec = fieldTypeSpec(field.type)
if (spec.wideColumn) {
return WIDE_WIDTH
}
return spec.narrowColumn ? NARROW_WIDTH : FIELD_WIDTH
}
function column(id: string, header: string, width: string, edge = 'px-1.5') {
@@ -152,6 +158,7 @@ watchPostEffect(() => {
:field="field"
:value="row.original.data[field.key]"
:image="mediaOf(row.original, field.key)"
:relations="summariesOf(row.original, field.key)"
class="min-w-0"
/>
</RouterLink>
@@ -160,6 +167,7 @@ watchPostEffect(() => {
:field="field"
:value="row.original.data[field.key]"
:image="mediaOf(row.original, field.key)"
:relations="summariesOf(row.original, field.key)"
/>
</template>
+21 -1
View File
@@ -2,18 +2,23 @@
import { t } from '@/i18n'
import { computed } from 'vue'
import RelationChip from '@/components/RelationChip.vue'
import type { BuiltinField } from '@/lib/contentTypes'
import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells'
import { isMissingImage, type MediaSummary } from '@/lib/mediaField'
import type { RelationSummary } from '@/lib/relationField'
const props = defineProps<{
field: BuiltinField
value: unknown
image?: MediaSummary | null
relations?: RelationSummary[]
compact?: boolean
}>()
const cell = computed(() => describeCell(props.field, props.value, props.image ?? undefined))
const cell = computed(() =>
describeCell(props.field, props.value, props.image ?? undefined, props.relations),
)
const shownImage = computed(() => {
const image = cell.value.image
return image && !isMissingImage(image) ? image : null
@@ -61,6 +66,21 @@ const TIP_UI = {
<UIcon name="i-lucide-unlink" :class="compact ? 'size-3' : 'size-3.5'" />
</span>
</UTooltip>
<span
v-else-if="cell.kind === 'relation'"
data-testid="relation-cell"
:title="cell.text"
class="flex min-w-0 items-center gap-1"
:class="compact ? 'flex-wrap' : 'overflow-hidden'"
>
<RelationChip
v-for="chip in cell.chips"
:key="chip.id"
:label="chip.label"
:missing="chip.missing"
/>
<span v-if="cell.more" class="text-xs text-muted">+{{ cell.more }}</span>
</span>
<span v-else-if="cell.kind === 'boolean'" class="flex items-center gap-1.5">
<UIcon
:name="cell.yes ? 'i-lucide-check' : 'i-lucide-minus'"
+20
View File
@@ -6,6 +6,7 @@ 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 {
@@ -18,6 +19,7 @@ import {
type BuilderField,
type FieldType,
} from '@/lib/schemaBuilderModel'
import type { RelationKind } from '@/lib/relationField'
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
const NO_DEFAULT = 'none'
@@ -29,6 +31,7 @@ const props = defineProps<{
disabled: boolean
compact: boolean
focusToken: number
ownerName?: string
}>()
const emit = defineEmits<{
@@ -37,6 +40,8 @@ const emit = defineEmits<{
unlock: []
reset: []
type: [type: FieldType]
relationType: [key: string]
relationKind: [kind: RelationKind]
required: [value: boolean]
unique: [value: boolean]
limit: [which: 'min' | 'max', value: string]
@@ -58,6 +63,7 @@ const showUnique = computed(() => canUnique.value || spec.value.showsUnsupported
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'))
@@ -193,6 +199,20 @@ watch(
@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"
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
withDefaults(defineProps<{ label: string; missing?: boolean; tall?: boolean }>(), {
missing: false,
tall: false,
})
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:label="label"
data-testid="relation-chip"
:data-missing="missing ? 'true' : undefined"
class="max-w-full ring-0"
:class="
missing
? 'border border-dashed border-accented bg-transparent text-toned'
: 'bg-(--rc-sand) text-(--rc-on-sand)'
"
:ui="{
base: [
tall ? 'h-5.5 px-2' : 'h-5 px-1.5',
'rounded-none py-0 text-xs font-medium whitespace-nowrap',
],
label: 'truncate',
}"
/>
</template>
+461
View File
@@ -0,0 +1,461 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, useId, watch } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import { useRelationSearch } from '@/composables/useRelationSearch'
import { relationLabel } from '@/lib/entryCells'
import {
RELATION_MAX,
isMissingRelation,
relationIdsOf,
type RelationSummary,
} from '@/lib/relationField'
import { relationRowOf } from '@/lib/relationRows'
import { useContentTypesStore } from '@/stores/contentTypes'
interface ChipItem {
label: string
value: number
missing: boolean
summary: RelationSummary
}
interface RowItem {
label: string
description: string
value: number
summary: RelationSummary
}
interface HighlightPayload {
ref?: HTMLElement
}
const SKELETON_ROWS = 3
const LISTBOX = '[role="listbox"]'
const OWNER_ATTRIBUTE = 'data-relation-owner'
const VOWEL = /^[aeiou]/
const props = withDefaults(
defineProps<{
label: string
name?: string
required?: boolean
many?: boolean
target: string
typeName: string
value: unknown
summaries?: RelationSummary[]
disabled?: boolean
error?: string
}>(),
{
name: undefined,
required: false,
many: false,
summaries: () => [],
disabled: false,
error: '',
},
)
const emit = defineEmits<{
change: [value: number | number[] | null, summaries: RelationSummary[]]
}>()
const id = useId()
const labelId = `${id}-label`
const helpId = `${id}-help`
const errorId = `${id}-error`
const store = useContentTypesStore()
const search = useRelationSearch(() => props.target)
const open = ref(false)
const searchTerm = ref('')
const announcement = ref('')
const activeId = ref<string | undefined>(undefined)
const listboxId = ref<string | undefined>(undefined)
const ids = computed(() => relationIdsOf(props.value))
const chips = computed<ChipItem[]>(() =>
ids.value.map((chipId) => {
const summary = props.summaries.find((candidate) => candidate.id === chipId) ?? {
id: chipId,
missing: true as const,
}
return {
label: relationLabel(summary),
value: chipId,
missing: isMissingRelation(summary),
summary,
}
}),
)
const targetType = computed(() => store.items.find((type) => type.key === props.target))
const targetName = computed(() => targetType.value?.name ?? props.target)
const atCap = computed(() => props.many && ids.value.length >= RELATION_MAX)
const rows = computed<RowItem[]>(() =>
search.entries.value
.map((entry) => relationRowOf(entry, props.target, targetType.value?.fields ?? []))
.filter((row) => !ids.value.includes(row.id))
.map((row) => ({
label: row.title,
description: row.detail,
value: row.id,
summary: row.summary,
})),
)
const menuItems = computed(() => (atCap.value || search.showSkeleton.value ? [] : rows.value))
const hasMissing = computed(() => chips.value.some((chip) => chip.missing))
const ownerWord = computed(() => props.typeName.replace(/s$/i, '').toLowerCase())
const help = computed(() => {
if (hasMissing.value) {
return props.required
? t('{0} is required. Choose another to save.', [props.label])
: t('You can still save. The entry keeps the link until you change it.')
}
if (!props.many) {
return ''
}
const plural = props.label.toLowerCase()
return VOWEL.test(ownerWord.value)
? t('An {0} can have many {1}.', [ownerWord.value, plural])
: t('A {0} can have many {1}.', [ownerWord.value, plural])
})
const describedBy = computed(() => (props.error ? `${errorId} ${helpId}` : helpId))
const emptyKind = computed(() => {
if (atCap.value) {
return 'cap'
}
if (search.showSkeleton.value) {
return 'loading'
}
const status = search.status.value
if (status === 'limited' || status === 'failed') {
return status
}
if (status !== 'ready') {
return 'none'
}
if (search.total.value > 0) {
return 'chosen'
}
return search.query.value === '' ? 'empty' : 'noMatch'
})
const baseUi = computed(() =>
props.error
? 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)] has-focus-visible:border-error'
: 'border-accented has-focus-visible:border-primary has-focus-visible:shadow-[inset_0_0_0_1px_var(--ui-primary)]',
)
const menuUi = computed(() => ({
base: `min-h-8 gap-1.5 rounded-none border bg-default px-2 py-1 text-sm ring-0 outline-0 has-focus-visible:ring-0 has-focus-visible:outline-0 max-md:min-h-11 max-md:text-lg ${baseUi.value} ${props.many ? '' : 'pe-8'}`,
tagsItem:
'h-6 gap-1.5 rounded-none bg-(--rc-sand) py-0 ps-2 pe-1 text-sm font-medium text-(--rc-on-sand) ring-0 has-data-[missing=true]:border has-data-[missing=true]:border-dashed has-data-[missing=true]:border-accented has-data-[missing=true]:bg-transparent has-data-[missing=true]:font-normal has-data-[missing=true]:text-toned max-md:h-8',
tagsItemText: 'truncate',
tagsItemDelete:
'relative rounded-none text-inherit hover:bg-transparent hover:text-inherit max-md:before:absolute max-md:before:-inset-1.5',
tagsInput: 'min-w-20 flex-1 placeholder:text-muted',
trailing: 'pe-2.5',
trailingIcon: 'size-3.5 text-muted',
content: 'rounded-none border border-default p-1 shadow-(--shadow-popover) ring-0',
empty: 'p-2 text-left text-sm text-muted',
group: 'p-0',
item: 'min-h-10 flex-col justify-center gap-0 rounded-none px-2 py-1 before:inset-0 before:rounded-none data-highlighted:not-data-disabled:before:bg-elevated max-md:min-h-12',
itemLabel: 'font-medium text-highlighted',
itemDescription: 'text-xs/5 text-muted',
}))
function removeName(element: HTMLElement, binding: { value: string }) {
const button = element.closest('button')
button?.removeAttribute('aria-labelledby')
button?.setAttribute('aria-label', binding.value)
}
const vRemoveName = { mounted: removeName, updated: removeName }
function removeLabel(item: ChipItem) {
return t('Remove {0} from {1}', [item.label, props.label])
}
function announce(message: string) {
announcement.value = ''
nextTick(() => {
announcement.value = message
})
}
function summaryOf(chipId: number, picked: RowItem[]) {
return (
chips.value.find((chip) => chip.value === chipId)?.summary ??
picked.find((row) => row.value === chipId)?.summary
)
}
function changeSingle(picked: RowItem[]) {
const row = picked[picked.length - 1]
if (!row) {
emit('change', null, [])
announce(t('{0} cleared.', [props.label]))
return
}
emit('change', row.value, [row.summary])
announce(t('{0} set to {1}.', [props.label, row.label]))
open.value = false
}
function changeMany(next: Array<ChipItem | RowItem>, picked: RowItem[]) {
const nextIds = next.map((item) => item.value)
emit(
'change',
nextIds,
nextIds.flatMap((nextId) => summaryOf(nextId, picked) ?? []),
)
const removed = chips.value.find((chip) => !nextIds.includes(chip.value))
if (removed) {
announce(t('{0} removed from {1}.', [removed.label, props.label]))
} else if (picked[0]) {
announce(t('{0} added to {1}.', [picked[0].label, props.label]))
}
}
function onUpdate(next: Array<ChipItem | RowItem>) {
if (props.disabled) {
return
}
const picked = next.filter(
(item): item is RowItem => !ids.value.includes(item.value) && 'description' in item,
)
if (props.many) {
changeMany(next, picked)
} else {
changeSingle(picked)
}
}
function onBackspace(event: KeyboardEvent) {
const input = event.target as HTMLInputElement
const lastChip = chips.value.length - 1
if (
event.key !== 'Backspace' ||
input.getAttribute('role') !== 'combobox' ||
input.value !== '' ||
lastChip < 0 ||
props.disabled
) {
return
}
event.preventDefault()
event.stopPropagation()
onUpdate(chips.value.slice(0, lastChip))
}
function onHighlight(payload: HighlightPayload | undefined) {
activeId.value = payload?.ref?.id || undefined
}
function clearSearch() {
searchTerm.value = ''
}
async function trackListbox() {
await nextTick()
const owned = `[${OWNER_ATTRIBUTE}="${id}"]`
listboxId.value =
document.querySelector<HTMLElement>(`${owned}${LISTBOX}, ${owned} ${LISTBOX}`)?.id ||
undefined
}
function announceResults() {
const total = search.total.value
announce(total > 0 ? t('{0} results', [total]) : t('No results'))
}
watch(searchTerm, (term) => {
if (open.value) {
search.search(term)
}
})
watch(open, (isOpen) => {
if (isOpen) {
search.search(searchTerm.value)
trackListbox()
} else {
activeId.value = undefined
listboxId.value = undefined
}
})
watch(
() => search.status.value,
(status) => {
if (open.value && status === 'ready') {
announceResults()
}
},
)
onMounted(() => store.load())
</script>
<template>
<div class="min-w-0">
<DirectoryField
:label="label"
:required="required"
:name="name"
:label-id="labelId"
:error="error"
:error-id="errorId"
>
<div
role="group"
:aria-labelledby="labelId"
data-testid="relation-control"
@keydown.capture="onBackspace"
>
<UInputMenu
:model-value="chips"
v-model:open="open"
v-model:search-term="searchTerm"
multiple
ignore-filter
open-on-focus
open-on-click
by="value"
:items="menuItems"
:placeholder="t('Search to add')"
:disabled="disabled"
:color="error ? 'error' : undefined"
:trailing="!many"
trailing-icon="i-lucide-search"
:aria-controls="listboxId"
:aria-required="required ? 'true' : undefined"
:aria-activedescendant="activeId"
:aria-describedby="describedBy"
class="w-full"
:content="{ sideOffset: 6, [OWNER_ATTRIBUTE]: id }"
:ui="menuUi"
@update:model-value="onUpdate"
@highlight="onHighlight"
>
<template #tags-item-text="{ item }">
<span :data-missing="item.missing ? 'true' : undefined">{{
item.label
}}</span>
</template>
<template #tags-item-delete="{ item }">
<UIcon
v-remove-name="removeLabel(item)"
name="i-lucide-x"
class="size-3.5"
/>
</template>
<template #item="{ item }">
<span class="font-medium text-highlighted">{{ item.label }}</span>
<span v-if="item.description" class="text-xs/5 text-muted">{{
item.description
}}</span>
</template>
<template #empty>
<div data-testid="relation-empty" class="flex flex-col items-start gap-2">
<template v-if="emptyKind === 'loading'">
<span class="sr-only" role="status" aria-live="polite">{{
t('Searching…')
}}</span>
<USkeleton
v-for="row in SKELETON_ROWS"
:key="row"
data-testid="relation-skeleton"
class="h-10 w-full rounded-none bg-accented max-md:h-12"
/>
</template>
<template v-else-if="emptyKind === 'failed'">
<span class="font-medium text-error">{{
t("Couldn't load {0}", [targetName])
}}</span>
<span>{{ t('Check your connection and try again.') }}</span>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-8 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:h-11"
@click="search.reload()"
>
{{ t('Try again') }}
</UButton>
</template>
<template v-else-if="emptyKind === 'limited'">
<span class="font-medium text-warning">{{
t('Too many requests')
}}</span>
<span>{{
search.limit.active.value
? t('Try again in {0}', [search.limit.formatted.value])
: t('Wait a moment before trying again.')
}}</span>
<UButton
v-if="!search.limit.active.value"
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-8 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:h-11"
@click="search.reload()"
>
{{ t('Try again') }}
</UButton>
</template>
<span v-else-if="emptyKind === 'cap'">{{
t('You can add up to {0}.', [RELATION_MAX])
}}</span>
<span v-else-if="emptyKind === 'empty'">{{
t('No {0} yet. Add one in Content, then come back.', [targetName])
}}</span>
<template v-else-if="emptyKind === 'noMatch'">
<span>{{
t('No {0} match "{1}"', [targetName, search.query.value])
}}</span>
<UButton
color="neutral"
variant="ghost"
class="h-8 px-2 text-xs text-highlighted max-md:h-11"
@click="clearSearch"
>
{{ t('Clear search') }}
</UButton>
</template>
<span v-else-if="emptyKind === 'chosen'">{{
t('No other {0} to add.', [targetName])
}}</span>
</div>
</template>
<template #content-bottom>
<UButton
v-if="search.hasMore.value && !atCap"
color="neutral"
variant="ghost"
:loading="search.loadingMore.value"
class="h-8 w-full justify-center text-xs text-highlighted max-md:h-11"
@click="search.more()"
>
{{ t('Load more') }}
</UButton>
</template>
</UInputMenu>
</div>
<p
v-if="help"
:id="helpId"
data-testid="relation-help"
class="mt-1.5 text-xs/4.5 text-pretty text-muted"
:class="error ? 'sr-only' : ''"
>
{{ help }}
</p>
<span class="sr-only" role="status" aria-live="polite" data-testid="relation-live">{{
announcement
}}</span>
</DirectoryField>
</div>
</template>
+257
View File
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import RelationChip from '@/components/RelationChip.vue'
import { RELATION_KINDS, isRelationKind, type RelationKind } from '@/lib/relationField'
import {
inverseNoun,
inverseSide,
kindFromInverseSide,
kindFromOwnerSide,
kindSentence,
ownerNoun,
ownerSide,
relationNames,
relationSample,
type RelationSide,
} from '@/lib/relationSentences'
import { useContentTypesStore } from '@/stores/contentTypes'
const DIAGRAMS: Record<RelationKind, readonly [number, number]> = {
one_to_one: [1, 1],
one_to_many: [3, 1],
many_to_many: [2, 2],
}
const props = defineProps<{
label: string
ownerName: string
typeKey: string
kind: RelationKind
locked: boolean
disabled: boolean
compact: boolean
error?: string
}>()
const emit = defineEmits<{ type: [key: string]; kind: [kind: RelationKind] }>()
const store = useContentTypesStore()
const typeItems = computed(() =>
store.items.map((type) => ({ label: type.name, value: type.key, hint: type.key })),
)
const related = computed(() => store.items.find((type) => type.key === props.typeKey))
const names = computed(() => relationNames(props.ownerName, related.value?.name ?? '', props.label))
const frozen = computed(() => props.locked || props.disabled)
const kindItems = computed(() =>
RELATION_KINDS.map((kind) => ({
value: kind,
label: kindTitle(kind),
sentence: kindSentence(kind, names.value),
})),
)
const sideItems = computed(() => [
{ label: t('one'), value: 'one' },
{ label: t('many'), value: 'many' },
])
const sample = computed(() => relationSample(props.kind))
function kindTitle(kind: RelationKind) {
if (kind === 'one_to_one') {
return t('One to one')
}
return kind === 'one_to_many' ? t('One to many') : t('Many to many')
}
function squares(count: number) {
return Array.from({ length: count }, (_, index) => index)
}
function onKind(value: unknown) {
if (isRelationKind(value)) {
emit('kind', value)
}
}
function onOwnerSide(value: unknown) {
emit('kind', kindFromOwnerSide(value as RelationSide, props.kind))
}
function onInverseSide(value: unknown) {
emit('kind', kindFromInverseSide(value as RelationSide, props.kind))
}
const SIDE_UI = {
base: 'mx-1 h-7 w-auto gap-1.5 rounded-none border border-accented bg-default py-0 ps-2 pe-7 text-sm font-medium text-highlighted ring-0',
trailing: 'pe-2',
trailingIcon: 'size-3 text-muted',
value: 'truncate',
}
const CARD_UI = {
root: 'w-full',
legend: 'mb-1.5 w-full font-medium text-highlighted',
item: 'group min-h-0 rounded-none border-accented p-2.5 text-highlighted has-data-[state=checked]:border-primary has-data-[state=checked]:bg-(--rc-sand) has-data-[state=checked]:text-(--rc-on-sand)',
wrapper: 'ms-0 w-full',
label: 'w-full text-base font-medium text-inherit',
}
onMounted(() => store.load())
</script>
<template>
<div class="flex min-w-0 flex-col gap-3.5" data-testid="relation-picker">
<p
v-if="locked"
data-testid="relation-locked-note"
class="flex items-center gap-1.5 text-xs/4.5 text-muted"
>
<UIcon name="i-lucide-lock" class="size-3 shrink-0" />
{{ t("The related type and the connection can't change after saving.") }}
</p>
<DirectoryField
:label="t('Related content type')"
name="relation-type"
:error="error"
:help="t('Choose the content type this field points to.')"
>
<USelect
:model-value="typeKey === '' ? undefined : typeKey"
:items="typeItems"
:placeholder="t('Choose a content type')"
:disabled="frozen"
:color="error ? 'error' : undefined"
class="w-full"
:ui="{
base: 'h-11 text-lg disabled:bg-elevated disabled:opacity-100 md:h-8 md:text-sm',
content: 'rounded-none shadow-(--shadow-popover)',
}"
@update:model-value="emit('type', String($event))"
>
<template #item-label="{ item }">
<span class="flex min-w-0 items-baseline gap-2">
<span class="truncate">{{ item.label }}</span>
<span class="font-mono text-xs text-muted">{{ item.hint }}</span>
</span>
</template>
</USelect>
</DirectoryField>
<URadioGroup
:model-value="kind"
:items="kindItems"
:disabled="frozen"
:legend="t('How are they connected?')"
variant="card"
indicator="hidden"
orientation="horizontal"
:ui="{
...CARD_UI,
fieldset: compact ? 'grid grid-cols-1 gap-2' : 'grid grid-cols-3 gap-2',
}"
@update:model-value="onKind"
>
<template #label="{ item }">
<span
class="flex flex-col gap-1.5 text-left"
:class="frozen && !(item.value === kind) ? 'opacity-60' : ''"
>
<span class="flex items-center gap-1.5" aria-hidden="true">
<span class="flex gap-0.75">
<span
v-for="square in squares(DIAGRAMS[item.value as RelationKind][0])"
:key="square"
class="size-2.5 bg-current"
/>
</span>
<span class="h-px w-5 bg-current" />
<span class="flex gap-0.75">
<span
v-for="square in squares(DIAGRAMS[item.value as RelationKind][1])"
:key="square"
class="size-2.5 bg-current"
/>
</span>
</span>
<span class="font-medium">{{ item.label }}</span>
<span
class="text-xs/4 font-normal text-pretty text-muted group-has-data-[state=checked]:text-(--rc-on-sand)"
>{{ item.sentence }}</span
>
</span>
</template>
</URadioGroup>
<div data-testid="relation-preview" class="flex flex-col gap-2 bg-elevated p-3">
<span class="flex flex-wrap items-center" data-testid="relation-owner-sentence">
{{ t('Each {0} has', [names.ownerSingular]) }}
<USelect
:model-value="ownerSide(kind)"
:items="sideItems"
:disabled="frozen"
:aria-label="t('Each {0} has', [names.ownerSingular])"
:ui="SIDE_UI"
@update:model-value="onOwnerSide"
/>
{{ ownerNoun(kind, names) }}
</span>
<span class="flex flex-wrap items-center" data-testid="relation-inverse-sentence">
{{ t('Each {0} has', [names.relatedSingular]) }}
<USelect
:model-value="inverseSide(kind)"
:items="sideItems"
:disabled="frozen"
:aria-label="t('Each {0} has', [names.relatedSingular])"
:ui="SIDE_UI"
@update:model-value="onInverseSide"
/>
{{ inverseNoun(kind, names) }}
<span class="text-muted">{{
` · ${t('shown on {0} as the field "{1}"', [names.relatedSingular, names.ownerPlural])}`
}}</span>
</span>
</div>
<div class="flex gap-3" :class="compact ? 'flex-col' : 'flex-row'">
<div
data-testid="relation-example-owner"
class="flex min-w-0 flex-1 flex-col gap-2 border border-default bg-default p-3"
>
<span class="font-semibold text-highlighted">{{ names.ownerSingular }}</span>
<span class="text-xs text-muted">{{ ownerNoun(kind, names) }}</span>
<div class="flex flex-wrap items-center gap-1.5">
<RelationChip
v-for="chip in sample.ownerChips"
:key="chip"
:label="chip"
tall
/>
<span v-if="sample.ownerMore" class="self-center text-xs text-muted"
>+{{ sample.ownerMore }}</span
>
</div>
</div>
<div
data-testid="relation-example-related"
class="flex min-w-0 flex-1 flex-col gap-2 border border-default bg-default p-3"
>
<span class="font-semibold text-highlighted">{{ names.relatedSingular }}</span>
<span class="text-xs text-muted">{{ inverseNoun(kind, names) }}</span>
<div class="flex flex-wrap items-center gap-1.5">
<RelationChip
v-for="chip in sample.relatedChips"
:key="chip"
:label="chip"
tall
/>
<span v-if="sample.relatedMore" class="self-center text-xs text-muted"
>+{{ sample.relatedMore }}</span
>
</div>
</div>
</div>
</div>
</template>
+2
View File
@@ -262,6 +262,7 @@ async function restoreRowFocus() {
:field="builder.selected.value"
:errors="selectedErrors"
:compact="false"
:owner-name="form.name"
/>
</template>
<template v-else>
@@ -320,6 +321,7 @@ async function restoreRowFocus() {
:focus-token="builder.labelFocusToken.value"
:field="builder.selected.value"
:errors="selectedErrors"
:owner-name="form.name"
compact
/>
</FieldSheet>
@@ -35,6 +35,7 @@ watch(
:name="remove.type.value.name"
:key-value="remove.type.value.key"
:total="remove.total.value"
:referrer="remove.referrer.value"
:pending="remove.pending.value"
:offline="remove.offline.value"
@confirm="remove.confirm"
+3 -3
View File
@@ -7,7 +7,7 @@ import SchemaChangeList from '@/components/SchemaChangeList.vue'
import { entriesLine } from '@/lib/schemaEntries'
import {
hasRefusedChange,
isMediaOnlyClean,
isNoDefaultOnlyClean,
summaryLine,
type FieldChange,
} from '@/lib/schemaBuilderDiff'
@@ -25,7 +25,7 @@ defineEmits<{ apply: []; close: [] }>()
const summary = computed(() => summaryLine(props.changes))
const entries = computed(() => entriesLine(props.name, props.total))
const refused = computed(() => hasRefusedChange(props.changes))
const mediaOnlyClean = computed(() => isMediaOnlyClean(props.changes))
const noDefaultOnlyClean = computed(() => isNoDefaultOnlyClean(props.changes))
</script>
<template>
@@ -53,7 +53,7 @@ const mediaOnlyClean = computed(() => isMediaOnlyClean(props.changes))
<p v-if="refused" class="text-xs/4.5 text-muted">
{{ t('Back to editing keeps your other changes.') }}
</p>
<p v-else-if="!mediaOnlyClean && !applying" class="text-xs/4.5 text-muted">
<p v-else-if="!noDefaultOnlyClean && !applying" class="text-xs/4.5 text-muted">
{{
t(
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.",