feat: media page T1
This commit is contained in:
1 parent
67d456baf6
commit
008b0dfbb9
46 files changed
+5742
-204
No files matched your search
@@ -0,0 +1,587 @@
|
||||
import { vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import MediaView from '../MediaView.vue'
|
||||
|
||||
type Row = [
|
||||
number,
|
||||
string,
|
||||
number,
|
||||
number | null,
|
||||
number | null,
|
||||
string,
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
boolean,
|
||||
string,
|
||||
]
|
||||
|
||||
const ROWS: Row[] = [
|
||||
[
|
||||
1,
|
||||
'north-wall.jpg',
|
||||
4089446,
|
||||
4032,
|
||||
3024,
|
||||
'image/jpeg',
|
||||
2,
|
||||
'Marta Alves',
|
||||
'2026-09-12T12:00:00Z',
|
||||
false,
|
||||
'0a',
|
||||
],
|
||||
[
|
||||
2,
|
||||
'kitchen-after.webp',
|
||||
1258291,
|
||||
2400,
|
||||
1600,
|
||||
'image/webp',
|
||||
3,
|
||||
'Inès Duarte',
|
||||
'2026-09-08T12:00:00Z',
|
||||
false,
|
||||
'1b',
|
||||
],
|
||||
[
|
||||
3,
|
||||
'kitchen-before.webp',
|
||||
1153434,
|
||||
2400,
|
||||
1600,
|
||||
'image/webp',
|
||||
1,
|
||||
'Inès Duarte',
|
||||
'2026-09-08T11:00:00Z',
|
||||
false,
|
||||
'2c',
|
||||
],
|
||||
[
|
||||
4,
|
||||
'logo-renew.svg',
|
||||
8192,
|
||||
null,
|
||||
null,
|
||||
'image/svg+xml',
|
||||
5,
|
||||
'jonas',
|
||||
'2026-09-05T12:00:00Z',
|
||||
false,
|
||||
'3d',
|
||||
],
|
||||
[
|
||||
5,
|
||||
'floorboards-copy.jpg',
|
||||
2516582,
|
||||
3600,
|
||||
2400,
|
||||
'image/jpeg',
|
||||
3,
|
||||
'Marta Alves',
|
||||
'2026-09-02T12:00:00Z',
|
||||
true,
|
||||
'0e',
|
||||
],
|
||||
[
|
||||
6,
|
||||
'IMG_2208.jpg',
|
||||
2516582,
|
||||
3600,
|
||||
2400,
|
||||
'image/jpeg',
|
||||
2,
|
||||
'jonas',
|
||||
'2026-08-30T12:00:00Z',
|
||||
true,
|
||||
'0e',
|
||||
],
|
||||
[
|
||||
7,
|
||||
'IMG_4471.jpg',
|
||||
3250585,
|
||||
3600,
|
||||
2400,
|
||||
'image/jpeg',
|
||||
2,
|
||||
'Marta Alves',
|
||||
'2026-08-29T12:00:00Z',
|
||||
true,
|
||||
'2f',
|
||||
],
|
||||
[
|
||||
8,
|
||||
'window-frame.jpg',
|
||||
1887437,
|
||||
3000,
|
||||
2000,
|
||||
'image/jpeg',
|
||||
0,
|
||||
'Inès Duarte',
|
||||
'2026-08-28T12:00:00Z',
|
||||
false,
|
||||
'3a',
|
||||
],
|
||||
[
|
||||
9,
|
||||
'stone-wall-detail.jpg',
|
||||
3250585,
|
||||
3600,
|
||||
2400,
|
||||
'image/jpeg',
|
||||
1,
|
||||
'jonas',
|
||||
'2026-08-21T12:00:00Z',
|
||||
true,
|
||||
'2f',
|
||||
],
|
||||
[
|
||||
10,
|
||||
'bathroom-tiles.png',
|
||||
880640,
|
||||
1800,
|
||||
1200,
|
||||
'image/png',
|
||||
0,
|
||||
'Marta Alves',
|
||||
'2026-08-04T12:00:00Z',
|
||||
false,
|
||||
'1b',
|
||||
],
|
||||
[
|
||||
11,
|
||||
'lime-plaster.jpg',
|
||||
2202009,
|
||||
3200,
|
||||
2133,
|
||||
'image/jpeg',
|
||||
2,
|
||||
'Inès Duarte',
|
||||
'2026-07-19T12:00:00Z',
|
||||
false,
|
||||
'1c',
|
||||
],
|
||||
[
|
||||
12,
|
||||
'floorboards.jpg',
|
||||
2516582,
|
||||
3600,
|
||||
2400,
|
||||
'image/jpeg',
|
||||
4,
|
||||
'Inès Duarte',
|
||||
'2026-06-14T12:00:00Z',
|
||||
true,
|
||||
'0e',
|
||||
],
|
||||
]
|
||||
|
||||
export const FULL_COUNTS = { all: 12, unused: 2, duplicates: 2 }
|
||||
export const MEGABYTE = 1024 * 1024
|
||||
|
||||
export function fixture(ids?: number[]): MediaItem[] {
|
||||
return ROWS.filter(([id]) => !ids || ids.includes(id)).map(
|
||||
([
|
||||
id,
|
||||
filename,
|
||||
size,
|
||||
width,
|
||||
height,
|
||||
content_type,
|
||||
used_in,
|
||||
uploaded_by,
|
||||
created_at,
|
||||
duplicate,
|
||||
sha256,
|
||||
]) => ({
|
||||
id,
|
||||
filename,
|
||||
url: `/media/${filename}`,
|
||||
size,
|
||||
content_type,
|
||||
width,
|
||||
height,
|
||||
sha256,
|
||||
uploaded_by,
|
||||
created_at,
|
||||
used_in,
|
||||
duplicate,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function listBody(items: MediaItem[], counts = FULL_COUNTS) {
|
||||
return { items, next_cursor: null, counts }
|
||||
}
|
||||
|
||||
export function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
export interface Handlers {
|
||||
list?: (path: string) => unknown
|
||||
usage?: (id: number) => unknown
|
||||
hash?: (path: string) => unknown
|
||||
remove?: (id: number) => unknown
|
||||
merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown
|
||||
}
|
||||
|
||||
export function libraryHandlers(
|
||||
outcome: (id: number) => unknown = () => undefined,
|
||||
ids?: number[],
|
||||
): Handlers {
|
||||
const gone = new Set<number>()
|
||||
return {
|
||||
list: () => listBody(fixture(ids).filter((item) => !gone.has(item.id))),
|
||||
remove: async (id) => {
|
||||
try {
|
||||
const result = await outcome(id)
|
||||
gone.add(id)
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
gone.add(id)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const kit = {
|
||||
requestMock: vi.fn(),
|
||||
wrapper: null as VueWrapper | null,
|
||||
}
|
||||
|
||||
function dispatcher(handlers: Handlers) {
|
||||
return (path: string, options?: RequestInit) => {
|
||||
const method = options?.method ?? 'GET'
|
||||
if (method === 'GET' && path.startsWith('/v1/media/?hash=')) {
|
||||
return (handlers.hash ?? (() => listBody([], FULL_COUNTS)))(path)
|
||||
}
|
||||
if (method === 'GET' && path.startsWith('/v1/media/?')) {
|
||||
return (handlers.list ?? (() => listBody(fixture())))(path)
|
||||
}
|
||||
const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path)
|
||||
if (method === 'GET' && usage) {
|
||||
return (handlers.usage ?? (() => []))(Number(usage[1]))
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/media/merge') {
|
||||
return (handlers.merge ?? (() => ({ articles_updated: 0, merged: 0 })))(
|
||||
JSON.parse(String(options?.body)),
|
||||
)
|
||||
}
|
||||
const single = /^\/v1\/media\/(\d+)$/.exec(path)
|
||||
if (method === 'DELETE' && single) {
|
||||
return (handlers.remove ?? (() => undefined))(Number(single[1]))
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method} ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFrom(dispatch: ReturnType<typeof dispatcher>) {
|
||||
return vi.fn(async (path: string, init?: RequestInit) => {
|
||||
const reply = (status: number, body?: unknown) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: new Headers(),
|
||||
json: async () => body,
|
||||
text: async () => '',
|
||||
})
|
||||
try {
|
||||
const body = await dispatch(path, init)
|
||||
return reply(body === undefined ? 204 : 200, body)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return reply(error.status)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function mountPage(
|
||||
handlers: Handlers = {},
|
||||
url = '/media',
|
||||
width = DESKTOP_WIDTH,
|
||||
options: { realApi?: boolean } = {},
|
||||
) {
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
|
||||
session.status = 'ready'
|
||||
const dispatch = dispatcher(handlers)
|
||||
if (options.realApi) {
|
||||
kit.requestMock = fetchFrom(dispatch)
|
||||
vi.stubGlobal('fetch', kit.requestMock)
|
||||
} else {
|
||||
kit.requestMock = vi.fn(async (path: string, init?: RequestInit) => dispatch(path, init))
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||
request: kit.requestMock,
|
||||
})
|
||||
}
|
||||
await router.push(url)
|
||||
kit.wrapper = mount(MediaView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return kit.wrapper
|
||||
}
|
||||
|
||||
export async function unmountPage() {
|
||||
kit.wrapper?.unmount()
|
||||
kit.wrapper = null
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
export function textOf(element: Element | null | undefined) {
|
||||
if (!element) {
|
||||
return ''
|
||||
}
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
const parts: string[] = []
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
|
||||
if (text) {
|
||||
parts.push(text)
|
||||
}
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function cards() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-media-card]'))
|
||||
}
|
||||
|
||||
export function cardFor(filename: string) {
|
||||
return cards().find((card) => textOf(card).includes(filename))!
|
||||
}
|
||||
|
||||
export function tabs() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
|
||||
}
|
||||
|
||||
export function tab(label: string) {
|
||||
return tabs().find((element) => textOf(element).startsWith(label))!
|
||||
}
|
||||
|
||||
export function findButton(label: string, root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(button) => textOf(button) === label,
|
||||
)
|
||||
}
|
||||
|
||||
export function panel() {
|
||||
return document.body.querySelector<HTMLElement>('[role="dialog"]')
|
||||
}
|
||||
|
||||
export function dialog() {
|
||||
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
|
||||
}
|
||||
|
||||
export function listCalls() {
|
||||
return kit.requestMock.mock.calls.filter(
|
||||
([path]) =>
|
||||
typeof path === 'string' && path.startsWith('/v1/media/?') && !path.includes('hash='),
|
||||
)
|
||||
}
|
||||
|
||||
export function lastListPath() {
|
||||
return listCalls().at(-1)![0] as string
|
||||
}
|
||||
|
||||
export function callsTo(method: string, pattern: RegExp) {
|
||||
return kit.requestMock.mock.calls.filter(
|
||||
([path, options]) => (options?.method ?? 'GET') === method && pattern.test(path),
|
||||
)
|
||||
}
|
||||
|
||||
export function press(element: Element, key: string) {
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
}
|
||||
|
||||
export function pressOnWindow(key: string) {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
|
||||
)
|
||||
}
|
||||
|
||||
export async function settle() {
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function announcer() {
|
||||
return textOf(document.body.querySelector('[data-testid="media-announcer"]'))
|
||||
}
|
||||
|
||||
export function checkboxFor(filename: string) {
|
||||
return document.body.querySelector<HTMLElement>(
|
||||
`[role="checkbox"][aria-label="Select ${filename}"]`,
|
||||
)!
|
||||
}
|
||||
|
||||
export function selectAllCheckbox() {
|
||||
return document.body
|
||||
.querySelector('[data-testid="image-count"]')!
|
||||
.parentElement!.querySelector<HTMLElement>('[role="checkbox"]')!
|
||||
}
|
||||
|
||||
export function tableHeaderCheckbox() {
|
||||
return document.body.querySelector<HTMLElement>('[role="checkbox"][aria-label="Select all"]')!
|
||||
}
|
||||
|
||||
export function bulkBar() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="media-bulk-bar"]')
|
||||
}
|
||||
|
||||
export function fileWith(name: string, type: string, size?: number, content = name) {
|
||||
const file = new File([content], name, { type })
|
||||
if (size !== undefined) {
|
||||
Object.defineProperty(file, 'size', { value: size })
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
export function hexOf(content: string) {
|
||||
return Array.from(new TextEncoder().encode(content), (byte) =>
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
)
|
||||
.join('')
|
||||
.padEnd(64, '0')
|
||||
.slice(0, 64)
|
||||
}
|
||||
|
||||
export function stubDigest() {
|
||||
const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => {
|
||||
const hex = Array.from(new Uint8Array(data), (byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.padEnd(64, '0')
|
||||
.slice(0, 64)
|
||||
return new Uint8Array(hex.match(/../g)!.map((pair) => Number.parseInt(pair, 16))).buffer
|
||||
})
|
||||
vi.stubGlobal('crypto', { subtle: { digest } })
|
||||
return digest
|
||||
}
|
||||
|
||||
export class FakeXhr extends EventTarget {
|
||||
static instances: FakeXhr[] = []
|
||||
upload = new EventTarget()
|
||||
method = ''
|
||||
url = ''
|
||||
status = 0
|
||||
responseText = ''
|
||||
withCredentials = false
|
||||
body: FormData | null = null
|
||||
aborted = false
|
||||
finished = false
|
||||
headers: Record<string, string> = {}
|
||||
|
||||
open(method: string, url: string) {
|
||||
this.method = method
|
||||
this.url = url
|
||||
}
|
||||
|
||||
send(body: FormData) {
|
||||
this.body = body
|
||||
FakeXhr.instances.push(this)
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.aborted = true
|
||||
this.finished = true
|
||||
this.dispatchEvent(new Event('abort'))
|
||||
}
|
||||
|
||||
getResponseHeader(name: string) {
|
||||
return this.headers[name.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
progress(loaded: number, total: number) {
|
||||
this.upload.dispatchEvent(Object.assign(new Event('progress'), { loaded, total }))
|
||||
}
|
||||
|
||||
respond(status: number, headers: Record<string, string> = {}, text = '') {
|
||||
this.status = status
|
||||
this.headers = Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
)
|
||||
this.responseText = text
|
||||
this.finished = true
|
||||
this.dispatchEvent(new Event('load'))
|
||||
}
|
||||
|
||||
fail() {
|
||||
this.finished = true
|
||||
this.dispatchEvent(new Event('error'))
|
||||
}
|
||||
|
||||
get fileName() {
|
||||
return (this.body?.get('file') as File).name
|
||||
}
|
||||
}
|
||||
|
||||
export function stubXhr() {
|
||||
FakeXhr.instances = []
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXhr)
|
||||
}
|
||||
|
||||
export function inFlight() {
|
||||
return FakeXhr.instances.filter((xhr) => !xhr.finished)
|
||||
}
|
||||
|
||||
export function xhrFor(name: string) {
|
||||
return FakeXhr.instances.find((xhr) => xhr.fileName === name)!
|
||||
}
|
||||
|
||||
export function fileInput() {
|
||||
return document.body.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
}
|
||||
|
||||
export async function chooseFiles(files: File[]) {
|
||||
const input = fileInput()
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await settle()
|
||||
}
|
||||
|
||||
export function dragEvent(type: string, types: string[], files: File[] = []) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { types, files } })
|
||||
document.body.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
export function uploadPanel() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="media-upload-panel"]')
|
||||
}
|
||||
|
||||
export function uploadRows() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="upload-row"]'))
|
||||
}
|
||||
|
||||
export function rowTexts() {
|
||||
return uploadRows().map(textOf)
|
||||
}
|
||||
Reference in new issue
Block a user