feat(SPA): media field type

This commit is contained in:
Florian Sylvain committed 2026-10-21 15:17:15 +02:00
1 parent a2d596f4ca
commit 3e7fc9a4a5
57 files changed
+6109 -75

No files matched your search

+1
View File
@@ -132,6 +132,7 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
:type-key="typeKey"
:entries="list.items.value"
:fields="fields"
:columns="columns.columns.value"
:names="list.names.value"
:caption="caption"
/>
+14 -2
View File
@@ -17,6 +17,7 @@ const props = withDefaults(
notice?: DirectoryNotice | null
closable?: boolean
confirmDisabled?: boolean
confirmBlocked?: boolean
wide?: boolean
closeGutter?: boolean
}>(),
@@ -27,6 +28,7 @@ const props = withDefaults(
notice: null,
closable: false,
confirmDisabled: false,
confirmBlocked: false,
wide: false,
closeGutter: false,
},
@@ -78,6 +80,12 @@ function onOpenChange(value: boolean) {
}
}
function onConfirm() {
if (!props.confirmBlocked) {
emit('confirm')
}
}
function onCancel() {
emit('cancel')
onOpenChange(false)
@@ -146,10 +154,14 @@ function onCancel() {
:color="tone === 'danger' ? 'error' : 'primary'"
:loading="pending"
:disabled="pending || confirmDisabled"
:aria-disabled="confirmBlocked ? 'true' : undefined"
block
class="h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3"
:class="wide ? 'disabled:opacity-40' : ''"
@click="emit('confirm')"
:class="[
wide ? 'disabled:opacity-40' : '',
confirmBlocked ? 'cursor-not-allowed opacity-40' : '',
]"
@click="onConfirm"
>
{{ confirmText }}
</UButton>
+14 -3
View File
@@ -9,8 +9,19 @@ withDefaults(
error?: string
help?: string
name?: string
labelId?: string
errorId?: string
}>(),
{ label: '', optional: false, required: false, error: '', help: '', name: undefined },
{
label: '',
optional: false,
required: false,
error: '',
help: '',
name: undefined,
labelId: undefined,
errorId: undefined,
},
)
</script>
@@ -30,7 +41,7 @@ withDefaults(
}"
>
<template v-if="label" #label>
{{ label }}<span v-if="required" aria-hidden="true"> *</span>
<span :id="labelId">{{ label }}<span v-if="required" aria-hidden="true"> *</span></span>
<span v-if="optional" class="font-normal text-muted"> {{ t('(optional)') }}</span>
</template>
<template v-if="$slots.hint" #hint>
@@ -38,7 +49,7 @@ withDefaults(
</template>
<slot />
<template v-if="error" #error>
<span class="flex items-start gap-1.5" role="alert">
<span :id="errorId" class="flex items-start gap-1.5" role="alert">
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ error }}</span>
</span>
+33 -3
View File
@@ -2,7 +2,16 @@
import { RouterLink } from 'vue-router'
import FieldCell from '@/components/FieldCell.vue'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { EMPTY_TEXT, cardFields, describeCell, isEmptyCell, plainCellText } from '@/lib/entryCells'
import {
EMPTY_TEXT,
cardFields,
describeCell,
isEmptyCell,
plainCellText,
thumbnailColumn,
} from '@/lib/entryCells'
import { t } from '@/i18n'
import { mediaOf } from '@/lib/mediaField'
import { entryPath } from '@/lib/routePaths'
import { computed } from 'vue'
@@ -10,14 +19,28 @@ const props = defineProps<{
typeKey: string
entries: ContentEntry[]
fields: BuiltinField[]
columns: BuiltinField[]
names: Record<number, string>
caption: string
}>()
const shown = computed(() => cardFields(props.fields))
const lead = computed(() => thumbnailColumn(props.columns))
function textOf(field: BuiltinField | undefined, entry: ContentEntry) {
return field ? plainCellText(describeCell(field, entry.data[field.key])) : ''
return field
? plainCellText(
describeCell(field, entry.data[field.key], mediaOf(entry, field.key) ?? undefined),
)
: ''
}
function titleOf(entry: ContentEntry) {
const { first } = shown.value
if (first && describeCell(first, entry.data[first.key]).kind === 'thumbnail') {
return t('Entry {0}', [entry.id])
}
return textOf(first, entry)
}
const secondIsChoice = computed(
@@ -46,9 +69,16 @@ function nameOf(entry: ContentEntry) {
data-testid="entry-card"
class="flex min-h-17 items-center gap-2 py-3 pr-1 pl-4 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
>
<FieldCell
v-if="lead"
:field="lead"
:value="entry.data[lead.key]"
:image="mediaOf(entry, lead.key)"
compact
/>
<span class="flex min-w-0 flex-1 flex-col gap-1">
<span class="truncate text-md/5.25 font-medium text-highlighted">{{
textOf(shown.first, entry)
titleOf(entry)
}}</span>
<span class="flex items-center gap-2 text-xs text-muted">
<template v-if="shown.second && !secondIsChoice">
+1
View File
@@ -110,6 +110,7 @@ function backToList() {
<EntryForm
:fields="page.fields.value"
:values="page.form.values.value"
:images="page.form.images.value"
:issues="page.form.issues.value"
:first-issue="page.form.firstIssue.value"
:focus-token="page.focusToken.value"
+18 -1
View File
@@ -3,9 +3,11 @@ import { computed } from 'vue'
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 type { BuiltinField } from '@/lib/contentTypes'
import type { FieldIssue } from '@/lib/entryForm'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { isMediaId, type MediaSummary } from '@/lib/mediaField'
const props = defineProps<{
field: BuiltinField
@@ -13,18 +15,33 @@ const props = defineProps<{
issue?: FieldIssue
isDefault: boolean
disabled: boolean
image?: MediaSummary | null
}>()
const emit = defineEmits<{ update: [key: string, value: unknown] }>()
const emit = defineEmits<{ update: [key: string, value: unknown, image?: MediaSummary | null] }>()
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 mediaId = computed(() => (isMediaId(props.value) ? props.value : null))
</script>
<template>
<div :data-entry-field="field.key" class="min-w-0">
<MediaFieldControl
v-if="isMedia"
:label="field.label"
:name="field.key"
:required="field.required"
:model-value="mediaId"
:image="image"
:disabled="disabled"
:error="issue?.message"
@change="(id, picked) => emit('update', field.key, id, picked)"
/>
<DirectoryField
v-else
:label="field.label"
:name="field.key"
:required="field.required"
+9 -3
View File
@@ -5,14 +5,16 @@ import { computed, nextTick, watch } from 'vue'
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 { groupFields } from '@/lib/entryLayout'
const FOCUSABLE =
'input:not([type="hidden"]), textarea, [role="spinbutton"], [role="switch"], [role="combobox"]'
'[data-field-focus], input:not([type="hidden"]), textarea, [role="spinbutton"], [role="switch"], [role="combobox"]'
const props = defineProps<{
fields: BuiltinField[]
values: EntryValues
images: MediaMap
issues: EntryIssues
firstIssue: string | null
focusToken: number
@@ -25,7 +27,10 @@ const props = defineProps<{
updatedLine: string
}>()
const emit = defineEmits<{ update: [key: string, value: unknown]; submit: [] }>()
const emit = defineEmits<{
update: [key: string, value: unknown, image?: MediaSummary | null]
submit: []
}>()
const rows = computed(() => groupFields(props.fields))
@@ -67,10 +72,11 @@ watch(() => props.focusToken, focusFirstIssue)
:key="field.key"
:field="field"
:value="values[field.key]"
:image="images[field.key] ?? null"
:issue="issues[field.key]"
:is-default="isDefault(field)"
:disabled="locked"
@update="(key, value) => emit('update', key, value)"
@update="(key, value, image) => emit('update', key, value, image)"
/>
</div>
+9 -2
View File
@@ -6,7 +6,8 @@ import RetryAlert from '@/components/RetryAlert.vue'
import type { SaveFailure } from '@/composables/useEntryDraft'
import type { BuiltinField } from '@/lib/contentTypes'
import type { EntryIssues } from '@/lib/entryForm'
import { schemaChangedLine, uniqueAlert } from '@/lib/entryMessages'
import { mediaChangedLine, schemaChangedLine, uniqueAlert } from '@/lib/entryMessages'
import { fieldTypeSpec } from '@/lib/fieldTypes'
const props = defineProps<{
failure: SaveFailure
@@ -22,6 +23,12 @@ defineEmits<{ retry: []; reload: [] }>()
const uniqueField = computed(() =>
props.fields.find((field) => props.issues[field.key]?.rule === 'unique'),
)
const changedLine = computed(() => {
const changed = props.fields.find((field) => field.key === props.schemaField)
return changed && fieldTypeSpec(changed.type).editor.control === 'media'
? mediaChangedLine(changed.label)
: schemaChangedLine(props.schemaField)
})
const alertUi = {
root: 'items-start gap-2.5 px-3.5 py-3',
title: 'font-semibold',
@@ -60,7 +67,7 @@ const alertUi = {
orientation="vertical"
icon="i-lucide-triangle-alert"
:title="t('The fields of {0} changed', [typeName])"
:description="schemaChangedLine(schemaField)"
:description="changedLine"
:ui="{ ...alertUi, actions: 'mt-1.5' }"
>
<template #actions>
+8 -1
View File
@@ -8,6 +8,7 @@ import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
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 { entryPath } from '@/lib/routePaths'
const ID_WIDTH = 'w-16.5'
@@ -150,10 +151,16 @@ watchPostEffect(() => {
<FieldCell
:field="field"
:value="row.original.data[field.key]"
:image="mediaOf(row.original, field.key)"
class="min-w-0"
/>
</RouterLink>
<FieldCell v-else :field="field" :value="row.original.data[field.key]" />
<FieldCell
v-else
:field="field"
:value="row.original.data[field.key]"
:image="mediaOf(row.original, field.key)"
/>
</template>
<template #created_at-cell="{ row }">
+52 -3
View File
@@ -4,14 +4,63 @@ import { t } from '@/i18n'
import { computed } from 'vue'
import type { BuiltinField } from '@/lib/contentTypes'
import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells'
import { isMissingImage, type MediaSummary } from '@/lib/mediaField'
const props = defineProps<{ field: BuiltinField; value: unknown }>()
const props = defineProps<{
field: BuiltinField
value: unknown
image?: MediaSummary | null
compact?: boolean
}>()
const cell = computed(() => describeCell(props.field, props.value))
const cell = computed(() => describeCell(props.field, props.value, props.image ?? undefined))
const shownImage = computed(() => {
const image = cell.value.image
return image && !isMissingImage(image) ? image : null
})
const squareClass = computed(() => (props.compact ? 'size-8' : 'size-10'))
const TIP_UI = {
content:
'h-auto items-center bg-inverted px-2 py-1.5 text-xs/4.5 text-inverted shadow-(--shadow-popover) ring-0',
text: 'whitespace-nowrap',
}
</script>
<template>
<span v-if="isEmptyCell(cell)" class="text-muted">{{ EMPTY_TEXT }}</span>
<span
v-if="isEmptyCell(cell)"
class="text-muted"
:class="field.type === 'media' && compact ? 'inline-block w-8 text-center' : ''"
>{{ EMPTY_TEXT }}</span
>
<span
v-else-if="cell.kind === 'thumbnail' && shownImage"
class="block shrink-0 overflow-hidden bg-elevated"
:class="squareClass"
>
<img
:src="shownImage.thumbnail_url"
:alt="shownImage.filename"
loading="lazy"
class="size-full object-cover"
/>
</span>
<UTooltip
v-else-if="cell.kind === 'thumbnail'"
:text="t('Image no longer in the library')"
:disabled="compact"
:ui="TIP_UI"
>
<span
role="img"
:aria-label="t('Image no longer in the library')"
:tabindex="compact ? undefined : 0"
class="flex shrink-0 items-center justify-center border border-dashed border-accented text-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="squareClass"
>
<UIcon name="i-lucide-unlink" :class="compact ? 'size-3' : 'size-3.5'" />
</span>
</UTooltip>
<span v-else-if="cell.kind === 'boolean'" class="flex items-center gap-1.5">
<UIcon
:name="cell.yes ? 'i-lucide-check' : 'i-lucide-minus'"
+24 -4
View File
@@ -7,6 +7,7 @@ import ChoiceList from '@/components/ChoiceList.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import FieldTypePicker from '@/components/FieldTypePicker.vue'
import SlugField from '@/components/SlugField.vue'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import {
BUILDER_LIMITS,
STARTER_KEY,
@@ -50,8 +51,14 @@ const emit = defineEmits<{
const root = ref<HTMLElement | null>(null)
const type = computed(() => props.field.type)
const spec = computed(() => fieldTypeSpec(type.value))
const hasLimits = computed(() => supportsLimits(type.value))
const canUnique = computed(() => supportsUnique(type.value))
const showUnique = computed(() => canUnique.value || spec.value.showsUnsupportedOptions)
const showRuleGrid = computed(
() => hasLimits.value || spec.value.supportsDefault || spec.value.showsUnsupportedOptions,
)
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'))
@@ -196,6 +203,7 @@ watch(
@update:model-value="emit('required', Boolean($event))"
/>
<AppSwitch
v-if="showUnique"
:model-value="canUnique && field.unique"
:label="t('Unique values')"
:description="uniqueHelp"
@@ -214,7 +222,7 @@ watch(
/>
</div>
<div class="grid items-start gap-3.5" :class="gridClass">
<div v-if="showRuleGrid" class="grid items-start gap-3.5" :class="gridClass">
<template v-if="hasLimits">
<DirectoryField :label="limitLabels[0]" name="min" :error="errors.min">
<UInput
@@ -255,7 +263,7 @@ watch(
</DirectoryField>
</template>
<DirectoryField
v-if="type !== 'boolean'"
v-if="type !== 'boolean' && spec.supportsDefault"
:label="t('Default value')"
name="default"
:error="errors.default"
@@ -292,14 +300,26 @@ watch(
@update:model-value="emit('default', String($event))"
/>
</DirectoryField>
<div v-if="!hasLimits" class="flex min-w-0 flex-col gap-1.5">
<div
v-if="!hasLimits && spec.showsUnsupportedOptions"
class="flex min-w-0 flex-col gap-1.5"
>
<span class="font-medium text-highlighted">{{ t('Limits') }}</span>
<span class="text-xs/4.5 text-muted">{{ noLimitsText }}</span>
</div>
</div>
<div
v-if="hint"
data-testid="builder-hint"
class="flex gap-2 bg-(--rc-info-soft) px-3 py-2.5 text-info"
>
<span class="flex pt-0.5"><UIcon name="i-lucide-info" class="size-4" /></span>
<span class="text-pretty text-toned">{{ hint }}</span>
</div>
<ChoiceList
v-if="type === 'select'"
v-if="spec.supportsChoices"
:options="field.options"
:row-errors="errors.choices"
:list-error="errors.options"
+56 -6
View File
@@ -49,21 +49,24 @@ import {
type Corner = 'tl' | 'tr' | 'bl' | 'br'
import { useImageUpload } from '@/composables/useImageUpload'
import type { ImageChoice } from '@/lib/imageChoice'
import type { FieldPick, ImageChoice } from '@/lib/imageChoice'
const props = withDefaults(
defineProps<{
initialFile?: File | null
inArticleIds?: number[]
choose?: ImageChoice
field?: FieldPick
}>(),
{ initialFile: null, inArticleIds: () => [], choose: undefined },
{ initialFile: null, inArticleIds: () => [], choose: undefined, field: undefined },
)
const emit = defineEmits<{
uploaded: [item: MediaItem]
inserted: [item: MediaItem, alt: string, src: string]
chosen: [item: MediaItem | null]
close: []
closed: []
}>()
type Phase = 'idle' | 'dragging-over' | 'duplicate' | 'crop' | 'uploading' | 'rejected' | 'failed'
@@ -78,6 +81,9 @@ const DROP_ZONE_UI = {
description: 'mt-0 max-w-75 text-xs/5 text-toned',
}
const HEADER_CLASS = 'min-h-12 items-start px-6 pt-5 pb-0 sm:px-6'
const FIELD_HEADER_CLASS = `${HEADER_CLASS} max-md:min-h-16`
const FIELD_CLOSE = { class: 'size-7 justify-center text-muted max-md:size-11' }
const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
const TAB_ITEMS = [
@@ -108,6 +114,7 @@ const succeeded = ref(false)
const tab = ref<Tab>('library')
const existing = ref<MediaItem | null>(null)
const selected = ref<MediaItem | null>(null)
const noneChosen = ref(false)
const alt = ref('')
const variant = ref('')
let tabTouched = false
@@ -192,6 +199,9 @@ const modalTitle = computed(() => {
if (phase.value === 'crop') {
return t('Crop and resize')
}
if (props.field) {
return t('Choose an image')
}
return props.choose?.title ?? t('Add an image')
})
@@ -432,6 +442,7 @@ function useExisting() {
}
function selectItem(item: MediaItem) {
noneChosen.value = false
if (selected.value?.id === item.id) {
return
}
@@ -441,8 +452,28 @@ function selectItem(item: MediaItem) {
requestVariants()
}
function selectNone() {
selected.value = null
noneChosen.value = true
}
const canConfirm = computed(() => selected.value !== null || noneChosen.value)
function confirmField() {
if (!canConfirm.value) {
return
}
emit('chosen', noneChosen.value ? null : selected.value)
open.value = false
}
function returnFocus(event: Event) {
event.preventDefault()
emit('closed')
}
function requestVariants() {
if (variantsRequested || !canManageVariants.value) {
if (props.field || variantsRequested || !canManageVariants.value) {
return
}
variantsRequested = true
@@ -451,6 +482,7 @@ function requestVariants() {
async function searchLibrary(query: string) {
selected.value = null
noneChosen.value = false
alt.value = ''
variant.value = ''
await library.search(query)
@@ -739,6 +771,7 @@ watch(open, (isOpen) => {
succeeded.value = false
existing.value = null
selected.value = null
noneChosen.value = false
alt.value = ''
variant.value = ''
variantsRequested = false
@@ -771,9 +804,11 @@ onUnmounted(() => {
v-model:open="open"
:title="modalTitle"
:description="modalDescription"
:content="field ? { onCloseAutoFocus: returnFocus } : undefined"
:close="field ? FIELD_CLOSE : undefined"
:ui="{
content: modalContentClass,
header: 'min-h-12 items-start px-6 pt-5 pb-0 sm:px-6',
header: field ? FIELD_HEADER_CLASS : HEADER_CLASS,
close: 'top-5 end-6',
title: 'text-lg/6 font-semibold',
body: 'p-0 sm:p-0',
@@ -813,11 +848,14 @@ onUnmounted(() => {
:first-new-id="library.firstNewId.value"
:in-article-ids="inArticleIds"
:selected="selected"
:plain="choose !== undefined"
:none-chosen="noneChosen"
:field="field"
:plain="choose !== undefined && !field"
:tile-ratio="choose && !choose.square ? 'landscape' : 'square'"
:hint="choose?.hint"
@search="searchLibrary"
@select="selectItem"
@select-none="selectNone"
@load-more="library.loadMore"
@retry="library.load()"
@upload="selectTab('upload')"
@@ -1128,7 +1166,19 @@ onUnmounted(() => {
{{ cancelLabel }}
</UButton>
<UButton
v-if="onLibraryTab"
v-if="onLibraryTab && field"
block
:class="[
FOOTER_BUTTON_CLASS,
canConfirm ? '' : 'cursor-not-allowed opacity-40',
]"
:aria-disabled="canConfirm ? undefined : 'true'"
@click="confirmField"
>
{{ noneChosen ? t('Remove image') : t('Use this image') }}
</UButton>
<UButton
v-else-if="onLibraryTab"
block
:class="FOOTER_BUTTON_CLASS"
:disabled="!selected"
+348
View File
@@ -0,0 +1,348 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, useId } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import DirectoryField from '@/components/DirectoryField.vue'
import ImageUploader from '@/components/ImageUploader.vue'
import type { MediaItem } from '@/composables/useMedia'
import {
imageButtonName,
imageMeta,
imageOfItem,
isMissingImage,
type MediaImage,
type MediaSummary,
} from '@/lib/mediaField'
const DESKTOP_QUERY = '(min-width: 48rem)'
const BUTTON_CLASS = 'h-11 gap-1.5 px-3 text-sm text-highlighted disabled:opacity-40 md:h-8'
const OUTLINE_CLASS = `${BUTTON_CLASS} border border-accented bg-default ring-0`
const ROW_BUTTON_CLASS = `${BUTTON_CLASS} max-md:flex-1`
const ROW_OUTLINE_CLASS = `${OUTLINE_CLASS} max-md:flex-1`
const FOCUS_RING =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
const props = withDefaults(
defineProps<{
label: string
required?: boolean
image?: MediaSummary | null
disabled?: boolean
error?: string
name?: string
}>(),
{ required: false, image: null, disabled: false, error: '', name: undefined },
)
const emit = defineEmits<{
change: [id: number | null, image: MediaSummary | null]
}>()
const modelValue = defineModel<number | null>({ default: null })
const id = useId()
const labelId = `${id}-label`
const helpId = `${id}-help`
const errorId = `${id}-error`
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const pickerOpen = ref(false)
const hovered = ref(false)
const announcement = ref('')
const mainButton = ref<{ $el: HTMLElement } | null>(null)
let opener: HTMLElement | null = null
let focusMain = false
const state = computed<'empty' | 'filled' | 'missing'>(() => {
if (modelValue.value === null) {
return 'empty'
}
return props.image && !isMissingImage(props.image) ? 'filled' : 'missing'
})
const filled = computed(() => (state.value === 'filled' ? (props.image as MediaImage) : null))
const meta = computed(() => (filled.value ? imageMeta(filled.value) : ''))
const help = computed(() => t('One image from your library.'))
const missingHelp = computed(() =>
props.required
? t('{0} is required. Choose another image to save.', [props.label])
: t('You can still save. The entry keeps the link until you change it.'),
)
const showHelp = computed(() => state.value !== 'missing')
const describedBy = computed(() => (props.error ? `${errorId} ${helpId}` : helpId))
const boxClass = computed(() => {
if (props.error) {
return 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)]'
}
return state.value === 'empty' ? 'border-dashed border-accented' : 'border-accented'
})
const mainLabel = computed(() => {
if (state.value === 'empty') {
return t('Pick an image')
}
return state.value === 'filled' ? t('Change image') : t('Choose another image')
})
const mainAriaLabel = computed(() =>
state.value === 'empty' ? t('Pick an image for {0}', [props.label]) : undefined,
)
const mainIcon = computed(() =>
state.value === 'empty' ? 'i-lucide-image-plus' : 'i-lucide-image',
)
const thumbName = computed(() => (filled.value ? imageButtonName(filled.value) : undefined))
function announce(message: string) {
announcement.value = ''
nextTick(() => {
announcement.value = message
})
}
function focusMainButton() {
mainButton.value?.$el.focus()
}
async function settleFocus() {
await nextTick()
focusMainButton()
}
function openPicker(event?: Event) {
if (props.disabled) {
return
}
opener = (event?.currentTarget as HTMLElement | null) ?? mainButton.value?.$el ?? null
focusMain = false
hovered.value = false
pickerOpen.value = true
}
function change(id: number | null, image: MediaSummary | null) {
modelValue.value = id
emit('change', id, image)
}
function pick(item: MediaItem) {
focusMain = true
if (item.id === modelValue.value) {
return
}
change(item.id, imageOfItem(item))
announce(t('Image selected: {0}', [item.filename]))
}
function onChosen(item: MediaItem | null) {
if (item) {
pick(item)
return
}
focusMain = true
if (modelValue.value !== null) {
change(null, null)
announce(t('Image removed from {0}.', [props.label]))
}
}
async function onPickerClosed() {
await nextTick()
const target = focusMain ? mainButton.value?.$el : opener
;(target?.isConnected ? target : mainButton.value?.$el)?.focus()
}
function drop(message: string) {
if (props.disabled) {
return
}
change(null, null)
announce(message)
settleFocus()
}
function remove() {
drop(t('Image removed from {0}.', [props.label]))
}
function clear() {
drop(t('{0} cleared.', [props.label]))
}
</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="media-control"
class="border bg-default p-3"
:class="boxClass"
>
<div
v-if="state === 'empty'"
class="flex flex-col gap-3 md:flex-row md:items-center"
data-testid="media-empty"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<span
class="flex size-10 shrink-0 items-center justify-center bg-elevated text-toned"
>
<UIcon name="i-lucide-image" class="size-5" />
</span>
<span class="text-muted">{{ t('No image selected.') }}</span>
</div>
<UButton
ref="mainButton"
color="neutral"
variant="outline"
:icon="mainIcon"
:disabled="disabled"
:aria-label="mainAriaLabel"
:aria-required="required ? 'true' : undefined"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="describedBy"
data-field-focus
:class="OUTLINE_CLASS"
@click="openPicker"
>
{{ mainLabel }}
</UButton>
</div>
<div
v-else
class="flex flex-col gap-3 md:flex-row md:items-start md:gap-4"
:data-testid="state === 'filled' ? 'media-filled' : 'media-missing'"
>
<button
v-if="filled"
type="button"
data-testid="media-thumbnail"
:aria-label="thumbName"
:disabled="disabled"
class="relative w-full shrink-0 overflow-hidden border bg-elevated max-md:aspect-video md:h-30 md:w-40"
:class="[
hovered && isDesktop ? 'border-(--ui-text-muted)' : 'border-accented',
disabled ? 'opacity-60' : '',
FOCUS_RING,
]"
@click="openPicker"
@pointerenter="hovered = true"
@pointerleave="hovered = false"
>
<img
:src="filled.thumbnail_url"
alt=""
class="absolute inset-0 size-full object-cover"
/>
<span
v-if="hovered && isDesktop && !disabled"
data-testid="media-hover-bar"
class="absolute inset-x-0 bottom-0 flex h-7 items-center justify-center gap-1.5 bg-inverted text-xs font-medium text-inverted"
>
<UIcon name="i-lucide-image" class="size-3.5" />
{{ t('Change image') }}
</span>
</button>
<div
v-else
role="img"
:aria-label="t('Image no longer in the library')"
data-testid="media-placeholder"
class="flex w-full shrink-0 items-center justify-center border border-dashed border-accented text-muted max-md:aspect-video md:h-30 md:w-40"
:class="disabled ? 'opacity-60' : ''"
>
<UIcon name="i-lucide-image-off" class="size-6" />
</div>
<div class="flex min-w-0 flex-1 flex-col gap-3 md:min-h-30 md:justify-between">
<div v-if="filled" class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-medium text-highlighted">{{
filled.filename
}}</span>
<span class="text-xs/4.5 text-muted" data-testid="media-meta">{{
meta
}}</span>
</div>
<div v-else class="flex flex-col gap-1">
<span class="font-medium text-highlighted">{{
t('This image is no longer in the library')
}}</span>
<span
:id="helpId"
class="text-xs/4.5 text-pretty text-muted"
data-testid="media-missing-help"
>{{ missingHelp }}</span
>
</div>
<div class="flex flex-wrap gap-2">
<UButton
ref="mainButton"
color="neutral"
variant="outline"
:icon="mainIcon"
:disabled="disabled"
:aria-required="required ? 'true' : undefined"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="describedBy"
data-field-focus
:class="ROW_OUTLINE_CLASS"
@click="openPicker"
>
{{ mainLabel }}
</UButton>
<UButton
v-if="filled"
color="neutral"
variant="ghost"
icon="i-lucide-x"
:disabled="disabled"
:aria-label="t('Remove image from {0}', [label])"
:class="ROW_BUTTON_CLASS"
@click="remove"
>
{{ t('Remove') }}
</UButton>
<UButton
v-else
color="neutral"
variant="ghost"
:disabled="disabled"
:aria-label="t('Clear {0}', [label])"
:class="ROW_BUTTON_CLASS"
@click="clear"
>
{{ t('Clear') }}
</UButton>
</div>
</div>
</div>
</div>
<p
v-if="showHelp"
:id="helpId"
data-testid="media-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="media-live">{{
announcement
}}</span>
</DirectoryField>
<ImageUploader
v-model:open="pickerOpen"
:field="{ optional: !required, currentId: modelValue }"
@chosen="onChosen"
@uploaded="pick"
@closed="onPickerClosed"
/>
</div>
</template>
+56 -6
View File
@@ -8,6 +8,7 @@ import VariantPickerField from '@/components/variants/VariantPickerField.vue'
import type { LibraryStatus } from '@/composables/useLibraryList'
import type { ImageVariant } from '@/composables/useImageVariants'
import type { MediaItem } from '@/composables/useMedia'
import type { FieldPick } from '@/lib/imageChoice'
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
const SEARCH_DEBOUNCE_MS = 120
@@ -41,11 +42,14 @@ const props = defineProps<{
plain?: boolean
tileRatio?: 'square' | 'landscape'
hint?: string
field?: FieldPick
noneChosen?: boolean
}>()
const emit = defineEmits<{
search: [query: string]
select: [item: MediaItem]
'select-none': []
'load-more': []
retry: []
upload: []
@@ -71,12 +75,19 @@ const searching = computed(() => props.query !== '')
const summary = computed(() =>
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
)
const chosen = computed(() => props.selected !== null || props.noneChosen)
const scrollHeight = computed(() => {
if (props.plain) {
return 'max-h-71 md:max-h-89'
}
return props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'
return chosen.value ? 'max-h-32 md:max-h-49.5' : 'max-h-77'
})
const showNoImage = computed(() => props.field?.optional === true && showGrid.value)
const emptyDescription = computed(() =>
props.field
? t('Images you upload appear here, ready to use in any article or entry.')
: t('Images you upload appear here, ready to use in any article.'),
)
const showEmptyLibrary = computed(
() => props.status === 'ready' && props.items.length === 0 && !searching.value,
)
@@ -85,7 +96,7 @@ const showNoResults = computed(
)
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
const showLoadMore = computed(
() => showGrid.value && props.hasMore && (props.plain || !props.selected),
() => showGrid.value && props.hasMore && (props.plain || !chosen.value),
)
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
@@ -115,7 +126,19 @@ function isSelected(item: MediaItem) {
}
function isInArticle(item: MediaItem) {
return props.inArticleIds.includes(item.id)
return !props.field && props.inArticleIds.includes(item.id)
}
function isCurrent(item: MediaItem) {
return props.field?.currentId === item.id
}
function tileName(item: MediaItem) {
if (!props.field) {
return accessibleName(item)
}
const name = `${item.filename}, ${formatSize(item.size)}`
return isCurrent(item) ? `${name}, ${t('Current')}` : name
}
function onTileKeydown(event: KeyboardEvent, item: MediaItem) {
@@ -206,7 +229,7 @@ onBeforeUnmount(clearDebounce)
variant="naked"
icon="i-lucide-images"
:title="t('Your library is empty')"
:description="t('Images you upload appear here, ready to use in any article.')"
:description="emptyDescription"
:ui="EMPTY_STATE_UI"
>
<template #actions>
@@ -253,6 +276,26 @@ onBeforeUnmount(clearDebounce)
</div>
<template v-else-if="showGrid">
<button
v-if="showNoImage"
type="button"
data-testid="picker-no-image"
:aria-pressed="noneChosen"
class="flex min-h-10 items-center gap-2.5 border px-3 text-start focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary max-md:min-h-12"
:class="
noneChosen
? 'border-primary bg-(--rc-sand) text-(--rc-on-sand)'
: 'border-accented bg-default text-highlighted'
"
@click="emit('select-none')"
>
<UIcon name="i-lucide-image-off" class="size-4 shrink-0" />
<span class="font-medium">{{ t('No image') }}</span>
<span class="flex-1" :class="noneChosen ? 'text-(--rc-on-sand)' : 'text-muted'">{{
t('Leave this field empty.')
}}</span>
<UIcon v-if="noneChosen" name="i-lucide-check" class="size-4 shrink-0" />
</button>
<span v-if="!plain" data-testid="picker-summary" class="text-xs/5 text-muted">{{
summary
}}</span>
@@ -268,7 +311,7 @@ onBeforeUnmount(clearDebounce)
type="button"
data-picker-tile
:data-image-id="item.id"
:aria-label="accessibleName(item)"
:aria-label="tileName(item)"
:aria-pressed="isSelected(item)"
class="group flex min-w-0 flex-col gap-1.5 text-start focus-visible:outline-none"
@click="emit('select', item)"
@@ -287,6 +330,11 @@ onBeforeUnmount(clearDebounce)
class="w-full border border-default"
:class="tileAspect"
/>
<span
v-if="isCurrent(item)"
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-(--rc-sand) px-1.25 text-[0.6875rem] font-medium text-(--rc-on-sand)"
>{{ t('Current') }}</span
>
<span
v-if="isInArticle(item)"
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300"
@@ -338,7 +386,7 @@ onBeforeUnmount(clearDebounce)
<p v-if="hint" class="text-xs/5 text-muted">{{ hint }}</p>
<div
v-if="selected && !plain"
v-if="selected && !plain && !noneChosen"
data-testid="picker-panel"
class="flex flex-col gap-3 border-t border-default pt-3"
>
@@ -355,12 +403,14 @@ onBeforeUnmount(clearDebounce)
</div>
</div>
<VariantPickerField
v-if="!field"
v-model:variant="variant"
:image="selected"
:variants="variants ?? []"
:ready="variantsReady ?? false"
/>
<UFormField
v-if="!field"
:label="t('Alt text (optional)')"
:help="
t(
+3
View File
@@ -23,6 +23,9 @@ const badge = computed(() => {
if (props.cause) {
return t('Possible cause')
}
if (props.change.flag === 'cannotApply') {
return t("Can't be applied")
}
if (props.change.flag === 'mayNotFit') {
return t('Entries may not fit')
}
+13 -2
View File
@@ -5,7 +5,12 @@ import { computed } from 'vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
import SchemaChangeList from '@/components/SchemaChangeList.vue'
import { entriesLine } from '@/lib/schemaEntries'
import { summaryLine, type FieldChange } from '@/lib/schemaBuilderDiff'
import {
hasRefusedChange,
isMediaOnlyClean,
summaryLine,
type FieldChange,
} from '@/lib/schemaBuilderDiff'
const props = defineProps<{
name: string
@@ -19,6 +24,8 @@ 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))
</script>
<template>
@@ -32,6 +39,7 @@ const entries = computed(() => entriesLine(props.name, props.total))
wide
:pending="applying"
:confirm-disabled="offline"
:confirm-blocked="refused"
@confirm="$emit('apply')"
@close="$emit('close')"
>
@@ -42,7 +50,10 @@ const entries = computed(() => entriesLine(props.name, props.total))
<span class="text-muted">{{ entries }}</span>
</div>
<SchemaChangeList :changes="changes" test-id="review-changes" />
<p v-if="!applying" class="text-xs/4.5 text-muted">
<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">
{{
t(
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.",
@@ -111,3 +111,59 @@ describe('FieldCell', () => {
expect(element.className).toContain('text-muted')
})
})
describe('FieldCell for media', () => {
const LIME = {
id: 7,
filename: 'lime-plaster-workshop.jpg',
width: 1600,
height: 900,
size: 245760,
thumbnail_url: '/media/lime-plaster-workshop.jpg',
}
async function showMedia(value: unknown, image: unknown, compact = false) {
await mountInApp(FieldCell, {
field: apiField('Thumbnail', 'thumbnail', 'media'),
value,
image,
compact,
})
return cell()
}
it('draws a 40 px thumbnail', async () => {
const element = await showMedia(7, LIME)
const img = element.querySelector('img')!
expect(img.getAttribute('src')).toBe(LIME.thumbnail_url)
expect(img.getAttribute('alt')).toBe(LIME.filename)
expect(element.className).toContain('size-10')
})
it('draws a 32 px thumbnail when compact', async () => {
const element = await showMedia(7, LIME, true)
expect(element.className).toContain('size-8')
})
it('shows a muted dash for no image, centred in 32 px when compact', async () => {
const plain = await showMedia(null, null)
expect(text(plain)).toBe('–')
expect(plain.className).toContain('text-muted')
unmountApp()
const compact = await showMedia(null, null, true)
expect(compact.className).toContain('w-8')
expect(compact.className).toContain('text-center')
})
it('shows the dashed placeholder with its name for a missing image', async () => {
const element = await showMedia(7, { id: 7, missing: true })
expect(element.getAttribute('role')).toBe('img')
expect(element.getAttribute('aria-label')).toBe('Image no longer in the library')
expect(element.className).toContain('border-dashed')
expect(element.className).toContain('size-10')
})
})
@@ -0,0 +1,533 @@
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 { ApiError, NetworkError } from '@/composables/useApi'
import { drawsIcon, fieldLibrary } from '@/views/__tests__/mediaFieldKit'
import { DESKTOP_WIDTH, MOBILE_WIDTH } from './viewport'
import {
altField,
apiKit,
click,
clickTab,
dialogText,
findButton,
installApi,
listCalls,
modalOpen,
mountUploader,
openUploader,
pageBody,
press,
searchField,
secondPage,
selectedTab,
selectTile,
settle,
skeletonTiles,
tabButtons,
textOf,
tile,
tiles,
typeInto,
type PickerRoutes,
} from './pickerKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => 'a'.repeat(64)) }))
addCollection(lucide)
let wrapper: ReturnType<typeof mountUploader> | null = null
const RealXMLHttpRequest = global.XMLHttpRequest
class FakeUpload {
static last: FakeUpload | null = null
listeners: Record<string, Array<() => void>> = {}
status = 0
responseText = ''
upload = { addEventListener: () => undefined }
open() {}
constructor() {
FakeUpload.last = this
}
addEventListener(type: string, listener: () => void) {
this.listeners[type] = [...(this.listeners[type] ?? []), listener]
}
send() {}
abort() {}
answer(status: number, body: string) {
this.status = status
this.responseText = body
this.listeners.load?.forEach((listener) => listener())
}
}
beforeEach(() => {
global.XMLHttpRequest = FakeUpload as unknown as typeof XMLHttpRequest
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
vi.unstubAllGlobals()
global.XMLHttpRequest = RealXMLHttpRequest
document.body.innerHTML = ''
})
function libraryRoutes(extra: PickerRoutes = {}): PickerRoutes {
return { list: () => pageBody(fieldLibrary()), ...extra }
}
async function openField(
field: { optional: boolean; currentId: number | null } = { optional: true, currentId: 1 },
routes: PickerRoutes = libraryRoutes(),
width = DESKTOP_WIDTH,
) {
installApi(routes)
wrapper = mountUploader({ field }, width)
await openUploader(wrapper)
}
const summary = () => textOf(document.body.querySelector('[data-testid="picker-summary"]'))
const strip = () => document.body.querySelector('[data-testid="picker-panel"]')
const noImage = () =>
document.body.querySelector<HTMLButtonElement>('[data-testid="picker-no-image"]')
const variantRequests = () =>
apiKit.request.mock.calls.filter(([path]) => String(path).includes('/v1/image-variants'))
describe('The dialog of a field', () => {
it('is titled Choose an image, with the tabs Upload and Library opening on Library', async () => {
await openField()
expect(dialogText()).toContain('Choose an image')
expect(dialogText()).not.toContain('Add an image')
expect(tabButtons().map(textOf)).toEqual(['Upload', 'Library'])
expect(textOf(selectedTab())).toBe('Library')
expect(searchField().placeholder).toBe('Search by filename')
})
it('shows the newest first line with the count, square tiles and Load more', async () => {
await openField()
expect(summary()).toBe('Newest first · 48 images')
expect(tiles()).toHaveLength(12)
expect(findButton('Load more')).toBeInstanceOf(HTMLButtonElement)
const thumb = tile('north-wall.jpg').querySelector('[data-testid="media-thumb"]')!
expect(thumb.className).toContain('aspect-square')
})
it('has 4 columns on desktop and 3 on mobile', async () => {
await openField()
expect(tiles()[0]!.parentElement!.className).toContain('md:grid-cols-4')
wrapper!.unmount()
document.body.innerHTML = ''
await openField(undefined, undefined, MOBILE_WIDTH)
expect(tiles()[0]!.parentElement!.className).toContain('grid-cols-3')
})
it('opens with Cancel enabled and Use this image announced as disabled', async () => {
await openField()
expect(findButton('Cancel')?.disabled).toBe(false)
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
await click(findButton('Use this image'))
expect(wrapper!.emitted('chosen')).toBeUndefined()
expect(modalOpen()).toBe(true)
})
it('never asks for the variants and shows neither a variant select nor an alt text', async () => {
await openField()
await selectTile('north-wall.jpg')
expect(variantRequests()).toHaveLength(0)
expect(altField()).toBeNull()
expect(dialogText()).not.toContain('Alt text')
expect(dialogText()).not.toContain('Variant')
})
})
describe('The No image row', () => {
it('tops the Library tab of an optional field', async () => {
await openField({ optional: true, currentId: 1 })
const row = noImage()!
expect(textOf(row)).toBe('No image Leave this field empty.')
expect(row.getAttribute('aria-pressed')).toBe('false')
expect(drawsIcon(row, 'image-off')).toBe(true)
const order = [
...document.body.querySelectorAll('input, [data-testid], [data-picker-tile]'),
]
expect(order.indexOf(searchField())).toBeLessThan(order.indexOf(row))
expect(order.indexOf(row)).toBeLessThan(order.indexOf(tiles()[0]!))
})
it('is absent on a required field, whose title stays Choose an image', async () => {
await openField({ optional: false, currentId: 1 })
expect(noImage()).toBeNull()
expect(dialogText()).toContain('Choose an image')
})
it.each([
['empty', { list: () => pageBody([], null) }],
['failed', { list: () => Promise.reject(new ApiError(500)) }],
['loading', { list: () => new Promise(() => undefined) }],
])('is absent while the library is %s', async (_name, routes) => {
await openField({ optional: true, currentId: null }, routes as PickerRoutes)
expect(noImage()).toBeNull()
})
it('takes the sand fill, the primary border and a check when chosen, releasing any tile', async () => {
await openField()
await selectTile('kitchen-after.webp')
await click(noImage())
const row = noImage()!
expect(row.getAttribute('aria-pressed')).toBe('true')
expect(row.className).toContain('bg-(--rc-sand)')
expect(row.className).toContain('border-primary')
expect(drawsIcon(row, 'check')).toBe(true)
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('false')
expect(strip()).toBeNull()
expect(findButton('Load more')).toBeUndefined()
expect(findButton('Remove image')).toBeInstanceOf(HTMLButtonElement)
expect(findButton('Use this image')).toBeUndefined()
})
it('is released when a tile is chosen again', async () => {
await openField()
await click(noImage())
await selectTile('kitchen-after.webp')
expect(noImage()!.getAttribute('aria-pressed')).toBe('false')
expect(findButton('Use this image')).toBeInstanceOf(HTMLButtonElement)
})
it('emits the removal and closes with Remove image', async () => {
await openField()
await click(noImage())
await click(findButton('Remove image'))
expect(wrapper!.emitted('chosen')).toEqual([[null]])
await vi.waitFor(() => expect(modalOpen()).toBe(false))
})
})
describe('Tiles of a field', () => {
it('mark the current image with the sand badge instead of In this article', async () => {
await openField({ optional: true, currentId: 1 })
const current = tile('north-wall.jpg')
const badge = current.querySelector<HTMLElement>('span[class*="bottom-1"]')!
expect(textOf(badge)).toBe('Current')
expect(badge.className).toContain('bg-(--rc-sand)')
expect(badge.className).toContain('text-(--rc-on-sand)')
expect(badge.className).toContain('h-4.5')
expect(badge.className).toContain('bottom-1')
expect(badge.className).toContain('left-1')
expect(dialogText()).not.toContain('In this article')
expect(tiles().filter((element) => textOf(element).includes('Current'))).toHaveLength(1)
})
it('are buttons named with the filename and size, plus Current for the current image', async () => {
await openField({ optional: true, currentId: 1 })
expect(tile('kitchen-after.webp').getAttribute('aria-label')).toBe(
'kitchen-after.webp, 1.2 MB',
)
expect(tiles()[0]!.getAttribute('aria-label')).toBe('north-wall.jpg, 3.9 MB, Current')
expect(
tiles().every((element) => !element.getAttribute('aria-label')!.includes('used')),
).toBe(true)
expect(tiles().every((element) => element.hasAttribute('aria-pressed'))).toBe(true)
})
it('keep the article picker names and badges unchanged', async () => {
installApi(libraryRoutes())
wrapper = mountUploader({})
await openUploader(wrapper)
expect(dialogText()).toContain('Add an image')
expect(tiles()[0]!.getAttribute('aria-label')).toContain('used in')
expect(dialogText()).toContain('In this article')
expect(noImage()).toBeNull()
})
})
describe('Choosing an image', () => {
it('gives the tile the primary outline and a check, and shows the strip below the grid', async () => {
await openField()
await selectTile('lime-plaster-workshop.jpg')
const chosen = tile('lime-plaster-workshop.jpg')
expect(chosen.getAttribute('aria-pressed')).toBe('true')
expect(chosen.querySelector('[data-testid="picker-check"]')).not.toBeNull()
expect(chosen.innerHTML).toContain('outline-primary')
expect(textOf(strip())).toBe('lime-plaster-workshop.jpg 1600 × 900 · 240 KB')
expect(strip()!.querySelector('[data-testid="media-thumb"]')!.className).toContain(
'size-14',
)
expect(strip()!.querySelector('input, select, [role="combobox"]')).toBeNull()
})
it('draws the strip of a vector image as Vector', async () => {
await openField()
await selectTile('logo-renew.svg')
expect(textOf(strip())).toBe('logo-renew.svg Vector · 8 KB')
})
it('hides Load more while a tile is selected and shows it again after a search release', async () => {
await openField()
expect(findButton('Load more')).toBeInstanceOf(HTMLButtonElement)
await selectTile('north-wall.jpg')
expect(findButton('Load more')).toBeUndefined()
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBeNull()
})
it('emits the item and closes with Use this image', async () => {
await openField()
await selectTile('bathroom-tiles.png')
await click(findButton('Use this image'))
const [[item]] = wrapper!.emitted('chosen') as [[{ id: number; filename: string }]]
expect(item.id).toBe(8)
expect(item.filename).toBe('bathroom-tiles.png')
await vi.waitFor(() => expect(modalOpen()).toBe(false))
})
it('lets the user pick the current tile again', async () => {
await openField({ optional: true, currentId: 1 })
await selectTile('north-wall.jpg')
await click(findButton('Use this image'))
expect(wrapper!.emitted('chosen')).toHaveLength(1)
})
it('selects with Enter and Space', async () => {
await openField()
press(tile('north-wall.jpg'), 'Enter')
await settle()
expect(tile('north-wall.jpg').getAttribute('aria-pressed')).toBe('true')
press(tile('kitchen-after.webp'), ' ')
await settle()
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
expect(tile('north-wall.jpg').getAttribute('aria-pressed')).toBe('false')
})
})
describe('Closing without choosing', () => {
it.each([
['Cancel', async () => click(findButton('Cancel'))],
[
'Escape',
async () => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await settle()
},
],
])('emits nothing with %s and reports the close', async (_name, close) => {
await openField()
await selectTile('north-wall.jpg')
await close()
await vi.waitFor(() => expect(modalOpen()).toBe(false))
expect(wrapper!.emitted('chosen')).toBeUndefined()
expect(wrapper!.emitted('closed')).toHaveLength(1)
})
})
describe('Library states of a field', () => {
it('shows the field copy of the empty library and Upload an image opens the Upload tab', async () => {
await openField({ optional: true, currentId: null }, { list: () => pageBody([], null) })
expect(textOf(selectedTab())).toBe('Upload')
await clickTab('Library')
expect(dialogText()).toContain('Your library is empty')
expect(dialogText()).toContain(
'Images you upload appear here, ready to use in any article or entry.',
)
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
await click(findButton('Upload an image'))
expect(textOf(selectedTab())).toBe('Upload')
})
it('keeps the article copy of the empty library', async () => {
installApi({ list: () => pageBody([], null) })
wrapper = mountUploader({})
await openUploader(wrapper)
await clickTab('Library')
expect(dialogText()).toContain(
'Images you upload appear here, ready to use in any article.',
)
expect(dialogText()).not.toContain('or entry')
})
it.each([
['500', () => Promise.reject(new ApiError(500))],
['a network error', () => Promise.reject(new NetworkError())],
])('shows the error alert with Try again on %s and no No image row', async (_name, list) => {
await openField({ optional: true, currentId: 1 }, { list })
expect(dialogText()).toContain("Couldn't load your library")
expect(dialogText()).toContain('Check your connection and try again.')
expect(noImage()).toBeNull()
})
it('repeats only the library request with Try again', async () => {
let fail = true
await openField(
{ optional: true, currentId: 1 },
{ list: () => (fail ? Promise.reject(new ApiError(500)) : pageBody(fieldLibrary())) },
)
fail = false
const before = apiKit.request.mock.calls.length
await click(findButton('Try again'))
expect(apiKit.request.mock.calls.length - before).toBe(1)
expect(listCalls()).toHaveLength(2)
expect(tiles()).toHaveLength(12)
expect(dialogText()).not.toContain("Couldn't load your library")
})
it('draws 8 skeleton tiles after 150 ms on desktop, 9 on mobile, with Use this image disabled', async () => {
vi.useFakeTimers()
installApi({ list: () => new Promise(() => undefined) })
wrapper = mountUploader({ field: { optional: true, currentId: 1 } })
;(wrapper.vm as unknown as { open: boolean }).open = true
await vi.advanceTimersByTimeAsync(0)
expect(skeletonTiles()).toHaveLength(0)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(8)
expect(noImage()).toBeNull()
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
})
it('draws 9 skeleton tiles on mobile', async () => {
vi.useFakeTimers()
installApi({ list: () => new Promise(() => undefined) })
wrapper = mountUploader({ field: { optional: true, currentId: 1 } }, MOBILE_WIDTH)
;(wrapper.vm as unknown as { open: boolean }).open = true
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(9)
})
it('trims and debounces the search, releases the selection and reloads the grid', async () => {
await openField(undefined, {
list: (query) =>
query.get('q') ? pageBody(fieldLibrary().slice(0, 1), 1) : pageBody(fieldLibrary()),
})
await selectTile('kitchen-after.webp')
await typeInto(searchField(), ' wall ')
await new Promise((resolve) => setTimeout(resolve, 200))
await settle()
expect(listCalls().map((query) => query.get('q'))).toEqual([null, 'wall'])
expect(tiles()).toHaveLength(1)
expect(strip()).toBeNull()
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
})
it('reads the no results copy with Clear search', async () => {
await openField(undefined, {
list: (query) => (query.get('q') ? pageBody([], 0) : pageBody(fieldLibrary())),
})
await typeInto(searchField(), 'wall')
await new Promise((resolve) => setTimeout(resolve, 200))
await settle()
expect(dialogText()).toContain('No images match "wall"')
expect(findButton('Clear search')).toBeInstanceOf(HTMLButtonElement)
})
it('moves the focus to the first new tile after Load more', async () => {
await openField(undefined, {
list: (query) =>
query.get('page') === '2' ? pageBody(secondPage()) : pageBody(fieldLibrary()),
})
await click(findButton('Load more'))
await flushPromises()
expect(tiles()).toHaveLength(24)
expect(document.activeElement).toBe(tiles()[12])
})
})
describe('The Upload tab of a field', () => {
it('shows the existing drop zone', async () => {
await openField()
await clickTab('Upload')
expect(dialogText()).toContain('Drop an image here, or choose a file')
expect(dialogText()).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
expect(findButton('Use this image')).toBeUndefined()
})
async function chooseSvg() {
const input = document.body.querySelector<HTMLInputElement>(
'[data-testid="image-file-input"]',
)!
const file = new File(['<svg/>'], 'diagram.svg', { type: 'image/svg+xml' })
Object.defineProperty(input, 'files', { value: { 0: file, length: 1 }, configurable: true })
input.dispatchEvent(new Event('change'))
await settle()
}
it('emits the uploaded item and closes a moment later', async () => {
await openField(undefined, libraryRoutes())
await clickTab('Upload')
await chooseSvg()
const item = { ...fieldLibrary()[0]!, id: 13, filename: 'diagram.svg' }
FakeUpload.last!.answer(201, JSON.stringify(item))
await settle()
expect(wrapper!.emitted('uploaded')).toEqual([[item]])
await vi.waitFor(() => expect(modalOpen()).toBe(false), { timeout: 2000 })
})
it('chooses nothing when the server refuses the file', async () => {
await openField(undefined, libraryRoutes())
await clickTab('Upload')
await chooseSvg()
FakeUpload.last!.answer(413, '')
await settle()
expect(wrapper!.emitted('uploaded')).toBeUndefined()
expect(wrapper!.emitted('chosen')).toBeUndefined()
expect(dialogText()).toContain("can't be used")
})
})
@@ -0,0 +1,590 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { flushPromises } from '@vue/test-utils'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { setLocale } from '@/i18n'
import { useApi } from '@/composables/useApi'
import type { MediaImage, MediaSummary } from '@/lib/mediaField'
import { drawsIcon, fieldLibrary } from '@/views/__tests__/mediaFieldKit'
import MediaFieldControl from '../MediaFieldControl.vue'
import { byText, holder, mountInApp, text, unmountApp } from './directoryKit'
import { MOBILE_WIDTH } from './viewport'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => null) }))
addCollection(lucide)
const LIME: MediaImage = {
id: 7,
filename: 'lime-plaster-workshop.jpg',
width: 1600,
height: 900,
size: 245760,
thumbnail_url: '/media/lime-plaster-workshop.jpg',
}
const VECTOR: MediaImage = {
id: 4,
filename: 'logo-renew.svg',
width: null,
height: null,
size: 8192,
thumbnail_url: '/media/logo-renew.svg',
}
const MISSING: MediaSummary = { id: 7, missing: true }
beforeEach(() => {
const items = fieldLibrary()
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: vi.fn().mockResolvedValue({
items,
total: 48,
page: 1,
size: 25,
counts: { all: 48, unused: 2, duplicates: 2 },
}),
} as unknown as ReturnType<typeof useApi>)
})
afterEach(() => {
setLocale('en')
unmountApp()
})
type Props = Record<string, unknown>
async function mountControl(props: Props = {}, width?: number) {
const wrapper = await mountInApp(
MediaFieldControl,
{ label: 'Thumbnail', modelValue: null, image: null, ...props },
{},
width,
)
return wrapper
}
async function mountStateful(value: number | null, image: MediaSummary | null, required = false) {
const Host = defineComponent({
setup() {
const current = ref(value)
const shown = ref(image)
return () =>
h(MediaFieldControl, {
label: 'Thumbnail',
required,
modelValue: current.value,
image: shown.value,
onChange: (id: number | null, next: MediaSummary | null) => {
current.value = id
shown.value = next
},
})
},
})
return mountInApp(Host)
}
const control = () => document.body.querySelector<HTMLElement>('[data-testid="media-control"]')!
const buttons = () => [...control().querySelectorAll<HTMLButtonElement>('button')]
const buttonNamed = (name: string) =>
buttons().find((button) => (button.getAttribute('aria-label') ?? text(button)) === name)
const thumbnail = () =>
control().querySelector<HTMLButtonElement>('[data-testid="media-thumbnail"]')
const live = () => text(document.body.querySelector('[data-testid="media-live"]'))
const dialog = () => document.body.querySelector('[role="dialog"]')
const main = () => control().querySelector<HTMLButtonElement>('[data-field-focus]')!
async function openFromMain() {
main().click()
await flushPromises()
await flushPromises()
}
async function chooseInPicker(filename: string) {
const tile = [...document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')].find(
(element) => element.getAttribute('aria-label')!.startsWith(`${filename},`),
)!
tile.click()
await flushPromises()
byText('button', 'Use this image')!.click()
await flushPromises()
await flushPromises()
}
describe('Empty, optional', () => {
it('draws the label, a dashed box, the icon square, the text, the button and the help', async () => {
await mountControl()
expect(text(document.body)).toContain('Thumbnail')
expect(text(document.body)).not.toContain('Thumbnail *')
const box = control()
expect(box.className).toContain('border-dashed')
expect(box.className).toContain('border-accented')
expect(box.className).toContain('p-3')
expect(text(box)).toContain('No image selected.')
const square = box.querySelector('span.size-10')!
expect(square.className).toContain('bg-elevated')
expect(drawsIcon(square, 'image')).toBe(true)
const pick = buttonNamed('Pick an image for Thumbnail')!
expect(text(pick)).toBe('Pick an image')
expect(drawsIcon(pick, 'image-plus')).toBe(true)
expect(pick.className).toContain('border-accented')
expect(text(document.body)).toContain('One image from your library.')
expect(thumbnail()).toBeNull()
})
it('stacks the box and makes the button 44 px high on mobile', async () => {
await mountControl({}, MOBILE_WIDTH)
const pick = buttonNamed('Pick an image for Thumbnail')!
expect(pick.className).toContain('h-11')
expect(pick.className).not.toContain('flex-1')
expect(pick.parentElement!.className).toContain('flex-col')
})
})
describe('Empty, required', () => {
it('reads Thumbnail * with the asterisk hidden from assistive technology', async () => {
await mountControl({ required: true })
const label = document.body.querySelector('label')!
expect(text(label)).toBe('Thumbnail *')
expect(label.querySelector('[aria-hidden="true"]')!.textContent).toBe(' *')
expect(text(control())).toContain('No image selected.')
expect(text(document.body)).toContain('One image from your library.')
})
})
describe('Filled', () => {
it('draws the thumbnail, the filename, the size line, the buttons and the help', async () => {
await mountControl({ modelValue: 7, image: LIME })
const box = control()
expect(box.className).toContain('border-accented')
expect(box.className).not.toContain('border-dashed')
const thumb = thumbnail()!
expect(thumb.className).toContain('md:h-30')
expect(thumb.className).toContain('md:w-40')
expect(thumb.querySelector('img')!.getAttribute('src')).toBe(LIME.thumbnail_url)
expect(text(box)).toContain('lime-plaster-workshop.jpg')
expect(text(box.querySelector('[data-testid="media-meta"]'))).toBe('1600 × 900 · 240 KB')
expect(box.querySelector('[data-testid="media-meta"]')!.className).toContain('text-xs/4.5')
const change = buttonNamed('Change image')!
expect(drawsIcon(change, 'image')).toBe(true)
const remove = buttonNamed('Remove image from Thumbnail')!
expect(text(remove)).toBe('Remove')
expect(drawsIcon(remove, 'x')).toBe(true)
expect(text(document.body)).toContain('One image from your library.')
})
it('reads Vector for an image without dimensions', async () => {
await mountControl({ modelValue: 4, image: VECTOR })
expect(text(control().querySelector('[data-testid="media-meta"]'))).toBe('Vector · 8 KB')
})
it('fills the width at 16:9 with two 44 px buttons on mobile', async () => {
await mountControl({ modelValue: 7, image: LIME }, MOBILE_WIDTH)
expect(thumbnail()!.className).toContain('max-md:aspect-video')
expect(thumbnail()!.className).toContain('w-full')
expect(buttonNamed('Change image')!.className).toContain('h-11')
expect(buttonNamed('Remove image from Thumbnail')!.className).toContain('h-11')
expect(buttonNamed('Change image')!.className).toContain('max-md:flex-1')
})
})
describe('Hover and focus', () => {
it('darkens the border and shows the Change image bar on desktop only', async () => {
await mountControl({ modelValue: 7, image: LIME })
const thumb = thumbnail()!
thumb.dispatchEvent(new Event('pointerenter'))
await flushPromises()
expect(thumb.className).toContain('border-(--ui-text-muted)')
const bar = thumb.querySelector('[data-testid="media-hover-bar"]')!
expect(text(bar)).toBe('Change image')
expect(bar.className).toContain('h-7')
expect(bar.className).toContain('bg-inverted')
expect(bar.className).toContain('text-inverted')
expect(drawsIcon(bar, 'image')).toBe(true)
thumb.dispatchEvent(new Event('pointerleave'))
await flushPromises()
expect(thumb.querySelector('[data-testid="media-hover-bar"]')).toBeNull()
})
it('has no hover state on mobile', async () => {
await mountControl({ modelValue: 7, image: LIME }, MOBILE_WIDTH)
thumbnail()!.dispatchEvent(new Event('pointerenter'))
await flushPromises()
expect(thumbnail()!.querySelector('[data-testid="media-hover-bar"]')).toBeNull()
expect(thumbnail()!.className).not.toContain('border-(--ui-text-muted)')
})
it('draws a 2 px primary outline with a 2 px offset when keyboard focused', async () => {
await mountControl({ modelValue: 7, image: LIME })
const classes = thumbnail()!.className
expect(classes).toContain('focus-visible:outline-2')
expect(classes).toContain('focus-visible:outline-offset-2')
expect(classes).toContain('focus-visible:outline-primary')
})
})
describe('Error', () => {
it('draws the error border and line, ties them to the main button and hides the help from sight', async () => {
await mountControl({ required: true, error: 'Pick an image for Thumbnail.' })
expect(control().className).toContain('border-error')
expect(control().className).toContain('shadow-[inset_0_0_0_1px_var(--ui-error)]')
const alert = document.body.querySelector('[role="alert"]')!
expect(text(alert)).toBe('Pick an image for Thumbnail.')
expect(drawsIcon(alert, 'circle-alert')).toBe(true)
const pick = main()
expect(pick.getAttribute('aria-invalid')).toBe('true')
const ids = pick.getAttribute('aria-describedby')!.split(' ')
expect(ids).toHaveLength(2)
expect(document.getElementById(ids[0]!)).toBe(alert)
expect(text(document.getElementById(ids[1]!))).toBe('One image from your library.')
expect(document.getElementById(ids[1]!)!.className).toContain('sr-only')
})
it('has no aria-invalid and only the help id without an error', async () => {
await mountControl()
expect(main().getAttribute('aria-invalid')).toBeNull()
expect(main().getAttribute('aria-describedby')!.split(' ')).toHaveLength(1)
})
})
describe('Missing image', () => {
it('draws a dashed placeholder, the title, the optional help and the two buttons, with no help line below', async () => {
await mountControl({ modelValue: 7, image: MISSING })
const placeholder = control().querySelector('[data-testid="media-placeholder"]')!
expect(placeholder.getAttribute('role')).toBe('img')
expect(placeholder.getAttribute('aria-label')).toBe('Image no longer in the library')
expect(placeholder.className).toContain('border-dashed')
expect(drawsIcon(placeholder, 'image-off')).toBe(true)
expect(placeholder.querySelector('svg')!.getAttribute('class')).toContain('size-6')
expect(text(control())).toContain('This image is no longer in the library')
expect(text(control())).toContain(
'You can still save. The entry keeps the link until you change it.',
)
expect(text(buttonNamed('Choose another image'))).toBe('Choose another image')
expect(buttonNamed('Clear Thumbnail')).toBeInstanceOf(HTMLButtonElement)
expect(text(buttonNamed('Clear Thumbnail'))).toBe('Clear')
expect(document.body.querySelector('[data-testid="media-help"]')).toBeNull()
expect(thumbnail()).toBeNull()
})
it('reads the required help and keeps the main button first', async () => {
await mountControl({ modelValue: 7, image: MISSING, required: true })
expect(text(control())).toContain('Thumbnail is required. Choose another image to save.')
expect(main().textContent).toContain('Choose another image')
expect(main().getAttribute('aria-required')).toBe('true')
})
it('takes the error border and line when the field is required and saved', async () => {
await mountControl({
modelValue: 7,
image: MISSING,
required: true,
error: 'Pick an image for Thumbnail.',
})
expect(control().className).toContain('border-error')
expect(text(document.body.querySelector('[role="alert"]'))).toBe(
'Pick an image for Thumbnail.',
)
expect(document.body.querySelector('[data-testid="media-help"]')).toBeNull()
})
it('stacks a 16:9 placeholder and full width buttons on mobile', async () => {
await mountControl({ modelValue: 7, image: MISSING }, MOBILE_WIDTH)
expect(control().querySelector('[data-testid="media-placeholder"]')!.className).toContain(
'max-md:aspect-video',
)
expect(buttonNamed('Clear Thumbnail')!.className).toContain('h-11')
})
})
describe('Saving and read-only', () => {
it('puts the thumbnail at 60 % and disables the buttons, and the picker cannot open', async () => {
await mountControl({ modelValue: 7, image: LIME, disabled: true })
expect(thumbnail()!.className).toContain('opacity-60')
expect(thumbnail()!.disabled).toBe(true)
expect(buttons().every((button) => button.disabled)).toBe(true)
expect(buttonNamed('Change image')!.className).toContain('disabled:opacity-40')
thumbnail()!.click()
buttonNamed('Change image')!.click()
await flushPromises()
expect(dialog()).toBeNull()
})
it('disables the pick button of an empty control', async () => {
await mountControl({ disabled: true })
expect(main().disabled).toBe(true)
})
it('puts the missing placeholder at 60 %', async () => {
await mountControl({ modelValue: 7, image: MISSING, disabled: true })
expect(control().querySelector('[data-testid="media-placeholder"]')!.className).toContain(
'opacity-60',
)
})
})
describe('Events', () => {
function emitted() {
return holder.wrapper!.findComponent(MediaFieldControl).emitted()
}
it('opens the picker in field mode from the thumbnail, Change image and Pick an image', async () => {
await mountControl({ modelValue: 7, image: LIME })
thumbnail()!.click()
await flushPromises()
expect(text(dialog())).toContain('Choose an image')
byText('button', 'Cancel')!.click()
await vi.waitFor(() => expect(dialog()).toBeNull())
buttonNamed('Change image')!.click()
await flushPromises()
expect(text(dialog())).toContain('Choose an image')
expect(emitted()['update:modelValue']).toBeUndefined()
})
it('opens from Pick an image on an empty control', async () => {
await mountControl()
await openFromMain()
expect(text(dialog())).toContain('Choose an image')
expect(text(dialog())).toContain('No image')
})
it('hides No image in the picker of a required field', async () => {
await mountControl({ required: true })
await openFromMain()
expect(text(dialog())).not.toContain('Leave this field empty.')
})
it('emits the id and the library data of the picked image and announces it', async () => {
await mountControl({ modelValue: 7, image: LIME })
await openFromMain()
await chooseInPicker('bathroom-tiles.png')
expect(emitted()['update:modelValue']).toEqual([[8]])
const [[id, image]] = emitted().change as [[number, MediaImage]]
expect(id).toBe(8)
expect(image).toEqual({
id: 8,
filename: 'bathroom-tiles.png',
width: 800,
height: 600,
size: 51200,
thumbnail_url: '/media/bathroom-tiles.png',
})
expect(live()).toBe('Image selected: bathroom-tiles.png')
})
it('moves the focus to Change image after Use this image', async () => {
await mountStateful(null, null)
await openFromMain()
await chooseInPicker('bathroom-tiles.png')
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
expect(text(main())).toBe('Change image')
})
it('does not announce or emit when the current image is chosen again', async () => {
await mountControl({ modelValue: 7, image: LIME })
await openFromMain()
await chooseInPicker('lime-plaster-workshop.jpg')
expect(emitted()['update:modelValue']).toBeUndefined()
expect(emitted().change).toBeUndefined()
expect(live()).toBe('')
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
})
it('takes a finished upload as the value, announces it and closes the dialog', async () => {
await mountControl()
await openFromMain()
const uploaded = { ...fieldLibrary()[1]!, id: 13, filename: 'diagram.svg' }
holder.wrapper!.findComponent({ name: 'ImageUploader' }).vm.$emit('uploaded', uploaded)
await flushPromises()
expect(emitted()['update:modelValue']).toEqual([[13]])
expect(live()).toBe('Image selected: diagram.svg')
})
it('emits null for Remove, announces it and focuses Pick an image', async () => {
await mountStateful(7, LIME)
buttonNamed('Remove image from Thumbnail')!.click()
await flushPromises()
expect(emitted()['update:modelValue']).toEqual([[null]])
expect(emitted().change).toEqual([[null, null]])
expect(live()).toBe('Image removed from Thumbnail.')
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
expect(text(main())).toBe('Pick an image')
})
it('emits null for Clear, announces Thumbnail cleared and focuses Pick an image', async () => {
await mountStateful(7, MISSING)
buttonNamed('Clear Thumbnail')!.click()
await flushPromises()
expect(emitted()['update:modelValue']).toEqual([[null]])
expect(live()).toBe('Thumbnail cleared.')
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
})
it('emits null from the No image row with Remove image and focuses Pick an image', async () => {
await mountControl({ modelValue: 7, image: LIME })
await openFromMain()
document.body.querySelector<HTMLButtonElement>('[data-testid="picker-no-image"]')!.click()
await flushPromises()
byText('button', 'Remove image')!.click()
await flushPromises()
expect(emitted()['update:modelValue']).toEqual([[null]])
expect(live()).toBe('Image removed from Thumbnail.')
})
it('returns the focus to the opener after Cancel and after Escape', async () => {
await mountControl({ modelValue: 7, image: LIME })
const opener = buttonNamed('Change image')!
opener.focus()
opener.click()
await flushPromises()
byText('button', 'Cancel')!.click()
await vi.waitFor(() => expect(document.activeElement).toBe(opener))
thumbnail()!.focus()
thumbnail()!.click()
await flushPromises()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await vi.waitFor(() => expect(document.activeElement).toBe(thumbnail()))
})
it('announces nothing when the picker opens', async () => {
await mountControl({ modelValue: 7, image: LIME })
await openFromMain()
expect(live()).toBe('')
})
})
describe('Names and roles', () => {
it('is a group labelled by the field label', async () => {
await mountControl({ modelValue: 7, image: LIME })
const labelledBy = control().getAttribute('aria-labelledby')!
expect(control().getAttribute('role')).toBe('group')
expect(text(document.getElementById(labelledBy))).toBe('Thumbnail')
})
it('carries the exact accessible names', async () => {
await mountControl({ modelValue: 7, image: LIME })
expect(thumbnail()!.getAttribute('aria-label')).toBe(
'Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB',
)
expect(buttonNamed('Remove image from Thumbnail')).toBeInstanceOf(HTMLButtonElement)
expect(buttonNamed('Change image')).toBeInstanceOf(HTMLButtonElement)
})
it('names the placeholder, the empty button and the clear button', async () => {
await mountControl({ modelValue: 7, image: MISSING })
expect(control().querySelector('[role="img"]')!.getAttribute('aria-label')).toBe(
'Image no longer in the library',
)
expect(buttonNamed('Clear Thumbnail')).toBeInstanceOf(HTMLButtonElement)
unmountApp()
await mountControl()
expect(buttonNamed('Pick an image for Thumbnail')).toBeInstanceOf(HTMLButtonElement)
})
it('marks the main button required only when the field is', async () => {
await mountControl({ required: true })
expect(main().getAttribute('aria-required')).toBe('true')
unmountApp()
await mountControl()
expect(main().getAttribute('aria-required')).toBeNull()
})
it('has two tab stops in order: thumbnail, Change image, Remove', async () => {
await mountControl({ modelValue: 7, image: LIME })
expect(
buttons().map((button) => button.getAttribute('aria-label') ?? text(button)),
).toEqual([
'Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB',
'Change image',
'Remove image from Thumbnail',
])
expect(buttons().every((button) => button.tabIndex === 0)).toBe(true)
})
it('keeps one polite live region', async () => {
await mountControl({ modelValue: 7, image: LIME })
const regions = document.body.querySelectorAll('[aria-live="polite"][role="status"]')
expect(regions).toHaveLength(1)
})
})
describe('French', () => {
it('reads the empty, filled and missing states in French', async () => {
setLocale('fr')
await mountControl()
expect(text(control())).toContain('Aucune image sélectionnée.')
expect(buttonNamed('Choisir une image pour Thumbnail')).toBeInstanceOf(HTMLButtonElement)
expect(text(document.body)).toContain('Une image de votre bibliothèque.')
unmountApp()
await mountControl({ modelValue: 7, image: LIME })
expect(buttonNamed('Changer d’image')).toBeInstanceOf(HTMLButtonElement)
expect(buttonNamed('Retirer l’image de Thumbnail')).toBeInstanceOf(HTMLButtonElement)
expect(thumbnail()!.getAttribute('aria-label')).toBe(
'Changer d’image, lime-plaster-workshop.jpg, 1600 par 900, 240 Ko',
)
unmountApp()
await mountControl({ modelValue: 7, image: MISSING })
expect(text(control())).toContain('Cette image n’est plus dans la bibliothèque')
expect(buttonNamed('Effacer Thumbnail')).toBeInstanceOf(HTMLButtonElement)
})
})
+13 -3
View File
@@ -78,8 +78,12 @@ export const Host = defineComponent({
props: {
initialFile: { type: Object as () => File | null, default: null },
inArticleIds: { type: Array as () => number[], default: () => [1, 4] },
field: {
type: Object as () => { optional: boolean; currentId: number | null } | undefined,
default: undefined,
},
},
emits: ['uploaded', 'inserted', 'close'],
emits: ['uploaded', 'inserted', 'close', 'chosen', 'closed'],
setup(_props, { emit }) {
const open = ref(false)
return {
@@ -88,13 +92,19 @@ export const Host = defineComponent({
onInserted: (item: MediaItem, alt: string, src: string) =>
emit('inserted', item, alt, src),
onClose: () => emit('close'),
onChosen: (item: MediaItem | null) => emit('chosen', item),
onClosed: () => emit('closed'),
}
},
template: `<ImageUploader v-model:open="open" :initial-file="initialFile" :in-article-ids="inArticleIds" @uploaded="onUploaded" @inserted="onInserted" @close="onClose" />`,
template: `<ImageUploader v-model:open="open" :initial-file="initialFile" :in-article-ids="inArticleIds" :field="field" @uploaded="onUploaded" @inserted="onInserted" @close="onClose" @chosen="onChosen" @closed="onClosed" />`,
})
export function mountUploader(
props: { initialFile?: File | null; inArticleIds?: number[] } = {},
props: {
initialFile?: File | null
inArticleIds?: number[]
field?: { optional: boolean; currentId: number | null }
} = {},
width = DESKTOP_WIDTH,
) {
setViewportWidth(width)
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import { effectScope } from 'vue'
import { useEntryForm } from '@/composables/useEntryForm'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { issueFor } from '@/lib/entryForm'
import type { MediaImage } from '@/lib/mediaField'
const fields: BuiltinField[] = [
{
key: 'title',
label: 'Title',
type: 'text',
required: true,
unique: false,
default: null,
min: null,
max: null,
options: [],
},
{
key: 'thumbnail',
label: 'Thumbnail',
type: 'media',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: [],
},
]
function image(id: number): MediaImage {
return {
id,
filename: `image-${id}.jpg`,
width: 800,
height: 600,
size: 51200,
thumbnail_url: '/t.jpg',
}
}
function entry(thumbnail: number | null): ContentEntry {
return {
id: 42,
content_type: 'events',
data: { title: 'Open day', thumbnail },
media: thumbnail === null ? {} : { thumbnail: image(thumbnail) },
created_by: 1,
updated_by: 1,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
}
function build(stored: ContentEntry) {
const form = effectScope().run(() =>
useEntryForm(
() => fields,
() => false,
),
)!
form.reset(stored)
return form
}
describe('dirty tracking of a media value', () => {
it('starts clean after reset', () => {
expect(build(entry(7)).dirty.value).toBe(false)
})
it('turns dirty when another image is picked', () => {
const form = build(entry(7))
form.setValue('thumbnail', 8, image(8))
expect(form.dirty.value).toBe(true)
expect(form.images.value.thumbnail).toEqual(image(8))
})
it('is clean again when 8 is picked then 7', () => {
const form = build(entry(7))
form.setValue('thumbnail', 8, image(8))
form.setValue('thumbnail', 7, image(7))
expect(form.dirty.value).toBe(false)
})
it('turns dirty when the image is removed and clean when it is restored', () => {
const form = build(entry(7))
form.setValue('thumbnail', null, null)
expect(form.dirty.value).toBe(true)
expect(form.images.value.thumbnail).toBeUndefined()
form.setValue('thumbnail', 7, image(7))
expect(form.dirty.value).toBe(false)
})
it('is clean again after a reset from the answered entry', () => {
const form = build(entry(7))
form.setValue('thumbnail', 8, image(8))
form.reset(entry(8))
expect(form.dirty.value).toBe(false)
expect(form.images.value.thumbnail).toEqual(image(8))
})
it('clears the thumbnail error when an image is picked', () => {
const form = build(entry(null))
form.rejectField('thumbnail', issueFor(fields[1], 'required'))
expect(form.hasIssues.value).toBe(true)
form.setValue('thumbnail', 8, image(8))
expect(form.hasIssues.value).toBe(false)
})
})
+4 -3
View File
@@ -5,6 +5,7 @@ import { useEntries } from '@/composables/useEntries'
import { useEntryForm } from '@/composables/useEntryForm'
import { useEntryLoad } from '@/composables/useEntryLoad'
import type { ContentEntry } from '@/lib/contentTypes'
import type { MediaSummary } from '@/lib/mediaField'
import { resolveEditorPhase, type EntryTarget } from '@/lib/entryEditorState'
import { buildData, issueFor, validateEntry } from '@/lib/entryForm'
import { classifyRefusal, isGoneError, parseEntryRefusal } from '@/lib/entryServerError'
@@ -91,7 +92,7 @@ export function useEntryDraft(typeKey: string, target: EntryTarget) {
return null
}
failure.value = 'none'
const issues = validateEntry(fields.value, form.values.value)
const issues = validateEntry(fields.value, form.values.value, form.images.value)
if (Object.keys(issues).length > 0) {
form.reject(issues)
focusToken.value++
@@ -113,8 +114,8 @@ export function useEntryDraft(typeKey: string, target: EntryTarget) {
}
}
function change(key: string, value: unknown) {
form.setValue(key, value)
function change(key: string, value: unknown, image?: MediaSummary | null) {
form.setValue(key, value, image)
}
function reload() {
+20 -1
View File
@@ -1,8 +1,10 @@
import { computed, ref } from 'vue'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import type { MediaMap, MediaSummary } from '@/lib/mediaField'
import {
carriesDefault,
firstIssueKey,
initialImages,
initialValues,
valuesDiffer,
type EntryIssues,
@@ -13,6 +15,7 @@ import {
export function useEntryForm(fields: () => BuiltinField[], creating: () => boolean) {
const values = ref<EntryValues>({})
const saved = ref<EntryValues>({})
const images = ref<MediaMap>({})
const issues = ref<EntryIssues>({})
const rejected = ref(false)
@@ -24,12 +27,27 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
const next = initialValues(fields(), entry)
values.value = { ...next }
saved.value = { ...next }
images.value = initialImages(fields(), entry)
issues.value = {}
rejected.value = false
}
function setValue(key: string, value: unknown) {
function setImage(key: string, image: MediaSummary | null | undefined) {
if (image === undefined) {
return
}
const next = { ...images.value }
if (image === null) {
delete next[key]
} else {
next[key] = image
}
images.value = next
}
function setValue(key: string, value: unknown, image?: MediaSummary | null) {
values.value = { ...values.value, [key]: value }
setImage(key, image)
rejected.value = false
if (issues.value[key]) {
const rest = { ...issues.value }
@@ -53,6 +71,7 @@ export function useEntryForm(fields: () => BuiltinField[], creating: () => boole
return {
values,
images,
issues,
rejected,
dirty,
+1 -1
View File
@@ -82,7 +82,7 @@ export function useSchemaBuilder(source?: EditSource) {
const baselineFields = computed(() => (baseline.value ? baselinePayload(baseline.value) : []))
const baselineKeys = computed(() => baselineFields.value.map((field) => field.key))
const savedSignatures = computed(() => baselineSignatures(baselineFields.value))
const changes = computed(() => diffFields(baselineFields.value, form.value.fields))
const changes = computed(() => diffFields(baselineFields.value, form.value.fields, entryTotal))
const needsReview = computed(() => editing && entryTotal > 0 && changes.value.length > 0)
const {
saveState,
+2 -2
View File
@@ -6,7 +6,7 @@ import { ApiError } from '@/composables/useApi'
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
import { SCHEMA_PATH } from '@/lib/routePaths'
import type { BuilderForm } from '@/lib/schemaBuilderModel'
import { flaggedChanges, type FieldChange } from '@/lib/schemaBuilderDiff'
import { flaggedChanges, hasRefusedChange, type FieldChange } from '@/lib/schemaBuilderDiff'
import { formFromType } from '@/lib/schemaBuilderLoad'
import { buildPayload, buildUpdatePayload } from '@/lib/schemaBuilderPayload'
import type { TypeTouch } from '@/lib/schemaBuilderTouch'
@@ -210,7 +210,7 @@ export function useSchemaSave(context: SchemaSaveContext) {
}
async function apply() {
if (saving.value) {
if (saving.value || (editing && hasRefusedChange(changes.value))) {
return
}
failure.value = 'none'
+2
View File
@@ -14,10 +14,12 @@ const SOURCES = [
'components/EntryForm.vue',
'components/EntryNotices.vue',
'components/MarkdownField.vue',
'components/MediaFieldControl.vue',
'composables/useEntryEditorPage.ts',
'composables/useEntryRemoval.ts',
'lib/entryForm.ts',
'lib/entryMessages.ts',
'lib/mediaField.ts',
'lib/entryEditors.ts',
'views/EntryEditorPage.vue',
]
+52
View File
@@ -0,0 +1,52 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { messages } from './messages'
const SOURCES = [
'components/ImageUploader.vue',
'components/MediaFieldControl.vue',
'components/MediaPicker.vue',
'components/SchemaChangeRow.vue',
'components/SchemaReviewDialog.vue',
'components/FieldCell.vue',
'components/EntryNotices.vue',
'lib/fieldTypes.ts',
'lib/mediaField.ts',
'lib/schemaBuilderDiff.ts',
]
const SAME_IN_FRENCH = new Set<string>(['Date'])
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('Media 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(60)
})
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)) {
expect(french).not.toBe(key)
}
},
)
})
+39
View File
@@ -2609,6 +2609,45 @@ export const messages = {
Heading: 'Titre',
List: 'Liste',
id: 'identifiant',
'Image no longer in the library': 'Image absente de la bibliothèque',
'Use this image': 'Utiliser cette image',
'Choose another image': 'Choisir une autre image',
'Clear {0}': 'Effacer {0}',
'Image removed from {0}.': 'Image retirée de {0}.',
'Image selected: {0}': 'Image sélectionnée : {0}',
'No image selected.': 'Aucune image sélectionnée.',
'One image from your library.': 'Une image de votre bibliothèque.',
'Pick an image': 'Choisir une image',
'Pick an image for {0}': 'Choisir une image pour {0}',
'Remove image from {0}': 'Retirer l’image de {0}',
'This image is no longer in the library': 'Cette image n’est plus dans la bibliothèque',
'You can still save. The entry keeps the link until you change it.':
'Vous pouvez quand même enregistrer. L’entrée garde le lien tant que vous ne le changez pas.',
'{0} cleared.': '{0} effacé.',
'{0} is required. Choose another image to save.':
'{0} est obligatoire. Choisissez une autre image pour enregistrer.',
Current: 'Actuelle',
'Images you upload appear here, ready to use in any article or entry.':
'Les images que vous importez apparaissent ici, prêtes à servir dans n’importe quel article ou entrée.',
'Leave this field empty.': 'Laisser ce champ vide.',
"Can't be applied": 'Ne peut pas être appliqué',
'Back to editing keeps your other changes.':
'Revenir à la modification conserve vos autres changements.',
'Pick an image for {0}.': 'Choisissez une image pour {0}.',
"The {0} field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.":
'Le champ {0} a été modifié après l’ouverture de ce formulaire. Rechargez le formulaire pour continuer. Ce que vous avez saisi ici ne peut pas être enregistré avec les anciens champs.',
'{0} must be an image from the library.': '{0} doit être une image de la bibliothèque.',
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.':
'Contient une image de la médiathèque. Les entrées pointent vers l’image au lieu d’en garder une copie, donc elles affichent toujours sa version actuelle.',
'One image from your media library': 'Une image de votre médiathèque',
'Change image, {0}, {1}, {2}': 'Changer d’image, {0}, {1}, {2}',
'{0} by {1}': '{0} par {1}',
'1 entry is unaffected.': '1 entrée n’est pas concernée.',
"A media field can't have a default, so the existing entry would have no image. Make {0} optional to add it.":
'Un champ média ne peut pas avoir de valeur par défaut, donc l’entrée existante n’aurait pas d’image. Rendez {0} facultatif pour l’ajouter.',
"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.',
} as const
export type MessageKey = keyof typeof messages
+1
View File
@@ -9,6 +9,7 @@ const CONTROLS: Record<FieldType, string> = {
boolean: 'switch',
date: 'date',
select: 'select',
media: 'media',
}
function editor(type: FieldType) {
+538
View File
@@ -0,0 +1,538 @@
import { describe, expect, it } from 'vitest'
import type { ContentType } from '@/composables/useContentTypes'
import type { MediaItem } from '@/composables/useMedia'
import type { ContentEntry } from '@/lib/contentTypes'
import { cardFields, describeCell, plainCellText, thumbnailColumn } from '@/lib/entryCells'
import { ENTRY_EDITORS } from '@/lib/entryEditors'
import { buildData, entryTitle, initialValues, validateEntry } from '@/lib/entryForm'
import { ruleMessage } from '@/lib/entryMessages'
import { groupFields } from '@/lib/entryLayout'
import { ApiError } from '@/lib/apiError'
import { classifyRefusal, parseEntryRefusal } from '@/lib/entryServerError'
import { FIELD_TYPES, fieldTypeSpec } from '@/lib/fieldTypes'
import {
imageButtonName,
imageMeta,
imageOfItem,
isMediaId,
isMissingImage,
mediaOf,
} from '@/lib/mediaField'
import { diffFields, summaryLine } from '@/lib/schemaBuilderDiff'
import { baselinePayload, fieldFromApi, formFromType } from '@/lib/schemaBuilderLoad'
import { changeFieldType, createField } from '@/lib/schemaBuilderModel'
import { buildPayload, payloadField } from '@/lib/schemaBuilderPayload'
import { sampleEntry } from '@/lib/schemaBuilderSample'
import { apiField, eventsFields } from './schemaFixtures'
const LIME = {
id: 7,
filename: 'lime-plaster-workshop.jpg',
width: 1600,
height: 900,
size: 245760,
thumbnail_url: '/media/lime-plaster-workshop.jpg',
}
const VECTOR = {
id: 4,
filename: 'logo-renew.svg',
width: null,
height: null,
size: 8192,
thumbnail_url: '/media/logo-renew.svg',
}
const MISSING = { id: 7, missing: true as const }
function mediaField(rest: object = {}) {
return apiField('Thumbnail', 'thumbnail', 'media', { options: [], ...rest })
}
const media = ENTRY_EDITORS.media
describe('media in the registry', () => {
it('is the seventh type, after select', () => {
expect(FIELD_TYPES).toHaveLength(7)
expect(FIELD_TYPES[6]).toBe('media')
expect(FIELD_TYPES[5]).toBe('select')
})
it('describes itself', () => {
const spec = fieldTypeSpec('media')
expect(spec.label()).toBe('Media')
expect(spec.description()).toBe('One image from your media library')
expect(spec.icon).toBe('i-lucide-image')
expect(spec.editor.control).toBe('media')
expect(spec.cell).toBe('thumbnail')
expect(spec.narrowColumn).toBe(true)
expect(spec.alignEnd).toBe(false)
expect(spec.pairs).toBe(false)
})
it('supports no unique, limits, default or choices', () => {
const spec = fieldTypeSpec('media')
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?.()).toBe(
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.',
)
})
it.each(FIELD_TYPES.filter((type) => type !== 'media'))(
'%s keeps its builder options and has no hint',
(type) => {
const spec = fieldTypeSpec(type)
expect(spec.supportsDefault).toBe(true)
expect(spec.showsUnsupportedOptions).toBe(true)
expect(spec.builderHint).toBeNull()
expect(spec.supportsChoices).toBe(type === 'select')
},
)
it('is not the text fallback for a loaded field', () => {
expect(fieldTypeSpec('media')).not.toBe(fieldTypeSpec('text'))
const field = fieldFromApi(mediaField(), 1)
expect(field.type).toBe('media')
})
it('starts empty on create and keeps the stored id on edit', () => {
const field = mediaField()
expect(media.initial(field, undefined, true)).toBeNull()
expect(media.initial(mediaField({ default: 7 }), 3, true)).toBeNull()
expect(media.initial(field, 7, false)).toBe(7)
expect(media.initial(field, null, false)).toBeNull()
expect(media.initial(field, undefined, false)).toBeNull()
expect(media.initial(field, 'x', false)).toBeNull()
})
it('has one help line and no counter', () => {
expect(media.help(mediaField())).toBe('One image from your library.')
expect(media.counter(mediaField(), 7)).toBeNull()
})
})
describe('client validation of a media value', () => {
it.each([
['required', null, 'required'],
['required', undefined, 'required'],
['optional', null, null],
['optional', 7, null],
['required', 7, null],
['optional', 0, 'type'],
['optional', -1, 'type'],
['optional', 7.5, 'type'],
['optional', '7', 'type'],
['optional', true, 'type'],
['optional', 4294967296, 'type'],
['optional', 4294967295, null],
] as const)('%s holding %j gives %s', (required, value, rule) => {
const field = mediaField({ required: required === 'required' })
expect(media.validate(field, value)).toBe(rule)
})
it('reads a required field whose image is gone as required', () => {
const field = mediaField({ required: true })
expect(media.validate(field, 7, { image: MISSING })).toBe('required')
expect(media.validate(field, 7, { image: LIME })).toBeNull()
})
it('lets an optional field keep a missing image', () => {
expect(media.validate(mediaField(), 7, { image: MISSING })).toBeNull()
})
it('hands the image of each field to the validation of the form', () => {
const field = mediaField({ required: true })
expect(validateEntry([field], { thumbnail: 7 }, { thumbnail: MISSING })).toMatchObject({
thumbnail: { rule: 'required', message: 'Pick an image for Thumbnail.' },
})
expect(validateEntry([field], { thumbnail: 7 }, { thumbnail: LIME })).toEqual({})
expect(validateEntry([field], { thumbnail: null })).toMatchObject({
thumbnail: { rule: 'required' },
})
})
})
describe('messages of a media field', () => {
const field = mediaField()
it.each([
['required', 'Pick an image for Thumbnail.'],
['type', 'Thumbnail must be an image from the library.'],
] as const)('the rule %s reads "%s"', (rule, message) => {
expect(ruleMessage(rule, field)).toBe(message)
})
it.each([
['invalid_entry: thumbnail: required', 'required'],
['invalid_entry: thumbnail: type', 'type'],
])('maps the server answer %s to the field', (body, rule) => {
const refusal = parseEntryRefusal(new ApiError(422, undefined, body))
expect(classifyRefusal([field], refusal)).toMatchObject({ kind: 'field', field, rule })
})
it('maps an unknown media key to a schema change', () => {
const refusal = parseEntryRefusal(
new ApiError(422, undefined, 'invalid_entry: thumbnail: unknown_field'),
)
expect(classifyRefusal([field], refusal)).toEqual({ kind: 'schema', key: 'thumbnail' })
})
})
describe('payload of a media value', () => {
it.each([
[7, 7],
[null, null],
[undefined, null],
] as const)('sends %j as %j', (value, sent) => {
expect(media.payload(mediaField(), value)).toBe(sent)
})
it('keeps the stored id of a missing image and never drops the key', () => {
const fields = [apiField('Title', 'title', 'text'), mediaField()]
const entry = {
id: 1,
content_type: 'events',
data: { title: 'x', thumbnail: 7 },
media: { thumbnail: MISSING },
} as unknown as ContentEntry
const values = initialValues(fields, entry)
expect(buildData(fields, values)).toEqual({ title: 'x', thumbnail: 7 })
expect(buildData(fields, initialValues(fields, null))).toEqual({
title: null,
thumbnail: null,
})
})
it('sends the picked id as a JSON number', () => {
const sent = JSON.stringify(buildData([mediaField()], { thumbnail: 8 }))
expect(sent).toBe('{"thumbnail":8}')
})
})
describe('media ids', () => {
it.each([
[1, true],
[4294967295, true],
[0, false],
[-1, false],
[7.5, false],
['7', false],
[null, false],
[4294967296, false],
])('%j is an id: %s', (value, expected) => {
expect(isMediaId(value)).toBe(expected)
})
})
describe('media summaries', () => {
it('reads the map of an entry by field key', () => {
const entry = { media: { thumbnail: LIME, cover: MISSING } } as unknown as ContentEntry
expect(mediaOf(entry, 'thumbnail')).toEqual(LIME)
expect(isMissingImage(mediaOf(entry, 'cover')!)).toBe(true)
expect(mediaOf(entry, 'absent')).toBeNull()
expect(mediaOf({} as ContentEntry, 'thumbnail')).toBeNull()
expect(mediaOf(null, 'thumbnail')).toBeNull()
})
it('describes an image', () => {
expect(imageMeta(LIME)).toBe('1600 × 900 · 240 KB')
expect(imageMeta(VECTOR)).toBe('Vector · 8 KB')
expect(imageButtonName(LIME)).toBe(
'Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB',
)
expect(imageButtonName(VECTOR)).toBe('Change image, logo-renew.svg, Vector, 8 KB')
})
it('builds a summary from a library item without a request', () => {
const item = {
id: 8,
filename: 'bathroom-tiles.png',
url: '/media/bathroom-tiles.png',
size: 51200,
width: 800,
height: 600,
} as MediaItem
expect(imageOfItem(item)).toEqual({
id: 8,
filename: 'bathroom-tiles.png',
width: 800,
height: 600,
size: 51200,
thumbnail_url: '/media/bathroom-tiles.png',
})
expect(imageMeta(imageOfItem(item))).toBe('800 × 600 · 50 KB')
})
})
describe('media in the list', () => {
const field = mediaField()
it('describes a cell from the item own media object', () => {
expect(describeCell(field, 7, LIME)).toEqual({
kind: 'thumbnail',
text: LIME.filename,
image: LIME,
})
expect(describeCell(field, 7, MISSING)).toEqual({
kind: 'thumbnail',
text: '',
image: MISSING,
})
expect(describeCell(field, 7)).toEqual({ kind: 'thumbnail', text: '', image: MISSING })
expect(describeCell(field, null).kind).toBe('empty')
expect(describeCell(field, undefined).kind).toBe('empty')
})
it('reads a plain text of dash for none and never the id', () => {
expect(plainCellText(describeCell(field, null))).toBe('–')
expect(plainCellText(describeCell(field, 7, MISSING))).toBe('–')
expect(plainCellText(describeCell(field, 7, MISSING))).not.toContain('7')
expect(plainCellText(describeCell(field, 7, LIME))).toBe(LIME.filename)
})
it('picks a leading thumbnail and keeps the card text fields', () => {
const title = apiField('Title', 'title', 'text')
const starts = apiField('Starts on', 'starts_on', 'date')
const category = apiField('Category', 'category', 'select')
expect(cardFields([title, field, starts, category])).toEqual({
first: title,
second: starts,
choice: category,
})
expect(cardFields([field, starts])).toMatchObject({ first: field, second: starts })
expect(thumbnailColumn([title, field, starts])).toBe(field)
expect(thumbnailColumn([title, starts])).toBeUndefined()
})
})
describe('titles and layout', () => {
const entry = {
id: 42,
content_type: 'events',
data: { thumbnail: 7 },
media: { thumbnail: LIME },
} as unknown as ContentEntry
it('never uses a media value as the title of an entry', () => {
expect(entryTitle([mediaField()], entry)).toBe('Entry 42')
expect(entryTitle([mediaField(), apiField('Title', 'title', 'text')], entry)).toBe(
'Entry 42',
)
})
it('never pairs a media field with a neighbour', () => {
const flag = apiField('Online', 'online', 'boolean')
const number = apiField('Capacity', 'capacity', 'number')
const date = apiField('Starts on', 'starts_on', 'date')
for (const before of [flag, number, date]) {
const rows = groupFields([before, mediaField(), apiField('Ends on', 'ends_on', 'date')])
expect(rows.map((row) => row.length)).toEqual([1, 1, 1])
}
})
})
describe('media in the schema builder', () => {
const events: ContentType = {
id: 1,
key: 'events',
name: 'Events',
icon: null,
fields: [...eventsFields, mediaField()],
created_at: '',
updated_at: '',
}
it('sends the exact body of a media field', () => {
const field = createField(1, { label: 'Thumbnail', key: 'thumbnail', type: 'media' })
expect(payloadField(field)).toEqual({
key: 'thumbnail',
label: 'Thumbnail',
type: 'media',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: [],
})
expect(payloadField({ ...field, required: true }).required).toBe(true)
})
it('ignores stale settings of another type when sending', () => {
const stale = createField(1, {
label: 'Thumbnail',
key: 'thumbnail',
type: 'media',
unique: true,
defaultValue: 'x',
min: '3',
max: '9',
options: ['a'],
})
expect(payloadField(stale)).toMatchObject({
unique: false,
default: null,
min: null,
max: null,
options: [],
})
})
it('round-trips a loaded media field with no change', () => {
let next = 1
const form = formFromType(events, () => next++)
const baseline = baselinePayload(events)
expect(form.fields[7]!.type).toBe('media')
expect(buildPayload(form).fields).toEqual(baseline)
expect(diffFields(baseline, form.fields, 37)).toEqual([])
})
it.each(['text', 'number', 'select', 'richtext'] as const)(
'resets unique, default, limits and options changing a new %s row to media and back',
(from) => {
const row = createField(1, {
label: 'Thumbnail',
key: 'thumbnail',
required: true,
type: from,
unique: true,
defaultValue: 'x',
min: '1',
max: '80',
options: ['a', 'b'],
})
const toMedia = changeFieldType(row, 'media')
expect(toMedia).toMatchObject({
label: 'Thumbnail',
key: 'thumbnail',
required: true,
type: 'media',
unique: false,
defaultValue: '',
min: '',
max: '',
options: [],
})
expect(changeFieldType(toMedia, 'number')).toMatchObject({
type: 'number',
min: '',
max: '',
defaultValue: '',
required: true,
})
},
)
it('previews a media value as null', () => {
const field = createField(1, { label: 'Thumbnail', key: 'thumbnail', type: 'media' })
expect(sampleEntry('events', [field]).data.thumbnail).toBeNull()
})
})
describe('review of a media field', () => {
const events: ContentType = {
id: 1,
key: 'events',
name: 'Events',
icon: null,
fields: eventsFields,
created_at: '',
updated_at: '',
}
const baseline = baselinePayload(events)
function loaded() {
let next = 1
return formFromType(events, () => next++).fields
}
function withThumbnail(rest: object = {}) {
return [
...loaded(),
createField(99, { label: 'Thumbnail', key: 'thumbnail', type: 'media', ...rest }),
]
}
it('flags a required media field added to a type with entries as impossible', () => {
const [change] = diffFields(baseline, withThumbnail({ required: true }), 12)
expect(change).toMatchObject({ kind: 'add', key: 'thumbnail', flag: 'cannotApply' })
expect(change!.notes[0]!.text).toBe(
"Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it.",
)
})
it('words the single entry case apart', () => {
const [change] = diffFields(baseline, withThumbnail({ required: true }), 1)
expect(change!.notes[0]!.text).toBe(
"Media, required. A media field can't have a default, so the existing entry would have no image. Make Thumbnail optional to add it.",
)
})
it.each([
[12, 'Media, optional. 12 entries are unaffected.'],
[1, 'Media, optional. 1 entry is unaffected.'],
[0, 'Media, optional.'],
])('reads the clean row for %i entries', (total, text) => {
const [change] = diffFields(baseline, withThumbnail(), total)
expect(change!.flag).toBeNull()
expect(change!.notes[0]!.text).toBe(text)
})
it('keeps the add note of the other types unchanged by the count', () => {
const added = [...loaded(), createField(99, { label: 'Speaker', key: 'speaker' })]
expect(diffFields(baseline, added, 12)[0]!.notes[0]!.text).toBe('Text, optional.')
expect(diffFields(baseline, added)[0]!.notes[0]!.text).toBe('Text, optional.')
})
it('keeps the summary line', () => {
expect(summaryLine(diffFields(baseline, withThumbnail({ required: true }), 12))).toBe(
'Add 1 field',
)
})
it('flags making a saved optional media field required as one that may not fit', () => {
const saved: ContentType = { ...events, fields: [...eventsFields, mediaField()] }
const base = baselinePayload(saved)
let next = 1
const fields = formFromType(saved, () => next++).fields.map((field) =>
field.key === 'thumbnail' ? { ...field, required: true } : field,
)
const [change] = diffFields(base, fields, 12)
expect(change).toMatchObject({ kind: 'change', flag: 'mayNotFit' })
expect(change!.notes[0]).toMatchObject({
text: 'Now required.',
hint: 'An entry may have no value.',
})
})
})
+3
View File
@@ -1,3 +1,5 @@
import type { MediaMap } from '@/lib/mediaField'
export interface BuiltinField {
key: string
label: string
@@ -24,6 +26,7 @@ export interface ContentEntry {
id: number
content_type: string
data: Record<string, unknown>
media?: MediaMap
created_by: number
updated_by: number
created_at: string
+25 -3
View File
@@ -2,6 +2,7 @@ import { formatNumber, t } from '@/i18n'
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 { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
export { formatDayValue }
@@ -12,6 +13,7 @@ export interface CellView {
kind: CellKind
text: string
yes?: boolean
image?: MediaSummary
}
export const EMPTY_TEXT = '–'
@@ -37,12 +39,23 @@ export function richTextFirstLine(markdown: string) {
return ''
}
export function describeCell(field: BuiltinField, value: unknown): CellView {
function thumbnailCell(value: unknown, image: MediaSummary | undefined): CellView {
const shown = image ?? { id: Number(value), missing: true as const }
return {
kind: 'thumbnail',
text: isMissingImage(shown) ? '' : shown.filename,
image: shown,
}
}
export function describeCell(field: BuiltinField, value: unknown, image?: MediaSummary): CellView {
if (value === undefined || value === null || value === '') {
return EMPTY
}
const kind = fieldTypeSpec(field.type).cell
switch (kind) {
case 'thumbnail':
return thumbnailCell(value, image)
case 'boolean':
return { kind, text: '', yes: value === true }
case 'number':
@@ -75,7 +88,7 @@ export function plainCellText(cell: CellView) {
if (cell.kind === 'boolean') {
return cell.yes ? t('Yes') : t('No')
}
return cell.text
return cell.text || EMPTY_TEXT
}
export interface CardFields {
@@ -84,8 +97,17 @@ export interface CardFields {
choice?: BuiltinField
}
function isThumbnail(field: BuiltinField) {
return fieldTypeSpec(field.type).cell === 'thumbnail'
}
export function thumbnailColumn(columns: BuiltinField[]) {
return columns.find(isThumbnail)
}
export function cardFields(fields: BuiltinField[]): CardFields {
const [first, second] = fields
const [first, ...rest] = fields
const second = rest.find((field) => !isThumbnail(field))
const choice = fields.find((field) => field.type === 'select')
return { first, second, choice: choice === first ? undefined : choice }
}
+30 -2
View File
@@ -3,18 +3,23 @@ import type { BuiltinField } from '@/lib/contentTypes'
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'
export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select'
export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select' | 'media'
export interface FieldCounter {
text: string
over: boolean
}
export interface EntryContext {
image: MediaSummary | null
}
export interface EntryEditorSpec {
control: FieldControl
initial: (field: BuiltinField, stored: unknown, creating: boolean) => unknown
validate: (field: BuiltinField, value: unknown) => EntryRule | null
validate: (field: BuiltinField, value: unknown, context?: EntryContext) => EntryRule | null
payload: (field: BuiltinField, value: unknown) => unknown
help: (field: BuiltinField) => string
counter: (field: BuiltinField, value: unknown) => FieldCounter | null
@@ -108,6 +113,21 @@ function booleanPayload(field: BuiltinField, value: unknown) {
return field.required ? false : null
}
function mediaViolation(
field: BuiltinField,
value: unknown,
context?: EntryContext,
): EntryRule | null {
if (value === null || value === undefined) {
return field.required ? 'required' : null
}
if (!isMediaId(value)) {
return 'type'
}
const gone = context?.image ? isMissingImage(context.image) : false
return field.required && gone ? 'required' : null
}
function noHelp() {
return ''
}
@@ -169,4 +189,12 @@ export const ENTRY_EDITORS: Record<FieldType, EntryEditorSpec> = {
help: noHelp,
counter: noCounter,
},
media: {
control: 'media',
initial: (_field, stored, creating) => (!creating && isMediaId(stored) ? stored : null),
validate: mediaViolation,
payload: (_field, value) => (value === undefined ? null : value),
help: () => t('One image from your library.'),
counter: noCounter,
},
}
+23 -3
View File
@@ -4,6 +4,7 @@ import { describeCell, plainCellText } from '@/lib/entryCells'
import { ruleMessage, type EntryRule } from '@/lib/entryMessages'
import { isBlank } from '@/lib/entryValue'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { mediaOf, type MediaMap } from '@/lib/mediaField'
export type EntryValues = Record<string, unknown>
@@ -27,6 +28,17 @@ export function initialValues(fields: BuiltinField[], stored: ContentEntry | nul
)
}
export function initialImages(fields: BuiltinField[], stored: ContentEntry | null): MediaMap {
const images: MediaMap = {}
for (const field of fields) {
const summary = mediaOf(stored, field.key)
if (summary && stored?.data[field.key] === summary.id) {
images[field.key] = summary
}
}
return images
}
export function buildData(fields: BuiltinField[], values: EntryValues) {
return Object.fromEntries(
fields.map((field) => [
@@ -40,10 +52,16 @@ export function issueFor(field: BuiltinField, rule: EntryRule): FieldIssue {
return { rule, message: ruleMessage(rule, field) }
}
export function validateEntry(fields: BuiltinField[], values: EntryValues): EntryIssues {
export function validateEntry(
fields: BuiltinField[],
values: EntryValues,
images: MediaMap = {},
): EntryIssues {
const issues: EntryIssues = {}
for (const field of fields) {
const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key])
const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key], {
image: images[field.key] ?? null,
})
if (rule) {
issues[field.key] = issueFor(field, rule)
}
@@ -73,5 +91,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' ? t('Entry {0}', [entry.id]) : plainCellText(cell)
return cell.kind === 'empty' || cell.kind === 'thumbnail'
? t('Entry {0}', [entry.id])
: plainCellText(cell)
}
+11
View File
@@ -24,6 +24,8 @@ function requiredMessage(field: BuiltinField) {
return t('Choose an option for {0}.', [field.label])
case 'boolean':
return t('Choose yes or no for {0}.', [field.label])
case 'media':
return t('Pick an image for {0}.', [field.label])
default:
return t('Fill in {0}.', [field.label])
}
@@ -39,6 +41,8 @@ function typeMessage(field: BuiltinField) {
return t('{0} must be a date, like 2026-11-21.', [field.label])
case 'select':
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])
default:
return t('{0} must be text.', [field.label])
}
@@ -134,6 +138,13 @@ export function rangeHelp(field: BuiltinField) {
return typeof max === 'number' ? t('At most {0}.', [String(max)]) : ''
}
export function mediaChangedLine(label: string) {
return t(
"The {0} field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.",
[label],
)
}
export function schemaChangedLine(key: string) {
const name = key.replace(/_/g, ' ')
return t(
+64 -2
View File
@@ -1,11 +1,26 @@
import { t } from '@/i18n'
import { ENTRY_EDITORS, type EntryEditorSpec } from '@/lib/entryEditors'
export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const
export const FIELD_TYPES = [
'text',
'richtext',
'number',
'boolean',
'date',
'select',
'media',
] as const
export type FieldType = (typeof FIELD_TYPES)[number]
export type FieldCellKind = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'choice'
export type FieldCellKind =
| 'text'
| 'richtext'
| 'number'
| 'boolean'
| 'date'
| 'choice'
| 'thumbnail'
export interface FieldTypeSpec {
icon: string
@@ -16,6 +31,10 @@ export interface FieldTypeSpec {
alignEnd: boolean
supportsUnique: boolean
supportsLimits: boolean
supportsDefault: boolean
supportsChoices: boolean
showsUnsupportedOptions: boolean
builderHint: (() => string) | null
pairs: boolean
editor: EntryEditorSpec
}
@@ -32,6 +51,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
alignEnd: false,
supportsUnique: true,
supportsLimits: true,
supportsDefault: true,
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
pairs: false,
editor: ENTRY_EDITORS.text,
},
@@ -44,6 +67,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
alignEnd: false,
supportsUnique: false,
supportsLimits: true,
supportsDefault: true,
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
pairs: false,
editor: ENTRY_EDITORS.richtext,
},
@@ -56,6 +83,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
alignEnd: true,
supportsUnique: true,
supportsLimits: true,
supportsDefault: true,
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
pairs: true,
editor: ENTRY_EDITORS.number,
},
@@ -68,6 +99,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
alignEnd: false,
supportsUnique: false,
supportsLimits: false,
supportsDefault: true,
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
pairs: true,
editor: ENTRY_EDITORS.boolean,
},
@@ -80,6 +115,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
alignEnd: false,
supportsUnique: true,
supportsLimits: true,
supportsDefault: true,
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
pairs: true,
editor: ENTRY_EDITORS.date,
},
@@ -92,9 +131,32 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
alignEnd: false,
supportsUnique: true,
supportsLimits: false,
supportsDefault: true,
supportsChoices: true,
showsUnsupportedOptions: true,
builderHint: null,
pairs: false,
editor: ENTRY_EDITORS.select,
},
media: {
icon: 'i-lucide-image',
label: () => t('Media'),
description: () => t('One image from your media library'),
cell: 'thumbnail',
narrowColumn: true,
alignEnd: false,
supportsUnique: false,
supportsLimits: false,
supportsDefault: false,
supportsChoices: false,
showsUnsupportedOptions: false,
builderHint: () =>
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.',
),
pairs: false,
editor: ENTRY_EDITORS.media,
},
}
export function isFieldType(type: string): type is FieldType {
+5
View File
@@ -5,3 +5,8 @@ export interface ImageChoice {
square?: boolean
confirmLabel: string
}
export interface FieldPick {
optional: boolean
currentId: number | null
}
+64
View File
@@ -0,0 +1,64 @@
import { t } from '@/i18n'
import type { MediaItem } from '@/composables/useMedia'
import type { ContentEntry } from '@/lib/contentTypes'
import { formatDimensions, formatSize } from '@/lib/mediaFormat'
const MEDIA_ID_MAX = 4294967295
export interface MediaImage {
id: number
filename: string
width: number | null
height: number | null
size: number
thumbnail_url: string
}
export interface MissingImage {
id: number
missing: true
}
export type MediaSummary = MediaImage | MissingImage
export type MediaMap = Record<string, MediaSummary>
export function isMissingImage(image: MediaSummary): image is MissingImage {
return 'missing' in image
}
export function isMediaId(value: unknown): value is number {
return (
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MEDIA_ID_MAX
)
}
export function mediaOf(
entry: Pick<ContentEntry, 'media'> | null | undefined,
key: string,
): MediaSummary | null {
return entry?.media?.[key] ?? null
}
export function imageOfItem(item: MediaItem): MediaImage {
return {
id: item.id,
filename: item.filename,
width: item.width,
height: item.height,
size: item.size,
thumbnail_url: item.url,
}
}
export function imageMeta(image: MediaImage) {
return `${formatDimensions(image.width, image.height)} · ${formatSize(image.size)}`
}
export function imageButtonName(image: MediaImage) {
const dimensions =
image.width === null || image.height === null
? formatDimensions(null, null)
: t('{0} by {1}', [String(image.width), String(image.height)])
return t('Change image, {0}, {1}, {2}', [image.filename, dimensions, formatSize(image.size)])
}
+53 -5
View File
@@ -1,10 +1,10 @@
import { t } from '@/i18n'
import { formatNumber, t } from '@/i18n'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import type { BuilderField } from '@/lib/schemaBuilderModel'
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
export type ChangeKind = 'add' | 'change' | 'remove'
export type ChangeFlag = 'mayNotFit' | 'removesData'
export type ChangeFlag = 'mayNotFit' | 'removesData' | 'cannotApply'
export interface ChangeNote {
text: string
@@ -19,6 +19,7 @@ export interface FieldChange {
label: string
notes: ChangeNote[]
flag: ChangeFlag | null
mediaAdd?: boolean
}
type Bound = number | string | null
@@ -150,7 +151,35 @@ function changeNotes(before: PayloadField, after: PayloadField) {
return notes
}
function addNote(field: PayloadField) {
function mediaAddNote(field: PayloadField, total: number) {
const summary = `${fieldTypeSpec(field.type).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)
}
if (total <= 0) {
return note(summary)
}
const unaffected =
total === 1
? t('1 entry is unaffected.')
: t('{0} entries are unaffected.', [formatNumber(total)])
return note(`${summary} ${unaffected}`)
}
function addNote(field: PayloadField, total: number) {
if (!fieldTypeSpec(field.type).supportsDefault) {
return mediaAddNote(field, total)
}
const parts = [
fieldTypeSpec(field.type).label(),
field.required ? t('required') : t('optional'),
@@ -175,9 +204,19 @@ function flagOf(notes: readonly ChangeNote[]): ChangeFlag | null {
return notes.some((entry) => entry.flagged) ? 'mayNotFit' : null
}
function addFlag(
field: PayloadField,
notes: readonly ChangeNote[],
total: number,
): ChangeFlag | null {
const refused = !fieldTypeSpec(field.type).supportsDefault && field.required && total > 0
return refused ? 'cannotApply' : flagOf(notes)
}
export function diffFields(
baseline: readonly PayloadField[],
fields: readonly BuilderField[],
total = 0,
): FieldChange[] {
const before = new Map(baseline.map((field) => [field.key, field]))
const additions: FieldChange[] = []
@@ -186,14 +225,15 @@ export function diffFields(
const after = payloadField(field)
const previous = field.saved ? before.get(field.key) : undefined
if (!field.saved || !previous) {
const notes = [addNote(after)]
const notes = [addNote(after, total)]
additions.push({
kind: 'add',
uid: field.uid,
key: after.key,
label: after.label,
notes,
flag: flagOf(notes),
flag: addFlag(after, notes, total),
...(fieldTypeSpec(after.type).supportsDefault ? {} : { mediaAdd: true }),
})
} else if (fieldSignature(previous) !== fieldSignature(after)) {
const notes = changeNotes(previous, after)
@@ -247,6 +287,14 @@ 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 hasRefusedChange(changes: readonly FieldChange[]) {
return changes.some((change) => change.flag === 'cannotApply')
}
export function summaryLine(changes: readonly FieldChange[]) {
const count = (kind: ChangeKind) => changes.filter((change) => change.kind === kind).length
const added = count('add')
+9 -1
View File
@@ -107,6 +107,10 @@ export function supportsUnique(type: FieldType) {
return FIELD_TYPE_SPECS[type].supportsUnique
}
export function supportsChoices(type: FieldType) {
return FIELD_TYPE_SPECS[type].supportsChoices
}
export function supportsLimits(type: FieldType) {
return FIELD_TYPE_SPECS[type].supportsLimits
}
@@ -132,8 +136,12 @@ export function isUnique(field: BuilderField) {
return field.unique && supportsUnique(field.type)
}
export function supportsDefault(type: FieldType) {
return FIELD_TYPE_SPECS[type].supportsDefault
}
export function effectiveDefault(field: BuilderField) {
if (isUnique(field) || field.defaultValue === '') {
if (!supportsDefault(field.type) || isUnique(field) || field.defaultValue === '') {
return ''
}
if (field.type === 'select') {
+2 -1
View File
@@ -1,6 +1,7 @@
import {
effectiveDefault,
isUnique,
supportsChoices,
supportsLimits,
trimmedOptions,
type BuilderField,
@@ -59,7 +60,7 @@ export function payloadField(field: BuilderField): PayloadField {
default: defaultFor(field),
min: limit(field, field.min),
max: limit(field, field.max),
options: field.type === 'select' ? trimmedOptions(field) : [],
options: supportsChoices(field.type) ? trimmedOptions(field) : [],
}
}
+2
View File
@@ -47,6 +47,8 @@ export function sampleValue(field: BuilderField): string | number | boolean | nu
return field.defaultOn
case 'date':
return given === '' ? SAMPLE_DATE : given
case 'media':
return null
case 'select': {
const choices = trimmedOptions(field).filter((option) => option !== '')
return given !== '' ? given : (choices[0] ?? null)
@@ -0,0 +1,290 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { MediaSummary } from '@/lib/mediaField'
import {
calls,
eventEntries,
installSelectPolyfills,
listPage,
mountList,
text,
unmountAll,
} from './contentTestKit'
import { drawsIcon } from './mediaFieldKit'
import { eventsWithThumbnail } from './schemaTestKit'
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
vi.unstubAllGlobals()
await unmountAll()
})
const KEY = 'renewcms-content-columns-events'
const COLUMNS = ['title', 'thumbnail', 'starts_on', 'category']
function image(id: number, filename: string): MediaSummary {
return {
id,
filename,
width: 1600,
height: 900,
size: 245760,
thumbnail_url: `/media/${filename}`,
}
}
const THUMBNAILS: Record<number, [number | null, MediaSummary | null]> = {
42: [7, image(7, 'lime-plaster-workshop.jpg')],
41: [8, image(8, 'bathroom-tiles.png')],
40: [null, null],
39: [9, { id: 9, missing: true }],
38: [10, image(10, 'timber-joinery.jpg')],
37: [null, null],
36: [11, image(11, 'floorboards.jpg')],
}
const items = eventEntries.map((entry) => {
const [thumbnail, summary] = THUMBNAILS[entry.id]!
return {
...entry,
data: { ...entry.data, thumbnail },
...(summary ? { media: { thumbnail: summary } } : {}),
}
})
function thumbnailSecond() {
const type = eventsWithThumbnail()
const [title, ...rest] = type.fields
const thumbnail = rest.pop()!
return { ...type, fields: [title!, thumbnail, ...rest] }
}
function thumbnailFirst() {
const type = eventsWithThumbnail()
const thumbnail = type.fields[type.fields.length - 1]!
return { ...type, fields: [thumbnail, ...type.fields.slice(0, -1)] }
}
function mountWithThumbnail(
width?: number,
columns: string[] | null = COLUMNS,
type: () => ReturnType<typeof eventsWithThumbnail> = thumbnailSecond,
) {
return mountList('/content/events', {
width,
before: () => {
if (columns) {
localStorage.setItem(KEY, JSON.stringify(columns))
}
},
type,
content: () => [type()],
list: (_key, query) => listPage(items, 7, query),
})
}
const headers = () => [...document.body.querySelectorAll('thead th')]
const rowsOf = () => [...document.body.querySelectorAll('tbody tr')]
const thumbCell = (row: number) => rowsOf()[row]!.querySelectorAll('td')[2]!
describe('The Thumbnail column of the table', () => {
it('has a Thumbnail header between Title and Starts on, 80 px wide, with no sort', async () => {
await mountWithThumbnail()
expect(headers().map(text)).toEqual([
'ID',
'Title',
'Thumbnail',
'Starts on',
'Category',
'Created',
'Updated',
'Updated by',
])
const header = headers()[2]!
expect(header.querySelector('svg')).toBeNull()
expect(header.hasAttribute('aria-sort')).toBe(false)
expect(header.className).toContain('w-23')
expect(header.querySelector('span')!.className).not.toContain('justify-end')
})
it('follows the schema order of the type, so Thumbnail after Description comes last', async () => {
await mountWithThumbnail(undefined, COLUMNS, eventsWithThumbnail)
expect(headers().map(text).slice(0, 5)).toEqual([
'ID',
'Title',
'Starts on',
'Category',
'Thumbnail',
])
})
it('draws a 40 px thumbnail from the item media with the filename as alt', async () => {
await mountWithThumbnail()
const img = thumbCell(0).querySelector('img')!
expect(img.getAttribute('src')).toBe('/media/lime-plaster-workshop.jpg')
expect(img.getAttribute('alt')).toBe('lime-plaster-workshop.jpg')
expect(img.parentElement!.className).toContain('size-10')
expect(rowsOf()[0]!.querySelector('td')!.className).toContain('h-12')
})
it('shows a muted dash for no image', async () => {
await mountWithThumbnail()
const cell = thumbCell(2)
expect(text(cell)).toBe('–')
expect(cell.querySelector('span')!.className).toContain('text-muted')
expect(cell.querySelector('img')).toBeNull()
})
it('shows a dashed 40 px square with the unlink icon and the missing name for a gone image', async () => {
await mountWithThumbnail()
const placeholder = thumbCell(3).querySelector('[role="img"]')!
expect(placeholder.getAttribute('aria-label')).toBe('Image no longer in the library')
expect(placeholder.className).toContain('size-10')
expect(placeholder.className).toContain('border-dashed')
expect(drawsIcon(placeholder, 'unlink')).toBe(true)
expect(thumbCell(3).querySelector('img')).toBeNull()
})
it('makes no request for the thumbnails', async () => {
await mountWithThumbnail()
expect(calls.filter((url) => url.startsWith('/v1/media'))).toEqual([])
})
it('keeps a media first column as the link to the editor', async () => {
await mountWithThumbnail(undefined, ['thumbnail', 'title'], thumbnailFirst)
const link = rowsOf()[0]!.querySelector<HTMLAnchorElement>('[data-testid="entry-link"]')!
expect(link.getAttribute('href')).toBe('/content/events/42')
expect(link.querySelector('img')).not.toBeNull()
})
})
describe('The tooltip of the missing placeholder', () => {
it('reads the missing copy on hover on desktop', async () => {
await mountWithThumbnail()
const placeholder = thumbCell(3).querySelector<HTMLElement>('[role="img"]')!
placeholder.focus()
placeholder.dispatchEvent(new MouseEvent('pointermove', { bubbles: true }))
await flushPromises()
expect(placeholder.tabIndex).toBe(0)
await vi.waitFor(() =>
expect([...document.body.querySelectorAll('[role="tooltip"]')].map(text)).toContain(
'Image no longer in the library',
),
)
})
})
describe('The cards on mobile', () => {
const cards = () => [...document.body.querySelectorAll('[data-testid="entry-card"]')]
it('leads each card with a 32 px thumbnail and keeps the minimum height', async () => {
await mountWithThumbnail(MOBILE_WIDTH)
const first = cards()[0]!
const lead = first.firstElementChild!
expect(lead.querySelector('img')!.parentElement!.className).toContain('size-8')
expect(first.className).toContain('min-h-17')
expect(text(first)).toContain('Lime plaster workshop')
expect(text(first)).toContain('21 Nov 2026')
})
it('draws a dash for none and a dashed 32 px square for a missing image', async () => {
await mountWithThumbnail(MOBILE_WIDTH)
expect(text(cards()[2]!.firstElementChild)).toBe('–')
const placeholder = cards()[3]!.querySelector('[role="img"]')!
expect(placeholder.className).toContain('size-8')
expect(placeholder.className).toContain('border-dashed')
expect(placeholder.getAttribute('aria-label')).toBe('Image no longer in the library')
expect(placeholder.getAttribute('tabindex')).toBeNull()
})
it('leaves the card unchanged when no media column is chosen', async () => {
await mountWithThumbnail(MOBILE_WIDTH, ['title', 'starts_on', 'category'])
const first = cards()[0]!
expect(first.querySelector('img')).toBeNull()
expect(first.querySelector('[aria-label="Image no longer in the library"]')).toBeNull()
expect([...first.children].filter((child) => child.tagName === 'SPAN')).toHaveLength(2)
})
it('reads Entry N as the title when the first column is media and never the id', async () => {
await mountWithThumbnail(MOBILE_WIDTH, ['thumbnail', 'title', 'starts_on'], thumbnailFirst)
const first = cards()[0]!
expect(text(first.querySelector('.font-medium'))).toBe('Entry 42')
expect(text(first)).not.toContain(' 7 ')
})
})
describe('The Columns menu', () => {
function openColumns() {
const trigger = [...document.body.querySelectorAll('button')].find(
(candidate) =>
text(candidate) === 'Columns' || candidate.getAttribute('aria-label') === 'Columns',
)!
trigger.click()
return flushPromises()
}
it('lists Thumbnail with the image icon, its key and a checkbox, with 4 of 6', async () => {
await mountWithThumbnail(undefined, COLUMNS, eventsWithThumbnail)
await openColumns()
const row = [...document.body.querySelectorAll('[data-testid="column-row"]')].find(
(candidate) => text(candidate).includes('Thumbnail'),
)!
expect(text(row)).toBe('Thumbnail thumbnail')
expect(drawsIcon(row, 'image')).toBe(true)
expect(row.querySelector('[role="checkbox"]')!.getAttribute('aria-checked')).toBe('true')
expect(text(document.body.querySelector('[data-testid="column-picker"]'))).toContain(
'4 of 6',
)
})
it('reads the mobile summary and keeps the cap and the stored choice', async () => {
await mountWithThumbnail(MOBILE_WIDTH, COLUMNS, eventsWithThumbnail)
await openColumns()
expect(text(document.body.querySelector('[role="dialog"]'))).toContain(
'Choose up to 6 fields to show. 4 selected.',
)
const row = [...document.body.querySelectorAll('[data-testid="column-row"]')].find(
(candidate) => text(candidate).includes('Thumbnail'),
)!
row.querySelector<HTMLElement>('[role="checkbox"]')!.click()
await flushPromises()
expect(JSON.parse(localStorage.getItem(KEY)!)).toEqual(['title', 'starts_on', 'category'])
})
it('offers Thumbnail unchecked by default beyond the first four fields', async () => {
await mountWithThumbnail(undefined, null, eventsWithThumbnail)
await openColumns()
const row = [...document.body.querySelectorAll('[data-testid="column-row"]')].find(
(candidate) => text(candidate).includes('Thumbnail'),
)!
expect(row.querySelector('[role="checkbox"]')!.getAttribute('aria-checked')).toBe('false')
})
})
@@ -0,0 +1,590 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { MediaSummary } from '@/lib/mediaField'
import {
answerWrite,
button,
calls,
chip,
entry42,
entryWrites,
failWithBody,
fieldWrapper,
hasText,
main,
mountEditor,
pending,
save,
setField,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
import {
answerMediaWrite,
entryWithThumbnail,
focusedName,
libraryBody,
mediaButton,
mediaControl,
mediaState,
mediaText,
mountMedia,
pickFromLibrary,
summaryOf,
} from './entryMediaKit'
import { drawsIcon } from './mediaFieldKit'
import { customTypes, eventsWithThumbnail, thumbnailField, apiField } from './schemaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountAll)
const MISSING: MediaSummary = { id: 7, missing: true }
const REQUIRED_TEXT = 'Pick an image for Thumbnail.'
const fieldKeys = () =>
[...document.body.querySelectorAll<HTMLElement>('[data-entry-field]')].map(
(element) => element.dataset.entryField,
)
const sentData = (index = 0) => entryWrites[index]!.body.data as Record<string, unknown>
const liveText = () => text(fieldWrapper('thumbnail').querySelector('[data-testid="media-live"]'))
const errorLine = () => text(fieldWrapper('thumbnail').querySelector('[role="alert"]'))
async function editTitle() {
await typeInto('title', 'Lime plaster workshop, two days')
}
describe('Where the control sits', () => {
it('is the eighth field, after Description, on its own full width row', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
expect(fieldKeys()).toEqual([
'title',
'starts_on',
'ends_on',
'online',
'capacity',
'category',
'description',
'thumbnail',
])
const row = fieldWrapper('thumbnail').parentElement!
expect(row.children).toHaveLength(1)
expect(row.className).not.toContain('md:grid-cols-2')
expect(row.nextElementSibling!.getAttribute('data-testid')).toBe('entry-meta')
})
it('never pairs with a boolean, number or date field before it', async () => {
const fields = [
apiField('Online', 'online', 'boolean'),
thumbnailField(),
apiField('Title', 'title', 'text', { required: true }),
]
const type = { ...customTypes[0]!, fields }
await mountEditor('/content/events/new', { type: () => type, content: () => [type] })
const row = fieldWrapper('thumbnail').parentElement!
expect(row.children).toHaveLength(1)
expect(fieldWrapper('online').parentElement!.children).toHaveLength(1)
})
it('shows the new entry control empty, optional and without a Default tag', async () => {
await mountMedia('/content/events/new')
expect(mediaState()).toBe('empty')
expect(mediaText()).toContain('Thumbnail')
expect(mediaText()).not.toContain('Default')
expect(mediaText()).toContain('One image from your library.')
})
it('shows the footer lines after the control on mobile too', async () => {
await mountMedia('/content/events/42', { thumbnail: 7, width: MOBILE_WIDTH })
expect(
fieldWrapper('thumbnail').parentElement!.nextElementSibling!.getAttribute(
'data-testid',
),
).toBe('entry-meta')
})
})
describe('What the control shows and sends', () => {
it.each([
['null', null, 'none', async () => {}, 'empty', null],
['7', 7, undefined, async () => {}, 'filled', 7],
['missing 7', 7, MISSING, async () => {}, 'missing', 7],
[
'7 then 8',
7,
undefined,
async () => {
await pickFromLibrary('bathroom-tiles.png')
},
'filled',
8,
],
[
'7 then Remove',
7,
undefined,
async () => {
mediaButton('Remove image from Thumbnail')!.click()
await flushPromises()
},
'empty',
null,
],
[
'missing 7 then Clear',
7,
MISSING,
async () => {
mediaButton('Clear Thumbnail')!.click()
await flushPromises()
},
'empty',
null,
],
[
'null then 8',
null,
'none',
async () => {
await pickFromLibrary('bathroom-tiles.png', 'Pick an image for Thumbnail')
},
'filled',
8,
],
] as const)(
'loads %s and shows %s after the save',
async (_name, loaded, media, action, shown, sent) => {
await mountMedia('/content/events/42', {
thumbnail: loaded,
media,
writeEntry: (write) => {
const answered = answerWrite(write)
const id = (write.body.data as Record<string, unknown>).thumbnail
return typeof id !== 'number'
? answered
: {
...answered,
media: {
thumbnail:
id === 7 && media === MISSING ? MISSING : summaryOf(id),
},
}
},
})
await action()
await editTitle()
await save()
expect(entryWrites).toHaveLength(1)
expect(sentData().thumbnail).toBe(sent)
expect(Object.keys(sentData())).toContain('thumbnail')
expect(mediaState()).toBe(shown)
},
)
it('keeps a missing optional image unchanged and saves', async () => {
await mountMedia('/content/events/42', { thumbnail: 7, media: MISSING })
await editTitle()
await save()
expect(sentData().thumbnail).toBe(7)
expect(hasText('Entry saved')).toBe(true)
})
})
describe('The data of the control', () => {
it('shows the stored image of the entry with its size line', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
expect(mediaText()).toContain('lime-plaster-workshop.jpg')
expect(mediaText()).toContain('1600 × 900 · 240 KB')
})
it('shows the picked library item before any save with no request for it', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('bathroom-tiles.png')
expect(mediaText()).toContain('bathroom-tiles.png')
expect(mediaText()).toContain('800 × 600 · 50 KB')
expect(
mediaControl()
.querySelector('[data-testid="media-thumbnail"] img')!
.getAttribute('src'),
).toBe('/media/bathroom-tiles.png')
expect(calls.filter((url) => /^\/v1\/media\/\d+/.test(url))).toEqual([])
expect(entryWrites).toHaveLength(0)
expect(liveText()).toBe('Image selected: bathroom-tiles.png')
})
it('rebuilds the control from the answered media after the PUT', async () => {
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: (write) => ({
...answerWrite(write),
media: {
thumbnail: { ...summaryOf(8), filename: 'bathroom-tiles-final.png' },
},
}),
})
await pickFromLibrary('bathroom-tiles.png')
await save()
expect(mediaText()).toContain('bathroom-tiles-final.png')
expect(mediaText()).toContain('800 × 600 · 50 KB')
})
})
describe('Dirty tracking of a media value', () => {
it('reads Saved and disables Save when 8 is picked then 7 again', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('bathroom-tiles.png')
expect(chip()!.textContent).toContain('Unsaved changes')
await pickFromLibrary('lime-plaster-workshop.jpg')
expect(chip()!.textContent).toContain('Saved')
expect(chip()!.textContent).not.toContain('Unsaved')
expect((button('Save') as HTMLButtonElement).disabled).toBe(true)
})
it('stays Saved when the stored image is picked again from the library', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('lime-plaster-workshop.jpg')
expect(chip()!.textContent).not.toContain('Unsaved')
expect((button('Save') as HTMLButtonElement).disabled).toBe(true)
expect(liveText()).toBe('')
})
it('prompts the leave guard after picking another image', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('bathroom-tiles.png')
document.body.querySelector<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a')!.click()
await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true))
})
it('does not prompt after picking and restoring the stored image', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('bathroom-tiles.png')
await pickFromLibrary('lime-plaster-workshop.jpg')
document.body.querySelector<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a')!.click()
await flushPromises()
expect(hasText('Leave without saving?')).toBe(false)
})
})
describe('Client validation', () => {
it('refuses an empty required field with no request, the error, Not saved and the focus', async () => {
const wrapper = await mountMedia('/content/events/new', { required: true })
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await save()
expect(entryWrites).toHaveLength(0)
expect(errorLine()).toBe(REQUIRED_TEXT)
expect(mediaControl().className).toContain('border-error')
expect(chip()!.textContent).toContain('Not saved')
expect(focusedName()).toBe('Pick an image for Thumbnail')
expect(document.activeElement!.getAttribute('aria-invalid')).toBe('true')
})
it('refuses a required field whose image is gone, focusing Choose another image', async () => {
await mountMedia('/content/events/42', { thumbnail: 7, media: MISSING, required: true })
await editTitle()
await save()
expect(entryWrites).toHaveLength(0)
expect(errorLine()).toBe(REQUIRED_TEXT)
expect(mediaText()).toContain('Thumbnail is required. Choose another image to save.')
expect(drawsIcon(fieldWrapper('thumbnail'), 'circle-alert')).toBe(true)
expect(chip()!.textContent).toContain('Not saved')
expect(focusedName()).toBe('Choose another image')
})
it('clears the error and the border when an image is picked, validating nothing else', async () => {
await mountMedia('/content/events/new', { required: true })
await typeInto('title', 'Open day')
await save()
expect(errorLine()).toBe(REQUIRED_TEXT)
expect(hasText('Pick a date for Starts on.')).toBe(true)
await pickFromLibrary('bathroom-tiles.png', 'Pick an image for Thumbnail')
expect(errorLine()).toBe('')
expect(mediaControl().className).not.toContain('border-error')
expect(chip()!.textContent).toContain('Unsaved changes')
expect(hasText('Pick a date for Starts on.')).toBe(true)
})
it('focuses only the first field in schema order that has an error', async () => {
await mountMedia('/content/events/new', { required: true })
await save()
expect(document.activeElement).toBe(fieldWrapper('title').querySelector('input'))
})
it('focuses the thumbnail control when it is the first error', async () => {
const wrapper = await mountMedia('/content/events/new', { required: true })
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await save()
expect(fieldWrapper('thumbnail').contains(document.activeElement)).toBe(true)
})
})
describe('Saving', () => {
it('dims the form to 70 %, the thumbnail to 60 % and disables the buttons', async () => {
const answer = pending()
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: () => answer.promise,
})
await editTitle()
await save()
const form = document.body.querySelector('[data-testid="entry-form"]')!
expect(form.className).toContain('opacity-70')
expect(
fieldWrapper('thumbnail')
.querySelector('[data-testid="media-thumbnail"]')!
.className.split(' '),
).toContain('opacity-60')
expect(
[...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].every(
(candidate) => candidate.disabled,
),
).toBe(true)
expect(hasText('Saving…')).toBe(true)
answer.resolve(undefined)
await flushPromises()
})
})
describe('Server answers for a media field', () => {
it('shows 422 required under the control with the error border and the focus on the first button', async () => {
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: required'),
})
await editTitle()
await save()
expect(errorLine()).toBe(REQUIRED_TEXT)
expect(mediaControl().className).toContain('border-error')
expect(chip()!.textContent).toContain('Not saved')
expect(fieldWrapper('thumbnail').contains(document.activeElement)).toBe(true)
})
it('shows 422 type under the control and focuses Change image', async () => {
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: type'),
})
await pickFromLibrary('bathroom-tiles.png')
await save()
expect(errorLine()).toBe('Thumbnail must be an image from the library.')
expect(chip()!.textContent).toContain('Not saved')
expect(focusedName()).toBe('Change image')
})
it('shows the changed type notice for an unknown media key and locks the control', async () => {
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: unknown_field'),
})
await editTitle()
await save()
expect(hasText('The fields of Events changed')).toBe(true)
expect(
hasText(
"The Thumbnail field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.",
),
).toBe(true)
expect(button('Reload form')).toBeInstanceOf(HTMLButtonElement)
expect(
[...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].every(
(candidate) => candidate.disabled,
),
).toBe(true)
expect(
fieldWrapper('thumbnail').querySelector('[data-testid="media-thumbnail"]')!.className,
).toContain('opacity-60')
})
it('keeps the old copy for the other keys', async () => {
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: failWithBody(422, 'invalid_entry: colour: unknown_field'),
})
await editTitle()
await save()
expect(hasText('A field was removed and a new required field, Colour, was added')).toBe(
true,
)
expect(hasText('The Thumbnail field was changed')).toBe(false)
})
it('rebuilds the form from a refetched type and entry on Reload form', async () => {
let reads = 0
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: unknown_field'),
entry: () => {
reads += 1
return reads === 1
? entryWithThumbnail(7)
: { ...entryWithThumbnail(8), media: { thumbnail: summaryOf(8) } }
},
})
await editTitle()
await save()
button('Reload form')!.click()
await flushPromises()
await flushPromises()
expect(reads).toBe(2)
expect(mediaText()).toContain('bathroom-tiles.png')
expect(hasText('The fields of Events changed')).toBe(false)
expect(
[...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].some(
(candidate) => candidate.disabled,
),
).toBe(false)
})
})
describe('The create payload', () => {
it('sends thumbnail null among the other keys when the field is left empty', async () => {
const wrapper = await mountMedia('/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]).toMatchObject({
method: 'POST',
url: '/v1/content-type/events/entry',
})
expect(Object.keys(sentData())).toEqual([
'title',
'starts_on',
'ends_on',
'online',
'capacity',
'category',
'description',
'thumbnail',
])
expect(sentData().thumbnail).toBeNull()
})
it('sends the picked id as a JSON number', async () => {
const wrapper = await mountMedia('/content/events/new')
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await pickFromLibrary('bathroom-tiles.png', 'Pick an image for Thumbnail')
await save()
expect(sentData().thumbnail).toBe(8)
expect(JSON.stringify(entryWrites[0]!.body)).toContain('"thumbnail":8')
})
})
describe('The title of an entry whose first field is media', () => {
const type = {
...customTypes[0]!,
fields: [
thumbnailField(),
apiField('Title', 'title', 'text', { required: true, options: [] }),
],
}
const entry = {
...entry42,
data: { thumbnail: 7, title: 'Lime plaster workshop' },
media: { thumbnail: summaryOf(7) },
}
const options = {
type: () => type,
content: () => [type],
entry: () => entry,
library: () => libraryBody(),
}
it('reads Entry 42 as the page title and never the numeric id', async () => {
await mountEditor('/content/events/42', options)
expect(text(main().querySelector('h1'))).toBe('Entry 42')
expect(text(main().querySelector('h1'))).not.toContain('7')
})
it('reads Entry 42 in the saved toast', async () => {
await mountEditor('/content/events/42', {
...options,
writeEntry: (write) => ({
...answerMediaWrite(write),
data: { ...entry.data, ...(write.body.data as object) },
}),
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(hasText('Entry saved')).toBe(true)
expect(hasText('"Entry 42" is up to date.')).toBe(true)
})
it('reads Entry 42 in the leave prompt', async () => {
await mountEditor('/content/events/42', options)
await typeInto('title', 'Changed')
document.body.querySelector<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a')!.click()
await vi.waitFor(() =>
expect(hasText('Your changes to "Entry 42" haven\'t been saved.')).toBe(true),
)
})
})
describe('Accessibility of the control in the form', () => {
it('draws the control with the image icon and the group label', async () => {
await mountMedia('/content/events/new')
expect(mediaControl().getAttribute('role')).toBe('group')
expect(drawsIcon(mediaControl(), 'image')).toBe(true)
expect(eventsWithThumbnail().fields).toHaveLength(8)
})
})
@@ -0,0 +1,159 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
button,
calls,
chip,
failWith,
fieldWrapper,
hasText,
text,
unmountAll,
} from './entryEditorKit'
import {
focusedName,
libraryBody,
mediaButton,
mediaState,
mediaText,
mountMedia,
pickFromLibrary,
} from './entryMediaKit'
import { fieldLibrary } from './mediaFieldKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountAll)
const dialog = () => document.body.querySelector<HTMLElement>('[role="dialog"]')
const tiles = () => [...document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')]
async function open(opener = 'Change image') {
mediaButton(opener)!.click()
await flushPromises()
await flushPromises()
}
describe('The picker opened from the entry editor', () => {
it('loads only the library page and never the variants', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await open()
expect(calls.filter((url) => url.startsWith('/v1/media/'))).toEqual([
'/v1/media/?page=1&size=25',
])
expect(calls.some((url) => url.includes('image-variants'))).toBe(false)
expect(text(dialog())).toContain('Choose an image')
expect(text(dialog())).not.toContain('Alt text')
})
it('marks the stored image as Current', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await open()
const current = tiles().filter((tile) =>
tile.getAttribute('aria-label')!.endsWith(', Current'),
)
expect(current.map((tile) => tile.dataset.imageId)).toEqual(['7'])
})
it('is 640 px wide on desktop and a bottom sheet on mobile', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await open()
expect(dialog()!.className).toContain('sm:max-w-160')
unmountAll()
await mountMedia('/content/events/42', { thumbnail: 7, width: MOBILE_WIDTH })
await open()
expect(dialog()!.className).toContain('max-sm:!bottom-0')
})
it('does not open while the form is saving', async () => {
let release = () => {}
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: () => new Promise((resolve) => (release = () => resolve(undefined))),
})
const title = fieldWrapper('title').querySelector('input')!
title.value = 'Changed'
title.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
button('Save')!.click()
await flushPromises()
mediaButton('Change image')!.click()
await flushPromises()
expect(dialog()).toBeNull()
expect(hasText('Saving…')).toBe(true)
release()
await flushPromises()
})
it('shows the empty library with the field copy and focuses nothing else', async () => {
await mountMedia('/content/events/new', {
library: () => ({ ...libraryBody([]), total: 0 }),
})
await open('Pick an image for Thumbnail')
const library = [...document.body.querySelectorAll<HTMLElement>('[role="tab"]')].find(
(tab) => text(tab) === 'Library',
)!
library.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
await flushPromises()
expect(text(dialog())).toContain(
'Images you upload appear here, ready to use in any article or entry.',
)
})
it('shows the error alert when the library fails and keeps the entry untouched', async () => {
await mountMedia('/content/events/42', { thumbnail: 7, library: failWith(500) })
await open()
expect(text(dialog())).toContain("Couldn't load your library")
expect(text(dialog())).toContain('Try again')
expect(chip()!.textContent).toContain('Saved')
})
})
describe('Focus and announcements in the editor', () => {
it('lands on Change image after Use this image', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('bathroom-tiles.png')
expect(focusedName()).toBe('Change image')
expect(mediaState()).toBe('filled')
expect(mediaText()).toContain('bathroom-tiles.png')
})
it('lands on Pick an image after removing the image', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
mediaButton('Remove image from Thumbnail')!.click()
await flushPromises()
await flushPromises()
expect(focusedName()).toBe('Pick an image for Thumbnail')
expect(chip()!.textContent).toContain('Unsaved changes')
})
it('returns to the opener with Cancel', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
const opener = mediaButton('Change image')!
opener.focus()
await open()
;[...dialog()!.querySelectorAll('button')].find((b) => text(b) === 'Cancel')!.click()
await vi.waitFor(() => expect(document.activeElement).toBe(opener))
expect(fieldLibrary()).toHaveLength(12)
})
})
@@ -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(6)
expect(all('[role="radio"]')).toHaveLength(7)
expect(tile('Date').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter(
(radio) => radio !== tile('Date'),
@@ -421,7 +421,7 @@ describe('Saved fields are locked', () => {
})
describe('Fields added since the last save', () => {
it('selects a new row with New, an Auto key, six selectable tiles and no lock', async () => {
it('selects a new row with New, an Auto key, seven selectable tiles and no lock', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
@@ -209,10 +209,11 @@ const TILES = [
['Yes or no', 'An on or off switch', 'toggle-left'],
['Date', 'A calendar day, without a time', 'calendar'],
['Choice', 'One option from a list you define', 'list'],
['Media', 'One image from your media library', 'image'],
] as const
describe('Type tiles', () => {
it('shows six radio tiles with an icon, a name and a description', async () => {
it('shows seven radio tiles with an icon, a name and a description', async () => {
await mountAt('/schema/new')
expect(el('[role="radiogroup"]')).not.toBeNull()
@@ -0,0 +1,404 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
button,
eventsWithThumbnail,
failWithBody,
mountAt,
posts,
puts,
text,
thumbnailMount,
unmountAll,
updatedType,
} from './schemaTestKit'
import {
addField,
all,
badgesOf,
chip,
click,
el,
fieldKeyInput,
inspector,
inspectorTitle,
labelInput,
nameInput,
pickTile,
rowOf,
saveButton,
selectRow,
tile,
toggle,
typeInto,
} from './builderKit'
import { drawsIcon } from './mediaFieldKit'
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
})
afterEach(unmountAll)
const TILES = [
['Text', 'type', 'Short text, like a title or a name'],
['Rich text', 'pilcrow', 'Long text with formatting, written in Markdown'],
['Number', 'hash', 'Whole or decimal numbers'],
['Yes or no', 'toggle-left', 'An on or off switch'],
['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'],
]
const HINT =
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.'
const grid = () => all('[role="radio"]')[0]!.closest<HTMLElement>('[class*="grid-cols"]')!
const savedThumbnail = (rest: object = {}) => thumbnailMount(rest)
async function addMediaField() {
await addField('Thumbnail')
await pickTile('Media')
}
describe('Seven type tiles', () => {
it('lists the seven 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)
labels.forEach((label, index) => {
const [name, , description] = TILES[index]!
expect(text(label)).toBe(`${name} ${description}`)
})
})
it('draws the icon of each tile', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
const labels = all('[role="radio"]').map((radio) => radio.closest('label')!)
labels.forEach((label, index) => {
expect(drawsIcon(label, TILES[index]![1])).toBe(true)
})
})
it('has 3 columns in the desktop inspector', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
expect(grid().className).toContain('grid-cols-3')
})
it('has 2 columns in the mobile sheet', async () => {
await mountAt('/schema/events', { width: MOBILE_WIDTH })
await click(button('Add field'))
expect(grid().className).toContain('grid-cols-2')
expect(grid().className).not.toContain('grid-cols-3')
})
})
describe('Adding a media field', () => {
it('shows the New badge, an editable Auto key with its help and all seven tiles enabled', async () => {
await mountAt('/schema/events')
await addMediaField()
expect(badgesOf('Thumbnail')).toContain('New')
expect(fieldKeyInput()!.value).toBe('thumbnail')
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).not.toBeNull()
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<HTMLButtonElement>('[role="radio"]').every((radio) => !radio.disabled)).toBe(
true,
)
expect(tile('Media').getAttribute('data-state')).toBe('checked')
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).toBeNull()
expect(text(inspector())).not.toContain("Can't change after saving")
expect(chip()).toBe('Unsaved changes')
})
it('draws the row as a grip, a 28 px square with the image icon, the label, the key and New', async () => {
await mountAt('/schema/events')
await addMediaField()
const row = rowOf('Thumbnail')!
const square = row.querySelector('[data-field-row] > span')!
expect(row.querySelector('[data-grip]')).not.toBeNull()
expect(square.className).toContain('size-7')
expect(drawsIcon(square, 'image')).toBe(true)
expect(text(row.querySelector('[data-testid="field-row-label"]'))).toBe('Thumbnail')
expect(text(row.querySelector('[data-testid="field-row-key"]'))).toBe('thumbnail')
expect(row.querySelector('[data-testid="field-row-key"]')!.className).toContain('font-mono')
expect(badgesOf('Thumbnail')).toContain('New')
})
it('keeps label, key and Required and resets the rest changing a new row to Media and back', async () => {
await mountAt('/schema/events')
await addField('Thumbnail')
await toggle('Required')
await typeInto(el<HTMLInputElement>('input[name="max"]'), '80')
await pickTile('Media')
expect(labelInput()!.value).toBe('Thumbnail')
expect(fieldKeyInput()!.value).toBe('thumbnail')
expect(text(inspector())).toContain(HINT)
expect(el('input[name="max"]')).toBeNull()
await pickTile('Number')
expect(text(inspector())).not.toContain(HINT)
expect(el<HTMLInputElement>('input[name="max"]')!.value).toBe('')
expect(el<HTMLInputElement>('input[name="min"]')!.value).toBe('')
expect(el<HTMLInputElement>('input[name="default"]')!.value).toBe('')
expect(labelInput()!.value).toBe('Thumbnail')
})
})
describe('The inspector of a saved media field', () => {
it('reads Field: Thumbnail and shows only what applies, in order', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Thumbnail')
expect(inspectorTitle()).toBe('Field: Thumbnail')
const inside = text(inspector())
const order = [
'Label',
'Key',
"Used in the API, so it can't change.",
'Type',
"Can't change after saving",
'Required',
"An entry can't be saved with this field empty.",
HINT,
].map((part) => inside.indexOf(part))
expect(order.every((index) => index >= 0)).toBe(true)
expect([...order].sort((a, b) => a - b)).toEqual(order)
expect(labelInput()!.value).toBe('Thumbnail')
expect(fieldKeyInput()!.value).toBe('thumbnail')
expect(fieldKeyInput()!.readOnly).toBe(true)
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
})
it('selects Media, dims the six other tiles and makes them unselectable', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Thumbnail')
expect(all('[role="radio"]')).toHaveLength(7)
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.every((radio) => radio.disabled || radio.hasAttribute('data-disabled'))).toBe(
true,
)
expect(
others.every((radio) => radio.closest('label')!.innerHTML.includes('opacity-40')),
).toBe(true)
await pickTile('Number')
expect(tile('Media').getAttribute('data-state')).toBe('checked')
expect(chip()).toBe('Saved')
})
it('draws the hint as an info block with an info icon', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Thumbnail')
const hint = el('[data-testid="builder-hint"]')!
expect(text(hint)).toBe(HINT)
expect(hint.className).toContain('bg-(--rc-info-soft)')
expect(drawsIcon(hint, 'info')).toBe(true)
})
it('does not render the options that do not apply', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Thumbnail')
const inside = text(inspector())
expect(inside).not.toContain('Unique values')
expect(inside).not.toContain('Default value')
expect(inside).not.toContain('Limits')
expect(inside).not.toContain('Shortest')
expect(inside).not.toContain('Choices')
expect(el('input[name="min"]')).toBeNull()
expect(el('input[name="max"]')).toBeNull()
expect(el('input[name="default"]')).toBeNull()
expect(el('[data-testid="choice-row"]')).toBeNull()
})
it('still shows the disabled Unique switch on a Yes or no field', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Online')
expect(text(inspector())).toContain('Unique values')
expect(text(inspector())).toContain('Limits')
})
})
describe('A required media field', () => {
it('carries the Required marker on its row and the switch on', async () => {
await mountAt('/schema/events', savedThumbnail({ required: true }))
await selectRow('Thumbnail')
const marker = rowOf('Thumbnail')!.querySelector('[aria-label="Required"]')!
expect(text(marker)).toBe('R')
expect(
el('[data-testid="field-inspector"] [role="switch"][aria-checked="true"]'),
).not.toBeNull()
expect(text(inspector())).toContain("An entry can't be saved with this field empty.")
})
it('shows no marker on an optional media field', async () => {
await mountAt('/schema/events', savedThumbnail())
expect(rowOf('Thumbnail')!.querySelector('[aria-label="Required"]')).toBeNull()
})
})
describe('The mobile sheet of a media field', () => {
it('opens the 92 % sheet titled Field: Thumbnail with the inspector, two columns and Done', async () => {
await mountAt('/schema/events', { ...savedThumbnail(), width: MOBILE_WIDTH })
await click(rowOf('Thumbnail')!.querySelector('[data-field-row]'))
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Thumbnail')
expect(text(el('[role="dialog"]'))).toContain(HINT)
expect(grid().className).toContain('grid-cols-2')
expect(button('Done')).toBeInstanceOf(HTMLButtonElement)
expect(el('[role="dialog"]')!.className).toContain('h-11/12')
})
})
describe('Media in the API preview', () => {
it('shows thumbnail: null and no example text', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await addMediaField()
await click(el('[data-testid="api-preview-toggle"]'))
const data = JSON.parse(el('[data-testid="api-json"]')!.textContent!).data
expect(data.thumbnail).toBeNull()
expect(Object.keys(data)).toEqual(['title', 'thumbnail'])
expect(el('[data-testid="api-json"]')!.textContent).not.toContain(
'Example text"\n "thumbnail',
)
})
})
describe('Saving a media field', () => {
it('sends the field in the exact shape in the PUT body', async () => {
await mountAt('/schema/events', {
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
})
await addMediaField()
await click(saveButton())
expect(puts).toHaveLength(1)
const fields = (puts[0]!.body as { fields: unknown[] }).fields
expect(fields[7]).toEqual({
key: 'thumbnail',
label: 'Thumbnail',
type: 'media',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: [],
})
})
it('sends the field in the POST body of a new type', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Gallery')
await selectRow('Title')
await addMediaField()
await toggle('Required')
await click(saveButton())
expect(posts).toHaveLength(1)
const fields = (posts[0]!.body as { fields: Record<string, unknown>[] }).fields
expect(fields[1]).toEqual({
key: 'thumbnail',
label: 'Thumbnail',
type: 'media',
required: true,
unique: false,
default: null,
min: null,
max: null,
options: [],
})
})
it('shows the existing invalid field failure on 422 invalid_field and keeps the edits', async () => {
await mountAt('/schema/events', {
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
update: failWithBody(422, 'invalid_field'),
})
await addMediaField()
await click(saveButton())
expect(text(document.body)).toContain('Check the fields marked in the list.')
expect(rowOf('Thumbnail')).toBeInstanceOf(HTMLElement)
expect(chip()).toBe('Unsaved changes')
})
it('runs the existing refusal flow on a stale 409 schema_incompatible', async () => {
await mountAt('/schema/events', {
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
update: failWithBody(409, 'schema_incompatible'),
})
await addMediaField()
await toggle('Required')
await click(saveButton())
expect(text(document.body)).toContain('Nothing was changed')
expect(rowOf('Thumbnail')).toBeInstanceOf(HTMLElement)
})
it('round-trips a saved media field with no change after a save', async () => {
await mountAt('/schema/events', {
...savedThumbnail(),
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
update: (body) =>
updatedType('events', { ...body, fields: eventsWithThumbnail().fields }),
})
await selectRow('Thumbnail')
await typeInto(labelInput(), 'Cover')
await click(saveButton())
expect(puts).toHaveLength(1)
const sent = (puts[0]!.body as { fields: { label: string; type: string }[] }).fields[7]!
expect(sent.type).toBe('media')
expect(sent.label).toBe('Cover')
})
})
@@ -0,0 +1,303 @@
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,
entryPage,
failWithBody,
mountAt,
puts,
text,
thumbnailMount,
unmountAll,
} from './schemaTestKit'
import {
addField,
click,
dialog,
el,
pickTile,
saveButton,
selectRow,
setMax,
toggle,
all,
} from './builderKit'
import { drawsIcon } from './mediaFieldKit'
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
})
afterEach(async () => {
vi.restoreAllMocks()
await unmountAll()
})
const REFUSED_TEXT =
"Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it."
const withEntries = (total: number) => ({ entries: () => entryPage(total) })
async function addThumbnail(required: boolean) {
await addField('Thumbnail')
await pickTile('Media')
if (required) {
await toggle('Required')
}
}
async function reviewWith(required: boolean, total = 12, extra: object = {}) {
await mountAt('/schema/events', { ...withEntries(total), ...extra })
await addThumbnail(required)
await click(saveButton())
}
const rowText = () => text(el('[data-testid="change-row"]'))
const apply = () => button('Apply changes') as HTMLButtonElement
const back = () => button('Back to editing') as HTMLButtonElement
describe('A required media field added to a type with entries', () => {
it('opens the review without sending the PUT', async () => {
await reviewWith(true)
expect(puts).toHaveLength(0)
expect(text(dialog())).toContain('Review changes to Events')
expect(text(dialog())).toContain('Apply them to save the content type.')
})
it('reads the summary, the entry count, the row, the flag and the note', async () => {
await reviewWith(true)
expect(text(el('[data-testid="review-summary"]'))).toBe('Add 1 field')
expect(text(dialog())).toContain(
'Events has 12 entries. Each one is checked against the new fields when you apply.',
)
expect(text(el('[data-testid="change-title"]'))).toBe('Add Thumbnail')
expect(text(el('[data-testid="change-flag"]'))).toBe("Can't be applied")
expect(text(el('[data-testid="change-text"]'))).toBe(REFUSED_TEXT)
expect(text(dialog())).toContain('Back to editing keeps your other changes.')
expect(text(dialog())).not.toContain('Flagged changes can be refused')
})
it('draws the flag as text with a warning icon, not only a colour', async () => {
await reviewWith(true)
const flag = el('[data-testid="change-flag"]')!
expect(text(flag)).toBe("Can't be applied")
expect(drawsIcon(flag, 'triangle-alert')).toBe(true)
})
it('keeps Back to editing enabled and announces Apply changes as disabled', async () => {
await reviewWith(true)
expect(back().disabled).toBe(false)
expect(apply().getAttribute('aria-disabled')).toBe('true')
})
it('sends nothing when Apply changes is pressed', async () => {
await reviewWith(true)
await click(apply())
expect(puts).toHaveLength(0)
expect(text(dialog())).toContain('Review changes to Events')
})
it('stays disabled whatever the connection', async () => {
await reviewWith(true)
expect(apply().getAttribute('aria-disabled')).toBe('true')
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(apply().getAttribute('aria-disabled')).toBe('true')
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(apply().getAttribute('aria-disabled')).toBe('true')
})
it('shows the bottom sheet on mobile', async () => {
await reviewWith(true, 12, { width: MOBILE_WIDTH })
expect(dialog()!.className).toContain('max-sm:')
expect(text(el('[data-testid="change-flag"]'))).toBe("Can't be applied")
})
})
describe('Back to editing after the refusal', () => {
it.each([
['Back to editing', async () => click(back())],
[
'Esc',
async () => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await flushPromises()
},
],
])('closes with %s, keeps every edit and sends nothing', async (_name, close) => {
await reviewWith(true)
await close()
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(puts).toHaveLength(0)
expect(text(el('#dashboard-content'))).toContain('Thumbnail')
expect(text(el('[data-testid="builder-chip"]'))).toBe('Unsaved changes')
})
it('shows the clean row with Apply enabled once Required is turned off', async () => {
await reviewWith(true)
await click(back())
await vi.waitFor(() => expect(dialog()).toBeNull())
await toggle('Required')
await click(saveButton())
expect(text(el('[data-testid="change-text"]'))).toBe(
'Media, optional. 12 entries are unaffected.',
)
expect(el('[data-testid="change-flag"]')).toBeNull()
expect(apply().getAttribute('aria-disabled')).toBeNull()
expect(apply().disabled).toBe(false)
expect(text(dialog())).not.toContain('Flagged changes can be refused')
expect(text(dialog())).not.toContain('Back to editing keeps your other changes.')
})
})
describe('The clean row', () => {
it.each([
[12, 'Media, optional. 12 entries are unaffected.'],
[1, 'Media, optional. 1 entry is unaffected.'],
])('reads the row for %i entries and enables Apply changes', async (total, line) => {
await reviewWith(false, total)
expect(rowText()).toContain('Add Thumbnail')
expect(text(el('[data-testid="change-text"]'))).toBe(line)
expect(el('[data-testid="change-flag"]')).toBeNull()
expect(apply().disabled).toBe(false)
expect(apply().getAttribute('aria-disabled')).toBeNull()
})
it('applies with the PUT and the media field', async () => {
await reviewWith(false)
await click(apply())
expect(puts).toHaveLength(1)
expect((puts[0]!.body as { fields: { type: string }[] }).fields[7]!.type).toBe('media')
})
it('stays disabled while offline, as before', async () => {
await reviewWith(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(apply().disabled).toBe(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
})
})
describe('When the review is not refused', () => {
it('sends the PUT at once for a type with no entries', async () => {
await mountAt('/schema/events', withEntries(0))
await addThumbnail(true)
await click(saveButton())
expect(dialog()).toBeNull()
expect(puts).toHaveLength(1)
})
it('flags making a saved optional media field required as one that may not fit and keeps Apply enabled', async () => {
await mountAt('/schema/events', { ...thumbnailMount(), ...withEntries(12) })
await selectRow('Thumbnail')
await toggle('Required')
await click(saveButton())
expect(text(el('[data-testid="change-title"]'))).toBe('Change Thumbnail')
expect(text(el('[data-testid="change-text"]'))).toBe('Now required.')
expect(text(el('[data-testid="change-flag"]'))).toBe('Entries may not fit')
expect(apply().disabled).toBe(false)
expect(apply().getAttribute('aria-disabled')).toBeNull()
})
it('lists both rows and disables Apply for a new required Thumbnail with a Capacity change', async () => {
await mountAt('/schema/events', withEntries(12))
await setMax('2000')
await addThumbnail(true)
await click(saveButton())
const titles = all('[data-testid="change-title"]').map(text)
expect(titles).toEqual(['Add Thumbnail', 'Change Capacity'])
expect(apply().getAttribute('aria-disabled')).toBe('true')
expect(text(dialog())).toContain('Back to editing keeps your other changes.')
})
})
describe('The server refuses anyway', () => {
it('runs the existing Nothing was changed flow on a stale 409', async () => {
await mountAt('/schema/events', {
...thumbnailMount(),
...withEntries(12),
update: failWithBody(409, 'schema_incompatible'),
})
await selectRow('Thumbnail')
await toggle('Required')
await click(saveButton())
await click(apply())
expect(puts).toHaveLength(1)
expect(text(dialog())).toContain('Nothing was changed')
expect(text(dialog())).not.toContain('Review changes to Events')
expect(text(dialog())).toContain('Possible cause')
expect(text(dialog())).toContain('Change Thumbnail')
})
})
describe('The flagged changes note of existing reviews', () => {
const NOTE = "Flagged changes can be refused if existing entries don't fit."
it('still shows for an unflagged text field added to a type with entries', async () => {
await mountAt('/schema/events', withEntries(12))
await addField('Speaker')
await click(saveButton())
expect(text(dialog())).toContain(NOTE)
})
it('still shows for a flagged change', async () => {
await mountAt('/schema/events', withEntries(12))
await setMax('2000')
await click(saveButton())
expect(text(dialog())).toContain(NOTE)
})
it('still shows when an optional media field comes with another change', async () => {
await mountAt('/schema/events', withEntries(12))
await addThumbnail(false)
await addField('Speaker')
await click(saveButton())
expect(text(dialog())).toContain(NOTE)
})
})
@@ -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(6)
expect(all('[role="radio"]')).toHaveLength(7)
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
'Text',
)
+105
View File
@@ -0,0 +1,105 @@
import { flushPromises } from '@vue/test-utils'
import type { MediaItem } from '@/composables/useMedia'
import { imageOfItem, type MediaSummary } from '@/lib/mediaField'
import {
answerWrite,
entry42,
fieldWrapper,
lookupNames,
mountEditor,
text,
} from './entryEditorKit'
import { fieldLibrary } from './mediaFieldKit'
import { eventsWithThumbnail } from './schemaTestKit'
import type { EntryWrite, SchemaMount } from './schemaTestKit'
export const LIBRARY_COUNTS = { all: 48, unused: 2, duplicates: 2 }
export function libraryBody(items: MediaItem[] = fieldLibrary()) {
return { items, total: 48, page: 1, size: 25, counts: LIBRARY_COUNTS }
}
export function summaryOf(id: number): MediaSummary {
const item = fieldLibrary().find((candidate) => candidate.id === id)!
return imageOfItem(item)
}
export function entryWithThumbnail(thumbnail: number | null, media?: MediaSummary | 'none') {
const shown =
media === 'none' || thumbnail === null ? undefined : (media ?? summaryOf(thumbnail))
return {
...entry42,
data: { ...entry42.data, thumbnail },
...(shown ? { media: { thumbnail: shown } } : {}),
}
}
export function answerMediaWrite(write: EntryWrite) {
const answered = answerWrite(write)
const id = (write.body.data as Record<string, unknown>).thumbnail
return typeof id === 'number' ? { ...answered, media: { thumbnail: summaryOf(id) } } : answered
}
export function mountMedia(
path: string,
options: SchemaMount & {
required?: boolean
thumbnail?: number | null
media?: MediaSummary | 'none'
} = {},
) {
const { required, thumbnail = null, media, ...rest } = options
const type = eventsWithThumbnail({ required: required ?? false })
return mountEditor(path, {
type: () => type,
content: () => [type],
entry: () => entryWithThumbnail(thumbnail, media),
writeEntry: answerMediaWrite,
library: () => libraryBody(),
lookup: lookupNames,
...rest,
})
}
export function mediaControl() {
return fieldWrapper('thumbnail').querySelector<HTMLElement>('[data-testid="media-control"]')!
}
export function mediaState() {
const box = mediaControl()
if (box.querySelector('[data-testid="media-empty"]')) {
return 'empty'
}
return box.querySelector('[data-testid="media-filled"]') ? 'filled' : 'missing'
}
export function mediaText() {
return text(fieldWrapper('thumbnail'))
}
export function mediaButton(name: string) {
return [...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].find(
(button) => (button.getAttribute('aria-label') ?? text(button)) === name,
)
}
export function focusedName() {
const active = document.activeElement
return active?.getAttribute('aria-label') ?? text(active)
}
export async function pickFromLibrary(filename: string, opener = 'Change image') {
mediaButton(opener)!.click()
await flushPromises()
await flushPromises()
const tile = [...document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')].find(
(element) => element.getAttribute('aria-label')!.startsWith(`${filename},`),
)!
tile.click()
await flushPromises()
;[...document.body.querySelectorAll('button')]
.find((button) => text(button) === 'Use this image')!
.click()
await flushPromises()
await flushPromises()
}
+56
View File
@@ -0,0 +1,56 @@
import lucide from 'virtual:lucide-subset'
import type { MediaItem } from '@/composables/useMedia'
import { fixture } from './mediaTestKit'
const SHAPES = 'path, circle, rect, line, polyline, polygon, ellipse'
function shapesOf(root: Element) {
return [...root.querySelectorAll(SHAPES)]
.map((shape) =>
[
shape.tagName.toLowerCase(),
...[...shape.attributes].map((a) => `${a.name}=${a.value}`),
]
.sort()
.join(' '),
)
.join('|')
}
function iconShapes(name: string) {
const holder = document.createElement('div')
holder.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">${lucide.icons[name]!.body}</svg>`
return shapesOf(holder.firstElementChild!)
}
export function drawsIcon(container: Element | null | undefined, name: string) {
const wanted = iconShapes(name)
return [...(container?.querySelectorAll('svg') ?? [])].some((svg) => shapesOf(svg) === wanted)
}
export function fieldLibrary(): MediaItem[] {
return fixture().map((item) => {
if (item.id === 7) {
return {
...item,
filename: 'lime-plaster-workshop.jpg',
url: '/media/lime-plaster-workshop.jpg',
width: 1600,
height: 900,
size: 245760,
}
}
if (item.id === 8) {
return {
...item,
filename: 'bathroom-tiles.png',
url: '/media/bathroom-tiles.png',
content_type: 'image/png',
width: 800,
height: 600,
size: 51200,
}
}
return item
})
}
+29 -1
View File
@@ -11,7 +11,7 @@ import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
import { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
export { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
@@ -44,6 +44,21 @@ export const customTypes = [
},
].map((type) => ({ ...type, created_at: '2026-09-01T09:30:00Z' }))
export function thumbnailField(rest: object = {}) {
return apiField('Thumbnail', 'thumbnail', 'media', { options: [], ...rest })
}
export function eventsWithThumbnail(rest: object = {}) {
return { ...customTypes[0]!, fields: [...eventsFields, thumbnailField(rest)] }
}
export function thumbnailMount(rest: object = {}) {
return {
content: () => [eventsWithThumbnail(rest), customTypes[1]!, customTypes[2]!],
type: (key: string) => (key === 'events' ? eventsWithThumbnail(rest) : customTypes[1]),
}
}
function articleField(label: string, key: string, type: string, rest: object = {}) {
return {
key,
@@ -122,6 +137,14 @@ export interface PostedRequest {
export const entryCounts: Record<string, number> = { events: 37, recipes: 0 }
export const emptyLibrary = {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, unused: 0, duplicates: 0 },
}
export function entryPage(total: number) {
return { items: [], total, page: 1, size: 1 }
}
@@ -157,6 +180,7 @@ export interface SchemaMount {
type?: (key: string) => unknown
list?: (key: string, query: URLSearchParams) => unknown
lookup?: (ids: number[]) => unknown
library?: (query: URLSearchParams) => unknown
before?: () => void
}
@@ -226,6 +250,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
return (options.create ?? (() => createdType(body)))(body)
}
calls.push(url)
const library = url.match(/^\/v1\/media\/\?(.*)$/)
if (library) {
return (options.library ?? (() => emptyLibrary))(new URLSearchParams(library[1]))
}
if (url === '/v1/content-type') {
return (options.content ?? (() => [...customTypes]))()
}
+60 -4
View File
@@ -68,6 +68,9 @@ const extraSources: Record<string, Source[]> = {
'Key already taken',
'Schema incompatible',
'Offline',
'Media field selected',
'New media field, seven types',
'Media field, required',
],
alias: {},
},
@@ -81,6 +84,50 @@ const extraSources: Record<string, Source[]> = {
'Delete type: blocked',
'Delete type: confirm',
'Delete type: server fallback (409)',
'Review: required media field refused',
'Review: optional media field',
],
alias: {},
},
],
content: [
{
board: schemaBoard,
group: '5 Content and entry list',
states: ['Thumbnail column', 'Columns menu with Thumbnail', 'Missing image: tooltip'],
alias: {},
},
],
entry: [
{
board: schemaBoard,
group: '6 Entry editor',
states: [
'Media control: empty, optional',
'Media control: empty, required',
'Media control: filled',
'Media control: hover',
'Media control: focus',
'Media control: validation error',
'Media control: image no longer in the library',
'Media control: missing image, required',
'Media control: saving',
'Media control: read-only, type changed',
],
alias: {},
},
{
board: schemaBoard,
group: '10 Media picker for fields',
states: [
'Library: nothing chosen',
'Library: image chosen',
'Library: No image chosen',
'Library: required field',
'Library: empty',
'Library: failed to load',
'Library: loading',
'Upload tab',
],
alias: {},
},
@@ -694,13 +741,22 @@ async function readScreen(name: string): Promise<Screen> {
: headingStates.length
? headingStates
: tableStates(statesMarkdown)
const configured = extraSources[name] ?? []
const sameGroup = configured.filter((source) => source.group === referenceGroups[0]!)
const mergedStates = [
...states,
...sameGroup.flatMap((source) => source.states).filter((state) => !states.includes(state)),
]
const primary: Source = {
board: reference[1]!,
group: referenceGroups[0]!,
states,
alias: boardAlias[name] ?? {},
states: mergedStates,
alias: Object.assign(
{},
boardAlias[name] ?? {},
...sameGroup.map((source) => source.alias),
),
}
const configured = extraSources[name] ?? []
const documentedExtras = referenceGroups
.slice(1)
.filter((group) => !configured.some((source) => source.group === group))
@@ -709,7 +765,7 @@ async function readScreen(name: string): Promise<Screen> {
name,
board: primary.board,
group: primary.group,
states,
states: mergedStates,
sources: [
primary,
...configured.filter((source) => source.group !== primary.group),
+585 -3
View File
@@ -7425,11 +7425,593 @@ const entryStates: Record<string, VisualState> = {
},
}
const thumbnailSize = 245760
const thumbnailDesktopSize = { width: 160, height: 120 }
const thumbnailMobileSize = { width: 298, height: 167 }
function thumbnailSvgFor({ width, height }: { width: number; height: number }) {
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid slice">`,
'<style>.a{fill:#f3f2f0}.b{fill:#e6e4e0}.t{fill:#444}@media (prefers-color-scheme:dark){.a{fill:#1a1a1a}.b{fill:#222}.t{fill:#cfccc7}}</style>',
'<defs><pattern id="s" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">',
'<rect class="a" width="6" height="7"/><rect class="b" x="6" width="1" height="7"/></pattern></defs>',
`<rect fill="url(#s)" width="${width}" height="${height}"/>`,
`<text class="t" x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle" font-family="IBM Plex Mono, monospace" font-size="11">1600 × 900</text>`,
'</svg>',
].join('')
}
interface Thumbnail {
id: number
filename: string
}
const thumbnailLime: Thumbnail = { id: 7, filename: 'lime-plaster-workshop.jpg' }
function thumbnailSummary({ id, filename }: Thumbnail) {
return {
id,
filename,
width: 1600,
height: 900,
size: thumbnailSize,
thumbnail_url: `/media/${filename}`,
}
}
const thumbnailMissing = { id: 7, missing: true }
function thumbnailType(rest: object = {}) {
return {
...schemaCustomTypes[0]!,
fields: [
...schemaEventsFields,
schemaField('Thumbnail', 'thumbnail', 'media', { options: [], ...rest }),
],
}
}
function thumbnailTypes(rest: object = {}) {
return [thumbnailType(rest), ...schemaCustomTypes.slice(1)]
}
function thumbnailSchemaApi(
rest: object = {},
entries = 37,
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return schemaApi({
'GET /v1/content-type': { status: 200, body: thumbnailTypes(rest) },
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(entries) },
...overrides,
})
}
async function thumbnailStubImages(page: Page, before?: () => Promise<void>) {
const size = schemaIsMobile(page) ? thumbnailMobileSize : thumbnailDesktopSize
await page.route(/\/media\/[^/?]+\.(jpg|png|svg|webp)(\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: thumbnailSvgFor(size),
}),
)
await before?.()
await page.reload()
await page.waitForFunction(
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
)
}
async function thumbnailPickMedia(page: Page) {
await page.locator('label:has([role="radio"][value="media"])').click()
}
async function thumbnailAddMedia(page: Page, required = false) {
await schemaAddField(page, 'Thumbnail')
await thumbnailPickMedia(page)
if (required) {
await page.getByRole('switch', { name: 'Required' }).click()
}
}
async function thumbnailReview(page: Page, required: boolean) {
await thumbnailAddMedia(page, required)
await schemaCloseSheet(page)
await schemaSettle(page)
await page.getByRole('button', { name: 'Save', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await schemaSettle(page)
}
const mediaSchemaStates: Record<string, VisualState> = {
'Media field selected': {
path: '/schema/events',
api: thumbnailSchemaApi(),
steps: async (page) => {
await schemaOpenRow(page, 'Thumbnail')
await schemaSettle(page)
},
},
'New media field, seven types': {
path: '/schema/events',
api: schemaApi(),
steps: async (page) => {
await thumbnailAddMedia(page)
await schemaSettle(page)
},
},
'Media field, required': {
path: '/schema/events',
api: thumbnailSchemaApi({ required: true }),
steps: async (page) => {
await schemaOpenRow(page, 'Thumbnail')
await schemaSettle(page)
},
},
'Review: required media field refused': {
path: '/schema/events',
api: schemaApi({
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(12) },
}),
steps: async (page) => {
await thumbnailReview(page, true)
},
},
'Review: optional media field': {
path: '/schema/events',
api: schemaApi({
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(12) },
}),
steps: async (page) => {
await thumbnailReview(page, false)
},
},
}
const thumbnailListSamples: Array<[number, Thumbnail | 'missing' | null]> = [
[42, thumbnailLime],
[41, { id: 8, filename: 'winter-open-day.jpg' }],
[40, null],
[39, 'missing'],
[38, { id: 10, filename: 'limewash-masterclass.jpg' }],
[37, null],
[36, { id: 11, filename: 'floorboards-talk.jpg' }],
]
const thumbnailRows = contentRows.map((row) => {
const [, sample] = thumbnailListSamples.find(([id]) => id === row.id)!
const stored = sample === null ? null : sample === 'missing' ? thumbnailMissing.id : sample.id
const media =
sample === null
? undefined
: sample === 'missing'
? { thumbnail: thumbnailMissing }
: { thumbnail: thumbnailSummary(sample) }
return { ...row, data: { ...row.data, thumbnail: stored }, ...(media ? { media } : {}) }
})
function thumbnailListApi(): Record<string, ApiMock> {
return contentApi({
'GET /v1/content-type': { status: 200, body: thumbnailTypes() },
'GET /v1/content-type/events': { status: 200, body: thumbnailType() },
'GET /v1/content-type/events/entry': {
status: 200,
bodyFor: () => ({ items: thumbnailRows, total: 7, page: 1, size: 25 }),
},
})
}
const thumbnailColumnsKey = 'renewcms-content-columns-events'
const thumbnailColumns = ['title', 'thumbnail', 'starts_on', 'category']
async function thumbnailList(page: Page) {
await thumbnailStubImages(page, () =>
page.evaluate(
([key, value]) => localStorage.setItem(key!, value!),
[thumbnailColumnsKey, JSON.stringify(thumbnailColumns)],
),
)
await page.locator('tbody tr, [data-testid="entry-card"]').first().waitFor()
}
const mediaContentStates: Record<string, VisualState> = {
'Thumbnail column': {
path: '/content/events',
api: thumbnailListApi(),
steps: async (page) => {
await thumbnailList(page)
await schemaSettle(page)
},
},
'Columns menu with Thumbnail': {
path: '/content/events',
api: thumbnailListApi(),
steps: async (page) => {
await thumbnailList(page)
await page.getByRole('button', { name: 'Columns' }).click()
await page.getByText('Created and updated dates always show').waitFor()
await schemaSettle(page)
},
},
'Missing image: tooltip': {
path: '/content/events',
api: thumbnailListApi(),
steps: async (page) => {
await thumbnailList(page)
if (!schemaIsMobile(page)) {
await page.locator('[aria-label="Image no longer in the library"]').first().hover()
await page
.locator('[data-slot="content"]', { hasText: 'Image no longer in the library' })
.waitFor()
}
},
},
}
function thumbnailEntryApi(
thumbnail: number | null,
media: object | null,
rest: object = {},
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return entryApi({
'GET /v1/content-type': { status: 200, body: thumbnailTypes(rest) },
'GET /v1/content-type/events': { status: 200, body: thumbnailType(rest) },
'GET /v1/content-type/events/entry/42': {
status: 200,
body: entryRecord({ thumbnail }, media ? { media: { thumbnail: media } } : {}),
},
...overrides,
})
}
const thumbnailFilled = () => thumbnailEntryApi(7, thumbnailSummary(thumbnailLime))
async function thumbnailAlign(page: Page, labelCenter: number) {
if (!schemaIsMobile(page)) {
return
}
await page.evaluate((wanted) => {
const label = document.querySelector('[data-entry-field="thumbnail"] label')!
const box = label.getBoundingClientRect()
let scroller: HTMLElement | null = label.parentElement
while (scroller && !/(auto|scroll)/.test(getComputedStyle(scroller).overflowY)) {
scroller = scroller.parentElement
}
scroller!.scrollTop += box.top + box.height / 2 - wanted
}, labelCenter)
}
async function thumbnailEntry(page: Page, labelCenter: number, then?: () => Promise<void>) {
await thumbnailStubImages(page)
await page.getByTestId('media-control').scrollIntoViewIfNeeded()
await then?.()
await thumbnailAlign(page, labelCenter)
}
async function thumbnailSubmitAndSettle(page: Page, expected: string) {
await thumbnailSubmit(page)
await page.getByText(expected).first().waitFor()
await page.mouse.move(0, 0)
}
const thumbnailMobileLabelCenter = 411
const thumbnailMobileLabelCenterWithAlert = 405
function thumbnailControlState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
labelCenter = thumbnailMobileLabelCenter,
): VisualState {
return {
path: '/content/events/42',
api,
steps: async (page) => {
await thumbnailEntry(page, labelCenter, async () => {
await steps?.(page)
})
},
}
}
async function thumbnailSubmit(page: Page) {
await entryAppend(page, 'title', '.')
await entrySave(page)
}
const mediaEntryStates: Record<string, VisualState> = {
'Media control: empty, optional': thumbnailControlState(thumbnailEntryApi(null, null)),
'Media control: empty, required': thumbnailControlState(
thumbnailEntryApi(null, null, { required: true }),
),
'Media control: filled': thumbnailControlState(thumbnailFilled()),
'Media control: hover': thumbnailControlState(thumbnailFilled(), async (page) => {
if (!schemaIsMobile(page)) {
await page.getByTestId('media-thumbnail').hover()
await page.getByTestId('media-hover-bar').waitFor()
}
}),
'Media control: focus': thumbnailControlState(thumbnailFilled(), async (page) => {
await page.getByRole('button', { name: 'Change image', exact: true }).focus()
await page.keyboard.press('Shift+Tab')
}),
'Media control: validation error': thumbnailControlState(
thumbnailEntryApi(null, null, { required: true }),
(page) => thumbnailSubmitAndSettle(page, 'Pick an image for Thumbnail.'),
),
'Media control: image no longer in the library': thumbnailControlState(
thumbnailEntryApi(7, thumbnailMissing),
),
'Media control: missing image, required': thumbnailControlState(
thumbnailEntryApi(7, thumbnailMissing, { required: true }),
(page) => thumbnailSubmitAndSettle(page, 'Pick an image for Thumbnail.'),
),
'Media control: saving': thumbnailControlState(
{
...thumbnailFilled(),
'PUT /v1/content-type/events/entry/42': { status: 200, hang: true },
},
async (page) => {
await entryAppend(page, 'title', ', two days')
await entrySave(page)
await page.getByText('Saving…').first().waitFor()
await page.mouse.move(0, 0)
},
),
'Media control: read-only, type changed': thumbnailControlState(
{
...thumbnailEntryApi(7, thumbnailSummary(thumbnailLime), {}, {}),
'GET /v1/content-type/events/entry/42': {
status: 200,
body: entryRecord(
{ thumbnail: 7, capacity: 1 },
{ media: { thumbnail: thumbnailSummary(thumbnailLime) } },
),
},
'PUT /v1/content-type/events/entry/42': {
status: 422,
body: 'invalid_entry: thumbnail: unknown_field',
},
},
async (page) => {
await entryCapacityEdit(page)
await entrySave(page)
await page.getByText('The fields of Events changed').waitFor()
await page.mouse.move(0, 0)
},
thumbnailMobileLabelCenterWithAlert,
),
}
const pickerLibraryTotal = 48
function thumbnailLibrary(items = mediaItems(), total = pickerLibraryTotal): ApiMock {
return {
status: 200,
body: {
items,
total,
page: 1,
size: 25,
counts: { all: total, unused: 2, duplicates: 2 },
},
}
}
function thumbnailPickerApi(
rest: object = {},
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
const current = mediaItems([1])[0]!
return thumbnailEntryApi(
1,
{
id: current.id,
filename: current.filename,
width: current.width,
height: current.height,
size: current.size,
thumbnail_url: current.url,
},
rest,
{ 'GET /v1/media/': thumbnailLibrary(), ...overrides },
)
}
async function thumbnailOpenPicker(page: Page) {
await page.getByRole('button', { name: 'Change image', exact: true }).click()
await page.getByRole('dialog').waitFor()
}
function thumbnailPickerState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
): VisualState {
return {
path: '/content/events/42',
api,
steps: async (page) => {
await thumbnailOpenPicker(page)
await steps?.(page)
await page.mouse.move(0, 0)
},
}
}
const mediaPickerStates: Record<string, VisualState> = {
'Library: nothing chosen': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
await waitForPickerTiles(page)
}),
'Library: image chosen': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
await selectPickerTile(page, 'logo-renew.svg')
}),
'Library: No image chosen': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
await waitForPickerTiles(page)
await page.getByTestId('picker-no-image').click()
}),
'Library: required field': thumbnailPickerState(
thumbnailPickerApi({ required: true }),
async (page) => {
await waitForPickerTiles(page)
},
),
'Library: empty': thumbnailPickerState(
thumbnailPickerApi({}, { 'GET /v1/media/': thumbnailLibrary([], 0) }),
async (page) => {
await page.getByRole('tab', { name: 'Library', exact: true }).click()
await page.getByText('Your library is empty').waitFor()
},
),
'Library: failed to load': thumbnailPickerState(
thumbnailPickerApi({}, { 'GET /v1/media/': { status: 500 } }),
async (page) => {
await page.getByText("Couldn't load your library").waitFor()
},
),
'Library: loading': thumbnailPickerState(
thumbnailPickerApi({}, { 'GET /v1/media/': { status: 200, hang: true } }),
async (page) => {
await page.getByTestId('picker-skeleton-tile').first().waitFor()
},
),
'Upload tab': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
await page.getByRole('tab', { name: 'Upload', exact: true }).click()
}),
}
const mediaExceptions: Record<string, { desktop: number; mobile: number; reason: string }> = {
'schema|New media field, seven types': {
desktop: 13.0,
mobile: 19.5,
reason: 'the board draws a stray mobile-sized sheet overlay and a board miniature inside the desktop frame, and the mobile field sheet has the Move up, Move down and Remove row that the board does not draw',
},
'schema|Review: required media field refused': {
desktop: 45.0,
mobile: 10,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states; the dark capture of the same state passes',
},
'schema|Review: optional media field': {
desktop: 33.0,
mobile: 10,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states; the dark capture of the same state passes',
},
'schema|Media field selected': {
desktop: 10,
mobile: 24.0,
reason: 'the accepted spec requires a Move up, Move down and Remove row in the mobile field sheet that the board does not draw',
},
'schema|Media field, required': {
desktop: 10,
mobile: 24.0,
reason: 'the accepted spec requires a Move up, Move down and Remove row in the mobile field sheet that the board does not draw',
},
'entry|Media control: empty, optional': {
desktop: 38.5,
mobile: 10,
reason: 'the desktop board frames draw a stray miniature of the delete dialog state and an overlay of another viewport',
},
'entry|Media control: saving': {
desktop: 19.0,
mobile: 11.5,
reason: 'the light desktop board frame draws a stray miniature and overlay; on mobile the board pins the title row and chip above a form shifted by a negative margin, which a real page cannot do',
},
'entry|Media control: read-only, type changed': {
desktop: 18.5,
mobile: 25.5,
reason: 'the light desktop board frame draws a stray miniature and overlay; on mobile the board pins the title row and the warning alert above a form shifted by a negative margin, which a real page cannot do',
},
'entry|Library: nothing chosen': {
desktop: 17.5,
mobile: 11.5,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: image chosen': {
desktop: 42.0,
mobile: 13.0,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: No image chosen': {
desktop: 42.5,
mobile: 13.0,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: required field': {
desktop: 42.5,
mobile: 12.5,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: empty': {
desktop: 41.5,
mobile: 11.5,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: failed to load': {
desktop: 43.0,
mobile: 15.0,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: loading': {
desktop: 40.5,
mobile: 10,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Upload tab': {
desktop: 31.0,
mobile: 10,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Media control: filled': {
desktop: 10,
mobile: 11.5,
reason: 'the light mobile board frame draws a stray top-right miniature of the board, about 7% of the frame',
},
'entry|Media control: hover': {
desktop: 10,
mobile: 11.5,
reason: 'the light mobile board frame draws a stray top-right miniature of the board, about 7% of the frame',
},
'entry|Media control: focus': {
desktop: 10,
mobile: 12.0,
reason: 'the light mobile board frame draws a stray top-right miniature of the board, about 7% of the frame',
},
'entry|Media control: validation error': {
desktop: 10,
mobile: 10.5,
reason: 'on mobile the board pins the title row and chip above a form shifted by a negative margin, which a real page cannot do',
},
}
function withMediaExceptions(screen: string, states: Record<string, VisualState>) {
return Object.fromEntries(
Object.entries(states).map(([name, state]) => {
const cap = mediaExceptions[`${screen}|${name}`]
return [
name,
cap
? {
...state,
exception: {
maxPercent: { desktop: cap.desktop, mobile: cap.mobile },
reason: cap.reason,
approvedBy: 'fsylvain',
},
}
: state,
]
}),
)
}
export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates,
schema: schemaStates,
content: contentStates,
entry: entryStates,
schema: { ...schemaStates, ...withMediaExceptions('schema', mediaSchemaStates) },
content: { ...contentStates, ...mediaContentStates },
entry: {
...entryStates,
...withMediaExceptions('entry', { ...mediaEntryStates, ...mediaPickerStates }),
},
authors: authorsStates,
activity: activityStates,
profile: {