feat(SPA): media field type
This commit is contained in:
1 parent
a2d596f4ca
commit
3e7fc9a4a5
57 files changed
+6109
-75
No files matched your search
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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 }">
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
@@ -9,6 +9,7 @@ const CONTROLS: Record<FieldType, string> = {
|
||||
boolean: 'switch',
|
||||
date: 'date',
|
||||
select: 'select',
|
||||
media: 'media',
|
||||
}
|
||||
|
||||
function editor(type: FieldType) {
|
||||
|
||||
@@ -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.',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,3 +5,8 @@ export interface ImageChoice {
|
||||
square?: boolean
|
||||
confirmLabel: string
|
||||
}
|
||||
|
||||
export interface FieldPick {
|
||||
optional: boolean
|
||||
currentId: number | null
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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) : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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]))()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user