feat(SPA): media library indications for custom medial field type usage

This commit is contained in:
Florian Sylvain committed 2026-10-21 16:17:09 +02:00
1 parent 3e7fc9a4a5
commit 0fc3511e2b
28 files changed
+2060 -131

No files matched your search

+4 -4
View File
@@ -59,15 +59,15 @@ describe('formatType', () => {
describe('usedInLabel and accessibleName', () => {
it('pluralises the article count', () => {
expect(usedInLabel(1)).toBe('used in 1 article')
expect(usedInLabel(2)).toBe('used in 2 articles')
expect(usedInLabel(1)).toBe('used in 1 place')
expect(usedInLabel(2)).toBe('used in 2 places')
expect(usedInLabel(0)).toBe('unused')
})
it('builds the card name from filename, size and usage', () => {
expect(accessibleName(base)).toBe('north-wall.jpg, 3.9 MB, used in 2 articles')
expect(accessibleName(base)).toBe('north-wall.jpg, 3.9 MB, used in 2 places')
expect(accessibleName({ ...base, used_in: 1 })).toBe(
'north-wall.jpg, 3.9 MB, used in 1 article',
'north-wall.jpg, 3.9 MB, used in 1 place',
)
expect(accessibleName({ ...base, used_in: 0 })).toBe('north-wall.jpg, 3.9 MB, unused')
})
+200
View File
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest'
import type { MediaUsageItem } from '@/composables/useMedia'
import { deleteCopy, tooltipLines, pluralEntries, splitUsage, usageEntryTitle } from '../mediaUsage'
import { pluralPlaces } from '../mediaFormat'
function article(id: number, published = true): MediaUsageItem {
return { type: 'article', id, title: `Article ${id}`, published, fields: [] }
}
function entry(id: number, firstField?: { type: string; value: unknown }): MediaUsageItem {
return {
type: 'entry',
id,
fields: ['thumbnail'],
content_type_key: 'events',
content_type_name: 'Events',
first_field: firstField ? { key: 'title', ...firstField } : undefined,
}
}
function entries(count: number) {
return Array.from({ length: count }, (_, index) =>
entry(index + 1, { type: 'text', value: `Entry title ${index + 1}` }),
)
}
describe('splitUsage', () => {
it('counts articles and entries and ignores authors and sites', () => {
const split = splitUsage([
article(1),
article(2, false),
{ type: 'author', id: 3, fields: [] },
{ type: 'site', id: 4, fields: [] },
...entries(2),
])
expect(split.articles.map((row) => row.id)).toEqual([1, 2])
expect(split.entryCount).toBe(2)
expect(split.entries).toHaveLength(2)
expect(split.more).toBe(0)
})
it.each([
[1, 1, 0],
[3, 3, 0],
[4, 3, 1],
[5, 3, 2],
[120, 3, 117],
])('with %i entries keeps %i rows and %i more', (count, rows, more) => {
const split = splitUsage(entries(count))
expect(split.entryCount).toBe(count)
expect(split.entries).toHaveLength(rows)
expect(split.more).toBe(more)
})
it('keeps the order of the API', () => {
const split = splitUsage([entry(9), entry(2), entry(5), entry(1)])
expect(split.entries.map((row) => row.id)).toEqual([9, 2, 5])
})
it('gives zeros for an empty usage', () => {
expect(splitUsage([])).toMatchObject({ articles: [], entryCount: 0, more: 0 })
})
it('reads a row without a type as an article', () => {
expect(splitUsage([{ id: 7, title: 'Old', published: true }]).articles).toHaveLength(1)
})
it('carries the content type name, key and the derived title of an entry', () => {
const [row] = splitUsage([
entry(42, { type: 'text', value: 'Lime plaster workshop' }),
]).entries
expect(row).toEqual({
id: 42,
key: 'events',
typeName: 'Events',
title: 'Lime plaster workshop',
})
})
})
describe('usageEntryTitle', () => {
it.each([
['text', 'Lime plaster workshop', 'Lime plaster workshop'],
['text', '', 'Entry 42'],
['text', null, 'Entry 42'],
['richtext', '## Heritage walk\n\nMeet at 9', 'Heritage walk'],
['richtext', '', 'Entry 42'],
['number', 1500, '1,500'],
['number', 0, '0'],
['boolean', true, 'Yes'],
['boolean', false, 'No'],
['select', 'Workshop', 'Workshop'],
['media', 7, 'Entry 42'],
['media', null, 'Entry 42'],
['mystery', 'Odd value', 'Odd value'],
])('%s %j reads %s', (type, value, title) => {
expect(usageEntryTitle(entry(42, { type, value }))).toBe(title)
})
it('formats a date as the entry list does', () => {
expect(usageEntryTitle(entry(42, { type: 'date', value: '2026-11-21' }))).toBe(
'21 Nov 2026',
)
})
it('falls back to the id when there is no first field', () => {
expect(usageEntryTitle(entry(42))).toBe('Entry 42')
})
})
describe('wording', () => {
it('pluralises entries and places', () => {
expect(pluralEntries(1)).toBe('1 entry')
expect(pluralEntries(5)).toBe('5 entries')
expect(pluralPlaces(1)).toBe('1 place')
expect(pluralPlaces(7)).toBe('7 places')
})
})
describe('tooltipLines', () => {
it.each([
[[...entries(3)], ['Used in entries: 3']],
[
[article(1), article(2), ...entries(5)],
['Used in articles: 2', 'Used in entries: 5'],
],
[[article(1), article(2)], ['Used in articles: 2']],
[[{ type: 'author', id: 1, fields: [] }], []],
])('reads %#', (items, lines) => {
expect(tooltipLines(items as MediaUsageItem[])).toEqual(lines)
})
})
describe('deleteCopy', () => {
const NOTICE = 'This image is no longer in the library'
it.each([
[
[...Array(2).keys()].map((id) => article(id + 1)).concat(entries(5)),
"Delete an image that's in use?",
`"lime-plaster.jpg" is used in 5 entries and 2 articles. Those entry fields will show "${NOTICE}", and the articles will show a broken image. This can't be undone.`,
],
[
[article(1), entry(1)],
"Delete an image that's in use?",
`"lime-plaster.jpg" is used in 1 entry and 1 article. Those entry fields will show "${NOTICE}", and the articles will show a broken image. This can't be undone.`,
],
[
[article(1), article(2)],
"Delete an image that's in use?",
`"lime-plaster.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can't be undone.`,
],
[
entries(3),
"Delete an image that's in use?",
`"lime-plaster.jpg" is used in 3 entries. Those entry fields will show "${NOTICE}". This can't be undone.`,
],
])('case %#', (items, title, description) => {
const copy = deleteCopy('lime-plaster.jpg', items.length, {
status: 'ready',
items: items as MediaUsageItem[],
})
expect(copy.title).toBe(title)
expect(copy.description).toBe(description)
expect(copy.split).not.toBeNull()
})
it('is the unused dialog at used_in 0', () => {
const copy = deleteCopy('lime-plaster.jpg', 0, null)
expect(copy.title).toBe('Delete this image?')
expect(copy.description).toBe(
`"lime-plaster.jpg" will be deleted from your library. No article uses it. This can't be undone.`,
)
expect(copy.split).toBeNull()
})
it.each([
['loading', { status: 'loading' } as const],
['failed', { status: 'failed', failure: 'failed' } as const],
['absent', null],
[
'authors only',
{ status: 'ready', items: [{ type: 'author', id: 1, fields: [] }] } as const,
],
])('uses the generic copy when the usage is %s', (_name, state) => {
const copy = deleteCopy('lime-plaster.jpg', 2, state as never)
expect(copy.title).toBe("Delete an image that's in use?")
expect(copy.description).toBe(
`"lime-plaster.jpg" is in use. Articles will show a broken image and entry fields will show "${NOTICE}". This can't be undone.`,
)
expect(copy.split).toBeNull()
})
})
+5 -1
View File
@@ -71,8 +71,12 @@ export function pluralArticles(count: number) {
return t(count === 1 ? '1 article' : '{0} articles', [count])
}
export function pluralPlaces(count: number) {
return t(count === 1 ? '1 place' : '{0} places', [count])
}
export function usedInLabel(count: number) {
return count > 0 ? t('used in {0}', [pluralArticles(count)]) : t('unused')
return count > 0 ? t('used in {0}', [pluralPlaces(count)]) : t('unused')
}
export function accessibleName(item: MediaNameSource) {
+140
View File
@@ -0,0 +1,140 @@
import { t } from '@/i18n'
import type { MediaUsageItem, UsageState } from '@/composables/useMedia'
import { describeCell, plainCellText } from '@/lib/entryCells'
import { pluralArticles } from '@/lib/mediaFormat'
const ENTRY_ROWS_SHOWN = 3
const ARTICLE_TYPE = 'article'
const ENTRY_TYPE = 'entry'
export interface UsageArticle {
id: number
title: string
published: boolean
}
export interface UsageEntry {
id: number
key: string
typeName: string
title: string
}
export interface UsageSplit {
articles: UsageArticle[]
entries: UsageEntry[]
entryCount: number
more: number
}
export function pluralEntries(count: number) {
return t(count === 1 ? '1 entry' : '{0} entries', [count])
}
export function usageEntryTitle(item: MediaUsageItem) {
const fallback = t('Entry {0}', [item.id])
const first = item.first_field
if (!first) {
return fallback
}
const cell = describeCell(
{
key: first.key,
label: first.key,
type: first.type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
},
first.value,
)
return cell.kind === 'empty' || cell.kind === 'thumbnail' ? fallback : plainCellText(cell)
}
function toEntry(item: MediaUsageItem): UsageEntry {
return {
id: item.id,
key: item.content_type_key ?? '',
typeName: item.content_type_name ?? '',
title: usageEntryTitle(item),
}
}
export function splitUsage(items: MediaUsageItem[]): UsageSplit {
const articles = items
.filter((item) => item.type === undefined || item.type === ARTICLE_TYPE)
.map((item) => ({
id: item.id,
title: item.title ?? '',
published: item.published === true,
}))
const entryItems = items.filter((item) => item.type === ENTRY_TYPE)
return {
articles,
entries: entryItems.slice(0, ENTRY_ROWS_SHOWN).map(toEntry),
entryCount: entryItems.length,
more: Math.max(0, entryItems.length - ENTRY_ROWS_SHOWN),
}
}
export function tooltipLines(items: MediaUsageItem[]) {
const split = splitUsage(items)
return [
split.articles.length > 0 ? t('Used in articles: {0}', [split.articles.length]) : '',
split.entryCount > 0 ? t('Used in entries: {0}', [split.entryCount]) : '',
].filter(Boolean)
}
export function usageSplitOf(state: UsageState | null) {
return state?.status === 'ready' ? splitUsage(state.items) : null
}
function inUseDescription(filename: string, split: UsageSplit | null) {
const articles = split?.articles.length ?? 0
const entries = split?.entryCount ?? 0
if (articles > 0 && entries > 0) {
return t(
"\u0022{0}\u0022 is used in {1} and {2}. Those entry fields will show \u0022This image is no longer in the library\u0022, and the articles will show a broken image. This can't be undone.",
[filename, pluralEntries(entries), pluralArticles(articles)],
)
}
if (articles > 0) {
return t(
"\u0022{0}\u0022 is used in {1}. If you delete it, they will show a broken image. This can't be undone.",
[filename, pluralArticles(articles)],
)
}
if (entries > 0) {
return t(
"\u0022{0}\u0022 is used in {1}. Those entry fields will show \u0022This image is no longer in the library\u0022. This can't be undone.",
[filename, pluralEntries(entries)],
)
}
return t(
"\u0022{0}\u0022 is in use. Articles will show a broken image and entry fields will show \u0022This image is no longer in the library\u0022. This can't be undone.",
[filename],
)
}
export function deleteCopy(filename: string, usedIn: number, state: UsageState | null) {
if (usedIn <= 0) {
return {
title: t('Delete this image?'),
description: t(
"\u0022{0}\u0022 will be deleted from your library. No article uses it. This can't be undone.",
[filename],
),
split: null,
}
}
const split = usageSplitOf(state)
const known = split !== null && (split.articles.length > 0 || split.entryCount > 0)
return {
title: t("Delete an image that's in use?"),
description: inUseDescription(filename, known ? split : null),
split: known ? split : null,
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { InjectionKey } from 'vue'
import type { UsageState } from '@/composables/useMedia'
export interface MediaUsageContext {
stateOf: (id: number) => UsageState | null
ensure: (id: number) => void
}
export const MEDIA_USAGE_KEY: InjectionKey<MediaUsageContext> = Symbol('media-usage')