feat: relation field and picker
This commit is contained in:
1 parent
b45c99be3b
commit
37a77c9c22
92 files changed
+6826
-80
No files matched your search
@@ -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)"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -10,6 +10,7 @@ const CONTROLS: Record<FieldType, string> = {
|
||||
date: 'date',
|
||||
select: 'select',
|
||||
media: 'media',
|
||||
relation: 'relation',
|
||||
}
|
||||
|
||||
function editor(type: FieldType) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9,6 +9,7 @@ export function apiField(label: string, key: string, type: string, rest: object
|
||||
min: null,
|
||||
max: null,
|
||||
options: null,
|
||||
relation: null,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
@@ -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') {
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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"]'))
|
||||
}
|
||||
@@ -74,6 +74,12 @@ const extraSources: Record<string, Source[]> = {
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '7 Relations',
|
||||
states: ['Relation in the builder'],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '3 Review and delete',
|
||||
@@ -97,6 +103,12 @@ const extraSources: Record<string, Source[]> = {
|
||||
states: ['Thumbnail column', 'Columns menu with Thumbnail', 'Missing image: tooltip'],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '7 Relations',
|
||||
states: ['Relation in the list'],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
entry: [
|
||||
{
|
||||
@@ -116,6 +128,12 @@ const extraSources: Record<string, Source[]> = {
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '7 Relations',
|
||||
states: ['Relation in the entry form'],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '10 Media picker for fields',
|
||||
|
||||
+211
-2
@@ -8104,13 +8104,222 @@ function withMediaExceptions(screen: string, states: Record<string, VisualState>
|
||||
)
|
||||
}
|
||||
|
||||
function relationFieldOf(label: string, key: string, target: string, kind: string) {
|
||||
return schemaField(label, key, 'relation', {
|
||||
options: [],
|
||||
relation: { content_type: target, kind },
|
||||
})
|
||||
}
|
||||
|
||||
function relationTypeOf(id: number, key: string, name: string, fields: object[]) {
|
||||
return {
|
||||
id,
|
||||
key,
|
||||
name,
|
||||
icon: 'files',
|
||||
fields,
|
||||
created_at: '2026-09-01T09:30:00Z',
|
||||
updated_at: '2026-10-01T09:30:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
const relationVenueRows = [
|
||||
{ id: 3, name: 'Rue Verte hall', city: 'Lyon', details: '120 seats' },
|
||||
{ id: 4, name: 'Rue Verte yard', city: 'Lyon', details: 'outdoor' },
|
||||
{ id: 5, name: 'Rue du Port studio', city: 'Nantes', details: '24 seats' },
|
||||
]
|
||||
|
||||
const relationSpeakerRows = [
|
||||
{ id: 11, name: 'Inès Duarte' },
|
||||
{ id: 12, name: 'Jonas Pereira' },
|
||||
{ id: 13, name: 'Ana Costa' },
|
||||
{ id: 14, name: 'Luc Moreau' },
|
||||
]
|
||||
|
||||
const relationVenuesType = relationTypeOf(4, 'venues', 'Venues', [
|
||||
schemaField('Name', 'name', 'text', { required: true, options: [] }),
|
||||
schemaField('City', 'city', 'text', { options: [] }),
|
||||
schemaField('Details', 'details', 'text', { options: [] }),
|
||||
])
|
||||
|
||||
const relationSpeakersType = relationTypeOf(5, 'speakers', 'Speakers', [
|
||||
schemaField('Name', 'name', 'text', { required: true, options: [] }),
|
||||
])
|
||||
|
||||
function relationTypes(events: object) {
|
||||
return [events, ...schemaCustomTypes.slice(1), relationSpeakersType, relationVenuesType]
|
||||
}
|
||||
|
||||
function venueSummary(id: number) {
|
||||
const row = relationVenueRows.find((candidate) => candidate.id === id)!
|
||||
return {
|
||||
id,
|
||||
content_type: 'venues',
|
||||
first_field: { key: 'name', type: 'text', value: row.name },
|
||||
}
|
||||
}
|
||||
|
||||
function speakerSummary(id: number) {
|
||||
const row = relationSpeakerRows.find((candidate) => candidate.id === id)!
|
||||
return {
|
||||
id,
|
||||
content_type: 'speakers',
|
||||
first_field: { key: 'name', type: 'text', value: row.name },
|
||||
}
|
||||
}
|
||||
|
||||
function relationSearch(url: URL) {
|
||||
const needle = (url.searchParams.get('q') ?? '').toLowerCase()
|
||||
const matches = relationVenueRows.filter((row) => row.name.toLowerCase().includes(needle))
|
||||
return {
|
||||
items: matches.map((row) => ({
|
||||
id: row.id,
|
||||
content_type: 'venues',
|
||||
data: { name: row.name, city: row.city, details: row.details },
|
||||
created_by: 3,
|
||||
updated_by: 3,
|
||||
created_at: '2026-10-02T09:14:00Z',
|
||||
updated_at: '2026-10-12T11:02:00Z',
|
||||
relations: {},
|
||||
})),
|
||||
total: matches.length,
|
||||
page: 1,
|
||||
size: 10,
|
||||
}
|
||||
}
|
||||
|
||||
const relationBuilderStates: Record<string, VisualState> = {
|
||||
'Relation in the builder': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 27, mobile: 34.5 },
|
||||
reason: 'the board draws only the inspector under its own title, without the type bar, field list and API preview, plus a stray board miniature, while the app is the full builder and a bottom sheet on mobile',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
path: '/schema/events',
|
||||
api: schemaApi({
|
||||
'GET /v1/content-type': { status: 200, body: relationTypes(schemaCustomTypes[0]!) },
|
||||
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(0) },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await schemaAddField(page, 'Venue')
|
||||
await page.locator('label:has([role="radio"][value="relation"])').click()
|
||||
await page
|
||||
.locator('[data-testid="relation-picker"] button[role="combobox"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('option', { name: /Venues/ }).click()
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const relationEntryType = relationTypeOf(1, 'events', 'Events', [
|
||||
schemaField('Title', 'title', 'text', { required: true, unique: true, max: 80, options: [] }),
|
||||
relationFieldOf('Venue', 'venue', 'venues', 'one_to_many'),
|
||||
relationFieldOf('Speakers', 'speakers', 'speakers', 'many_to_many'),
|
||||
])
|
||||
|
||||
const relationEntryStates: Record<string, VisualState> = {
|
||||
'Relation in the entry form': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 17 },
|
||||
reason: 'on mobile the board reserves a spacer under the open list, hides the meta block and action bar and draws a stray miniature, while the app overlays the list on the next field and shows the meta block and sticky actions',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
path: '/content/events/42',
|
||||
api: entryApi({
|
||||
'GET /v1/content-type': { status: 200, body: relationTypes(relationEntryType) },
|
||||
'GET /v1/content-type/events': { status: 200, body: relationEntryType },
|
||||
'GET /v1/content-type/events/entry/42': {
|
||||
status: 200,
|
||||
body: entryRecord(
|
||||
{ venue: null, speakers: [11, 12] },
|
||||
{ relations: { speakers: [speakerSummary(11), speakerSummary(12)] } },
|
||||
),
|
||||
},
|
||||
'GET /v1/content-type/venues/entry': { status: 200, bodyFor: relationSearch },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
const input = entryField(page, 'venue').getByRole('combobox')
|
||||
await input.click()
|
||||
await input.fill('rue')
|
||||
await page.getByRole('option').first().waitFor()
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const relationListType = relationTypeOf(1, 'events', 'Events', [
|
||||
schemaField('Title', 'title', 'text', { required: true, unique: true, max: 80, options: [] }),
|
||||
schemaField('Starts on', 'starts_on', 'date', { required: true, options: [] }),
|
||||
relationFieldOf('Venue', 'venue', 'venues', 'one_to_many'),
|
||||
relationFieldOf('Speakers', 'speakers', 'speakers', 'many_to_many'),
|
||||
schemaField('Ends on', 'ends_on', 'date', { options: [] }),
|
||||
schemaField('Online', 'online', 'boolean', { default: false, options: [] }),
|
||||
schemaField('Capacity', 'capacity', 'number', { min: 1, max: 5000, options: [] }),
|
||||
schemaField('Category', 'category', 'select', { options: ['Talk', 'Workshop', 'Meetup'] }),
|
||||
schemaField('Description', 'description', 'richtext', { options: [] }),
|
||||
])
|
||||
|
||||
const relationListVenues: Record<number, number | null> = { 42: 3, 41: null, 40: 4 }
|
||||
const relationListSpeakers: Record<number, number[]> = { 42: [11, 12, 13, 14], 41: [12], 40: [] }
|
||||
|
||||
const relationListRows = contentRows.slice(0, 3).map((row) => {
|
||||
const id = Number(row.id)
|
||||
const venue = relationListVenues[id] ?? null
|
||||
const speakers = relationListSpeakers[id] ?? []
|
||||
return {
|
||||
...row,
|
||||
data: { ...row.data, venue, speakers },
|
||||
relations: {
|
||||
...(venue === null ? {} : { venue: venueSummary(venue) }),
|
||||
...(speakers.length === 0 ? {} : { speakers: speakers.map(speakerSummary) }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const relationListColumns = ['title', 'starts_on', 'venue', 'speakers']
|
||||
|
||||
const relationContentStates: Record<string, VisualState> = {
|
||||
'Relation in the list': {
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 17.5 },
|
||||
reason: 'on mobile the app card list has the sort and columns toolbar, the meta line and the New entry button that the board does not draw',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
path: '/content/events',
|
||||
api: contentApi({
|
||||
'GET /v1/content-type': { status: 200, body: relationTypes(relationListType) },
|
||||
'GET /v1/content-type/events': { status: 200, body: relationListType },
|
||||
'GET /v1/content-type/events/entry': {
|
||||
status: 200,
|
||||
body: { items: relationListRows, total: 3, page: 1, size: 25 },
|
||||
},
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await page.evaluate(
|
||||
([key, value]) => localStorage.setItem(key!, value!),
|
||||
[thumbnailColumnsKey, JSON.stringify(relationListColumns)],
|
||||
)
|
||||
await page.reload()
|
||||
await page.locator('tbody tr, [data-testid="entry-card"]').first().waitFor()
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
sites: sitesStates,
|
||||
schema: { ...schemaStates, ...withMediaExceptions('schema', mediaSchemaStates) },
|
||||
content: { ...contentStates, ...mediaContentStates },
|
||||
schema: {
|
||||
...schemaStates,
|
||||
...withMediaExceptions('schema', mediaSchemaStates),
|
||||
...relationBuilderStates,
|
||||
},
|
||||
content: { ...contentStates, ...mediaContentStates, ...relationContentStates },
|
||||
entry: {
|
||||
...entryStates,
|
||||
...withMediaExceptions('entry', { ...mediaEntryStates, ...mediaPickerStates }),
|
||||
...relationEntryStates,
|
||||
},
|
||||
authors: authorsStates,
|
||||
activity: activityStates,
|
||||
|
||||
Reference in new issue
Block a user