@bobbykim/manguito-cms-admin 0.6.2 → 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.
@@ -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 per id on mount.
202
+ // Chip labels load on mount, from the list endpoint.
187
203
  async function mountWithChips(ids: string[], props: { disabled?: boolean } = {}) {
188
- server.use(
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
- server.use(
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
- server.use(
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
  })