feat: relation field and picker
This commit is contained in:
1 parent
b45c99be3b
commit
37a77c9c22
92 files changed
+6826
-80
No files matched your search
@@ -0,0 +1,230 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import {
|
||||
button,
|
||||
calls,
|
||||
entryWrites,
|
||||
fieldWrapper,
|
||||
installSelectPolyfills,
|
||||
save,
|
||||
text,
|
||||
typeInto,
|
||||
unmountAll,
|
||||
} from './entryEditorKit'
|
||||
import {
|
||||
chipLabels,
|
||||
emptyText,
|
||||
liveText,
|
||||
mountRelation,
|
||||
openList,
|
||||
optionLabels,
|
||||
pickOption,
|
||||
relationError,
|
||||
relationInput,
|
||||
searchBody,
|
||||
settleSearch,
|
||||
typeSearch,
|
||||
} from './relationKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
installSelectPolyfills()
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
|
||||
})
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const searches = () => calls.filter((url) => url.includes('/venues/entry?'))
|
||||
const empty = { items: [], total: 0, page: 1, size: 10 }
|
||||
const skeletons = () => document.body.querySelectorAll('[data-testid="relation-skeleton"]')
|
||||
|
||||
function failing(error: unknown) {
|
||||
return () => {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
describe('Picker loading', () => {
|
||||
it('shows three skeleton rows after 150 ms and announces Searching…', async () => {
|
||||
await mountRelation('/content/events/42', { search: () => new Promise(() => undefined) })
|
||||
|
||||
await typeSearch('venue', 'zzz')
|
||||
await settleSearch(vi)
|
||||
expect(skeletons()).toHaveLength(0)
|
||||
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(skeletons()).toHaveLength(3)
|
||||
expect(
|
||||
text(document.body.querySelector('[data-testid="relation-empty"] [role="status"]')),
|
||||
).toBe('Searching…')
|
||||
expect(optionLabels()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('No match', () => {
|
||||
it('names the query and clears the search', async () => {
|
||||
await mountRelation('/content/events/42', {
|
||||
search: (key, query) => searchBody(key, query),
|
||||
})
|
||||
|
||||
await openList('venue', vi, 'zzz')
|
||||
|
||||
expect(emptyText()).toBe('No Venues match "zzz" Clear search')
|
||||
expect(liveText('venue')).toBe('No results')
|
||||
button('Clear search')!.click()
|
||||
await flushPromises()
|
||||
await settleSearch(vi)
|
||||
expect(relationInput('venue').value).toBe('')
|
||||
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=1&size=10')
|
||||
})
|
||||
|
||||
it('says the type has no entries at all', async () => {
|
||||
await mountRelation('/content/events/42', { search: () => empty })
|
||||
|
||||
await openList('venue', vi)
|
||||
|
||||
expect(emptyText()).toBe('No Venues yet. Add one in Content, then come back.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search failures', () => {
|
||||
it.each([
|
||||
['500', new ApiError(500)],
|
||||
['a network error', new NetworkError()],
|
||||
['404', new ApiError(404)],
|
||||
])(
|
||||
'shows the load error on %s and sends only the search again on Try again',
|
||||
async (_name, error) => {
|
||||
let fail = true
|
||||
await mountRelation('/content/events/42', {
|
||||
search: (key, query) => {
|
||||
if (fail) {
|
||||
throw error
|
||||
}
|
||||
return searchBody(key, query)
|
||||
},
|
||||
})
|
||||
|
||||
await openList('venue', vi, 'rue')
|
||||
|
||||
expect(emptyText()).toBe(
|
||||
"Couldn't load Venues Check your connection and try again. Try again",
|
||||
)
|
||||
fail = false
|
||||
const before = calls.length
|
||||
button('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(calls.slice(before)).toEqual([
|
||||
'/v1/content-type/venues/entry?q=rue&page=1&size=10',
|
||||
])
|
||||
expect(optionLabels()).toHaveLength(3)
|
||||
},
|
||||
)
|
||||
|
||||
it('shows the rate limit countdown on 429', async () => {
|
||||
await mountRelation('/content/events/42', { search: failing(new ApiError(429, 3)) })
|
||||
|
||||
await openList('venue', vi, 'rue')
|
||||
|
||||
expect(emptyText()).toBe('Too many requests Try again in 0:03')
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flushPromises()
|
||||
expect(emptyText()).toContain('Wait a moment before trying again.')
|
||||
expect(button('Try again')).toBeDefined()
|
||||
})
|
||||
|
||||
it('adds no local copy on 401', async () => {
|
||||
await mountRelation('/content/events/42', { search: failing(new ApiError(401)) })
|
||||
|
||||
await openList('venue', vi, 'rue')
|
||||
|
||||
expect(emptyText()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load more', () => {
|
||||
it('appends the next page', async () => {
|
||||
const names = Array.from({ length: 12 }, (_, index) => `Hall ${index + 1}`)
|
||||
await mountRelation('/content/events/42', {
|
||||
search: (_key, query) => {
|
||||
const page = Number(query.get('page'))
|
||||
const slice = names.slice((page - 1) * 10, page * 10)
|
||||
return {
|
||||
items: slice.map((name, index) => ({
|
||||
id: 100 + (page - 1) * 10 + index,
|
||||
data: { name, city: '', details: '' },
|
||||
relations: {},
|
||||
})),
|
||||
total: 12,
|
||||
page,
|
||||
size: 10,
|
||||
}
|
||||
},
|
||||
})
|
||||
await openList('venue', vi)
|
||||
expect(optionLabels()).toHaveLength(10)
|
||||
|
||||
button('Load more')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=2&size=10')
|
||||
expect(optionLabels()).toHaveLength(12)
|
||||
expect(button('Load more')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A missing target', () => {
|
||||
it('shows a dashed chip and lets an optional field save with the same id', async () => {
|
||||
await mountRelation('/content/events/42', {
|
||||
venue: 3,
|
||||
relations: { venue: { id: 3, missing: true } },
|
||||
})
|
||||
|
||||
expect(chipLabels('venue')).toEqual(['Entry no longer exists'])
|
||||
expect(fieldWrapper('venue').querySelector('[data-missing="true"]')).not.toBeNull()
|
||||
expect(text(fieldWrapper('venue').querySelector('[data-testid="relation-help"]'))).toBe(
|
||||
'You can still save. The entry keeps the link until you change it.',
|
||||
)
|
||||
await typeInto('title', 'Lime plaster workshop, two days')
|
||||
await save()
|
||||
expect(entryWrites[0]!.body.data).toMatchObject({ venue: 3 })
|
||||
})
|
||||
|
||||
it('refuses to save a required field with only a missing target', async () => {
|
||||
await mountRelation('/content/events/42', {
|
||||
venue: 3,
|
||||
venueField: { required: true },
|
||||
relations: { venue: { id: 3, missing: true } },
|
||||
})
|
||||
|
||||
expect(text(fieldWrapper('venue').querySelector('[data-testid="relation-help"]'))).toBe(
|
||||
'Venue is required. Choose another to save.',
|
||||
)
|
||||
await typeInto('title', 'Lime plaster workshop, two days')
|
||||
await save()
|
||||
|
||||
expect(entryWrites).toHaveLength(0)
|
||||
expect(relationError('venue')).toBe('Choose a Venue.')
|
||||
expect(document.activeElement).toBe(relationInput('venue'))
|
||||
})
|
||||
|
||||
it('replaces the missing chip when another target is picked', async () => {
|
||||
await mountRelation('/content/events/42', {
|
||||
venue: 3,
|
||||
relations: { venue: { id: 3, missing: true } },
|
||||
})
|
||||
await openList('venue', vi, 'rue')
|
||||
|
||||
await pickOption('Rue Verte yard')
|
||||
|
||||
expect(chipLabels('venue')).toEqual(['Rue Verte yard'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user