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.",
@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { ApiError } from '@/composables/useApi'
import { RELATION_SEARCH_DEBOUNCE_MS, useRelationSearch } from '@/composables/useRelationSearch'
import type { ContentEntry } from '@/lib/contentTypes'
import { RELATION_PAGE_SIZE } from '@/lib/relationField'
const searchEntries = vi.fn()
vi.mock('@/composables/useEntries', () => ({
useEntries: () => ({ searchEntries }),
}))
interface Deferred {
resolve: (value: unknown) => void
reject: (error: unknown) => void
}
let wrappers: VueWrapper[] = []
function setup() {
let api!: ReturnType<typeof useRelationSearch>
const wrapper = mount(
defineComponent({
setup() {
api = useRelationSearch(() => 'venues')
return () => h('div')
},
}),
)
wrappers.push(wrapper)
return api
}
function entry(id: number): ContentEntry {
return { id, data: {} } as unknown as ContentEntry
}
function answer(ids: number[], total: number, page = 1) {
return { items: ids.map(entry), total, page, size: RELATION_PAGE_SIZE }
}
function deferred() {
const pending: Deferred[] = []
searchEntries.mockImplementation(
() =>
new Promise((resolve, reject) => {
pending.push({ resolve, reject })
}),
)
return pending
}
async function debounce() {
await vi.advanceTimersByTimeAsync(RELATION_SEARCH_DEBOUNCE_MS)
await flushPromises()
}
beforeEach(() => {
vi.useFakeTimers()
searchEntries.mockReset()
})
afterEach(() => {
wrappers.forEach((wrapper) => wrapper.unmount())
wrappers = []
vi.useRealTimers()
})
describe('useRelationSearch query', () => {
it('tracks the latest trimmed query across consecutive searches', async () => {
searchEntries.mockResolvedValue(answer([], 0))
const search = setup()
search.search(' alpha ')
await debounce()
expect(search.query.value).toBe('alpha')
search.search('beta')
await debounce()
expect(search.query.value).toBe('beta')
expect(searchEntries).toHaveBeenLastCalledWith('venues', {
q: 'beta',
page: 1,
size: RELATION_PAGE_SIZE,
})
search.search('')
await debounce()
expect(search.query.value).toBe('')
})
})
describe('useRelationSearch debounce', () => {
it('issues one request for rapid searches', async () => {
searchEntries.mockResolvedValue(answer([1], 1))
const search = setup()
search.search('a')
await vi.advanceTimersByTimeAsync(RELATION_SEARCH_DEBOUNCE_MS - 1)
search.search('ab')
await vi.advanceTimersByTimeAsync(RELATION_SEARCH_DEBOUNCE_MS - 1)
expect(searchEntries).not.toHaveBeenCalled()
await debounce()
expect(searchEntries).toHaveBeenCalledTimes(1)
expect(searchEntries.mock.calls[0]![1].q).toBe('ab')
expect(search.status.value).toBe('ready')
})
})
describe('useRelationSearch stale responses', () => {
it('discards a page-1 answer superseded by a newer load', async () => {
const pending = deferred()
const search = setup()
search.search('old')
await debounce()
search.search('new')
await debounce()
expect(pending).toHaveLength(2)
pending[1]!.resolve(answer([2], 1))
await flushPromises()
pending[0]!.resolve(answer([1], 1))
await flushPromises()
expect(search.entries.value.map((item) => item.id)).toEqual([2])
})
it('discards an in-flight answer when a new search starts during the debounce', async () => {
const pending = deferred()
const search = setup()
search.search('old')
await debounce()
search.search('new')
pending[0]!.resolve(answer([1], 1))
await flushPromises()
expect(search.entries.value).toEqual([])
expect(search.status.value).toBe('loading')
})
it('discards an answer after stop', async () => {
const pending = deferred()
const search = setup()
search.search('x')
await debounce()
search.stop()
pending[0]!.resolve(answer([1], 1))
await flushPromises()
expect(search.entries.value).toEqual([])
})
})
describe('useRelationSearch load more', () => {
it('appends the next page and ends when everything is loaded', async () => {
searchEntries
.mockResolvedValueOnce(answer([1, 2], 3))
.mockResolvedValueOnce(answer([3], 3, 2))
const search = setup()
await search.reload()
expect(search.hasMore.value).toBe(true)
await search.more()
expect(searchEntries).toHaveBeenLastCalledWith('venues', {
q: '',
page: 2,
size: RELATION_PAGE_SIZE,
})
expect(search.entries.value.map((item) => item.id)).toEqual([1, 2, 3])
expect(search.hasMore.value).toBe(false)
expect(search.more()).toBeUndefined()
})
it('starts the limiter on a 429 and keeps the loaded rows', async () => {
searchEntries
.mockResolvedValueOnce(answer([1], 5))
.mockRejectedValueOnce(new ApiError(429, 7))
const search = setup()
await search.reload()
await search.more()
expect(search.status.value).toBe('limited')
expect(search.limit.remaining.value).toBe(7)
expect(search.entries.value.map((item) => item.id)).toEqual([1])
expect(search.loadingMore.value).toBe(false)
})
it('surfaces other errors as failed and keeps the loaded rows', async () => {
searchEntries.mockResolvedValueOnce(answer([1], 5)).mockRejectedValueOnce(new ApiError(500))
const search = setup()
await search.reload()
await search.more()
expect(search.status.value).toBe('failed')
expect(search.entries.value.map((item) => item.id)).toEqual([1])
})
it('goes idle on a 401 during load more', async () => {
searchEntries.mockResolvedValueOnce(answer([1], 5)).mockRejectedValueOnce(new ApiError(401))
const search = setup()
await search.reload()
await search.more()
expect(search.status.value).toBe('idle')
})
it('does not request page+1 of a stale set after a failed page-1', async () => {
searchEntries
.mockResolvedValueOnce(answer([1], 5))
.mockRejectedValueOnce(new ApiError(500))
.mockResolvedValueOnce(answer([9], 9))
const search = setup()
await search.reload()
await search.reload()
expect(search.status.value).toBe('failed')
await search.reload()
await search.more()
expect(searchEntries.mock.calls.map((call) => call[1].page)).toEqual([1, 1, 1, 2])
})
})
+22 -1
View File
@@ -3,6 +3,12 @@ import type { ContentEntry, EntryPage, EntryPageQuery } from '@/lib/contentTypes
import { entryEndpoint } from '@/lib/contentEndpoints'
import { jsonInit } from '@/lib/directoryRequest'
export interface EntrySearchQuery {
q: string
page: number
size: number
}
export function useEntries() {
const { request } = useApi()
const options = { onForbidden: 'passthrough' } as const
@@ -21,6 +27,21 @@ export function useEntries() {
return page
}
async function searchEntries(key: string, query: EntrySearchQuery): Promise<EntryPage> {
const params = new URLSearchParams()
if (query.q !== '') {
params.set('q', query.q)
}
params.set('page', String(query.page))
params.set('size', String(query.size))
const url = `${entryEndpoint(key)}?${params.toString()}`
const page = await request<EntryPage>(url, options)
if (!Array.isArray(page?.items) || typeof page.total !== 'number') {
throw new Error(`Unexpected response from ${url}`)
}
return page
}
async function requestEntry(url: string, init: RequestInit): Promise<ContentEntry> {
const entry = await request<ContentEntry>(url, { ...init, ...options })
if (
@@ -49,5 +70,5 @@ export function useEntries() {
await request<void>(entryEndpoint(key, id), { method: 'DELETE', ...options })
}
return { listEntries, getEntry, createEntry, saveEntry, deleteEntry }
return { listEntries, searchEntries, getEntry, createEntry, saveEntry, deleteEntry }
}
+14 -3
View File
@@ -6,6 +6,7 @@ import { useEntryForm } from '@/composables/useEntryForm'
import { useEntryLoad } from '@/composables/useEntryLoad'
import type { ContentEntry } from '@/lib/contentTypes'
import type { MediaSummary } from '@/lib/mediaField'
import type { RelationSummary } from '@/lib/relationField'
import { resolveEditorPhase, type EntryTarget } from '@/lib/entryEditorState'
import { buildData, issueFor, validateEntry } from '@/lib/entryForm'
import { classifyRefusal, isGoneError, parseEntryRefusal } from '@/lib/entryServerError'
@@ -92,7 +93,12 @@ export function useEntryDraft(typeKey: string, target: EntryTarget) {
return null
}
failure.value = 'none'
const issues = validateEntry(fields.value, form.values.value, form.images.value)
const issues = validateEntry(
fields.value,
form.values.value,
form.images.value,
form.relations.value,
)
if (Object.keys(issues).length > 0) {
form.reject(issues)
focusToken.value++
@@ -114,8 +120,13 @@ export function useEntryDraft(typeKey: string, target: EntryTarget) {
}
}
function change(key: string, value: unknown, image?: MediaSummary | null) {
form.setValue(key, value, image)
function change(
key: string,
value: unknown,
image?: MediaSummary | null,
summaries?: RelationSummary[],
) {
form.setValue(key, value, image, summaries)
}
function reload() {
+18 -1
View File
@@ -1,10 +1,12 @@
import { computed, ref } from 'vue'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import type { MediaMap, MediaSummary } from '@/lib/mediaField'
import type { RelationSummaries, RelationSummary } from '@/lib/relationField'
import {
carriesDefault,
firstIssueKey,
initialImages,
initialRelations,
initialValues,
valuesDiffer,
type EntryIssues,
@@ -16,6 +18,7 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
const values = ref<EntryValues>({})
const saved = ref<EntryValues>({})
const images = ref<MediaMap>({})
const relations = ref<RelationSummaries>({})
const issues = ref<EntryIssues>({})
const rejected = ref(false)
@@ -28,6 +31,7 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
values.value = { ...next }
saved.value = { ...next }
images.value = initialImages(fields(), entry)
relations.value = initialRelations(fields(), entry)
issues.value = {}
rejected.value = false
}
@@ -45,9 +49,21 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
images.value = next
}
function setValue(key: string, value: unknown, image?: MediaSummary | null) {
function setRelations(key: string, summaries: RelationSummary[] | undefined) {
if (summaries !== undefined) {
relations.value = { ...relations.value, [key]: summaries }
}
}
function setValue(
key: string,
value: unknown,
image?: MediaSummary | null,
summaries?: RelationSummary[],
) {
values.value = { ...values.value, [key]: value }
setImage(key, image)
setRelations(key, summaries)
rejected.value = false
if (issues.value[key]) {
const rest = { ...issues.value }
@@ -72,6 +88,7 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
return {
values,
images,
relations,
issues,
rejected,
dirty,
+131
View File
@@ -0,0 +1,131 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { useEntries } from '@/composables/useEntries'
import { useRetryAfter } from '@/composables/useRetryAfter'
import type { ContentEntry } from '@/lib/contentTypes'
import { RELATION_PAGE_SIZE } from '@/lib/relationField'
export const RELATION_SEARCH_DEBOUNCE_MS = 200
const UNAUTHORIZED = 401
const RATE_LIMITED = 429
const RATE_LIMIT_FALLBACK_SECONDS = 30
export type RelationSearchStatus = 'idle' | 'loading' | 'ready' | 'failed' | 'limited'
export function useRelationSearch(target: () => string) {
const { searchEntries } = useEntries()
const skeleton = useDelayedSkeleton()
const limit = useRetryAfter()
const entries = ref<ContentEntry[]>([])
const total = ref(0)
const page = ref(0)
const status = ref<RelationSearchStatus>('idle')
const loadingMore = ref(false)
const query = ref('')
let token = 0
let timer: ReturnType<typeof setTimeout> | null = null
const hasMore = computed(() => status.value === 'ready' && entries.value.length < total.value)
const searching = computed(() => status.value === 'loading' && !loadingMore.value)
const showSkeleton = computed(() => searching.value && skeleton.visible.value)
function clearTimer() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
}
function fail(error: unknown) {
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
status.value = 'idle'
} else if (error instanceof ApiError && error.status === RATE_LIMITED) {
limit.start(error.retryAfter ?? RATE_LIMIT_FALLBACK_SECONDS)
status.value = 'limited'
} else {
status.value = 'failed'
}
}
async function load(pageNumber: number) {
const current = ++token
loadingMore.value = pageNumber > 1
status.value = 'loading'
skeleton.start()
try {
const answer = await searchEntries(target(), {
q: query.value,
page: pageNumber,
size: RELATION_PAGE_SIZE,
})
if (current !== token) {
return
}
entries.value = pageNumber === 1 ? answer.items : [...entries.value, ...answer.items]
total.value = answer.total
page.value = pageNumber
status.value = 'ready'
} catch (error) {
if (current === token) {
if (pageNumber === 1) {
page.value = 0
}
fail(error)
}
} finally {
if (current === token) {
skeleton.stop()
loadingMore.value = false
}
}
}
function search(text: string) {
clearTimer()
query.value = text.trim()
token++
skeleton.stop()
loadingMore.value = false
timer = setTimeout(() => {
timer = null
load(1)
}, RELATION_SEARCH_DEBOUNCE_MS)
}
function reload() {
clearTimer()
return load(1)
}
function more() {
if (hasMore.value) {
return load(page.value + 1)
}
}
function stop() {
clearTimer()
token++
skeleton.stop()
loadingMore.value = false
}
onBeforeUnmount(stop)
return {
entries,
total,
status,
query,
hasMore,
loadingMore,
showSkeleton,
limit,
search,
reload,
more,
stop,
}
}
+10
View File
@@ -12,6 +12,7 @@ import { SCHEMA_PATH } from '@/lib/routePaths'
import { useContentTypesStore } from '@/stores/contentTypes'
const HAS_ENTRIES_BODY = 'content_type_has_entries'
const REFERENCED_BODY = 'content_type_referenced'
const TOAST_ID = 'content-type-deleted'
export function useSchemaDelete(source: () => EditSource) {
@@ -27,6 +28,12 @@ export function useSchemaDelete(source: () => EditSource) {
const offline = computed(() => !online.value)
const type = computed(() => source().type)
const total = computed(() => source().total)
const referrer = computed(
() =>
store.items.find((candidate) =>
candidate.fields.some((field) => field.relation?.content_type === type.value.key),
)?.name ?? '',
)
function open() {
if (pending.value) {
@@ -60,6 +67,8 @@ export function useSchemaDelete(source: () => EditSource) {
forbidden.value = true
} else if (status === 409 && body === HAS_ENTRIES_BODY) {
stage.value = 'conflict'
} else if (status === 409 && body === REFERENCED_BODY) {
stage.value = 'referenced'
} else {
stage.value = 'failed'
}
@@ -95,6 +104,7 @@ export function useSchemaDelete(source: () => EditSource) {
offline,
type,
total,
referrer,
open,
close,
viewEntries,
+1 -1
View File
@@ -16,7 +16,7 @@ const SOURCES = [
'lib/schemaBuilderDiff.ts',
]
const SAME_IN_FRENCH = new Set<string>(['Date'])
const SAME_IN_FRENCH = new Set<string>(['Date', 'Relation'])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
+55
View File
@@ -2663,6 +2663,61 @@ export const messages = {
"A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.":
'Un champ média ne peut pas avoir de valeur par défaut, donc les {0} entrées existantes n’auraient pas d’image. Rendez {1} facultatif pour l’ajouter.',
'{0} entries are unaffected.': '{0} entrées ne sont pas concernées.',
'Entry no longer exists': 'Cette entrée n’existe plus',
'Choose a {0}.': 'Choisissez une valeur pour {0}.',
'{0} must point to an existing entry.': '{0} doit pointer vers une entrée existante.',
'That {0} is already used by another entry.':
'Cette valeur de {0} est déjà utilisée par une autre entrée.',
Relation: 'Relation',
"Links to another type's entries": 'Renvoie vers les entrées d’un autre type',
"A relation can't have a default, so the existing entry would have no {0}. Make {0} optional to add it.":
'Une relation ne peut pas avoir de valeur par défaut, donc l’entrée existante n’aurait pas de valeur pour {0}. Rendez {0} facultatif pour l’ajouter.',
"A relation can't have a default, so the {0} existing entries would have no {1}. Make {1} optional to add it.":
'Une relation ne peut pas avoir de valeur par défaut, donc les {0} entrées existantes n’auraient pas de valeur pour {1}. Rendez {1} facultatif pour l’ajouter.',
Entries: 'Entrées',
'Each {0} has one {1}, and each {1} has one {0}.':
'Chaque {0} a un seul {1}, et chaque {1} a un seul {0}.',
'Each {0} has one {1}. A {1} has many {2}.':
'Chaque {0} a un seul {1}. Un {1} a plusieurs {2}.',
'Each {0} has many {1}, and each {2} has many {3}.':
'Chaque {0} a plusieurs {1}, et chaque {2} a plusieurs {3}.',
'Choose a related content type.': 'Choisissez un type de contenu lié.',
'{0} has a field that points to {1}. Remove that field first.':
'{0} a un champ qui pointe vers {1}. Supprimez d’abord ce champ.',
'Another content type has a field that points to {0}. Remove that field first.':
'Un autre type de contenu a un champ qui pointe vers {0}. Supprimez d’abord ce champ.',
'{0} is required. Choose another to save.':
'{0} est obligatoire. Choisissez une autre valeur pour enregistrer.',
'An {0} can have many {1}.': 'Un {0} peut avoir plusieurs {1}.',
'A {0} can have many {1}.': 'Un {0} peut avoir plusieurs {1}.',
'Remove {0} from {1}': 'Retirer {0} de {1}',
'{0} set to {1}.': '{0} défini sur {1}.',
'{0} removed from {1}.': '{0} retiré de {1}.',
'{0} added to {1}.': '{0} ajouté à {1}.',
'{0} results': '{0} résultats',
'No results': 'Aucun résultat',
'Search to add': 'Rechercher pour ajouter',
'Searching…': 'Recherche en cours…',
"Couldn't load {0}": 'Impossible de charger {0}',
'You can add up to {0}.': 'Vous pouvez en ajouter jusqu’à {0}.',
'No {0} yet. Add one in Content, then come back.':
'Aucun élément dans {0} pour l’instant. Ajoutez-en depuis Contenu, puis revenez.',
'No {0} match "{1}"': 'Aucun résultat dans {0} pour « {1} »',
'No other {0} to add.': 'Aucun autre élément de {0} à ajouter.',
one: 'un',
many: 'plusieurs',
'One to one': 'Un à un',
'One to many': 'Un à plusieurs',
'Many to many': 'Plusieurs à plusieurs',
"The related type and the connection can't change after saving.":
'Le type lié et la connexion ne peuvent plus changer après l’enregistrement.',
'Related content type': 'Type de contenu lié',
'Choose the content type this field points to.':
'Choisissez le type de contenu vers lequel ce champ pointe.',
'Choose a content type': 'Choisissez un type de contenu',
'How are they connected?': 'Comment sont-ils liés ?',
'Each {0} has': 'Chaque {0} a',
'shown on {0} as the field "{1}"': 'affiché sur {0} comme le champ « {1} »',
} as const
export type MessageKey = keyof typeof messages
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { messages } from './messages'
const SOURCES = [
'components/RelationField.vue',
'components/RelationPicker.vue',
'components/DeleteTypeDialog.vue',
'lib/relationSentences.ts',
'lib/fieldTypes.ts',
'lib/entryCells.ts',
'lib/entryMessages.ts',
'lib/schemaBuilderValidation.ts',
]
const SAME_IN_FRENCH = new Set<string>(['Relation'])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
function keysIn(source: string) {
return [...source.matchAll(LITERAL)].map((match) =>
(match[1] ?? match[2] ?? '').replace(/\\u0022/g, '"').replace(/\\(['"\\])/g, '$1'),
)
}
describe('Relation field messages', () => {
const keys = SOURCES.flatMap((file) =>
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
file,
key,
})),
)
it('finds the strings of the feature', () => {
expect(keys.length).toBeGreaterThan(40)
})
it.each(keys.map(({ file, key }) => [file, key]))(
'%s: "%s" has its own French entry',
(_file, key) => {
const french = (messages as Record<string, string>)[key as string]
expect(french).toMatch(/\S/)
if (
!SAME_IN_FRENCH.has(key as string) &&
!(key as string).match(/^(Date|Text|Number)$/)
) {
expect(french).not.toBe(key)
}
},
)
})
+1
View File
@@ -10,6 +10,7 @@ const CONTROLS: Record<FieldType, string> = {
date: 'date',
select: 'select',
media: 'media',
relation: 'relation',
}
function editor(type: FieldType) {
+5 -3
View File
@@ -50,9 +50,10 @@ function mediaField(rest: object = {}) {
const media = ENTRY_EDITORS.media
describe('media in the registry', () => {
it('is the seventh type, after select', () => {
expect(FIELD_TYPES).toHaveLength(7)
it('is the seventh type, after select and before relation', () => {
expect(FIELD_TYPES).toHaveLength(8)
expect(FIELD_TYPES[6]).toBe('media')
expect(FIELD_TYPES[7]).toBe('relation')
expect(FIELD_TYPES[5]).toBe('select')
})
@@ -82,7 +83,7 @@ describe('media in the registry', () => {
)
})
it.each(FIELD_TYPES.filter((type) => type !== 'media'))(
it.each(FIELD_TYPES.filter((type) => type !== 'media' && type !== 'relation'))(
'%s keeps its builder options and has no hint',
(type) => {
const spec = fieldTypeSpec(type)
@@ -374,6 +375,7 @@ describe('media in the schema builder', () => {
min: null,
max: null,
options: [],
relation: null,
})
expect(payloadField({ ...field, required: true }).required).toBe(true)
})
+483
View File
@@ -0,0 +1,483 @@
import { describe, expect, it } from 'vitest'
import type { ContentEntry } from '@/lib/contentTypes'
import { describeCell, plainCellText, relationLabel } from '@/lib/entryCells'
import { ENTRY_EDITORS } from '@/lib/entryEditors'
import {
buildData,
entryTitle,
initialRelations,
initialValues,
validateEntry,
valuesDiffer,
} from '@/lib/entryForm'
import { ruleMessage } from '@/lib/entryMessages'
import { groupFields } from '@/lib/entryLayout'
import { classifyRefusal, parseEntryRefusal } from '@/lib/entryServerError'
import { FIELD_TYPES, fieldTypeSpec } from '@/lib/fieldTypes'
import { isRelationId, relationIdsOf, summariesOf, type RelationSummary } from '@/lib/relationField'
import { relationRowOf } from '@/lib/relationRows'
import { ApiError } from '@/lib/apiError'
import { diffFields } from '@/lib/schemaBuilderDiff'
import { baselinePayload, fieldFromApi, formFromType } from '@/lib/schemaBuilderLoad'
import { createField } from '@/lib/schemaBuilderModel'
import { buildPayload, payloadField } from '@/lib/schemaBuilderPayload'
import { sampleEntry } from '@/lib/schemaBuilderSample'
import { validateField } from '@/lib/schemaBuilderValidation'
import { apiField } from './schemaFixtures'
const relation = ENTRY_EDITORS.relation
function field(kind: 'one_to_one' | 'one_to_many' | 'many_to_many', rest: object = {}) {
return apiField('Venue', 'venue', 'relation', {
options: [],
relation: { content_type: 'venues', kind },
...rest,
})
}
const single = field('one_to_many')
const many = field('many_to_many')
function target(id: number, name: string | null): RelationSummary {
return {
id,
content_type: 'venues',
first_field: { key: 'name', type: 'text', value: name },
}
}
describe('relation in the registry', () => {
it('is the eighth type, after media', () => {
expect(FIELD_TYPES).toHaveLength(8)
expect(FIELD_TYPES[7]).toBe('relation')
expect(FIELD_TYPES[6]).toBe('media')
})
it('describes itself', () => {
const spec = fieldTypeSpec('relation')
expect(spec.label()).toBe('Relation')
expect(spec.description()).toBe("Links to another type's entries")
expect(spec.icon).toBe('i-lucide-link')
expect(spec.editor.control).toBe('relation')
expect(spec.cell).toBe('relation')
expect(spec.pairs).toBe(false)
expect(spec.supportsUnique).toBe(false)
expect(spec.supportsLimits).toBe(false)
expect(spec.supportsDefault).toBe(false)
expect(spec.supportsChoices).toBe(false)
expect(spec.showsUnsupportedOptions).toBe(false)
expect(spec.builderHint).toBeNull()
expect(spec.holdsRelation).toBe(true)
})
it('is never the fallback type, while an unknown type still is', () => {
expect(fieldTypeSpec('relation')).not.toBe(fieldTypeSpec('text'))
expect(fieldTypeSpec('mystery')).toBe(fieldTypeSpec('text'))
expect(fieldFromApi(single, 1).type).toBe('relation')
})
it('has no counter and no help of its own', () => {
expect(relation.counter(single, 3)).toBeNull()
expect(relation.help(single)).toBe('')
})
it('never pairs with a neighbour', () => {
const fields = [
apiField('Online', 'online', 'boolean'),
single,
apiField('Cap', 'cap', 'number'),
]
expect(groupFields(fields).map((row) => row.length)).toEqual([1, 1, 1])
})
})
describe('empty and initial values', () => {
it.each([
['one_to_one', null],
['one_to_many', null],
['many_to_many', []],
] as const)('starts empty for %s on create', (kind, empty) => {
expect(relation.initial(field(kind), 3, true)).toEqual(empty)
})
it('has no default, so no Default tag ever shows', () => {
expect(relation.initial(field('one_to_many', { default: 3 }), undefined, true)).toBeNull()
})
it('takes the stored id or ids on edit', () => {
expect(relation.initial(single, 3, false)).toBe(3)
expect(relation.initial(single, null, false)).toBeNull()
expect(relation.initial(many, [11, 12], false)).toEqual([11, 12])
expect(relation.initial(many, undefined, false)).toEqual([])
})
it('copies the stored array', () => {
const stored = [11]
expect(relation.initial(many, stored, false)).not.toBe(stored)
})
})
describe('client validation of a relation value', () => {
it.each([
['single', true, null, 'required'],
['single', false, null, null],
['single', true, 3, null],
['single', false, 0, 'type'],
['single', false, '3', 'type'],
['single', false, [3], 'type'],
['many', true, [], 'required'],
['many', false, [], null],
['many', false, [11, 12], null],
['many', false, [11, 11], 'type'],
['many', false, 11, 'type'],
['many', false, Array.from({ length: 51 }, (_, index) => index + 1), 'type'],
['many', false, Array.from({ length: 50 }, (_, index) => index + 1), null],
])('%s required=%s value %j gives %s', (kind, required, value, rule) => {
const subject = kind === 'single' ? single : many
expect(relation.validate({ ...subject, required }, value)).toBe(rule)
})
it('treats a required relation whose targets are all missing as empty', () => {
const gone: RelationSummary = { id: 3, missing: true }
expect(
relation.validate({ ...single, required: true }, 3, { image: null, relations: [gone] }),
).toBe('required')
expect(
relation.validate({ ...single, required: false }, 3, {
image: null,
relations: [gone],
}),
).toBeNull()
expect(
relation.validate({ ...many, required: true }, [3, 4], {
image: null,
relations: [gone, { id: 4, missing: true }],
}),
).toBe('required')
expect(
relation.validate({ ...many, required: true }, [3, 4], {
image: null,
relations: [gone, target(4, 'Hall')],
}),
).toBeNull()
})
it('never produces min, max or options', () => {
const rules = [null, 0, '3', [3], [], [1, 1], 5].flatMap((value) =>
[single, many].map((subject) =>
relation.validate({ ...subject, required: true }, value),
),
)
expect(rules.filter((rule) => ['min', 'max', 'options'].includes(rule ?? ''))).toEqual([])
})
})
describe('messages of a relation field', () => {
it.each([
['required', 'Choose a Venue.'],
['type', 'Venue must point to an existing entry.'],
['unique', 'That Venue is already used by another entry.'],
] as const)('the rule %s reads "%s"', (rule, message) => {
expect(ruleMessage(rule, single)).toBe(message)
})
})
describe('the payload', () => {
it('sends the id, null, or an array of ids', () => {
expect(relation.payload(single, 3)).toBe(3)
expect(relation.payload(single, null)).toBeNull()
expect(relation.payload(single, undefined)).toBeNull()
expect(relation.payload(many, [11, 12])).toEqual([11, 12])
expect(relation.payload(many, undefined)).toEqual([])
})
it('puts null and [] in a create body, never absent', () => {
const fields = [single, { ...many, key: 'speakers' }]
const body = buildData(fields, initialValues(fields, null))
expect(body).toEqual({ venue: null, speakers: [] })
expect(Object.keys(body)).toEqual(['venue', 'speakers'])
})
})
describe('the ids helpers', () => {
it('accepts uint32 ids of at least 1', () => {
expect(isRelationId(1)).toBe(true)
expect(isRelationId(4294967295)).toBe(true)
expect(isRelationId(0)).toBe(false)
expect(isRelationId(4294967296)).toBe(false)
expect(isRelationId(1.5)).toBe(false)
expect(isRelationId('3')).toBe(false)
})
it('reads one id or many', () => {
expect(relationIdsOf(3)).toEqual([3])
expect(relationIdsOf([3, 'x', 4])).toEqual([3, 4])
expect(relationIdsOf(null)).toEqual([])
})
})
describe('summaries of an entry', () => {
const entry = {
id: 42,
data: { venue: 3, speakers: [11, 12] },
relations: {
venue: target(3, 'Rue Verte hall'),
speakers: [target(11, 'Ines Duarte'), target(12, 'Jonas Pereira')],
},
} as unknown as ContentEntry
it('wraps a single summary in an array', () => {
expect(summariesOf(entry, 'venue').map((summary) => summary.id)).toEqual([3])
expect(summariesOf(entry, 'speakers').map((summary) => summary.id)).toEqual([11, 12])
expect(summariesOf(entry, 'other')).toEqual([])
expect(summariesOf(null, 'venue')).toEqual([])
})
it('keeps only the summaries of the stored ids', () => {
const stale = { ...entry, data: { venue: 4, speakers: [12] } } as ContentEntry
const kept = initialRelations([single, { ...many, key: 'speakers' }], stale)
expect(kept).toEqual({ speakers: [entry.relations!.speakers![1]] })
})
})
describe('dirty tracking', () => {
it('compares ids by value and by order', () => {
expect(valuesDiffer([single], { venue: 3 }, { venue: 3 })).toBe(false)
expect(valuesDiffer([many], { venue: [11, 12] }, { venue: [11, 12] })).toBe(false)
expect(valuesDiffer([many], { venue: [12, 11] }, { venue: [11, 12] })).toBe(true)
expect(valuesDiffer([many], { venue: [11] }, { venue: [11, 12] })).toBe(true)
})
})
describe('labels and cells', () => {
it('uses the first field of the target, formatted like the list', () => {
expect(relationLabel(target(3, 'Rue Verte hall'))).toBe('Rue Verte hall')
expect(
relationLabel({
id: 5,
content_type: 'x',
first_field: { key: 'on', type: 'boolean', value: true },
}),
).toBe('Yes')
expect(
relationLabel({
id: 6,
content_type: 'x',
first_field: { key: 'on', type: 'number', value: 1500 },
}),
).toBe('1,500')
})
it('falls back to Entry N for a null first field', () => {
expect(relationLabel(target(7, null))).toBe('Entry 7')
expect(
relationLabel({
id: 8,
content_type: 'x',
first_field: { key: 'm', type: 'media', value: 9 },
}),
).toBe('Entry 8')
})
it('labels a missing target', () => {
expect(relationLabel({ id: 3, missing: true })).toBe('Entry no longer exists')
})
it('describes one chip, two chips and a +N, or an empty cell', () => {
const summaries = [11, 12, 13, 14].map((id) => target(id, `Speaker ${id}`))
const cell = describeCell(many, [11, 12, 13, 14], undefined, summaries)
expect(cell.kind).toBe('relation')
expect(cell.chips!.map((chip) => chip.label)).toEqual(['Speaker 11', 'Speaker 12'])
expect(cell.more).toBe(2)
expect(plainCellText(cell)).toBe('Speaker 11, Speaker 12, Speaker 13, Speaker 14')
expect(describeCell(many, []).kind).toBe('empty')
expect(describeCell(single, null).kind).toBe('empty')
expect(plainCellText(describeCell(many, []))).toBe('–')
})
it('never shows the numeric id when no summary came with the item', () => {
const cell = describeCell(single, 3)
expect(cell.chips).toEqual([{ id: 3, label: 'Entry no longer exists', missing: true }])
expect(cell.text).not.toContain('3')
})
it('titles an entry whose first field is a relation "Entry N"', () => {
const entry = {
id: 42,
data: { venue: 3 },
relations: { venue: target(3, 'Hall') },
} as unknown as ContentEntry
expect(entryTitle([single], entry)).toBe('Entry 42')
})
})
describe('rows of the picker', () => {
const fields = [
apiField('Name', 'name', 'text'),
apiField('City', 'city', 'text'),
apiField('Seats', 'seats', 'number'),
apiField('Notes', 'notes', 'text'),
]
const entry = (data: Record<string, unknown>) => ({ id: 3, data }) as unknown as ContentEntry
it('puts the first field in the title and up to two next fields in the second line', () => {
const row = relationRowOf(
entry({ name: 'Rue Verte hall', city: 'Lyon', seats: 120, notes: 'x' }),
'venues',
fields,
)
expect(row.title).toBe('Rue Verte hall')
expect(row.detail).toBe('Lyon · 120')
expect(row.summary.first_field).toEqual({
key: 'name',
type: 'text',
value: 'Rue Verte hall',
})
})
it('skips empty fields and falls back to Entry N', () => {
const row = relationRowOf(entry({ name: '', city: '', seats: 5 }), 'venues', fields)
expect(row.title).toBe('Entry 3')
expect(row.detail).toBe('5')
})
it('copes with a type that is not known yet', () => {
const row = relationRowOf(entry({ name: 'x' }), 'venues', [])
expect(row.title).toBe('Entry 3')
expect(row.detail).toBe('')
})
})
describe('server refusals', () => {
it.each([
[422, 'invalid_entry: venue: required', 'required'],
[422, 'invalid_entry: venue: type', 'type'],
[409, 'invalid_entry: venue: unique', 'unique'],
])('maps %i %s to a rule of the field', (status, body, rule) => {
const outcome = classifyRefusal(
[single],
parseEntryRefusal(new ApiError(status, undefined, body)),
)
expect(outcome).toMatchObject({ kind: 'field', rule })
})
it('maps unknown_field to a schema change', () => {
const outcome = classifyRefusal(
[single],
parseEntryRefusal(new ApiError(422, undefined, 'invalid_entry: venue: unknown_field')),
)
expect(outcome).toEqual({ kind: 'schema', key: 'venue' })
})
})
describe('the entry validation', () => {
it('reports the first rule of each relation field', () => {
const fields = [
{ ...single, required: true },
{ ...many, key: 'speakers', required: true },
]
const issues = validateEntry(fields, { venue: null, speakers: [] })
expect(issues.venue!.rule).toBe('required')
expect(issues.speakers!.rule).toBe('required')
expect(issues.venue!.message).toBe('Choose a Venue.')
})
})
describe('relation in the builder model', () => {
const events = {
id: 1,
key: 'events',
name: 'Events',
icon: null,
created_at: '',
updated_at: '',
}
it('loads the target and the kind', () => {
const loaded = fieldFromApi(field('many_to_many'), 1)
expect(loaded.relationType).toBe('venues')
expect(loaded.relationKind).toBe('many_to_many')
expect(loaded.saved).toBe(true)
})
it('sends the relation only for a relation field', () => {
const sent = payloadField(
createField(1, {
label: 'Venue',
key: 'venue',
type: 'relation',
relationType: 'venues',
}),
)
expect(sent).toEqual({
key: 'venue',
label: 'Venue',
type: 'relation',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: [],
relation: { content_type: 'venues', kind: 'one_to_many' },
})
expect(
payloadField(createField(2, { label: 'T', key: 't', relationType: 'venues' })).relation,
).toBeNull()
})
it('round trips a loaded field with no change', () => {
const type = { ...events, fields: [single, { ...many, key: 'speakers' }] }
let uid = 0
const form = formFromType(type, () => ++uid)
expect(buildPayload(form).fields).toEqual(baselinePayload(type))
expect(diffFields(baselinePayload(type), form.fields, 3)).toEqual([])
})
it('needs a related type', () => {
const created = createField(1, { label: 'Venue', key: 'venue', type: 'relation' })
expect(validateField(created, []).relation).toBe('Choose a related content type.')
expect(validateField({ ...created, relationType: 'venues' }, []).relation).toBeUndefined()
expect(validateField(createField(2, { label: 'T', key: 't' }), []).relation).toBeUndefined()
})
it('samples null for a single kind and [] for many_to_many', () => {
const singleField = createField(1, { label: 'Venue', key: 'venue', type: 'relation' })
const manyField = createField(2, {
label: 'Speakers',
key: 'speakers',
type: 'relation',
relationKind: 'many_to_many',
})
const data = sampleEntry('events', [singleField, manyField]).data
expect(data.venue).toBeNull()
expect(data.speakers).toEqual([])
})
})
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { ROW_DETAIL_FIELDS, relationRowOf } from '@/lib/relationRows'
function field(key: string, type: string): BuiltinField {
return { key, type, label: key } as unknown as BuiltinField
}
function entry(id: number, data: Record<string, unknown>): ContentEntry {
return { id, data } as unknown as ContentEntry
}
describe('relationRowOf', () => {
it('builds the summary from the first field', () => {
const row = relationRowOf(entry(7, { name: 'Hall' }), 'venues', [field('name', 'text')])
expect(row.id).toBe(7)
expect(row.title).toBe('Hall')
expect(row.summary).toEqual({
id: 7,
content_type: 'venues',
first_field: { key: 'name', type: 'text', value: 'Hall' },
})
})
it('falls back to an empty text summary when the type has no fields', () => {
const row = relationRowOf(entry(3, {}), 'venues', [])
expect(row.summary.first_field).toEqual({ key: '', type: 'text', value: null })
expect(row.detail).toBe('')
})
it('uses null when the first field is absent from the data', () => {
const row = relationRowOf(entry(3, {}), 'venues', [field('name', 'text')])
expect(row.summary.first_field).toEqual({ key: 'name', type: 'text', value: null })
})
it('joins at most the first detail fields and skips empty ones', () => {
const fields = [
field('name', 'text'),
field('city', 'text'),
field('country', 'text'),
field('zip', 'text'),
]
const row = relationRowOf(
entry(1, { name: 'Hall', city: 'Paris', country: 'France', zip: '75000' }),
'venues',
fields,
)
expect(ROW_DETAIL_FIELDS).toBe(2)
expect(row.detail).toBe('Paris · France')
const sparse = relationRowOf(
entry(1, { name: 'Hall', city: '', country: 'France', zip: '75000' }),
'venues',
fields,
)
expect(sparse.detail).toBe('France')
})
})
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
inverseNoun,
inverseSide,
kindFromInverseSide,
kindFromOwnerSide,
kindSentence,
ownerNoun,
ownerSide,
relationNames,
relationSample,
singularOf,
} from '@/lib/relationSentences'
import { RELATION_KINDS } from '@/lib/relationField'
const names = relationNames('Events', 'Venues', 'Venue')
describe('the names of both sides', () => {
it('draws the singular from the type name and the related singular from the field label', () => {
expect(names).toEqual({
ownerSingular: 'Event',
ownerPlural: 'Events',
relatedSingular: 'Venue',
relatedPlural: 'Venues',
})
})
it('removes only one final s and keeps one-letter names', () => {
expect(singularOf('Press mentions')).toBe('Press mention')
expect(singularOf('Glass')).toBe('Glas')
expect(singularOf('S')).toBe('S')
})
it('falls back when the related type is not chosen yet', () => {
expect(relationNames('Events', '', 'Place').relatedPlural).toBe('Places')
expect(relationNames('Events', 'Places', '').relatedSingular).toBe('Place')
expect(relationNames('', '', '').ownerSingular).toBe('Entry')
})
})
describe('the sentences of the cards', () => {
it.each([
['one_to_one', 'Each Event has one Venue, and each Venue has one Event.'],
['one_to_many', 'Each Event has one Venue. A Venue has many Events.'],
['many_to_many', 'Each Event has many Venues, and each Venue has many Events.'],
] as const)('reads %s as the design says', (kind, sentence) => {
expect(kindSentence(kind, names)).toBe(sentence)
})
})
describe('the preview', () => {
it.each([
['one_to_one', 'one', 'Venue', 'one', 'Event'],
['one_to_many', 'one', 'Venue', 'many', 'Events'],
['many_to_many', 'many', 'Venues', 'many', 'Events'],
] as const)('follows %s', (kind, owner, ownerWord, inverse, inverseWord) => {
expect(ownerSide(kind)).toBe(owner)
expect(ownerNoun(kind, names)).toBe(ownerWord)
expect(inverseSide(kind)).toBe(inverse)
expect(inverseNoun(kind, names)).toBe(inverseWord)
})
it('maps the selects of the sentences back to a kind', () => {
expect(kindFromOwnerSide('many', 'one_to_one')).toBe('many_to_many')
expect(kindFromOwnerSide('one', 'many_to_many')).toBe('one_to_many')
expect(kindFromOwnerSide('one', 'one_to_one')).toBe('one_to_one')
expect(kindFromInverseSide('one', 'many_to_many')).toBe('one_to_one')
expect(kindFromInverseSide('many', 'one_to_one')).toBe('one_to_many')
expect(kindFromInverseSide('many', 'many_to_many')).toBe('many_to_many')
})
it('keeps every kind stable through its own sides', () => {
for (const kind of RELATION_KINDS) {
expect(kindFromOwnerSide(ownerSide(kind), kind)).toBe(kind)
expect(kindFromInverseSide(inverseSide(kind), kind)).toBe(kind)
}
})
it('draws the example chips of the board', () => {
expect(relationSample('one_to_many')).toEqual({
ownerChips: ['Rue Verte hall'],
ownerMore: 0,
relatedChips: ['Open day', 'Lime plaster workshop'],
relatedMore: 1,
})
expect(relationSample('one_to_one').relatedChips).toEqual(['Open day'])
expect(relationSample('many_to_many').ownerChips).toHaveLength(2)
})
})
@@ -79,6 +79,7 @@ describe('create payload', () => {
min: null,
max: 80,
options: [],
relation: null,
},
{
key: 'starts_on',
@@ -90,6 +91,7 @@ describe('create payload', () => {
min: '2026-01-01',
max: '2027-12-31',
options: [],
relation: null,
},
{
key: 'online',
@@ -101,6 +103,7 @@ describe('create payload', () => {
min: null,
max: null,
options: [],
relation: null,
},
{
key: 'capacity',
@@ -112,6 +115,7 @@ describe('create payload', () => {
min: 1,
max: 5000,
options: [],
relation: null,
},
{
key: 'category',
@@ -123,6 +127,7 @@ describe('create payload', () => {
min: null,
max: null,
options: ['Talk', 'Workshop', 'Meetup'],
relation: null,
},
{
key: 'description',
@@ -134,12 +139,13 @@ describe('create payload', () => {
min: null,
max: 5000,
options: [],
relation: null,
},
],
})
})
it('has no icon key and exactly the nine field keys', () => {
it('has no icon key and exactly the ten field keys', () => {
const body = buildPayload(EVENTS)
expect(Object.keys(body).sort()).toEqual(['fields', 'key', 'name'])
@@ -151,6 +157,7 @@ describe('create payload', () => {
'max',
'min',
'options',
'relation',
'required',
'type',
'unique',
+1
View File
@@ -9,6 +9,7 @@ export function apiField(label: string, key: string, type: string, rest: object
min: null,
max: null,
options: null,
relation: null,
...rest,
}
}
+3
View File
@@ -1,4 +1,5 @@
import type { MediaMap } from '@/lib/mediaField'
import type { RelationDefinition, RelationMap } from '@/lib/relationField'
export interface BuiltinField {
key: string
@@ -10,6 +11,7 @@ export interface BuiltinField {
min: number | string | null
max: number | string | null
options: unknown[] | null
relation?: RelationDefinition | null
}
export interface ContentType {
@@ -27,6 +29,7 @@ export interface ContentEntry {
content_type: string
data: Record<string, unknown>
media?: MediaMap
relations?: RelationMap
created_by: number
updated_by: number
created_at: string
+64 -1
View File
@@ -3,19 +3,30 @@ import type { BuiltinField } from '@/lib/contentTypes'
import { fieldTypeSpec, type FieldCellKind } from '@/lib/fieldTypes'
import { formatDayValue } from '@/lib/entryValue'
import { isMissingImage, type MediaSummary } from '@/lib/mediaField'
import { isMissingRelation, relationIdsOf, type RelationSummary } from '@/lib/relationField'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
export { formatDayValue }
export type CellKind = 'empty' | FieldCellKind
export interface RelationChip {
id: number
label: string
missing: boolean
}
export interface CellView {
kind: CellKind
text: string
yes?: boolean
image?: MediaSummary
chips?: RelationChip[]
more?: number
}
export const RELATION_CHIPS_SHOWN = 2
export const EMPTY_TEXT = '–'
const EMPTY: CellView = { kind: 'empty', text: '' }
@@ -48,7 +59,57 @@ function thumbnailCell(value: unknown, image: MediaSummary | undefined): CellVie
}
}
export function describeCell(field: BuiltinField, value: unknown, image?: MediaSummary): CellView {
function summaryField(first: { key: string; type: string }): BuiltinField {
return {
key: first.key,
label: '',
type: first.type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
}
}
export function relationLabel(summary: RelationSummary) {
if (isMissingRelation(summary)) {
return t('Entry no longer exists')
}
const first = summary.first_field
const cell = describeCell(summaryField(first), first.value)
return cell.kind === 'empty' || cell.kind === 'thumbnail' || cell.kind === 'relation'
? t('Entry {0}', [summary.id])
: plainCellText(cell)
}
function relationCell(value: unknown, summaries: RelationSummary[]): CellView {
const ids = relationIdsOf(value)
if (ids.length === 0) {
return EMPTY
}
const chips = ids.map((id): RelationChip => {
const summary = summaries.find((candidate) => candidate.id === id) ?? {
id,
missing: true as const,
}
return { id, label: relationLabel(summary), missing: isMissingRelation(summary) }
})
return {
kind: 'relation',
text: chips.map((chip) => chip.label).join(', '),
chips: chips.slice(0, RELATION_CHIPS_SHOWN),
more: Math.max(0, chips.length - RELATION_CHIPS_SHOWN),
}
}
export function describeCell(
field: BuiltinField,
value: unknown,
image?: MediaSummary,
relations: RelationSummary[] = [],
): CellView {
if (value === undefined || value === null || value === '') {
return EMPTY
}
@@ -56,6 +117,8 @@ export function describeCell(field: BuiltinField, value: unknown, image?: MediaS
switch (kind) {
case 'thumbnail':
return thumbnailCell(value, image)
case 'relation':
return relationCell(value, relations)
case 'boolean':
return { kind, text: '', yes: value === true }
case 'number':
+24 -1
View File
@@ -4,8 +4,22 @@ import { rangeHelp, uniqueHelp, type EntryRule } from '@/lib/entryMessages'
import { asBoolean, asNumber, asText, charCount, isBlank, isIsoDay } from '@/lib/entryValue'
import type { FieldType } from '@/lib/fieldTypes'
import { isMediaId, isMissingImage, type MediaSummary } from '@/lib/mediaField'
import {
relationInitial,
relationPayload,
relationViolation,
type RelationSummary,
} from '@/lib/relationField'
export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select' | 'media'
export type FieldControl =
| 'input'
| 'markdown'
| 'number'
| 'switch'
| 'date'
| 'select'
| 'media'
| 'relation'
export interface FieldCounter {
text: string
@@ -14,6 +28,7 @@ export interface FieldCounter {
export interface EntryContext {
image: MediaSummary | null
relations: RelationSummary[]
}
export interface EntryEditorSpec {
@@ -197,4 +212,12 @@ export const ENTRY_EDITORS: Record<FieldType, EntryEditorSpec> = {
help: () => t('One image from your library.'),
counter: noCounter,
},
relation: {
control: 'relation',
initial: relationInitial,
validate: relationViolation,
payload: relationPayload,
help: noHelp,
counter: noCounter,
},
}
+17 -3
View File
@@ -2,9 +2,10 @@ import { t } from '@/i18n'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { describeCell, plainCellText } from '@/lib/entryCells'
import { ruleMessage, type EntryRule } from '@/lib/entryMessages'
import { isBlank } from '@/lib/entryValue'
import { isBlank, sameValue } from '@/lib/entryValue'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { mediaOf, type MediaMap } from '@/lib/mediaField'
import { relationSummariesFor, type RelationSummaries } from '@/lib/relationField'
export type EntryValues = Record<string, unknown>
@@ -39,6 +40,17 @@ export function initialImages(fields: BuiltinField[], stored: ContentEntry | nul
return images
}
export function isRelationField(field: BuiltinField) {
return fieldTypeSpec(field.type).editor.control === 'relation'
}
export function initialRelations(
fields: BuiltinField[],
stored: ContentEntry | null,
): RelationSummaries {
return relationSummariesFor(fields, stored, isRelationField)
}
export function buildData(fields: BuiltinField[], values: EntryValues) {
return Object.fromEntries(
fields.map((field) => [
@@ -56,11 +68,13 @@ export function validateEntry(
fields: BuiltinField[],
values: EntryValues,
images: MediaMap = {},
relations: RelationSummaries = {},
): EntryIssues {
const issues: EntryIssues = {}
for (const field of fields) {
const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key], {
image: images[field.key] ?? null,
relations: relations[field.key] ?? [],
})
if (rule) {
issues[field.key] = issueFor(field, rule)
@@ -74,7 +88,7 @@ export function firstIssueKey(fields: BuiltinField[], issues: EntryIssues) {
}
export function valuesDiffer(fields: BuiltinField[], current: EntryValues, saved: EntryValues) {
return fields.some((field) => !Object.is(current[field.key], saved[field.key]))
return fields.some((field) => !sameValue(current[field.key], saved[field.key]))
}
export function carriesDefault(field: BuiltinField, value: unknown) {
@@ -91,7 +105,7 @@ export function entryTitle(fields: BuiltinField[], entry: ContentEntry | null) {
return entry ? t('Entry {0}', [entry.id]) : t('New entry')
}
const cell = describeCell(first, entry.data[first.key])
return cell.kind === 'empty' || cell.kind === 'thumbnail'
return cell.kind === 'empty' || cell.kind === 'thumbnail' || cell.kind === 'relation'
? t('Entry {0}', [entry.id])
: plainCellText(cell)
}
+6
View File
@@ -26,6 +26,8 @@ function requiredMessage(field: BuiltinField) {
return t('Choose yes or no for {0}.', [field.label])
case 'media':
return t('Pick an image for {0}.', [field.label])
case 'relation':
return t('Choose a {0}.', [field.label])
default:
return t('Fill in {0}.', [field.label])
}
@@ -43,6 +45,8 @@ function typeMessage(field: BuiltinField) {
return t('{0} must be one of the choices.', [field.label])
case 'media':
return t('{0} must be an image from the library.', [field.label])
case 'relation':
return t('{0} must point to an existing entry.', [field.label])
default:
return t('{0} must be text.', [field.label])
}
@@ -76,6 +80,8 @@ function uniqueMessage(field: BuiltinField) {
return t('Another entry already uses this choice for {0}.', [field.label])
case 'date':
return t('Another entry already uses this date for {0}.', [field.label])
case 'relation':
return t('That {0} is already used by another entry.', [field.label])
default:
return t('Another entry already uses this {0}. Enter a different one.', [field.label])
}
+10
View File
@@ -57,3 +57,13 @@ export function freeSentinel(taken: string[], base = '__none__') {
}
return sentinel
}
export function sameValue(left: unknown, right: unknown) {
if (Array.isArray(left) && Array.isArray(right)) {
return (
left.length === right.length &&
left.every((item, index) => Object.is(item, right[index]))
)
}
return Object.is(left, right)
}
+64 -1
View File
@@ -1,4 +1,4 @@
import { t } from '@/i18n'
import { formatNumber, t } from '@/i18n'
import { ENTRY_EDITORS, type EntryEditorSpec } from '@/lib/entryEditors'
export const FIELD_TYPES = [
@@ -9,6 +9,7 @@ export const FIELD_TYPES = [
'date',
'select',
'media',
'relation',
] as const
export type FieldType = (typeof FIELD_TYPES)[number]
@@ -21,6 +22,7 @@ export type FieldCellKind =
| 'date'
| 'choice'
| 'thumbnail'
| 'relation'
export interface FieldTypeSpec {
icon: string
@@ -28,6 +30,7 @@ export interface FieldTypeSpec {
description: () => string
cell: FieldCellKind
narrowColumn: boolean
wideColumn: boolean
alignEnd: boolean
supportsUnique: boolean
supportsLimits: boolean
@@ -35,6 +38,8 @@ export interface FieldTypeSpec {
supportsChoices: boolean
showsUnsupportedOptions: boolean
builderHint: (() => string) | null
noDefaultCause: ((label: string, total: number) => string) | null
holdsRelation: boolean
pairs: boolean
editor: EntryEditorSpec
}
@@ -48,6 +53,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('Short text, like a title or a name'),
cell: 'text',
narrowColumn: false,
wideColumn: false,
alignEnd: false,
supportsUnique: true,
supportsLimits: true,
@@ -55,6 +61,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: false,
editor: ENTRY_EDITORS.text,
},
@@ -64,6 +72,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('Long text with formatting, written in Markdown'),
cell: 'richtext',
narrowColumn: false,
wideColumn: false,
alignEnd: false,
supportsUnique: false,
supportsLimits: true,
@@ -71,6 +80,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: false,
editor: ENTRY_EDITORS.richtext,
},
@@ -80,6 +91,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('Whole or decimal numbers'),
cell: 'number',
narrowColumn: true,
wideColumn: false,
alignEnd: true,
supportsUnique: true,
supportsLimits: true,
@@ -87,6 +99,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: true,
editor: ENTRY_EDITORS.number,
},
@@ -96,6 +110,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('An on or off switch'),
cell: 'boolean',
narrowColumn: true,
wideColumn: false,
alignEnd: false,
supportsUnique: false,
supportsLimits: false,
@@ -103,6 +118,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: true,
editor: ENTRY_EDITORS.boolean,
},
@@ -112,6 +129,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('A calendar day, without a time'),
cell: 'date',
narrowColumn: false,
wideColumn: false,
alignEnd: false,
supportsUnique: true,
supportsLimits: true,
@@ -119,6 +137,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: true,
editor: ENTRY_EDITORS.date,
},
@@ -128,6 +148,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('One option from a list you define'),
cell: 'choice',
narrowColumn: false,
wideColumn: false,
alignEnd: false,
supportsUnique: true,
supportsLimits: false,
@@ -135,6 +156,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: true,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: false,
editor: ENTRY_EDITORS.select,
},
@@ -144,6 +167,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('One image from your media library'),
cell: 'thumbnail',
narrowColumn: true,
wideColumn: false,
alignEnd: false,
supportsUnique: false,
supportsLimits: false,
@@ -154,9 +178,48 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
t(
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.',
),
noDefaultCause: (label, total) =>
total === 1
? t(
"A media field can't have a default, so the existing entry would have no image. Make {0} optional to add it.",
[label],
)
: t(
"A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.",
[formatNumber(total), label],
),
holdsRelation: false,
pairs: false,
editor: ENTRY_EDITORS.media,
},
relation: {
icon: 'i-lucide-link',
label: () => t('Relation'),
description: () => t("Links to another type's entries"),
cell: 'relation',
narrowColumn: false,
wideColumn: true,
alignEnd: false,
supportsUnique: false,
supportsLimits: false,
supportsDefault: false,
supportsChoices: false,
showsUnsupportedOptions: false,
builderHint: null,
noDefaultCause: (label, total) =>
total === 1
? t(
"A relation can't have a default, so the existing entry would have no {0}. Make {0} optional to add it.",
[label],
)
: t(
"A relation can't have a default, so the {0} existing entries would have no {1}. Make {1} optional to add it.",
[formatNumber(total), label],
),
holdsRelation: true,
pairs: false,
editor: ENTRY_EDITORS.relation,
},
}
export function isFieldType(type: string): type is FieldType {
+3 -1
View File
@@ -51,7 +51,9 @@ export function usageEntryTitle(item: MediaUsageItem) {
},
first.value,
)
return cell.kind === 'empty' || cell.kind === 'thumbnail' ? fallback : plainCellText(cell)
return cell.kind === 'empty' || cell.kind === 'thumbnail' || cell.kind === 'relation'
? fallback
: plainCellText(cell)
}
function toEntry(item: MediaUsageItem): UsageEntry {
+170
View File
@@ -0,0 +1,170 @@
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import type { EntryRule } from '@/lib/entryMessages'
export const RELATION_KINDS = ['one_to_one', 'one_to_many', 'many_to_many'] as const
export type RelationKind = (typeof RELATION_KINDS)[number]
export const DEFAULT_RELATION_KIND: RelationKind = 'one_to_many'
export const RELATION_MAX = 50
export const RELATION_PAGE_SIZE = 10
const RELATION_ID_MAX = 4294967295
export interface RelationDefinition {
content_type: string
kind: RelationKind
}
export interface RelationFirstField {
key: string
type: string
value: unknown
}
export interface RelationTarget {
id: number
content_type: string
first_field: RelationFirstField
}
export interface MissingRelation {
id: number
missing: true
}
export type RelationSummary = RelationTarget | MissingRelation
export type RelationMap = Record<string, RelationSummary | RelationSummary[]>
export type RelationSummaries = Record<string, RelationSummary[]>
export interface RelationContext {
relations: RelationSummary[]
}
export function isMissingRelation(summary: RelationSummary): summary is MissingRelation {
return 'missing' in summary
}
export function isRelationKind(value: unknown): value is RelationKind {
return (RELATION_KINDS as readonly unknown[]).includes(value)
}
export function isRelationId(value: unknown): value is number {
return (
typeof value === 'number' &&
Number.isInteger(value) &&
value >= 1 &&
value <= RELATION_ID_MAX
)
}
export function holdsManyRelations(field: Pick<BuiltinField, 'relation'>) {
return field.relation?.kind === 'many_to_many'
}
export function emptyRelationValue(field: Pick<BuiltinField, 'relation'>) {
return holdsManyRelations(field) ? [] : null
}
export function relationIdsOf(value: unknown): number[] {
if (Array.isArray(value)) {
return value.filter(isRelationId)
}
return isRelationId(value) ? [value] : []
}
export function relationInitial(field: BuiltinField, stored: unknown, creating: boolean) {
if (creating) {
return emptyRelationValue(field)
}
if (holdsManyRelations(field)) {
return Array.isArray(stored) && stored.every(isRelationId) ? [...stored] : []
}
return isRelationId(stored) ? stored : null
}
function hasDuplicates(ids: number[]) {
return new Set(ids).size !== ids.length
}
function onlyMissing(ids: number[], summaries: RelationSummary[]) {
const gone = new Set(summaries.filter(isMissingRelation).map((summary) => summary.id))
return ids.length > 0 && ids.every((id) => gone.has(id))
}
function manyViolation(field: BuiltinField, value: unknown, context?: RelationContext) {
if (value === null || value === undefined) {
return field.required ? 'required' : null
}
if (!Array.isArray(value)) {
return 'type'
}
if (value.length === 0) {
return field.required ? 'required' : null
}
if (value.length > RELATION_MAX || !value.every(isRelationId) || hasDuplicates(value)) {
return 'type'
}
return field.required && onlyMissing(value, context?.relations ?? []) ? 'required' : null
}
function singleViolation(field: BuiltinField, value: unknown, context?: RelationContext) {
if (value === null || value === undefined) {
return field.required ? 'required' : null
}
if (!isRelationId(value)) {
return 'type'
}
return field.required && onlyMissing([value], context?.relations ?? []) ? 'required' : null
}
export function relationViolation(
field: BuiltinField,
value: unknown,
context?: RelationContext,
): EntryRule | null {
return holdsManyRelations(field)
? manyViolation(field, value, context)
: singleViolation(field, value, context)
}
export function relationPayload(field: BuiltinField, value: unknown) {
if (holdsManyRelations(field)) {
return Array.isArray(value) ? [...value] : []
}
return value === undefined ? null : value
}
export function summariesOf(
entry: Pick<ContentEntry, 'relations'> | null | undefined,
key: string,
): RelationSummary[] {
const found = entry?.relations?.[key]
if (found === undefined) {
return []
}
return Array.isArray(found) ? found : [found]
}
export function relationSummariesFor(
fields: BuiltinField[],
entry: ContentEntry | null,
isRelation: (field: BuiltinField) => boolean,
): RelationSummaries {
const result: RelationSummaries = {}
for (const field of fields) {
if (!isRelation(field)) {
continue
}
const stored = relationIdsOf(entry?.data[field.key])
const summaries = summariesOf(entry, field.key).filter((summary) =>
stored.includes(summary.id),
)
if (summaries.length > 0) {
result[field.key] = summaries
}
}
return result
}
+47
View File
@@ -0,0 +1,47 @@
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { describeCell, isEmptyCell, plainCellText, relationLabel } from '@/lib/entryCells'
import type { RelationTarget } from '@/lib/relationField'
export const ROW_DETAIL_FIELDS = 2
const DETAIL_SEPARATOR = ' · '
const UNKNOWN_FIELD_TYPE = 'text'
const NO_TEXT_KINDS = ['thumbnail', 'relation']
export interface RelationRow {
id: number
title: string
detail: string
summary: RelationTarget
}
function summaryOf(entry: ContentEntry, targetKey: string, fields: BuiltinField[]): RelationTarget {
const first = fields[0]
return {
id: entry.id,
content_type: targetKey,
first_field: {
key: first?.key ?? '',
type: first?.type ?? UNKNOWN_FIELD_TYPE,
value: first ? (entry.data[first.key] ?? null) : null,
},
}
}
function detailOf(entry: ContentEntry, fields: BuiltinField[]) {
return fields
.slice(1, 1 + ROW_DETAIL_FIELDS)
.map((field) => describeCell(field, entry.data[field.key]))
.filter((cell) => !isEmptyCell(cell) && !NO_TEXT_KINDS.includes(cell.kind))
.map(plainCellText)
.join(DETAIL_SEPARATOR)
}
export function relationRowOf(
entry: ContentEntry,
targetKey: string,
fields: BuiltinField[],
): RelationRow {
const summary = summaryOf(entry, targetKey, fields)
return { id: entry.id, title: relationLabel(summary), detail: detailOf(entry, fields), summary }
}
+108
View File
@@ -0,0 +1,108 @@
import { t } from '@/i18n'
import type { RelationKind } from '@/lib/relationField'
export type RelationSide = 'one' | 'many'
export interface RelationNames {
ownerSingular: string
ownerPlural: string
relatedSingular: string
relatedPlural: string
}
export interface RelationSample {
ownerChips: string[]
ownerMore: number
relatedChips: string[]
relatedMore: number
}
const FINAL_PLURAL = /s$/i
const SAMPLE_VENUES = ['Rue Verte hall', 'Rue Verte yard']
const SAMPLE_EVENTS = ['Open day', 'Lime plaster workshop']
const SAMPLE_EVENTS_MORE = 1
export function singularOf(name: string) {
return name.length > 1 ? name.replace(FINAL_PLURAL, '') : name
}
export function relationNames(
ownerName: string,
relatedName: string,
fieldLabel: string,
): RelationNames {
const ownerPlural = ownerName.trim() || t('Entries')
const ownerSingular = ownerName.trim() ? singularOf(ownerPlural) : t('Entry')
const relatedSingular = fieldLabel.trim() || singularOf(relatedName.trim()) || t('Entry')
return {
ownerSingular,
ownerPlural,
relatedSingular,
relatedPlural: relatedName.trim() || `${relatedSingular}s`,
}
}
export function ownerSide(kind: RelationKind): RelationSide {
return kind === 'many_to_many' ? 'many' : 'one'
}
export function inverseSide(kind: RelationKind): RelationSide {
return kind === 'one_to_one' ? 'one' : 'many'
}
export function kindFromOwnerSide(side: RelationSide, kind: RelationKind): RelationKind {
if (side === 'many') {
return 'many_to_many'
}
return inverseSide(kind) === 'one' ? 'one_to_one' : 'one_to_many'
}
export function kindFromInverseSide(side: RelationSide, kind: RelationKind): RelationKind {
if (side === 'one') {
return 'one_to_one'
}
return ownerSide(kind) === 'many' ? 'many_to_many' : 'one_to_many'
}
export function kindSentence(kind: RelationKind, names: RelationNames) {
const { ownerSingular, ownerPlural, relatedSingular, relatedPlural } = names
switch (kind) {
case 'one_to_one':
return t('Each {0} has one {1}, and each {1} has one {0}.', [
ownerSingular,
relatedSingular,
])
case 'one_to_many':
return t('Each {0} has one {1}. A {1} has many {2}.', [
ownerSingular,
relatedSingular,
ownerPlural,
])
default:
return t('Each {0} has many {1}, and each {2} has many {3}.', [
ownerSingular,
relatedPlural,
relatedSingular,
ownerPlural,
])
}
}
export function ownerNoun(kind: RelationKind, names: RelationNames) {
return ownerSide(kind) === 'many' ? names.relatedPlural : names.relatedSingular
}
export function inverseNoun(kind: RelationKind, names: RelationNames) {
return inverseSide(kind) === 'many' ? names.ownerPlural : names.ownerSingular
}
export function relationSample(kind: RelationKind): RelationSample {
const many = inverseSide(kind) === 'many'
return {
ownerChips: ownerSide(kind) === 'many' ? SAMPLE_VENUES : SAMPLE_VENUES.slice(0, 1),
ownerMore: 0,
relatedChips: many ? SAMPLE_EVENTS : SAMPLE_EVENTS.slice(0, 1),
relatedMore: many ? SAMPLE_EVENTS_MORE : 0,
}
}
+15 -18
View File
@@ -19,7 +19,7 @@ export interface FieldChange {
label: string
notes: ChangeNote[]
flag: ChangeFlag | null
mediaAdd?: boolean
noDefaultAdd?: boolean
}
type Bound = number | string | null
@@ -151,20 +151,15 @@ function changeNotes(before: PayloadField, after: PayloadField) {
return notes
}
function mediaAddNote(field: PayloadField, total: number) {
const summary = `${fieldTypeSpec(field.type).label()}, ${field.required ? t('required') : t('optional')}.`
function noDefaultAddNote(field: PayloadField, total: number) {
const spec = fieldTypeSpec(field.type)
const summary = `${spec.label()}, ${field.required ? t('required') : t('optional')}.`
if (field.required) {
const cause =
total === 1
? t(
"A media field can't have a default, so the existing entry would have no image. Make {0} optional to add it.",
[field.label],
)
: t(
"A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.",
[formatNumber(total), field.label],
)
return note(`${summary} ${cause}`, '', true)
return note(
`${summary} ${spec.noDefaultCause?.(field.label, total) ?? ''}`.trim(),
'',
true,
)
}
if (total <= 0) {
return note(summary)
@@ -178,7 +173,7 @@ function mediaAddNote(field: PayloadField, total: number) {
function addNote(field: PayloadField, total: number) {
if (!fieldTypeSpec(field.type).supportsDefault) {
return mediaAddNote(field, total)
return noDefaultAddNote(field, total)
}
const parts = [
fieldTypeSpec(field.type).label(),
@@ -233,7 +228,7 @@ export function diffFields(
label: after.label,
notes,
flag: addFlag(after, notes, total),
...(fieldTypeSpec(after.type).supportsDefault ? {} : { mediaAdd: true }),
...(fieldTypeSpec(after.type).supportsDefault ? {} : { noDefaultAdd: true }),
})
} else if (fieldSignature(previous) !== fieldSignature(after)) {
const notes = changeNotes(previous, after)
@@ -287,8 +282,10 @@ export function flaggedChanges(changes: readonly FieldChange[]) {
return changes.filter((change) => change.flag === 'mayNotFit')
}
export function isMediaOnlyClean(changes: readonly FieldChange[]) {
return changes.length > 0 && changes.every((change) => change.mediaAdd && change.flag === null)
export function isNoDefaultOnlyClean(changes: readonly FieldChange[]) {
return (
changes.length > 0 && changes.every((change) => change.noDefaultAdd && change.flag === null)
)
}
export function hasRefusedChange(changes: readonly FieldChange[]) {
+3
View File
@@ -7,6 +7,7 @@ import {
type FieldType,
} from '@/lib/schemaBuilderModel'
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
import { DEFAULT_RELATION_KIND } from '@/lib/relationField'
const DATE_LENGTH = 10
@@ -42,6 +43,8 @@ export function fieldFromApi(source: BuiltinField, uid: number): BuilderField {
min: bound(source.min),
max: bound(source.max),
options: type === 'select' ? asOptions(source.options) : [],
relationType: source.relation?.content_type ?? '',
relationKind: source.relation?.kind ?? DEFAULT_RELATION_KIND,
saved: true,
})
}
+9
View File
@@ -1,4 +1,5 @@
import { FIELD_TYPE_SPECS, FIELD_TYPES, type FieldType } from '@/lib/fieldTypes'
import { DEFAULT_RELATION_KIND, type RelationKind } from '@/lib/relationField'
export { FIELD_TYPES, type FieldType }
export type KeyMode = 'auto' | 'manual'
@@ -39,6 +40,8 @@ export interface BuilderField {
min: string
max: string
options: string[]
relationType: string
relationKind: RelationKind
saved: boolean
}
@@ -71,6 +74,8 @@ export function createField(uid: number, overrides: Partial<BuilderField> = {}):
min: '',
max: '',
options: [],
relationType: '',
relationKind: DEFAULT_RELATION_KIND,
saved: false,
...overrides,
}
@@ -111,6 +116,10 @@ export function supportsChoices(type: FieldType) {
return FIELD_TYPE_SPECS[type].supportsChoices
}
export function holdsRelation(type: FieldType) {
return FIELD_TYPE_SPECS[type].holdsRelation
}
export function supportsLimits(type: FieldType) {
return FIELD_TYPE_SPECS[type].supportsLimits
}
+10
View File
@@ -1,5 +1,6 @@
import {
effectiveDefault,
holdsRelation,
isUnique,
supportsChoices,
supportsLimits,
@@ -7,6 +8,7 @@ import {
type BuilderField,
type BuilderForm,
} from '@/lib/schemaBuilderModel'
import type { RelationDefinition } from '@/lib/relationField'
export interface PayloadField {
key: string
@@ -18,6 +20,7 @@ export interface PayloadField {
min: number | string | null
max: number | string | null
options: string[]
relation: RelationDefinition | null
}
export interface ContentTypePayload {
@@ -50,6 +53,12 @@ export interface UpdatePayload {
fields: PayloadField[]
}
function relationFor(field: BuilderField): RelationDefinition | null {
return holdsRelation(field.type)
? { content_type: field.relationType, kind: field.relationKind }
: null
}
export function payloadField(field: BuilderField): PayloadField {
return {
key: field.key,
@@ -61,6 +70,7 @@ export function payloadField(field: BuilderField): PayloadField {
min: limit(field, field.min),
max: limit(field, field.max),
options: supportsChoices(field.type) ? trimmedOptions(field) : [],
relation: relationFor(field),
}
}
+4 -2
View File
@@ -30,7 +30,7 @@ export function previewRoutes(key: string) {
]
}
export function sampleValue(field: BuilderField): string | number | boolean | null {
export function sampleValue(field: BuilderField): string | number | boolean | null | never[] {
const given = effectiveDefault(field)
switch (field.type) {
case 'richtext':
@@ -49,6 +49,8 @@ export function sampleValue(field: BuilderField): string | number | boolean | nu
return given === '' ? SAMPLE_DATE : given
case 'media':
return null
case 'relation':
return field.relationKind === 'many_to_many' ? [] : null
case 'select': {
const choices = trimmedOptions(field).filter((option) => option !== '')
return given !== '' ? given : (choices[0] ?? null)
@@ -59,7 +61,7 @@ export function sampleValue(field: BuilderField): string | number | boolean | nu
}
export function sampleEntry(key: string, fields: readonly BuilderField[]) {
const data: Record<string, string | number | boolean | null> = {}
const data: Record<string, string | number | boolean | null | never[]> = {}
for (const field of fields) {
if (isValidFieldKey(field.key)) {
data[field.key] = sampleValue(field)
+2 -1
View File
@@ -1,7 +1,7 @@
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
export type TypeTouch = 'name' | 'key' | 'fields'
export type FieldTouch = 'label' | 'key' | 'min' | 'max' | 'default' | 'choices'
export type FieldTouch = 'label' | 'key' | 'min' | 'max' | 'default' | 'choices' | 'relation'
export function typeTouchKey(name: TypeTouch) {
return `type:${name}`
@@ -19,4 +19,5 @@ export const FIELD_ERROR_TOUCH: readonly (readonly [keyof FieldErrors, FieldTouc
['default', 'default'],
['options', 'choices'],
['choices', 'choices'],
['relation', 'relation'],
]
+5
View File
@@ -2,6 +2,7 @@ import { t } from '@/i18n'
import {
BUILDER_LIMITS,
RESERVED_TYPE_KEYS,
holdsRelation,
trimmedOptions,
type BuilderField,
type BuilderForm,
@@ -25,6 +26,7 @@ export interface FieldErrors {
default?: string
options?: string
choices?: Record<number, string>
relation?: string
}
export interface FormErrors {
@@ -190,6 +192,9 @@ export function validateField(field: BuilderField, earlierKeys: readonly string[
if (key) {
errors.key = key
}
if (holdsRelation(field.type) && field.relationType === '') {
errors.relation = t('Choose a related content type.')
}
if (field.type === 'text' || field.type === 'richtext') {
textLimits(field, errors)
} else if (field.type === 'number') {
+2 -2
View File
@@ -1,5 +1,5 @@
export type DeleteStage = 'closed' | 'blocked' | 'confirm' | 'failed' | 'conflict'
export type DeleteStage = 'closed' | 'blocked' | 'confirm' | 'failed' | 'conflict' | 'referenced'
export function isConfirmStage(stage: DeleteStage) {
return stage === 'confirm' || stage === 'failed'
return stage === 'confirm' || stage === 'failed' || stage === 'referenced'
}
@@ -0,0 +1,243 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { RelationSummary } from '@/lib/relationField'
import {
calls,
eventEntries,
installSelectPolyfills,
listPage,
mountList,
text,
unmountAll,
} from './contentTestKit'
import { drawsIcon } from './mediaFieldKit'
import { eventsFields } from './schemaTestKit'
import { apiField } from '@/lib/__tests__/schemaFixtures'
import { relationField, summaryFor } from './relationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(async () => {
void router
await unmountAll()
})
const KEY = 'renewcms-content-columns-events'
const COLUMNS = ['title', 'starts_on', 'venue', 'speakers']
const MISSING: RelationSummary = { id: 9, missing: true }
type Relations = Record<string, RelationSummary | RelationSummary[]>
const RELATIONS: Record<number, [number | null, number[], Relations]> = {
42: [3, [11, 12, 13, 14], {}],
41: [null, [12], {}],
40: [4, [], {}],
}
const eventType = () => ({
id: 1,
key: 'events',
name: 'Events',
icon: 'files',
created_at: '2026-09-01T09:30:00Z',
updated_at: '2026-10-15T09:30:00Z',
fields: [
eventsFields[0]!,
eventsFields[1]!,
relationField('Venue', 'venue', 'venues', 'one_to_many'),
relationField('Speakers', 'speakers', 'speakers', 'many_to_many'),
],
})
function itemOf(id: number, overrides: { relations?: Relations; venue?: number | null } = {}) {
const [venue, speakers] = RELATIONS[id]!
const base = eventEntries.find((entry) => entry.id === id)!
const shownVenue = overrides.venue === undefined ? venue : overrides.venue
return {
...base,
data: { ...base.data, venue: shownVenue, speakers },
relations: overrides.relations ?? {
...(shownVenue === null ? {} : { venue: summaryFor('venues', shownVenue) }),
...(speakers.length === 0
? {}
: { speakers: speakers.map((speaker) => summaryFor('speakers', speaker)) }),
},
}
}
function mountRelations(
width?: number,
items = [itemOf(42), itemOf(41), itemOf(40)],
type = eventType,
columns: string[] = COLUMNS,
) {
return mountList('/content/events', {
width,
before: () => localStorage.setItem(KEY, JSON.stringify(columns)),
type,
content: () => [type()],
list: (_key, query) => listPage(items, items.length, query),
})
}
const headers = () => [...document.body.querySelectorAll('thead th')]
const rowsOf = () => [...document.body.querySelectorAll('tbody tr')]
const cellOf = (row: number, column: number) => rowsOf()[row]!.querySelectorAll('td')[column]!
const chipsIn = (element: Element) => [...element.querySelectorAll('[data-testid="relation-chip"]')]
describe('The relation cell', () => {
it('shows one chip for a single relation', async () => {
await mountRelations()
const chips = chipsIn(cellOf(0, 3))
expect(chips.map(text)).toEqual(['Rue Verte hall'])
expect(chips[0]!.className).toContain('h-5')
expect(chips[0]!.className).toContain('px-1.5')
expect(chips[0]!.className).toContain('bg-(--rc-sand)')
})
it('shows two chips and a muted +2 for four speakers', async () => {
await mountRelations()
expect(chipsIn(cellOf(0, 4)).map(text)).toEqual(['Ines Duarte', 'Jonas Pereira'])
expect(text(cellOf(0, 4))).toBe('Ines Duarte Jonas Pereira +2')
const more = [...cellOf(0, 4).querySelectorAll('span')].find((span) => text(span) === '+2')!
expect(more.className).toContain('text-muted')
})
it('shows the muted dash for no venue and for an empty speakers list', async () => {
await mountRelations()
expect(text(cellOf(1, 3))).toBe('–')
expect(cellOf(1, 3).querySelector('span')!.className).toContain('text-muted')
expect(text(cellOf(2, 4))).toBe('–')
expect(text(cellOf(1, 4))).toBe('Jonas Pereira')
expect(text(cellOf(2, 3))).toBe('Rue Verte yard')
})
it('shows a dashed chip for a missing venue', async () => {
await mountRelations(undefined, [
itemOf(42, { venue: 9, relations: { venue: MISSING } }),
itemOf(41),
itemOf(40),
])
const chip = chipsIn(cellOf(0, 3))[0]!
expect(text(chip)).toBe('Entry no longer exists')
expect(chip.getAttribute('data-missing')).toBe('true')
expect(chip.className).toContain('border-dashed')
})
it('reads each item own relations with no extra request', async () => {
await mountRelations()
expect(calls.filter((url) => url.includes('/venues') || url.includes('/speakers'))).toEqual(
[],
)
})
})
describe('The grid of the board', () => {
it('lists the relation headers with the wide column and no sort', async () => {
await mountRelations()
expect(headers().map(text).slice(0, 5)).toEqual([
'ID',
'Title',
'Starts on',
'Venue',
'Speakers',
])
for (const index of [3, 4]) {
expect(headers()[index]!.className).toContain('w-50')
expect(headers()[index]!.querySelector('svg')).toBeNull()
expect(headers()[index]!.hasAttribute('aria-sort')).toBe(false)
}
})
it('keeps rows 48 px high', async () => {
await mountRelations()
expect(cellOf(0, 0).className).toContain('h-12')
})
})
describe('The mobile card', () => {
it('lists the title, then a labelled line for Venue and Speakers', async () => {
await mountRelations(MOBILE_WIDTH)
const cards = [...document.body.querySelectorAll('[data-testid="entry-card"]')]
const lines = [...cards[0]!.querySelectorAll('[data-testid="entry-card-relation"]')]
expect(lines.map(text)).toEqual([
'Venue Rue Verte hall',
'Speakers Ines Duarte Jonas Pereira +2',
])
expect(text(cards[1]!.querySelectorAll('[data-testid="entry-card-relation"]')[0])).toBe(
'Venue –',
)
expect(text(cards[2]!.querySelectorAll('[data-testid="entry-card-relation"]')[1])).toBe(
'Speakers –',
)
})
})
describe('The columns menu', () => {
it('lists Venue and Speakers with the link icon and their keys', async () => {
await mountRelations()
const toggle = [...document.body.querySelectorAll('button')].find(
(button) =>
text(button) === 'Columns' || button.getAttribute('aria-label') === 'Columns',
)!
toggle.click()
await vi.waitFor(() =>
expect(document.body.querySelectorAll('[data-testid="column-row"]').length).toBe(4),
)
const rows = [...document.body.querySelectorAll('[data-testid="column-row"]')]
expect(rows.map(text).slice(2)).toEqual(['Venue venue', 'Speakers speakers'])
expect(drawsIcon(rows[2]!, 'link')).toBe(true)
expect(drawsIcon(rows[3]!, 'link')).toBe(true)
})
})
describe('A relation as the first column', () => {
it('keeps the link to the editor around the chips', async () => {
const type = () => {
const [, , venue, speakers] = eventType().fields
return { ...eventType(), fields: [venue!, speakers!, eventsFields[0]!] }
}
await mountRelations(undefined, undefined, type, ['venue', 'speakers', 'title'])
const link = rowsOf()[0]!.querySelector<HTMLAnchorElement>('[data-testid="entry-link"]')!
expect(chipsIn(link).map(text)).toEqual(['Rue Verte hall'])
expect(link.getAttribute('href')).toBe('/content/events/42')
})
it('reads the labels joined by a comma as plain text on the card', async () => {
const type = () => {
const [, , venue, speakers] = eventType().fields
return { ...eventType(), fields: [speakers!, venue!, eventsFields[0]!] }
}
await mountRelations(MOBILE_WIDTH, undefined, type, ['speakers', 'venue', 'title'])
const titles = [
...document.body.querySelectorAll('[data-testid="entry-card"] .truncate'),
].map(text)
expect(titles[0]).toBe('Ines Duarte, Jonas Pereira, Ana Costa, Luc Moreau')
expect(titles.join('|')).not.toMatch(/\b1[1-4]\b/)
})
})
describe('Fixtures', () => {
it('keeps unrelated field helpers', () => {
expect(apiField('X', 'x', 'text').relation).toBeNull()
})
})
@@ -0,0 +1,563 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
button,
calls,
chip,
entryWrites,
fieldWrapper,
installSelectPolyfills,
save,
setField,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
import {
allTypes,
chipLabels,
entryWithRelations,
liveText,
mountRelation,
openList,
optionLabels,
pickOption,
press,
relationError,
relationInput,
removeButtons,
settleSearch,
summaryFor,
typeSearch,
} from './relationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
installSelectPolyfills()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
})
afterEach(unmountAll)
const searches = () => calls.filter((url) => url.includes('/entry?') && !url.endsWith('size=5'))
const sentData = (index = 0) => entryWrites[index]!.body.data as Record<string, unknown>
const fieldKeys = () =>
[...document.body.querySelectorAll<HTMLElement>('[data-entry-field]')].map(
(element) => element.dataset.entryField,
)
describe('Where the controls sit', () => {
it('follows the schema order, full width, each with its label', async () => {
await mountRelation('/content/events/42', { venue: 3, speakers: [11, 12] })
expect(fieldKeys().slice(-2)).toEqual(['venue', 'speakers'])
for (const key of ['venue', 'speakers']) {
const row = fieldWrapper(key).parentElement!
expect(row.children).toHaveLength(1)
expect(row.className).not.toContain('md:grid-cols-2')
}
expect(text(fieldWrapper('venue').querySelector('label'))).toBe('Venue')
expect(text(fieldWrapper('speakers').querySelector('label'))).toBe('Speakers')
})
it('never pairs with a boolean, number or date field before it', async () => {
await mountRelation('/content/events/new')
const rows = [...document.body.querySelectorAll('#entry-form > div')]
const venueRow = rows.find((row) => row.querySelector('[data-entry-field="venue"]'))!
expect(venueRow.children).toHaveLength(1)
expect(
venueRow.previousElementSibling!.querySelector('[data-entry-field="description"]'),
).not.toBeNull()
})
})
describe('Single relation, empty, focused search', () => {
it('sends only the trimmed, debounced search and lists rows with a second line', async () => {
await mountRelation('/content/events/42')
await typeSearch('venue', ' ru ')
await vi.advanceTimersByTimeAsync(100)
await typeSearch('venue', 'rue ')
await settleSearch(vi)
expect(searches()).toEqual(['/v1/content-type/venues/entry?q=rue&page=1&size=10'])
expect(optionLabels()).toEqual([
'Rue Verte hall Lyon · 120 seats',
'Rue Verte yard Lyon · outdoor',
'Rue du Port studio Nantes · 24 seats',
])
expect(relationInput('venue').getAttribute('placeholder')).toBe('Search to add')
expect(
document.body.querySelector('[role="option"]')!.hasAttribute('data-highlighted'),
).toBe(true)
expect(liveText('venue')).toBe('3 results')
})
it('lists the first page without q when the input is empty', async () => {
await mountRelation('/content/events/42')
await openList('venue', vi)
expect(searches()).toEqual(['/v1/content-type/venues/entry?page=1&size=10'])
expect(optionLabels()).toHaveLength(4)
})
it('never lets a stale answer overwrite a newer one', async () => {
const gates = new Map<string, (value: unknown) => void>()
await mountRelation('/content/events/42', {
search: (_key, query) =>
new Promise((resolve) => gates.set(query.get('q') ?? '', resolve)),
})
const body = (name: string) => ({
items: [{ id: 3, data: { name, city: '', details: '' }, relations: {} }],
total: 1,
page: 1,
size: 10,
})
await typeSearch('venue', 'old')
await settleSearch(vi)
await typeSearch('venue', 'new')
await settleSearch(vi)
gates.get('new')!(body('Fresh'))
await flushPromises()
gates.get('old')!(body('Stale'))
await flushPromises()
expect(optionLabels()).toEqual(['Fresh'])
})
})
describe('Picking a single target', () => {
it('shows one chip, sets the value, dirties the page and announces it', async () => {
await mountRelation('/content/events/42')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
expect(chipLabels('venue')).toEqual(['Rue Verte hall'])
expect(liveText('venue')).toBe('Venue set to Rue Verte hall.')
expect(chip()!.textContent).toBe('Unsaved changes')
expect(relationInput('venue').getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(relationInput('venue'))
expect(calls.filter((url) => url.includes('/venues/entry/'))).toEqual([])
await save()
expect(sentData().venue).toBe(3)
})
it('removes the chip with its x and says the field was cleared', async () => {
await mountRelation('/content/events/42', { venue: 3 })
expect(chipLabels('venue')).toEqual(['Rue Verte hall'])
removeButtons('venue')[0]!.click()
await flushPromises()
expect(chipLabels('venue')).toEqual([])
expect(liveText('venue')).toBe('Venue cleared.')
await save()
expect(sentData().venue).toBeNull()
})
it('replaces the chip when another row is picked', async () => {
await mountRelation('/content/events/42', { venue: 3 })
await openList('venue', vi, 'rue')
expect(optionLabels()).not.toContain('Rue Verte hall Lyon · 120 seats')
await pickOption('Rue Verte yard')
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
await save()
expect(sentData().venue).toBe(4)
})
})
describe('Many relation', () => {
it('shows the chips, the placeholder and the help line', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
expect(chipLabels('speakers')).toEqual(['Ines Duarte', 'Jonas Pereira'])
expect(relationInput('speakers').getAttribute('placeholder')).toBe('Search to add')
expect(text(fieldWrapper('speakers').querySelector('[data-testid="relation-help"]'))).toBe(
'An event can have many speakers.',
)
})
it('adds at the end, hides picked rows and announces', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
await openList('speakers', vi, 'ana')
expect(optionLabels()).toEqual(['Ana Costa'])
await pickOption('Ana Costa')
expect(chipLabels('speakers')).toEqual(['Ines Duarte', 'Jonas Pereira', 'Ana Costa'])
expect(liveText('speakers')).toBe('Ana Costa added to Speakers.')
await save()
expect(sentData().speakers).toEqual([11, 12, 13])
})
it('removes a chip with its x or Backspace in the empty input', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
removeButtons('speakers')[0]!.click()
await flushPromises()
expect(chipLabels('speakers')).toEqual(['Jonas Pereira'])
expect(liveText('speakers')).toBe('Ines Duarte removed from Speakers.')
relationInput('speakers').focus()
await press('speakers', 'Backspace')
expect(chipLabels('speakers')).toEqual([])
expect(liveText('speakers')).toBe('Jonas Pereira removed from Speakers.')
})
it('names each x "Remove <name> from <field>"', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
expect(
removeButtons('speakers').map((remove) => remove.getAttribute('aria-label')),
).toEqual(['Remove Ines Duarte from Speakers', 'Remove Jonas Pereira from Speakers'])
expect(removeButtons('speakers')[0]!.hasAttribute('aria-labelledby')).toBe(false)
})
it('cannot add a 51st target', async () => {
const ids = Array.from({ length: 50 }, (_, index) => 100 + index)
const relations = {
speakers: ids.map((id) => ({
id,
content_type: 'speakers',
first_field: { key: 'name', type: 'text', value: `Speaker ${id}` },
})),
}
await mountRelation('/content/events/42', { speakers: ids, relations })
await openList('speakers', vi)
expect(optionLabels()).toEqual([])
expect(text(document.body.querySelector('[data-testid="relation-empty"]'))).toBe(
'You can add up to 50.',
)
})
})
describe('What the control shows and sends', () => {
it('sends null and [] for a create left empty, never absent', async () => {
const wrapper = await mountRelation('/content/events/new')
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await save()
expect(entryWrites).toHaveLength(1)
expect(entryWrites[0]!.method).toBe('POST')
expect(entryWrites[0]!.url).toBe('/v1/content-type/events/entry')
expect(sentData()).toMatchObject({ venue: null, speakers: [] })
expect('venue' in sentData()).toBe(true)
})
it('sends a JSON number once a venue is picked on create', async () => {
const wrapper = await mountRelation('/content/events/new')
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
await save()
expect(sentData().venue).toBe(3)
})
it.each([
['loaded 3 and saved untouched', 3, [], 'Rue Verte hall', 3],
['loaded null and saved untouched', null, [], undefined, null],
])('%s', async (_name, venue, speakers, shown, sent) => {
await mountRelation('/content/events/42', { venue, speakers })
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(chipLabels('venue')[0]).toBe(shown)
expect(sentData().venue).toBe(sent)
})
it('saves a missing venue untouched with the same id', async () => {
await mountRelation('/content/events/42', {
venue: 3,
relations: { venue: { id: 3, missing: true } },
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(sentData().venue).toBe(3)
})
it('adds Jonas Pereira to [11] and removes both from [11, 12]', async () => {
await mountRelation('/content/events/42', { speakers: [11] })
await openList('speakers', vi, 'jonas')
await pickOption('Jonas Pereira')
expect(chipLabels('speakers')).toHaveLength(2)
await save()
expect(sentData().speakers).toEqual([11, 12])
})
it('removes both speakers and sends an empty array', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
removeButtons('speakers')[0]!.click()
await flushPromises()
removeButtons('speakers')[0]!.click()
await flushPromises()
await save()
expect(sentData().speakers).toEqual([])
})
it('rebuilds the control from the answered relations after a save', async () => {
await mountRelation('/content/events/42', { venue: 3 })
await openList('venue', vi, 'rue')
await pickOption('Rue Verte yard')
await save()
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
expect(chip()!.textContent).toContain('Saved')
})
})
describe('Dirty tracking', () => {
it('is saved again after picking 4 then 3', async () => {
await mountRelation('/content/events/42', { venue: 3 })
await openList('venue', vi, 'rue')
await pickOption('Rue Verte yard')
expect(chip()!.textContent).toBe('Unsaved changes')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
expect(chip()!.textContent).toBe('Saved')
expect(button('Save')?.hasAttribute('disabled')).toBe(true)
})
it('counts the same ids in another order as changed', async () => {
const wrapper = await mountRelation('/content/events/42', { speakers: [11, 12] })
await setField(wrapper, 'speakers', [12, 11])
expect(chip()!.textContent).toBe('Unsaved changes')
await setField(wrapper, 'speakers', [11, 12])
expect(chip()!.textContent).toBe('Saved')
})
})
describe('Required and empty', () => {
it('sends nothing, shows the error, the chip and focuses the control', async () => {
await mountRelation('/content/events/42', { venueField: { required: true } })
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(entryWrites).toHaveLength(0)
expect(relationError('venue')).toBe('Choose a Venue.')
expect(chip()!.textContent).toBe('Not saved')
expect(document.activeElement).toBe(relationInput('venue'))
expect(relationInput('venue').getAttribute('aria-invalid')).toBe('true')
expect(relationInput('venue').getAttribute('aria-required')).toBe('true')
await openList('venue', vi, 'rue')
await pickOption('Rue Verte hall')
expect(relationError('venue')).toBe('')
expect(chip()!.textContent).toBe('Unsaved changes')
})
it('focuses only the first field in schema order that has an error', async () => {
await mountRelation('/content/events/42', {
venueField: { required: true },
speakersField: { required: true },
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(document.activeElement).toBe(relationInput('venue'))
expect(relationError('speakers')).toBe('Choose a Speakers.')
})
})
describe('Server answers for a relation field', () => {
it.each([
[422, 'invalid_entry: venue: required', 'Choose a Venue.'],
[422, 'invalid_entry: venue: type', 'Venue must point to an existing entry.'],
[409, 'invalid_entry: venue: unique', 'That Venue is already used by another entry.'],
])('%i %s shows its copy under the control', async (status, body, message) => {
const { ApiError } = await import('@/composables/useApi')
await mountRelation('/content/events/42', {
venue: 3,
writeEntry: () => {
throw new ApiError(status, undefined, body)
},
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(relationError('venue')).toBe(message)
expect(chip()!.textContent).toBe('Not saved')
expect(document.activeElement).toBe(relationInput('venue'))
expect(document.body.querySelector('[role="alert"]')?.textContent).not.toContain(
'Not saved',
)
})
it('shows the read-only reload alert for unknown_field, as for media', async () => {
const { ApiError } = await import('@/composables/useApi')
await mountRelation('/content/events/42', {
venue: 3,
writeEntry: () => {
throw new ApiError(422, undefined, 'invalid_entry: venue: unknown_field')
},
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(text(document.body)).toContain('The fields of Events changed')
expect(text(document.body)).toContain(
'The Venue field was changed after you opened this form.',
)
expect(button('Reload form')).toBeDefined()
})
})
describe('Saving', () => {
it('dims the form and makes the control inoperable while the PUT is pending', async () => {
let release!: (value: unknown) => void
await mountRelation('/content/events/42', {
venue: 3,
speakers: [11],
writeEntry: () => new Promise((resolve) => (release = resolve)),
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(document.body.querySelector('#entry-form')!.className).toContain('opacity-70')
expect(relationInput('venue').disabled).toBe(true)
expect(button('Saving…')).toBeDefined()
release(entryWithRelations(3, [11]))
await flushPromises()
})
})
describe('Accessibility of the control', () => {
it('points aria-controls at its own listbox, not a foreign one earlier in the page', async () => {
await mountRelation('/content/events/42', { speakers: [11] })
const foreign = document.createElement('div')
foreign.setAttribute('role', 'listbox')
foreign.id = 'foreign-listbox'
document.body.prepend(foreign)
await openList('speakers', vi, 'a')
const own = document.body.querySelector(
'[data-relation-owner] [role="listbox"], [data-relation-owner][role="listbox"]',
)!
expect(own.id).not.toBe('')
expect(relationInput('speakers').getAttribute('aria-controls')).toBe(own.id)
foreign.remove()
})
it('is a labelled combobox on a listbox of options', async () => {
await mountRelation('/content/events/42', { speakers: [11] })
await openList('speakers', vi, 'a')
const input = relationInput('speakers')
const listbox = document.body.querySelector('[role="listbox"]')!
expect(input.getAttribute('aria-expanded')).toBe('true')
expect(input.getAttribute('aria-controls')).toBe(listbox.id)
expect(input.getAttribute('aria-activedescendant')).toBe(
document.body.querySelector('[role="option"]')!.id,
)
expect(fieldWrapper('speakers').querySelector('label')!.getAttribute('for')).toBe(input.id)
const group = fieldWrapper('speakers').querySelector('[role="group"]')!
expect(text(document.getElementById(group.getAttribute('aria-labelledby')!))).toBe(
'Speakers',
)
expect(input.getAttribute('aria-describedby')).toContain('-help')
})
it('has one polite live region', async () => {
await mountRelation('/content/events/42', { venue: 3 })
const live = fieldWrapper('venue').querySelector('[data-testid="relation-live"]')!
expect(live.getAttribute('role')).toBe('status')
expect(live.getAttribute('aria-live')).toBe('polite')
})
})
describe('Keyboard', () => {
it('moves with the arrows, picks with Enter and closes with Escape keeping the text', async () => {
await mountRelation('/content/events/42')
await openList('venue', vi, 'rue')
await press('venue', 'ArrowDown')
const highlighted = () =>
text(document.body.querySelector('[role="option"][data-highlighted]'))
expect(highlighted()).toContain('Rue Verte yard')
await press('venue', 'ArrowUp')
expect(highlighted()).toContain('Rue Verte hall')
await press('venue', 'ArrowDown')
await press('venue', 'Enter')
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
await openList('venue', vi, 'port')
await press('venue', 'Escape')
expect(relationInput('venue').getAttribute('aria-expanded')).toBe('false')
expect(relationInput('venue').value).toBe('port')
})
})
describe('Chips and the keyboard', () => {
it('reaches the chips with ArrowLeft from the empty input', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
relationInput('speakers').focus()
await press('speakers', 'ArrowLeft')
const items = [...fieldWrapper('speakers').querySelectorAll('[data-slot="tagsItem"]')]
expect(items.map((item) => item.getAttribute('data-state'))).toEqual(['inactive', 'active'])
})
it('keeps Backspace for the text when the input is not empty', async () => {
await mountRelation('/content/events/42', { speakers: [11, 12] })
await typeSearch('speakers', 'ab')
await press('speakers', 'Backspace')
expect(chipLabels('speakers')).toEqual(['Ines Duarte', 'Jonas Pereira'])
})
})
describe('Fixtures', () => {
it('keeps the sample data of the board', () => {
expect(allTypes()).toHaveLength(3)
expect(summaryFor('venues', 3).id).toBe(3)
})
})
describe('A relation as the first field', () => {
it('titles the page and the breadcrumb "Entry 42"', async () => {
const types = allTypes()
const [events] = types
const relationFirst = { ...events!, fields: [events!.fields[7]!, events!.fields[0]!] }
await mountRelation('/content/events/42', {
venue: 3,
type: () => relationFirst,
content: () => [relationFirst, ...types.slice(1)],
})
expect(text(document.body.querySelector('h1'))).toBe('Entry 42')
expect(text(document.body)).toContain('Events · Entry 42')
})
})
@@ -0,0 +1,230 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { ApiError, NetworkError } from '@/composables/useApi'
import {
button,
calls,
entryWrites,
fieldWrapper,
installSelectPolyfills,
save,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
import {
chipLabels,
emptyText,
liveText,
mountRelation,
openList,
optionLabels,
pickOption,
relationError,
relationInput,
searchBody,
settleSearch,
typeSearch,
} from './relationKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
installSelectPolyfills()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
})
afterEach(unmountAll)
const SKELETON_DELAY_MS = 150
const searches = () => calls.filter((url) => url.includes('/venues/entry?'))
const empty = { items: [], total: 0, page: 1, size: 10 }
const skeletons = () => document.body.querySelectorAll('[data-testid="relation-skeleton"]')
function failing(error: unknown) {
return () => {
throw error
}
}
describe('Picker loading', () => {
it('shows three skeleton rows after 150 ms and announces Searching…', async () => {
await mountRelation('/content/events/42', { search: () => new Promise(() => undefined) })
await typeSearch('venue', 'zzz')
await settleSearch(vi)
expect(skeletons()).toHaveLength(0)
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(skeletons()).toHaveLength(3)
expect(
text(document.body.querySelector('[data-testid="relation-empty"] [role="status"]')),
).toBe('Searching…')
expect(optionLabels()).toEqual([])
})
})
describe('No match', () => {
it('names the query and clears the search', async () => {
await mountRelation('/content/events/42', {
search: (key, query) => searchBody(key, query),
})
await openList('venue', vi, 'zzz')
expect(emptyText()).toBe('No Venues match "zzz" Clear search')
expect(liveText('venue')).toBe('No results')
button('Clear search')!.click()
await flushPromises()
await settleSearch(vi)
expect(relationInput('venue').value).toBe('')
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=1&size=10')
})
it('says the type has no entries at all', async () => {
await mountRelation('/content/events/42', { search: () => empty })
await openList('venue', vi)
expect(emptyText()).toBe('No Venues yet. Add one in Content, then come back.')
})
})
describe('Search failures', () => {
it.each([
['500', new ApiError(500)],
['a network error', new NetworkError()],
['404', new ApiError(404)],
])(
'shows the load error on %s and sends only the search again on Try again',
async (_name, error) => {
let fail = true
await mountRelation('/content/events/42', {
search: (key, query) => {
if (fail) {
throw error
}
return searchBody(key, query)
},
})
await openList('venue', vi, 'rue')
expect(emptyText()).toBe(
"Couldn't load Venues Check your connection and try again. Try again",
)
fail = false
const before = calls.length
button('Try again')!.click()
await flushPromises()
expect(calls.slice(before)).toEqual([
'/v1/content-type/venues/entry?q=rue&page=1&size=10',
])
expect(optionLabels()).toHaveLength(3)
},
)
it('shows the rate limit countdown on 429', async () => {
await mountRelation('/content/events/42', { search: failing(new ApiError(429, 3)) })
await openList('venue', vi, 'rue')
expect(emptyText()).toBe('Too many requests Try again in 0:03')
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(emptyText()).toContain('Wait a moment before trying again.')
expect(button('Try again')).toBeDefined()
})
it('adds no local copy on 401', async () => {
await mountRelation('/content/events/42', { search: failing(new ApiError(401)) })
await openList('venue', vi, 'rue')
expect(emptyText()).toBe('')
})
})
describe('Load more', () => {
it('appends the next page', async () => {
const names = Array.from({ length: 12 }, (_, index) => `Hall ${index + 1}`)
await mountRelation('/content/events/42', {
search: (_key, query) => {
const page = Number(query.get('page'))
const slice = names.slice((page - 1) * 10, page * 10)
return {
items: slice.map((name, index) => ({
id: 100 + (page - 1) * 10 + index,
data: { name, city: '', details: '' },
relations: {},
})),
total: 12,
page,
size: 10,
}
},
})
await openList('venue', vi)
expect(optionLabels()).toHaveLength(10)
button('Load more')!.click()
await flushPromises()
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=2&size=10')
expect(optionLabels()).toHaveLength(12)
expect(button('Load more')).toBeUndefined()
})
})
describe('A missing target', () => {
it('shows a dashed chip and lets an optional field save with the same id', async () => {
await mountRelation('/content/events/42', {
venue: 3,
relations: { venue: { id: 3, missing: true } },
})
expect(chipLabels('venue')).toEqual(['Entry no longer exists'])
expect(fieldWrapper('venue').querySelector('[data-missing="true"]')).not.toBeNull()
expect(text(fieldWrapper('venue').querySelector('[data-testid="relation-help"]'))).toBe(
'You can still save. The entry keeps the link until you change it.',
)
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(entryWrites[0]!.body.data).toMatchObject({ venue: 3 })
})
it('refuses to save a required field with only a missing target', async () => {
await mountRelation('/content/events/42', {
venue: 3,
venueField: { required: true },
relations: { venue: { id: 3, missing: true } },
})
expect(text(fieldWrapper('venue').querySelector('[data-testid="relation-help"]'))).toBe(
'Venue is required. Choose another to save.',
)
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(entryWrites).toHaveLength(0)
expect(relationError('venue')).toBe('Choose a Venue.')
expect(document.activeElement).toBe(relationInput('venue'))
})
it('replaces the missing chip when another target is picked', async () => {
await mountRelation('/content/events/42', {
venue: 3,
relations: { venue: { id: 3, missing: true } },
})
await openList('venue', vi, 'rue')
await pickOption('Rue Verte yard')
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
})
})
@@ -388,7 +388,7 @@ describe('Saved fields are locked', () => {
expect(text(inspector())).toContain("Used in the API, so it can't change.")
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).toBeNull()
expect(text(inspector())).toContain("Can't change after saving")
expect(all('[role="radio"]')).toHaveLength(7)
expect(all('[role="radio"]')).toHaveLength(8)
expect(tile('Date').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter(
(radio) => radio !== tile('Date'),
@@ -76,6 +76,7 @@ const FIELD_KEYS = [
'min',
'max',
'options',
'relation',
]
async function editRecipes() {
@@ -57,6 +57,7 @@ const TILES = [
['Date', 'calendar', 'A calendar day, without a time'],
['Choice', 'list', 'One option from a list you define'],
['Media', 'image', 'One image from your media library'],
['Relation', 'link', "Links to another type's entries"],
]
const HINT =
@@ -70,14 +71,14 @@ async function addMediaField() {
await pickTile('Media')
}
describe('Seven type tiles', () => {
it('lists the seven tiles in order, each with its name and description', async () => {
describe('Eight type tiles', () => {
it('lists the eight tiles in order, each with its name and description', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
const labels = all('[role="radio"]').map((radio) => radio.closest('label')!)
expect(labels).toHaveLength(7)
expect(labels).toHaveLength(8)
labels.forEach((label, index) => {
const [name, , description] = TILES[index]!
expect(text(label)).toBe(`${name} ${description}`)
@@ -112,7 +113,7 @@ describe('Seven type tiles', () => {
})
describe('Adding a media field', () => {
it('shows the New badge, an editable Auto key with its help and all seven tiles enabled', async () => {
it('shows the New badge, an editable Auto key with its help and all eight tiles enabled', async () => {
await mountAt('/schema/events')
await addMediaField()
@@ -123,7 +124,7 @@ describe('Adding a media field', () => {
expect(text(inspector())).toContain(
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
)
expect(all('[role="radio"]')).toHaveLength(7)
expect(all('[role="radio"]')).toHaveLength(8)
expect(all<HTMLButtonElement>('[role="radio"]').every((radio) => !radio.disabled)).toBe(
true,
)
@@ -198,17 +199,17 @@ describe('The inspector of a saved media field', () => {
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
})
it('selects Media, dims the six other tiles and makes them unselectable', async () => {
it('selects Media, dims the seven other tiles and makes them unselectable', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Thumbnail')
expect(all('[role="radio"]')).toHaveLength(7)
expect(all('[role="radio"]')).toHaveLength(8)
expect(tile('Media').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter(
(radio) => radio !== tile('Media'),
)
expect(others).toHaveLength(6)
expect(others).toHaveLength(7)
expect(others.every((radio) => radio.disabled || radio.hasAttribute('data-disabled'))).toBe(
true,
)
@@ -330,6 +331,7 @@ describe('Saving a media field', () => {
min: null,
max: null,
options: [],
relation: null,
})
})
@@ -354,6 +356,7 @@ describe('Saving a media field', () => {
min: null,
max: null,
options: [],
relation: null,
})
})
@@ -0,0 +1,537 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
button,
deletes,
entryPage,
failWithBody,
mountAt,
posts,
puts,
text,
unmountAll,
} from './schemaTestKit'
import {
addField,
all,
badgesOf,
byLabel,
chip,
click,
dialog,
el,
fieldKeyInput,
inspector,
inspectorTitle,
labelInput,
nameInput,
pickTile,
rowOf,
saveButton,
selectRow,
tile,
toggle,
typeInto,
} from './builderKit'
import { drawsIcon } from './mediaFieldKit'
import { allTypes, eventsWithRelations, speakersType, venuesType } from './relationKit'
import { customTypes } from './schemaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
beforeEach(() => {
Element.prototype.scrollIntoView = () => undefined
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
afterEach(async () => {
vi.restoreAllMocks()
await unmountAll()
})
const SENTENCES = {
one_to_one: 'Each Event has one Venue, and each Venue has one Event.',
one_to_many: 'Each Event has one Venue. A Venue has many Events.',
many_to_many: 'Each Event has many Venues, and each Venue has many Events.',
}
const types = () => [customTypes[0]!, venuesType, speakersType, customTypes[1]!]
function mountBuilder(path = '/schema/events', options: object = {}) {
return mountAt(path, { content: types, ...options })
}
const picker = () => el('[data-testid="relation-picker"]')!
const cards = () => all('[data-testid="relation-picker"] [role="radio"]')
const selectTrigger = () => picker().querySelector<HTMLElement>('button[role="combobox"]')!
const ownerSentence = () => text(el('[data-testid="relation-owner-sentence"]'))
const inverseSentence = () => text(el('[data-testid="relation-inverse-sentence"]'))
function openTrigger(trigger: Element) {
trigger.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
}
async function chooseOption(trigger: Element, label: string) {
openTrigger(trigger)
await flushPromises()
const option = all('[role="option"]').find((candidate) => text(candidate).startsWith(label))!
option.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
option.click()
await flushPromises()
}
async function addRelation(label = 'Venue') {
await addField(label)
await pickTile('Relation')
}
async function relateTo(name: string) {
await chooseOption(selectTrigger(), name)
}
describe('The type gallery', () => {
it('gains Relation as the last tile with the link icon, selectable like the others', async () => {
await mountBuilder()
await click(button('Add field'))
const labels = all('[role="radio"]').map((radio) => radio.closest('label')!)
expect(labels).toHaveLength(8)
const last = labels[7]!
expect(text(last)).toBe("Relation Links to another type's entries")
expect(drawsIcon(last, 'link')).toBe(true)
expect(tile('Relation').disabled).toBe(false)
})
it('dims the other tiles on a saved relation', async () => {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
})
await selectRow('Venue')
expect(tile('Relation').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter((radio) =>
radio.closest('label')!.innerHTML.includes('opacity-40'),
)
expect(others).toHaveLength(7)
expect(text(inspector())).toContain("Can't change after saving")
})
})
describe('Adding a relation field', () => {
it('shows New, an editable Auto key, the unsaved chip and the relation controls in order', async () => {
await mountBuilder()
await addRelation()
expect(badgesOf('Venue')).toContain('New')
expect(fieldKeyInput()!.value).toBe('venue')
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).not.toBeNull()
expect(chip()).toBe('1 problem to fix')
expect(inspectorTitle()).toBe('Field: Venue')
const copy = text(inspector())
expect(copy.indexOf('Type')).toBeLessThan(copy.indexOf('Related content type'))
expect(copy.indexOf('Related content type')).toBeLessThan(
copy.indexOf('How are they connected?'),
)
expect(copy).toContain('Choose the content type this field points to.')
})
it('cannot send a relation without a related type', async () => {
await mountBuilder('/schema/events', {
entries: () => entryPage(0),
})
await addRelation()
await click(saveButton())
expect(puts).toHaveLength(0)
expect(text(inspector())).toContain('Choose a related content type.')
})
})
describe('The related content type select', () => {
it('lists every custom type by name with its key, Events included', async () => {
await mountBuilder()
await addRelation()
openTrigger(selectTrigger())
await flushPromises()
expect(all('[role="option"]').map(text)).toEqual([
'Events events',
'Venues venues',
'Speakers speakers',
'Recipes recipes',
])
})
it('sets the sentences of the preview from the chosen type', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
expect(ownerSentence()).toBe('Each Event has one Venue')
expect(inverseSentence()).toContain('Each Venue has many Events')
})
})
describe('The three shape cards', () => {
it('shows the titles and sentences, with One to many selected by default', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
expect(cards().map((card) => text(card.closest('label')))).toEqual([
`One to one ${SENTENCES.one_to_one}`,
`One to many ${SENTENCES.one_to_many}`,
`Many to many ${SENTENCES.many_to_many}`,
])
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'unchecked',
'checked',
'unchecked',
])
expect(cards()[1]!.closest('label')!.className).toContain(
'has-data-[state=checked]:bg-(--rc-sand)',
)
})
it('is a radio group, one card at a time', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
await click(cards()[2])
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'unchecked',
'unchecked',
'checked',
])
})
it('stacks on one column on mobile', async () => {
await mountBuilder('/schema/events', { width: MOBILE_WIDTH })
await addRelation()
const fieldset = cards()[0]!.closest('[class*="grid-cols"]')!
expect(fieldset.className).toContain('grid-cols-1')
})
})
describe('The preview of both sides', () => {
it.each([
['One to one', 'Each Event has one Venue', 'Each Venue has one Event'],
['One to many', 'Each Event has one Venue', 'Each Venue has many Events'],
['Many to many', 'Each Event has many Venues', 'Each Venue has many Events'],
])('follows the card %s', async (card, owner, inverse) => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
await click(cards().find((radio) => text(radio.closest('label')).startsWith(card)))
expect(ownerSentence()).toBe(owner)
expect(inverseSentence()).toContain(inverse)
})
it('shows the muted note and the board example cards for One to many', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
expect(inverseSentence()).toContain('· shown on Venue as the field "Events"')
expect(el('[data-testid="relation-preview"]')!.className).toContain('bg-elevated')
expect(text(el('[data-testid="relation-example-owner"]'))).toBe(
'Event Venue Rue Verte hall',
)
expect(text(el('[data-testid="relation-example-related"]'))).toBe(
'Venue Events Open day Lime plaster workshop +1',
)
})
it('changes the kind through the selects of the sentences', async () => {
await mountBuilder()
await addRelation()
await relateTo('Venues')
const triggers = all('[data-testid="relation-preview"] button[role="combobox"]')
await chooseOption(triggers[0]!, 'many')
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'unchecked',
'unchecked',
'checked',
])
await chooseOption(triggers[1]!, 'one')
expect(cards().map((card) => card.getAttribute('data-state'))).toEqual([
'checked',
'unchecked',
'unchecked',
])
})
it('sends nothing about the inverse side', async () => {
await mountBuilder('/schema/events', { entries: () => entryPage(0) })
await addRelation()
await relateTo('Venues')
await click(saveButton())
const fields = (puts[0]!.body as { fields: { relation: unknown }[] }).fields
expect(fields[7]!.relation).toEqual({ content_type: 'venues', kind: 'one_to_many' })
expect(JSON.stringify(puts[0]!.body)).not.toContain('inverse')
})
})
describe('Plural and names', () => {
it('draws singular and plural forms from the type names and the field label', async () => {
await mountBuilder()
await addRelation('Place')
await relateTo('Venues')
await click(cards()[2])
expect(ownerSentence()).toBe('Each Event has many Venues')
expect(inverseSentence()).toContain('Each Place has many Events')
})
})
describe('The PUT and POST body', () => {
it('sends the relation as content_type and kind and every other field with relation null', async () => {
await mountBuilder('/schema/events', { entries: () => entryPage(0) })
await addRelation()
await relateTo('Venues')
await click(saveButton())
expect(puts).toHaveLength(1)
const fields = (puts[0]!.body as { fields: Record<string, unknown>[] }).fields
expect(fields[7]).toEqual({
key: 'venue',
label: 'Venue',
type: 'relation',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: [],
relation: { content_type: 'venues', kind: 'one_to_many' },
})
expect(fields.slice(0, 7).every((field) => field.relation === null)).toBe(true)
})
it('sends the chosen kind in the POST body of a new type', async () => {
await mountBuilder('/schema/new')
await typeInto(nameInput(), 'Gallery')
await selectRow('Title')
await addRelation('Speakers')
await relateTo('Speakers')
await click(cards()[2])
await click(saveButton())
const fields = (posts[0]!.body as { fields: Record<string, unknown>[] }).fields
expect(fields[1]!.relation).toEqual({ content_type: 'speakers', kind: 'many_to_many' })
})
it('makes a no-change round trip of a loaded relation', async () => {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
entries: () => entryPage(0),
})
await typeInto(nameInput(), 'Events and talks')
await click(saveButton())
const fields = (puts[0]!.body as { fields: Record<string, unknown>[] }).fields
expect(fields[7]).toMatchObject({
key: 'venue',
relation: { content_type: 'venues', kind: 'one_to_many' },
})
expect(fields[8]).toMatchObject({
key: 'speakers',
relation: { content_type: 'speakers', kind: 'many_to_many' },
})
})
})
describe('The inspector of a saved relation', () => {
async function openSaved() {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
})
await selectRow('Venue')
}
it('locks the key, the tiles, the related type and the cards but not Label or Required', async () => {
await openSaved()
expect(fieldKeyInput()!.readOnly).toBe(true)
expect(labelInput()!.disabled).toBe(false)
expect(selectTrigger().hasAttribute('disabled')).toBe(true)
expect(
cards().every(
(card) => card.hasAttribute('disabled') || card.hasAttribute('data-disabled'),
),
).toBe(true)
expect(cards()[1]!.getAttribute('data-state')).toBe('checked')
expect(text(el('[data-testid="relation-locked-note"]'))).toBe(
"The related type and the connection can't change after saving.",
)
expect(text(selectTrigger())).toContain('Venues')
await toggle('Required')
expect(chip()).toBe('Unsaved changes')
})
})
describe('Review before saving', () => {
async function reviewRelation(required: boolean, total = 12) {
await mountBuilder('/schema/events', { entries: () => entryPage(total) })
await addRelation()
await relateTo('Venues')
if (required) {
await toggle('Required')
}
await click(saveButton())
}
it('refuses a required relation on a type with entries without sending the PUT', async () => {
await reviewRelation(true)
expect(puts).toHaveLength(0)
expect(text(dialog())).toContain('Review changes to Events')
expect(text(el('[data-testid="change-title"]'))).toBe('Add Venue')
expect(text(el('[data-testid="change-flag"]'))).toBe("Can't be applied")
expect(text(el('[data-testid="change-text"]'))).toBe(
"Relation, required. A relation can't have a default, so the 12 existing entries would have no Venue. Make Venue optional to add it.",
)
expect((button('Back to editing') as HTMLButtonElement).disabled).toBe(false)
button('Apply changes')!.click()
await flushPromises()
expect(puts).toHaveLength(0)
})
it('shows the clean row for an optional relation and applies it', async () => {
await reviewRelation(false)
expect(text(el('[data-testid="change-text"]'))).toBe(
'Relation, optional. 12 entries are unaffected.',
)
expect(el('[data-testid="change-flag"]')).toBeNull()
await click(button('Apply changes'))
expect(puts).toHaveLength(1)
})
it('sends a required relation at once when the type has no entries', async () => {
await reviewRelation(true, 0)
expect(dialog()).toBeNull()
expect(puts).toHaveLength(1)
})
it('flags making a saved optional relation required', async () => {
await mountBuilder('/schema/events', {
type: () => eventsWithRelations(),
content: () => allTypes(),
entries: () => entryPage(12),
})
await selectRow('Venue')
await toggle('Required')
await click(saveButton())
expect(text(el('[data-testid="change-flag"]'))).toBe('Entries may not fit')
expect((button('Apply changes') as HTMLButtonElement).hasAttribute('aria-disabled')).toBe(
false,
)
})
})
describe('The server answers on the builder', () => {
it('runs the existing refusal flow on 409 schema_incompatible', async () => {
await mountBuilder('/schema/events', {
entries: () => entryPage(0),
update: failWithBodyUpdate(409, 'schema_incompatible'),
})
await addRelation()
await relateTo('Venues')
await click(saveButton())
expect(text(dialog())).toContain('Nothing was changed')
})
it('shows the invalid-field failure on 422 and keeps the edits', async () => {
await mountBuilder('/schema/events', {
entries: () => entryPage(0),
update: failWithBodyUpdate(422, 'invalid_field'),
})
await addRelation()
await relateTo('Venues')
await click(saveButton())
expect(text(el('[role="alert"]'))).toContain('Check the fields marked in the list.')
expect(rowOf('Venue')).toBeDefined()
expect(selectTrigger()).not.toBeNull()
})
})
function failWithBodyUpdate(status: number, body: string) {
const respond = failWithBody(status, body)
return () => respond()
}
describe('Deleting a type that another type points at', () => {
it('shows the referenced refusal and keeps the type', async () => {
await mountAt('/schema/venues', {
content: () => allTypes(),
type: (key: string) => allTypes().find((type) => type.key === key),
entries: () => entryPage(0),
remove: failWithBody(409, 'content_type_referenced'),
})
await click(byLabel('More actions'))
await click(all('[role="menuitem"]').find((item) => text(item) === 'Delete content type'))
await click(
all('button', dialog()).find((candidate) =>
text(candidate).startsWith('Delete content type'),
),
)
expect(deletes).toEqual(['/v1/content-type/venues'])
expect(text(dialog())).toContain("You can't delete Venues yet")
expect(text(dialog())).toContain(
'Events has a field that points to Venues. Remove that field first.',
)
expect(dialog()).not.toBeNull()
})
})
describe('The builder is for owners and admins', () => {
it('keeps the forbidden state and renders no relation control', async () => {
await mountAt('/schema/events', { role: 'user', content: types })
expect(text(el('#dashboard-content'))).toContain("You don't have access to Schema")
expect(el('[data-testid="relation-picker"]')).toBeNull()
})
})
@@ -70,6 +70,7 @@ const EVENTS_BODY = {
min: null,
max: 80,
options: [],
relation: null,
},
{
key: 'starts_on',
@@ -81,6 +82,7 @@ const EVENTS_BODY = {
min: '2026-01-01',
max: '2027-12-31',
options: [],
relation: null,
},
{
key: 'category',
@@ -92,6 +94,7 @@ const EVENTS_BODY = {
min: null,
max: null,
options: ['Talk', 'Workshop', 'Meetup'],
relation: null,
},
],
}
@@ -160,7 +160,7 @@ describe('New type, desktop (design state 1)', () => {
expect(copy).toContain(
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
)
expect(all('[role="radio"]')).toHaveLength(7)
expect(all('[role="radio"]')).toHaveLength(8)
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
'Text',
)
+254
View File
@@ -0,0 +1,254 @@
import { flushPromises } from '@vue/test-utils'
import type { RelationKind, RelationSummary } from '@/lib/relationField'
import {
answerWrite,
entry42,
fieldWrapper,
lookupNames,
mountEditor,
text,
} from './entryEditorKit'
import { apiField, customTypes, eventsFields, type SchemaMount } from './schemaTestKit'
export const SEARCH_DEBOUNCE_MS = 200
export const SEARCH_SIZE = 10
interface Row {
id: number
data: Record<string, unknown>
}
export const VENUES: Row[] = [
{ id: 3, data: { name: 'Rue Verte hall', city: 'Lyon', details: '120 seats' } },
{ id: 4, data: { name: 'Rue Verte yard', city: 'Lyon', details: 'outdoor' } },
{ id: 5, data: { name: 'Rue du Port studio', city: 'Nantes', details: '24 seats' } },
{ id: 6, data: { name: 'Quai Nord', city: 'Nantes', details: '' } },
]
export const SPEAKERS: Row[] = [
{ id: 11, data: { name: 'Ines Duarte' } },
{ id: 12, data: { name: 'Jonas Pereira' } },
{ id: 13, data: { name: 'Ana Costa' } },
{ id: 14, data: { name: 'Luc Moreau' } },
]
const ROWS: Record<string, Row[]> = { venues: VENUES, speakers: SPEAKERS }
const FIRST_FIELD: Record<string, string> = { venues: 'name', speakers: 'name' }
export const venuesType = {
...customTypes[1]!,
id: 4,
key: 'venues',
name: 'Venues',
fields: [
apiField('Name', 'name', 'text', { required: true, options: [] }),
apiField('City', 'city', 'text', { options: [] }),
apiField('Details', 'details', 'text', { options: [] }),
],
}
export const speakersType = {
...customTypes[1]!,
id: 5,
key: 'speakers',
name: 'Speakers',
fields: [apiField('Name', 'name', 'text', { required: true, options: [] })],
}
export function relationField(
label: string,
key: string,
target: string,
kind: RelationKind,
rest: object = {},
) {
return apiField(label, key, 'relation', {
options: [],
relation: { content_type: target, kind },
...rest,
})
}
export function eventsWithRelations(
venue: object = {},
speakers: object = {},
venueKind: RelationKind = 'one_to_many',
) {
return {
...customTypes[0]!,
fields: [
...eventsFields,
relationField('Venue', 'venue', 'venues', venueKind, venue),
relationField('Speakers', 'speakers', 'speakers', 'many_to_many', speakers),
],
}
}
export function allTypes(venue: object = {}, speakers: object = {}) {
return [eventsWithRelations(venue, speakers), venuesType, speakersType]
}
export function summaryFor(typeKey: string, id: number): RelationSummary {
const row = ROWS[typeKey]!.find((candidate) => candidate.id === id)!
const key = FIRST_FIELD[typeKey]!
return {
id,
content_type: typeKey,
first_field: { key, type: 'text', value: row.data[key] },
}
}
export const MISSING_VENUE: RelationSummary = { id: 3, missing: true }
export function entryWithRelations(
venue: number | null,
speakers: number[],
relations?: Record<string, RelationSummary | RelationSummary[]>,
) {
const shown = relations ?? {
...(venue === null ? {} : { venue: summaryFor('venues', venue) }),
...(speakers.length === 0
? {}
: { speakers: speakers.map((id) => summaryFor('speakers', id)) }),
}
return {
...entry42,
data: { ...entry42.data, venue, speakers },
relations: shown,
}
}
export function searchBody(typeKey: string, query: URLSearchParams) {
const needle = (query.get('q') ?? '').toLowerCase()
const page = Number(query.get('page') ?? '1')
const size = Number(query.get('size') ?? String(SEARCH_SIZE))
const matches = ROWS[typeKey]!.filter((row) =>
String(row.data[FIRST_FIELD[typeKey]!]).toLowerCase().includes(needle),
)
const items = matches.slice((page - 1) * size, page * size).map((row) => ({
...entry42,
id: row.id,
content_type: typeKey,
data: row.data,
relations: {},
}))
return { items, total: matches.length, page, size }
}
export function answerRelationWrite(write: {
method: string
url: string
body: Record<string, unknown>
}) {
const answered = answerWrite(write as never) as { data: Record<string, unknown> }
const data = answered.data
const venue = data.venue
const speakers = (data.speakers as number[] | undefined) ?? []
return {
...answered,
relations: {
...(typeof venue === 'number' ? { venue: summaryFor('venues', venue) } : {}),
...(speakers.length > 0
? { speakers: speakers.map((id) => summaryFor('speakers', id)) }
: {}),
},
}
}
export interface RelationMount extends SchemaMount {
venue?: number | null
speakers?: number[]
venueField?: object
speakersField?: object
relations?: Record<string, RelationSummary | RelationSummary[]>
search?: (typeKey: string, query: URLSearchParams) => unknown
}
export function mountRelation(path: string, options: RelationMount = {}) {
const {
venue = null,
speakers = [],
venueField,
speakersField,
relations,
search,
...rest
} = options
const types = allTypes(venueField, speakersField)
return mountEditor(path, {
type: (key) => types.find((type) => type.key === key),
content: () => types,
entry: () => entryWithRelations(venue, speakers, relations),
writeEntry: answerRelationWrite,
lookup: lookupNames,
list: (key, query) => (search ?? searchBody)(key, query),
...rest,
})
}
export function relationInput(key: string) {
return fieldWrapper(key).querySelector<HTMLInputElement>('input[role="combobox"]')!
}
export function chipLabels(key: string) {
return [...fieldWrapper(key).querySelectorAll('[data-slot="tagsItemText"]')].map((item) =>
text(item),
)
}
export function optionLabels() {
return [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].map((option) =>
text(option),
)
}
export async function typeSearch(key: string, value: string) {
const input = relationInput(key)
input.focus()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export async function settleSearch(vi: {
advanceTimersByTimeAsync: (ms: number) => Promise<unknown>
}) {
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + 10)
await flushPromises()
}
export function liveText(key: string) {
return text(fieldWrapper(key).querySelector('[data-testid="relation-live"]'))
}
export async function pickOption(label: string) {
const option = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
(candidate) => text(candidate).startsWith(label),
)!
option.click()
await flushPromises()
}
export async function openList(key: string, vi: Parameters<typeof settleSearch>[0], value = '') {
await typeSearch(key, value)
await settleSearch(vi)
}
export function removeButtons(key: string) {
return [
...fieldWrapper(key).querySelectorAll<HTMLButtonElement>('[data-slot="tagsItemDelete"]'),
]
}
export async function press(key: string, name: string) {
relationInput(key).dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true }))
await flushPromises()
}
export function relationError(key: string) {
return text(fieldWrapper(key).querySelector('[role="alert"]'))
}
export function emptyText() {
return text(document.body.querySelector('[data-testid="relation-empty"]'))
}