@bobbykim/manguito-cms-admin 0.6.3 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{ChangePasswordView-2g4WN8W1.js → ChangePasswordView-XlbFYOND.js} +1 -1
- package/dist/assets/{ContentFormView-BtRp4dKL.js → ContentFormView-DrHZ5Rbs.js} +1 -1
- package/dist/assets/{ContentListView-Dqeigsd6.js → ContentListView-DRdWO8se.js} +1 -1
- package/dist/assets/{ContentSettingsView-DXBl-Sgl.js → ContentSettingsView-DWAJdkta.js} +1 -1
- package/dist/assets/{LoginView-bDYuN8s-.js → LoginView-CBp1ioam.js} +1 -1
- package/dist/assets/{MediaDetailView-Bvikhn1E.js → MediaDetailView-LzGfNuER.js} +1 -1
- package/dist/assets/{MediaLibraryView-Db4Ne4kw.js → MediaLibraryView-CFjtPQPv.js} +1 -1
- package/dist/assets/{TaxonomyFormView-FTESoS4u.js → TaxonomyFormView-DIrMfRjS.js} +1 -1
- package/dist/assets/{TaxonomyListView-WriH284W.js → TaxonomyListView-C4W2nffO.js} +1 -1
- package/dist/assets/{UserFormView-BRMjuvR2.js → UserFormView-C-y6ny_B.js} +1 -1
- package/dist/assets/{UserListView-f37clqgU.js → UserListView-DMaUbteP.js} +1 -1
- package/dist/assets/{form-state-BWi4MQBH.js → form-state-UE25-PAE.js} +2 -2
- package/dist/assets/{index-DLFCFdeR.js → index-KlNPh31l.js} +2 -2
- package/dist/assets/{useNotification-CmqzkyhR.js → useNotification-zHjVKMIq.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/components/fields/ReferenceSelect.vue +48 -16
- package/src/components/fields/__tests__/ReferenceSelect.test.ts +147 -17
|
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
|
2
2
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
3
3
|
import { defineComponent, h, ref } from 'vue'
|
|
4
4
|
import { createPinia, setActivePinia } from 'pinia'
|
|
5
|
-
import { http, HttpResponse } from 'msw'
|
|
5
|
+
import { http, HttpResponse, delay } from 'msw'
|
|
6
6
|
import type { ParsedField, ParsedContentType } from '@bobbykim/manguito-cms-core'
|
|
7
7
|
import { server } from '../../../test-utils/server'
|
|
8
8
|
import { useSchemaStore } from '../../../stores/schema'
|
|
@@ -61,6 +61,22 @@ function makeField(overrides: Partial<ParsedField> = {}): ParsedField {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// The admin list endpoint as chip labels use it: rows for the ids asked for
|
|
65
|
+
// with filter[id], in any order, and nothing for an id that no longer exists.
|
|
66
|
+
// Every request URL lands in `requests`.
|
|
67
|
+
function serveLabels(names: Record<string, string>, requests: URL[] = []) {
|
|
68
|
+
server.use(
|
|
69
|
+
http.get(`${ADMIN}/api/content/content--user`, ({ request }) => {
|
|
70
|
+
const url = new URL(request.url)
|
|
71
|
+
requests.push(url)
|
|
72
|
+
const ids = url.searchParams.getAll('filter[id]')
|
|
73
|
+
const data = ids.filter((id) => names[id] !== undefined).reverse().map((id) => ({ id, title: names[id] }))
|
|
74
|
+
return HttpResponse.json({ ok: true, data, meta: { total: data.length, page: 1, per_page: 100, total_pages: 1 } })
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
return requests
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
let pinia: ReturnType<typeof createPinia>
|
|
65
81
|
|
|
66
82
|
beforeEach(() => {
|
|
@@ -183,13 +199,9 @@ describe('ReferenceSelect', () => {
|
|
|
183
199
|
const manyField = () =>
|
|
184
200
|
makeField({ ui_component: { component: 'typeahead-select', ref: 'content--user', rel: 'many-to-many' } })
|
|
185
201
|
|
|
186
|
-
// Chip labels load
|
|
202
|
+
// Chip labels load on mount, from the list endpoint.
|
|
187
203
|
async function mountWithChips(ids: string[], props: { disabled?: boolean } = {}) {
|
|
188
|
-
|
|
189
|
-
http.get(`${ADMIN}/api/content/content--user/:id`, ({ params }) =>
|
|
190
|
-
HttpResponse.json({ ok: true, data: { id: params.id, title: NAMES[String(params.id)] } })
|
|
191
|
-
)
|
|
192
|
-
)
|
|
204
|
+
serveLabels(NAMES)
|
|
193
205
|
const wrapper = mount(ReferenceSelect, {
|
|
194
206
|
global: { plugins: [pinia] },
|
|
195
207
|
props: { field: manyField(), modelValue: ids, ...props },
|
|
@@ -224,11 +236,7 @@ describe('ReferenceSelect', () => {
|
|
|
224
236
|
// MUTATION: drop the focus step in moveChip. The pressed button moves
|
|
225
237
|
// with its chip (or disappears at an end), focus falls to <body>, and a
|
|
226
238
|
// keyboard user must tab back from the top for every step.
|
|
227
|
-
|
|
228
|
-
http.get(`${ADMIN}/api/content/content--user/:id`, ({ params }) =>
|
|
229
|
-
HttpResponse.json({ ok: true, data: { id: params.id, title: NAMES[String(params.id)] } })
|
|
230
|
-
)
|
|
231
|
-
)
|
|
239
|
+
serveLabels(NAMES)
|
|
232
240
|
// A parent with a real v-model, so a move re-renders the list.
|
|
233
241
|
const Parent = defineComponent({
|
|
234
242
|
setup() {
|
|
@@ -273,11 +281,7 @@ describe('ReferenceSelect', () => {
|
|
|
273
281
|
})
|
|
274
282
|
|
|
275
283
|
it('input is disabled and counter shows X / Y when max_items is reached', async () => {
|
|
276
|
-
|
|
277
|
-
http.get(`${ADMIN}/api/content/content--user/:id`, ({ params }) =>
|
|
278
|
-
HttpResponse.json({ ok: true, data: { id: params['id'], title: `User ${params['id']}` } })
|
|
279
|
-
)
|
|
280
|
-
)
|
|
284
|
+
serveLabels({ u1: 'User u1', u2: 'User u2' })
|
|
281
285
|
const field = makeField({
|
|
282
286
|
validation: { required: false, max_items: 2 },
|
|
283
287
|
ui_component: { component: 'typeahead-select', ref: 'content--user', rel: 'many-to-many' },
|
|
@@ -307,4 +311,130 @@ describe('ReferenceSelect', () => {
|
|
|
307
311
|
await wrapper.findAll('[role="option"]')[0]!.trigger('click')
|
|
308
312
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['u1'])
|
|
309
313
|
})
|
|
314
|
+
|
|
315
|
+
describe('chip labels (#57)', () => {
|
|
316
|
+
const manyField = (max_items?: number) =>
|
|
317
|
+
makeField({
|
|
318
|
+
validation: { required: false, ...(max_items !== undefined && { max_items }) },
|
|
319
|
+
ui_component: { component: 'typeahead-select', ref: 'content--user', rel: 'many-to-many' },
|
|
320
|
+
})
|
|
321
|
+
const removeLabels = (w: ReturnType<typeof mountComponent>) =>
|
|
322
|
+
w.findAll('button[aria-label^="Remove "]').map((b) => b.attributes('aria-label'))
|
|
323
|
+
|
|
324
|
+
it('loads every chip label in one list request, with no item reads', async () => {
|
|
325
|
+
// MUTATION: fetch each label from GET /:id, as before. Each chip then
|
|
326
|
+
// loads its whole item (every paragraph and link) in its own request:
|
|
327
|
+
// itemReads is 3 and the list is never asked.
|
|
328
|
+
let itemReads = 0
|
|
329
|
+
server.use(
|
|
330
|
+
http.get(`${ADMIN}/api/content/content--user/:id`, () => {
|
|
331
|
+
itemReads++
|
|
332
|
+
return HttpResponse.json({ ok: true, data: { id: 'x', title: 'x' } })
|
|
333
|
+
})
|
|
334
|
+
)
|
|
335
|
+
const requests = serveLabels({ u1: 'Alice', u2: 'Bob', u3: 'Cara' })
|
|
336
|
+
const w = mountComponent(manyField(), ['u1', 'u2', 'u3'])
|
|
337
|
+
await flushPromises()
|
|
338
|
+
|
|
339
|
+
expect(itemReads).toBe(0)
|
|
340
|
+
expect(requests).toHaveLength(1)
|
|
341
|
+
expect(requests[0]!.searchParams.getAll('filter[id]')).toEqual(['u1', 'u2', 'u3'])
|
|
342
|
+
expect(requests[0]!.searchParams.get('per_page')).toBe('3')
|
|
343
|
+
// The response comes back in another order; chips keep the saved one.
|
|
344
|
+
expect(removeLabels(w)).toEqual(['Remove Alice', 'Remove Bob', 'Remove Cara'])
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('labels an id the list does not return with the id itself', async () => {
|
|
348
|
+
// MUTATION: leave a missing id without a label. Its chip then reads
|
|
349
|
+
// "Remove undefined", or blank, instead of naming the deleted item's id.
|
|
350
|
+
// A deleted item's id is still a well-formed uuid; the list just has no row for it.
|
|
351
|
+
const gone = '00000000-0000-4000-8000-0000000000ff'
|
|
352
|
+
serveLabels({ u1: 'Alice' })
|
|
353
|
+
const w = mountComponent(manyField(), ['u1', gone])
|
|
354
|
+
await flushPromises()
|
|
355
|
+
expect(removeLabels(w)).toEqual(['Remove Alice', `Remove ${gone}`])
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('labels ids that arrive after mount', async () => {
|
|
359
|
+
// MUTATION: load labels only in onMounted. An id the parent sets later
|
|
360
|
+
// (a form reset, a reused instance) then shows its bare id forever.
|
|
361
|
+
const requests = serveLabels({ u1: 'Alice', u2: 'Bob' })
|
|
362
|
+
const w = mountComponent(manyField(), ['u1'])
|
|
363
|
+
await flushPromises()
|
|
364
|
+
await w.setProps({ modelValue: ['u1', 'u2'] })
|
|
365
|
+
await flushPromises()
|
|
366
|
+
expect(removeLabels(w)).toEqual(['Remove Alice', 'Remove Bob'])
|
|
367
|
+
// Only the new id is asked for the second time.
|
|
368
|
+
expect(requests.map((r) => r.searchParams.getAll('filter[id]'))).toEqual([['u1'], ['u2']])
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('asks for at most 100 ids per request', async () => {
|
|
372
|
+
// MUTATION: send every id in one request. With 101 ids, per_page=101 is
|
|
373
|
+
// over the API's limit of 100, it answers 400, and no chip gets a label.
|
|
374
|
+
const names = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`u${i}`, `User ${i}`]))
|
|
375
|
+
const requests = serveLabels(names)
|
|
376
|
+
const w = mountComponent(manyField(), Object.keys(names))
|
|
377
|
+
await flushPromises()
|
|
378
|
+
expect(requests.map((r) => r.searchParams.getAll('filter[id]').length)).toEqual([100, 1])
|
|
379
|
+
expect(requests.map((r) => r.searchParams.get('per_page'))).toEqual(['100', '1'])
|
|
380
|
+
expect(removeLabels(w)).toContain('Remove User 100')
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('a one-to-one reference loads its label from the list too', async () => {
|
|
384
|
+
// MUTATION: keep the per-id read for a single id. The one chip then
|
|
385
|
+
// loads its whole item.
|
|
386
|
+
const requests = serveLabels({ u1: 'Alice' })
|
|
387
|
+
const w = mountComponent(makeField(), 'u1')
|
|
388
|
+
await flushPromises()
|
|
389
|
+
expect(requests[0]!.searchParams.getAll('filter[id]')).toEqual(['u1'])
|
|
390
|
+
expect(w.text()).toContain('Alice')
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('keeps the newest search results when an older answer arrives later', async () => {
|
|
395
|
+
// MUTATION: drop the searchSeq check. The slow answer to "al" then lands
|
|
396
|
+
// after the answer to "alice" and replaces it.
|
|
397
|
+
server.use(
|
|
398
|
+
http.get(`${ADMIN}/api/content/content--user`, async ({ request }) => {
|
|
399
|
+
const q = new URL(request.url).searchParams.get('search')
|
|
400
|
+
if (q === 'al') {
|
|
401
|
+
await delay(1000)
|
|
402
|
+
return HttpResponse.json({ ok: true, data: [{ id: 'u9', title: 'Old answer' }] })
|
|
403
|
+
}
|
|
404
|
+
return HttpResponse.json({ ok: true, data: [{ id: 'u1', title: 'Alice' }] })
|
|
405
|
+
})
|
|
406
|
+
)
|
|
407
|
+
const wrapper = mountComponent(makeField())
|
|
408
|
+
await wrapper.find('input').setValue('al')
|
|
409
|
+
await wrapper.find('input').trigger('input')
|
|
410
|
+
await vi.advanceTimersByTimeAsync(300)
|
|
411
|
+
await wrapper.find('input').setValue('alice')
|
|
412
|
+
await wrapper.find('input').trigger('input')
|
|
413
|
+
await vi.runAllTimersAsync()
|
|
414
|
+
await flushPromises()
|
|
415
|
+
expect(wrapper.text()).toContain('Alice')
|
|
416
|
+
expect(wrapper.text()).not.toContain('Old answer')
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('searches with search=, the parameter the list endpoint reads', async () => {
|
|
420
|
+
// MUTATION: send filters[title][like]=… as before. The API's filter parser
|
|
421
|
+
// reads only filter[…] with gt/gte/lt/lte, so it ignores the parameter and
|
|
422
|
+
// every search returns the first 10 items, whatever was typed.
|
|
423
|
+
const requests: URL[] = []
|
|
424
|
+
server.use(
|
|
425
|
+
http.get(`${ADMIN}/api/content/content--user`, ({ request }) => {
|
|
426
|
+
requests.push(new URL(request.url))
|
|
427
|
+
return HttpResponse.json({ ok: true, data: [{ id: 'u1', title: 'Alice' }] })
|
|
428
|
+
})
|
|
429
|
+
)
|
|
430
|
+
const wrapper = mountComponent(makeField())
|
|
431
|
+
await wrapper.find('input').setValue('ali')
|
|
432
|
+
await wrapper.find('input').trigger('input')
|
|
433
|
+
await vi.runAllTimersAsync()
|
|
434
|
+
await flushPromises()
|
|
435
|
+
expect(requests).toHaveLength(1)
|
|
436
|
+
expect(requests[0]!.searchParams.get('search')).toBe('ali')
|
|
437
|
+
expect(requests[0]!.searchParams.get('per_page')).toBe('10')
|
|
438
|
+
expect([...requests[0]!.searchParams.keys()].filter((k) => k.startsWith('filter'))).toEqual([])
|
|
439
|
+
})
|
|
310
440
|
})
|