feat: media picker and image removal
This commit is contained in:
1 parent
008b0dfbb9
commit
b3793d5dbb
54 files changed
+3769
-1412
No files matched your search
@@ -0,0 +1,522 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import { MOBILE_WIDTH } from './viewport'
|
||||
import { fixture } from '@/views/__tests__/mediaTestKit'
|
||||
import {
|
||||
altField,
|
||||
apiKit,
|
||||
click,
|
||||
clickTab,
|
||||
dialogText,
|
||||
findButton,
|
||||
installApi,
|
||||
listCalls,
|
||||
modalOpen,
|
||||
mountUploader,
|
||||
openUploader,
|
||||
pageBody,
|
||||
press,
|
||||
searchField,
|
||||
secondPage,
|
||||
selectedTab,
|
||||
selectTile,
|
||||
settle,
|
||||
skeletonTiles,
|
||||
textOf,
|
||||
tile,
|
||||
tiles,
|
||||
typeInto,
|
||||
type PickerRoutes,
|
||||
} from './pickerKit'
|
||||
|
||||
vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
|
||||
return {
|
||||
...actual,
|
||||
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
let wrapper: ReturnType<typeof mountUploader> | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function openLibrary(routes: PickerRoutes = {}) {
|
||||
installApi(routes)
|
||||
wrapper = mountUploader()
|
||||
await openUploader(wrapper)
|
||||
}
|
||||
|
||||
async function openWithFakeTimers(routes: PickerRoutes = {}, width?: number) {
|
||||
vi.useFakeTimers()
|
||||
installApi(routes)
|
||||
wrapper = mountUploader({}, width)
|
||||
;(wrapper.vm as unknown as { open: boolean }).open = true
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
function summary() {
|
||||
return textOf(document.body.querySelector('[data-testid="picker-summary"]'))
|
||||
}
|
||||
|
||||
async function searchFor(value: string) {
|
||||
await typeInto(searchField(), value)
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
await settle()
|
||||
}
|
||||
|
||||
describe('The library has images, so Library opens', () => {
|
||||
it('selects Library with a disabled "Insert image" and draws skeletons only after 150 ms', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await openWithFakeTimers({ list: () => pending.promise })
|
||||
|
||||
expect(textOf(selectedTab())).toBe('Library')
|
||||
expect(findButton('Cancel')?.disabled).toBe(false)
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
expect(skeletonTiles()).toHaveLength(0)
|
||||
expect(tiles()).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(8)
|
||||
|
||||
pending.resolve(pageBody())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(0)
|
||||
expect(tiles()).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('draws 9 skeleton tiles on mobile', async () => {
|
||||
await openWithFakeTimers({ list: () => new Promise(() => undefined) }, MOBILE_WIDTH)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('asks for 24 images and nothing else', async () => {
|
||||
await openLibrary()
|
||||
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
expect(listCalls()[0]!.toString()).toBe('limit=24')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The library is empty, so Upload opens', () => {
|
||||
it('opens the Upload tab idle, and Library then shows the empty panel', async () => {
|
||||
await openLibrary({ list: () => pageBody([], null) })
|
||||
|
||||
expect(textOf(selectedTab())).toBe('Upload')
|
||||
expect(dialogText()).toContain('Drop an image here, or choose a file')
|
||||
|
||||
await clickTab('Library')
|
||||
|
||||
expect(dialogText()).toContain('Your library is empty')
|
||||
expect(dialogText()).toContain(
|
||||
'Images you upload appear here, ready to use in any article.',
|
||||
)
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('switches to Upload from "Upload an image"', async () => {
|
||||
await openLibrary({ list: () => pageBody([], null) })
|
||||
await clickTab('Library')
|
||||
|
||||
await click(findButton('Upload an image'))
|
||||
|
||||
expect(textOf(selectedTab())).toBe('Upload')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A dropped file opens Upload and sends no list request', () => {
|
||||
it('stays quiet until the Library tab is selected', async () => {
|
||||
installApi({ hash: () => new Promise(() => undefined) })
|
||||
wrapper = mountUploader({
|
||||
initialFile: new File(['x'], 'before.png', { type: 'image/png' }),
|
||||
})
|
||||
await openUploader(wrapper)
|
||||
|
||||
expect(textOf(selectedTab())).toBe('Upload')
|
||||
expect(listCalls()).toHaveLength(0)
|
||||
|
||||
await clickTab('Library')
|
||||
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Library, nothing selected', () => {
|
||||
it('shows the line, the 12 tiles in order and the tags on the images in the article', async () => {
|
||||
await openLibrary()
|
||||
|
||||
expect(summary()).toBe('Newest first · 48 images')
|
||||
expect(tiles().map((element) => element.getAttribute('data-image-id'))).toEqual(
|
||||
fixture().map((item) => String(item.id)),
|
||||
)
|
||||
const tagged = tiles()
|
||||
.filter((element) => textOf(element).includes('In this article'))
|
||||
.map((element) => element.getAttribute('data-image-id'))
|
||||
expect(tagged).toEqual(['1', '4'])
|
||||
expect(textOf(tile('north-wall.jpg'))).toContain('north-wall.jpg')
|
||||
expect(findButton('Load more')?.getAttribute('aria-disabled')).toBeNull()
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A search is active, so the count is not shown', () => {
|
||||
it('reads "Newest first"', async () => {
|
||||
await openLibrary({
|
||||
list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()),
|
||||
})
|
||||
|
||||
await searchFor('wall')
|
||||
|
||||
expect(summary()).toBe('Newest first')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load more is hidden on the last page', () => {
|
||||
it('does not show "Load more" without a cursor', async () => {
|
||||
await openLibrary({ list: () => pageBody(fixture(), null) })
|
||||
|
||||
expect(findButton('Load more')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("A tile's accessible name", () => {
|
||||
it.each([
|
||||
['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles'],
|
||||
['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article'],
|
||||
['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused'],
|
||||
])('names %s', async (filename, name) => {
|
||||
await openLibrary()
|
||||
|
||||
expect(tile(filename).getAttribute('aria-label')).toBe(name)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A tile is selected by pointer or keyboard', () => {
|
||||
it.each([
|
||||
['clicks', async () => click(tile('kitchen-after.webp'))],
|
||||
[
|
||||
'presses Enter',
|
||||
async () => {
|
||||
press(tile('kitchen-after.webp'), 'Enter')
|
||||
await settle()
|
||||
},
|
||||
],
|
||||
[
|
||||
'presses Space',
|
||||
async () => {
|
||||
press(tile('kitchen-after.webp'), ' ')
|
||||
await settle()
|
||||
},
|
||||
],
|
||||
])('selects the tile when the user %s on it', async (_name, act) => {
|
||||
await openLibrary()
|
||||
|
||||
await act()
|
||||
|
||||
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(
|
||||
tile('kitchen-after.webp').querySelector('[data-testid="picker-check"]'),
|
||||
).not.toBeNull()
|
||||
expect(findButton('Insert image')?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The selection panel', () => {
|
||||
it('shows the thumbnail, the details and the alt text field', async () => {
|
||||
await openLibrary()
|
||||
|
||||
await selectTile('kitchen-after.webp')
|
||||
|
||||
const panel = textOf(document.body.querySelector('[data-testid="picker-panel"]'))
|
||||
expect(panel).toContain('kitchen-after.webp')
|
||||
expect(panel).toContain('2400 × 1600 · 1.2 MB')
|
||||
expect(panel).toContain('Alt text (optional)')
|
||||
expect(panel).toContain(
|
||||
"Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration.",
|
||||
)
|
||||
expect(altField()?.value).toBe('')
|
||||
expect(findButton('Load more')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads "Vector · 8 KB" for an SVG', async () => {
|
||||
await openLibrary()
|
||||
|
||||
await selectTile('logo-renew.svg')
|
||||
|
||||
expect(textOf(document.body.querySelector('[data-testid="picker-panel"]'))).toContain(
|
||||
'Vector · 8 KB',
|
||||
)
|
||||
})
|
||||
|
||||
it('allows an image already in the article to be selected', async () => {
|
||||
await openLibrary()
|
||||
|
||||
await selectTile('north-wall.jpg')
|
||||
|
||||
expect(findButton('Insert image')?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection is single', () => {
|
||||
it('selects only the last tile and empties the alt text', async () => {
|
||||
await openLibrary()
|
||||
await selectTile('kitchen-after.webp')
|
||||
await typeInto(altField()!, 'A kitchen')
|
||||
|
||||
await selectTile('window-frame.jpg')
|
||||
|
||||
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('false')
|
||||
expect(tile('window-frame.jpg').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(altField()!.value).toBe('')
|
||||
})
|
||||
|
||||
it('keeps the tile selected, and its alt text, when it is activated again', async () => {
|
||||
await openLibrary()
|
||||
await selectTile('kitchen-after.webp')
|
||||
await typeInto(altField()!, 'A kitchen')
|
||||
|
||||
await selectTile('kitchen-after.webp')
|
||||
|
||||
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(altField()!.value).toBe('A kitchen')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Insert', () => {
|
||||
function kitchenAfterAtStaticUrl() {
|
||||
return pageBody(
|
||||
fixture().map((item) =>
|
||||
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it('emits the item and the alt text, then closes at once with no write request', async () => {
|
||||
await openLibrary({ list: kitchenAfterAtStaticUrl })
|
||||
await selectTile('kitchen-after.webp')
|
||||
await typeInto(altField()!, 'A kitchen with new oak worktops and white tiles')
|
||||
|
||||
await click(findButton('Insert image'))
|
||||
|
||||
const [item, alt] = wrapper!.emitted('inserted')![0] as [
|
||||
{ id: number; url: string },
|
||||
string,
|
||||
]
|
||||
expect(item.id).toBe(2)
|
||||
expect(item.url).toBe('/static/uploadedImages/2.webp')
|
||||
expect(alt).toBe('A kitchen with new oak worktops and white tiles')
|
||||
expect(modalOpen()).toBe(false)
|
||||
expect(apiKit.request.mock.calls.every(([, options]) => !options?.method)).toBe(true)
|
||||
})
|
||||
|
||||
it('inserts an empty alt for a decorative image', async () => {
|
||||
await openLibrary()
|
||||
await selectTile('kitchen-after.webp')
|
||||
|
||||
await click(findButton('Insert image'))
|
||||
|
||||
expect(wrapper!.emitted('inserted')![0]![1]).toBe('')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['activates "Cancel"', async () => click(findButton('Cancel'))],
|
||||
[
|
||||
'activates the x',
|
||||
async () =>
|
||||
click(document.body.querySelector('[role="dialog"] button[aria-label="Close"]')),
|
||||
],
|
||||
[
|
||||
'presses Escape',
|
||||
async () => {
|
||||
document.body
|
||||
.querySelector('[role="dialog"]')!
|
||||
.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await settle()
|
||||
},
|
||||
],
|
||||
])('changes nothing when the user %s', async (_name, act) => {
|
||||
await openLibrary()
|
||||
await selectTile('kitchen-after.webp')
|
||||
await typeInto(altField()!, 'A kitchen')
|
||||
|
||||
await act()
|
||||
|
||||
expect(modalOpen()).toBe(false)
|
||||
expect(wrapper!.emitted('inserted')).toBeUndefined()
|
||||
expect(wrapper!.emitted('uploaded')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search', () => {
|
||||
it('searches after a 120 ms debounce, with no cursor, and clears the selection', async () => {
|
||||
await openWithFakeTimers()
|
||||
tile('kitchen-after.webp').click()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(findButton('Insert image')?.disabled).toBe(false)
|
||||
|
||||
await typeInto(searchField(), 'wall')
|
||||
await vi.advanceTimersByTimeAsync(119)
|
||||
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(listCalls()).toHaveLength(2)
|
||||
expect(listCalls()[1]!.get('q')).toBe('wall')
|
||||
expect(listCalls()[1]!.get('limit')).toBe('24')
|
||||
expect(listCalls()[1]!.has('cursor')).toBe(false)
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('draws skeleton tiles while a search loads, after 150 ms', async () => {
|
||||
await openWithFakeTimers({
|
||||
list: (query) => (query.get('q') ? new Promise(() => undefined) : pageBody()),
|
||||
})
|
||||
|
||||
await typeInto(searchField(), 'wall')
|
||||
await vi.advanceTimersByTimeAsync(120)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('clears the search from the x in the field', async () => {
|
||||
await openLibrary({
|
||||
list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()),
|
||||
})
|
||||
await searchFor('wall')
|
||||
|
||||
await click(document.body.querySelector('button[aria-label="Clear search"]'))
|
||||
|
||||
expect(searchField().value).toBe('')
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.has('q')).toBe(false)
|
||||
expect(last.get('limit')).toBe('24')
|
||||
})
|
||||
|
||||
it('shows the no results panel and "Clear search" empties the field', async () => {
|
||||
await openLibrary({
|
||||
list: (query) => (query.get('q') ? pageBody([], null) : pageBody()),
|
||||
})
|
||||
await searchFor('wall')
|
||||
|
||||
expect(dialogText()).toContain('No images match "wall"')
|
||||
expect(dialogText()).toContain(
|
||||
'Check the spelling, or search for a shorter part of the filename.',
|
||||
)
|
||||
expect(searchField().value).toBe('wall')
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
|
||||
await click(findButton('Clear search'))
|
||||
|
||||
expect(searchField().value).toBe('')
|
||||
expect(listCalls().at(-1)!.has('q')).toBe(false)
|
||||
expect(tiles()).toHaveLength(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load more', () => {
|
||||
it('shows progress, appends the new tiles and moves focus to the first one', async () => {
|
||||
const more = deferred<unknown>()
|
||||
await openWithFakeTimers({
|
||||
list: (query) => (query.get('cursor') ? more.promise : pageBody()),
|
||||
})
|
||||
const button = findButton('Load more')!
|
||||
button.focus()
|
||||
|
||||
button.click()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.get('cursor')).toBe('c24')
|
||||
expect(last.get('limit')).toBe('24')
|
||||
const loading = findButton('Loading…')!
|
||||
expect(loading.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(document.activeElement).toBe(loading)
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(skeletonTiles()).toHaveLength(4)
|
||||
|
||||
more.resolve(pageBody(secondPage(), null))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushPromises()
|
||||
|
||||
expect(tiles()).toHaveLength(24)
|
||||
expect(skeletonTiles()).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(tiles()[12])
|
||||
})
|
||||
|
||||
it('draws a row of 3 skeleton tiles on mobile', async () => {
|
||||
await openWithFakeTimers(
|
||||
{ list: (query) => (query.get('cursor') ? new Promise(() => undefined) : pageBody()) },
|
||||
MOBILE_WIDTH,
|
||||
)
|
||||
|
||||
findButton('Load more')!.click()
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('combines the cursor with the search', async () => {
|
||||
await openLibrary({
|
||||
list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 'c24'),
|
||||
})
|
||||
await searchFor('wall')
|
||||
|
||||
await click(findButton('Load more'))
|
||||
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.get('q')).toBe('wall')
|
||||
expect(last.get('limit')).toBe('24')
|
||||
expect(last.get('cursor')).toBe('c24')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The library fails to load', () => {
|
||||
it.each([
|
||||
['a 500 response', () => Promise.reject(new ApiError(500))],
|
||||
['a request that never reaches the server', () => Promise.reject(new NetworkError())],
|
||||
])('shows the alert for %s and retries', async (_name, failure) => {
|
||||
let failing = true
|
||||
await openLibrary({ list: () => (failing ? failure() : pageBody()) })
|
||||
|
||||
expect(dialogText()).toContain("Couldn't load your library")
|
||||
expect(dialogText()).toContain('Check your connection and try again.')
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
|
||||
failing = false
|
||||
await click(findButton('Try again'))
|
||||
|
||||
expect(listCalls()).toHaveLength(2)
|
||||
expect(tiles()).toHaveLength(12)
|
||||
expect(dialogText()).not.toContain("Couldn't load your library")
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user