@bobbykim/manguito-cms-admin 0.4.3 → 0.5.1

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.
Files changed (42) hide show
  1. package/dist/assets/{ChangePasswordView-BILytTnH.js → ChangePasswordView-D-B7xv5c.js} +1 -1
  2. package/dist/assets/ContentFormView-Bfee-uVL.js +1 -0
  3. package/dist/assets/{ContentListView-CWHYBV23.js → ContentListView-BpgXHLa6.js} +1 -1
  4. package/dist/assets/{ContentSettingsView-B9BHm-xg.js → ContentSettingsView-CuuxtUQh.js} +1 -1
  5. package/dist/assets/{LoginView-8tF3SdN4.js → LoginView-BdqDyYJ6.js} +1 -1
  6. package/dist/assets/{MediaDetailView-HfRoV5W8.js → MediaDetailView-DmKNv8bW.js} +1 -1
  7. package/dist/assets/MediaLibraryView-OGPHji7F.js +1 -0
  8. package/dist/assets/MediaThumbnail-q7pxungQ.js +1 -0
  9. package/dist/assets/TaxonomyFormView-Dzaz47pa.js +1 -0
  10. package/dist/assets/{TaxonomyListView-DKnsY_ab.js → TaxonomyListView-kaf2LX5v.js} +1 -1
  11. package/dist/assets/{UserFormView-DhH0m-Vq.js → UserFormView-KlTIvn2s.js} +1 -1
  12. package/dist/assets/{UserListView-Dk3Jv2Z8.js → UserListView-_HSsX1vW.js} +1 -1
  13. package/dist/assets/{ComputedDisplay-Df_HvjzX.js → field-defaults-m6kJphku.js} +2 -2
  14. package/dist/assets/{index-ivedPYqQ.css → index-C4qBpxpk.css} +1 -1
  15. package/dist/assets/{index-C-iAN6wp.js → index-DHBhcS6-.js} +2 -2
  16. package/dist/assets/{useNotification-kY57Myug.js → useNotification-B8AtuEWL.js} +1 -1
  17. package/dist/codegen/index.cjs +7 -0
  18. package/dist/codegen/index.js +7 -0
  19. package/dist/index.html +2 -2
  20. package/package.json +3 -3
  21. package/src/components/fields/MediaUpload.vue +5 -19
  22. package/src/components/fields/ParagraphEmbed.vue +108 -45
  23. package/src/components/fields/ReferenceSelect.vue +2 -2
  24. package/src/components/fields/__tests__/ParagraphEmbed.test.ts +64 -0
  25. package/src/components/fields/__tests__/ReferenceSelect.test.ts +19 -0
  26. package/src/components/shared/MediaSelectModal.vue +4 -21
  27. package/src/components/shared/MediaThumbnail.vue +108 -0
  28. package/src/components/shared/__tests__/MediaThumbnail.test.ts +118 -0
  29. package/src/composables/__tests__/useFormValidation.test.ts +27 -0
  30. package/src/utils/__tests__/field-defaults.test.ts +26 -0
  31. package/src/utils/__tests__/file-kind.test.ts +102 -0
  32. package/src/utils/__tests__/taxonomy-form.test.ts +63 -0
  33. package/src/utils/field-defaults.ts +31 -0
  34. package/src/utils/file-kind.ts +83 -0
  35. package/src/utils/taxonomy-form.ts +28 -0
  36. package/src/views/content/ContentFormView.vue +1 -32
  37. package/src/views/media/MediaLibraryView.vue +7 -22
  38. package/src/views/taxonomy/TaxonomyFormView.vue +2 -27
  39. package/dist/assets/ContentFormView-BsTU-zzP.js +0 -1
  40. package/dist/assets/MediaLibraryView-COQmdQPB.js +0 -1
  41. package/dist/assets/TaxonomyFormView-BibZ2qjc.js +0 -1
  42. package/dist/assets/useTabIndicator-CvEsGBJh.js +0 -1
@@ -2,6 +2,7 @@
2
2
  import { ref, computed, onMounted } from 'vue'
3
3
  import { useDebounceFn } from '@vueuse/core'
4
4
  import type { ParsedField } from '@bobbykim/manguito-cms-core'
5
+ import { relationCardinality } from '@bobbykim/manguito-cms-core/cardinality'
5
6
  import { useApiClient } from '../../composables/useApiClient'
6
7
  import { useSchemaStore } from '../../stores/schema'
7
8
 
@@ -25,9 +26,8 @@ const typeaheadComp = computed(() => {
25
26
  })
26
27
 
27
28
  const refName = computed(() => typeaheadComp.value?.ref ?? '')
28
- const relType = computed(() => typeaheadComp.value?.rel ?? 'one-to-one')
29
29
  const maxItems = computed(() => props.field.validation.max_items ?? null)
30
- const isMulti = computed(() => relType.value !== 'one-to-one')
30
+ const isMulti = computed(() => relationCardinality(props.field) === 'many')
31
31
  const isContentRef = computed(() => !!schemaStore.contentTypes[refName.value])
32
32
 
33
33
  // First text/plain field in the referenced schema — used as the display label.
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { defineComponent, h } from 'vue'
4
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
5
+ import ParagraphEmbed from '../ParagraphEmbed.vue'
6
+
7
+ // Stands in for a generated paragraph form: one button that emits a value.
8
+ const StubForm = defineComponent({
9
+ props: { modelValue: { type: Object, default: null }, disabled: Boolean },
10
+ emits: ['update:modelValue'],
11
+ setup(_, { emit }) {
12
+ return () => h('button', { class: 'stub-form', onClick: () => emit('update:modelValue', { url: 'edited' }) }, 'stub')
13
+ },
14
+ })
15
+
16
+ const field = (rel: 'one-to-one' | 'one-to-many'): ParsedField =>
17
+ ({
18
+ name: 'link', label: 'Link', field_type: 'paragraph', required: false, nullable: true, order: 0,
19
+ validation: { required: false }, db_column: null,
20
+ ui_component: { component: 'paragraph-embed', ref: 'paragraph--link', rel },
21
+ }) as ParsedField
22
+
23
+ const mountWith = (rel: 'one-to-one' | 'one-to-many', modelValue: unknown) =>
24
+ mount(ParagraphEmbed, { props: { field: field(rel), modelValue, formComponent: StubForm } })
25
+
26
+ describe('ParagraphEmbed, one-to-one', () => {
27
+ it('when empty, Add emits a single object, not a list', async () => {
28
+ // MUTATION: render list mode for every rel. Add then emits [{ order: 0 }].
29
+ const w = mountWith('one-to-one', null)
30
+ await w.get('button').trigger('click')
31
+ expect(w.emitted('update:modelValue')?.[0]).toEqual([{}])
32
+ })
33
+
34
+ it('edits emit the item object', async () => {
35
+ // MUTATION: wrap single edits as [value].
36
+ const w = mountWith('one-to-one', { url: 'a' })
37
+ await w.get('.stub-form').trigger('click')
38
+ expect(w.emitted('update:modelValue')?.[0]).toEqual([{ url: 'edited' }])
39
+ })
40
+
41
+ it('Remove emits null and offers no second item', async () => {
42
+ // MUTATION: keep the list's Add button visible in single mode.
43
+ const w = mountWith('one-to-one', { url: 'a' })
44
+ expect(w.findAll('button').filter((b) => b.text().includes('Add'))).toHaveLength(0)
45
+ await w.get('[aria-label="Remove Link"]').trigger('click')
46
+ expect(w.emitted('update:modelValue')?.[0]).toEqual([null])
47
+ })
48
+
49
+ it('treats a legacy array value as empty, showing Add', () => {
50
+ // MUTATION: index into an array value. A stale list renders as an item.
51
+ const w = mountWith('one-to-one', [{ url: 'a' }])
52
+ expect(w.findAll('.stub-form')).toHaveLength(0)
53
+ expect(w.text()).toContain('Add Link')
54
+ })
55
+ })
56
+
57
+ describe('ParagraphEmbed, one-to-many (unchanged)', () => {
58
+ it('Add appends an ordered item to the list', async () => {
59
+ // MUTATION: route one-to-many into single mode.
60
+ const w = mountWith('one-to-many', [])
61
+ await w.get('button').trigger('click')
62
+ expect(w.emitted('update:modelValue')?.[0]).toEqual([[{ order: 0 }]])
63
+ })
64
+ })
@@ -193,4 +193,23 @@ describe('ReferenceSelect', () => {
193
193
  expect(wrapper.find('input').attributes('disabled')).toBeDefined()
194
194
  expect(wrapper.text()).toContain('2 / 2')
195
195
  })
196
+
197
+ it('a deprecated one-to-many reference is a single picker and emits one id', async () => {
198
+ // MUTATION: keep `isMulti = relType !== 'one-to-one'`. Selecting then emits
199
+ // ['u1'], which the API now rejects with 422.
200
+ server.use(
201
+ http.get(`${ADMIN}/api/content/content--user`, () =>
202
+ HttpResponse.json({ ok: true, data: [{ id: 'u1', title: 'Alice' }] })
203
+ )
204
+ )
205
+ const wrapper = mountComponent(
206
+ makeField({ ui_component: { component: 'typeahead-select', ref: 'content--user', rel: 'one-to-many' } })
207
+ )
208
+ await wrapper.find('input').setValue('al')
209
+ await wrapper.find('input').trigger('input')
210
+ await vi.runAllTimersAsync()
211
+ await flushPromises()
212
+ await wrapper.findAll('[role="option"]')[0]!.trigger('click')
213
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['u1'])
214
+ })
196
215
  })
@@ -2,6 +2,8 @@
2
2
  import { ref, computed, watch, onMounted } from 'vue'
3
3
  import type { MediaItem } from '@bobbykim/manguito-cms-core'
4
4
  import { useApiClient } from '../../composables/useApiClient'
5
+ import MediaThumbnail from './MediaThumbnail.vue'
6
+ import { fileName } from '../../utils/file-kind'
5
7
 
6
8
  type MediaType = 'image' | 'video' | 'file'
7
9
 
@@ -188,29 +190,10 @@ function confirm() {
188
190
  : 'border-transparent hover:border-gray-300',
189
191
  ]"
190
192
  :aria-pressed="selectedItem?.id === item.id"
191
- :aria-label="`Select ${item.alt ?? item.url.split('/').pop()}`"
193
+ :aria-label="`Select ${item.alt ?? fileName(item.url)}`"
192
194
  @click="selectedItem = item"
193
195
  >
194
- <!-- Image thumbnail -->
195
- <img
196
- v-if="item.type === 'image'"
197
- :src="item.url"
198
- :alt="item.alt ?? ''"
199
- class="h-full w-full object-cover"
200
- loading="lazy"
201
- />
202
-
203
- <!-- Video / file icon -->
204
- <div
205
- v-else
206
- class="flex h-full w-full flex-col items-center justify-center gap-1 bg-gray-50 text-gray-400"
207
- aria-hidden="true"
208
- >
209
- <span class="text-3xl">{{ item.type === 'video' ? '▶' : '📄' }}</span>
210
- <span class="max-w-full truncate px-1 text-xs">
211
- {{ item.url.split('/').pop() }}
212
- </span>
213
- </div>
196
+ <MediaThumbnail :item="item" :label="fileName(item.url)" />
214
197
 
215
198
  <!-- Selection overlay tick -->
216
199
  <div
@@ -0,0 +1,108 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
3
+ import type { MediaItem } from '@bobbykim/manguito-cms-core'
4
+ import { fileKind, TONE_CLASS } from '../../utils/file-kind'
5
+
6
+ // The thumbnail every media view shows: the image itself, a video's first
7
+ // frame, or a typed document badge. Fills its parent; the parent sets the size.
8
+ const props = defineProps<{
9
+ item: Pick<MediaItem, 'type' | 'url' | 'mime_type' | 'alt'>
10
+ // Optional caption lines: under a file badge, or over a video frame.
11
+ label?: string | undefined
12
+ detail?: string | undefined
13
+ }>()
14
+
15
+ const root = ref<HTMLElement | null>(null)
16
+
17
+ // A video loads only once it scrolls into view, so a grid of videos does not
18
+ // fetch every file's metadata at once. Without IntersectionObserver (older
19
+ // browsers, jsdom) it loads immediately.
20
+ const canObserve = typeof IntersectionObserver !== 'undefined'
21
+ const inView = ref(!canObserve)
22
+ const videoFailed = ref(false)
23
+ let observer: IntersectionObserver | null = null
24
+
25
+ function stopObserving() {
26
+ observer?.disconnect()
27
+ observer = null
28
+ }
29
+
30
+ // Starts fresh for the current item. The media field preview keeps this
31
+ // component mounted while its selection changes, so this runs on mount AND on
32
+ // every item change: a failure or a pending observer belongs to the old item.
33
+ function track() {
34
+ stopObserving()
35
+ videoFailed.value = false
36
+ inView.value = !canObserve
37
+ if (props.item.type !== 'video' || !canObserve || !root.value) return
38
+ observer = new IntersectionObserver((entries) => {
39
+ if (entries.some((e) => e.isIntersecting)) {
40
+ inView.value = true
41
+ stopObserving()
42
+ }
43
+ })
44
+ observer.observe(root.value)
45
+ }
46
+
47
+ onMounted(track)
48
+ watch(() => [props.item.url, props.item.type], track)
49
+ onBeforeUnmount(stopObserving)
50
+
51
+ // `#t=0.1` asks the browser for the frame just after the start: some codecs
52
+ // paint black at exactly 0. preload="metadata" fetches only what that needs.
53
+ const frameSrc = computed(() => (inView.value ? `${props.item.url}#t=0.1` : undefined))
54
+
55
+ const kind = computed(() => fileKind(props.item.mime_type, props.item.url))
56
+ </script>
57
+
58
+ <template>
59
+ <div ref="root" class="relative h-full w-full overflow-hidden">
60
+ <img
61
+ v-if="item.type === 'image'"
62
+ :src="item.url"
63
+ :alt="item.alt ?? ''"
64
+ class="h-full w-full object-cover"
65
+ loading="lazy"
66
+ />
67
+
68
+ <template v-else-if="item.type === 'video' && !videoFailed">
69
+ <video
70
+ :src="frameSrc"
71
+ preload="metadata"
72
+ muted
73
+ playsinline
74
+ class="h-full w-full bg-gray-900 object-cover"
75
+ @error="videoFailed = true"
76
+ />
77
+ <span
78
+ class="absolute left-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-[10px] text-white"
79
+ aria-hidden="true"
80
+ >▶</span>
81
+ <div
82
+ v-if="label || detail"
83
+ class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-1 pb-1 pt-4 text-center"
84
+ >
85
+ <p v-if="label" class="truncate text-xs text-white">{{ label }}</p>
86
+ <p v-if="detail" class="text-[10px] text-gray-200">{{ detail }}</p>
87
+ </div>
88
+ </template>
89
+
90
+ <div
91
+ v-else
92
+ class="flex h-full w-full flex-col items-center justify-center gap-1 bg-gray-50 p-1 text-gray-400"
93
+ >
94
+ <span class="relative inline-flex" aria-hidden="true">
95
+ <svg viewBox="0 0 24 24" class="h-9 w-9" fill="none" stroke="currentColor" stroke-width="1.5">
96
+ <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
97
+ <path d="M14 3v5h5" />
98
+ </svg>
99
+ </span>
100
+ <span
101
+ data-testid="file-badge"
102
+ :class="['rounded px-1 text-[10px] font-semibold leading-4 text-white', item.type === 'video' ? 'bg-gray-700' : TONE_CLASS[kind.tone]]"
103
+ >{{ item.type === 'video' ? 'VIDEO' : kind.label }}</span>
104
+ <span v-if="label" class="max-w-full truncate text-center text-xs">{{ label }}</span>
105
+ <span v-if="detail" class="text-xs text-gray-300">{{ detail }}</span>
106
+ </div>
107
+ </div>
108
+ </template>
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import MediaThumbnail from '../MediaThumbnail.vue'
4
+
5
+ const image = { type: 'image' as const, url: 'https://cdn.example.com/a.jpg', mime_type: 'image/jpeg', alt: 'A cat' }
6
+ const video = { type: 'video' as const, url: 'https://cdn.example.com/clip.mp4', mime_type: 'video/mp4' }
7
+ const pdf = { type: 'file' as const, url: 'https://cdn.example.com/report.pdf', mime_type: 'application/pdf' }
8
+
9
+ afterEach(() => {
10
+ vi.unstubAllGlobals()
11
+ })
12
+
13
+ describe('MediaThumbnail', () => {
14
+ it('renders an image as a lazy <img> with its alt text', () => {
15
+ // MUTATION: render every item through the video or file branch.
16
+ const img = mount(MediaThumbnail, { props: { item: image } }).get('img')
17
+ expect(img.attributes('src')).toBe(image.url)
18
+ expect(img.attributes('alt')).toBe('A cat')
19
+ expect(img.attributes('loading')).toBe('lazy')
20
+ })
21
+
22
+ it('renders a video as a muted, metadata-only <video> seeked to its first frame', () => {
23
+ // MUTATION: drop `#t=0.1` or use preload="auto". The frame is then either
24
+ // black (time 0 on some codecs) or the whole file downloads per thumbnail.
25
+ const v = mount(MediaThumbnail, { props: { item: video } }).get('video')
26
+ expect(v.attributes('src')).toBe(`${video.url}#t=0.1`)
27
+ expect(v.attributes('preload')).toBe('metadata')
28
+ expect((v.element as HTMLVideoElement).muted).toBe(true)
29
+ expect(v.attributes('controls')).toBeUndefined()
30
+ })
31
+
32
+ it('falls back to the video icon when the frame cannot load', async () => {
33
+ // MUTATION: ignore the video's error event. A broken file then shows an
34
+ // empty black box instead of an icon.
35
+ const w = mount(MediaThumbnail, { props: { item: video } })
36
+ await w.get('video').trigger('error')
37
+ expect(w.find('video').exists()).toBe(false)
38
+ expect(w.text()).toContain('VIDEO')
39
+ })
40
+
41
+ it('loads the video only once it scrolls into view', async () => {
42
+ // MUTATION: bind `src` immediately. A grid of many videos then fetches every
43
+ // file's metadata at once.
44
+ let fire: ((entries: Array<{ isIntersecting: boolean }>) => void) | undefined
45
+ vi.stubGlobal(
46
+ 'IntersectionObserver',
47
+ class {
48
+ constructor(cb: (entries: Array<{ isIntersecting: boolean }>) => void) {
49
+ fire = cb
50
+ }
51
+ observe() {}
52
+ disconnect() {}
53
+ }
54
+ )
55
+ const w = mount(MediaThumbnail, { props: { item: video } })
56
+ expect(w.get('video').attributes('src')).toBeUndefined()
57
+ fire!([{ isIntersecting: true }])
58
+ await w.vm.$nextTick()
59
+ expect(w.get('video').attributes('src')).toBe(`${video.url}#t=0.1`)
60
+ })
61
+
62
+ it('renders a file as a typed badge with its caption lines', () => {
63
+ // MUTATION: render the old generic glyph. The badge text and the caption
64
+ // lines disappear.
65
+ const w = mount(MediaThumbnail, { props: { item: pdf, label: 'report.pdf', detail: '1.2 MB' } })
66
+ expect(w.get('[data-testid="file-badge"]').text()).toBe('PDF')
67
+ expect(w.text()).toContain('report.pdf')
68
+ expect(w.text()).toContain('1.2 MB')
69
+ })
70
+
71
+ it('omits the caption lines when none are given', () => {
72
+ // MUTATION: always render the file name, which crowds the small field preview.
73
+ const w = mount(MediaThumbnail, { props: { item: pdf } })
74
+ expect(w.text().trim()).toBe('PDF')
75
+ })
76
+
77
+ it('shows a video caption over its frame when one is given', () => {
78
+ // MUTATION: render label/detail only in the file branch. Video tiles in the
79
+ // library then lose the name and size the old placeholder showed.
80
+ const w = mount(MediaThumbnail, { props: { item: video, label: 'clip.mp4', detail: '3.4 MB' } })
81
+ expect(w.find('video').exists()).toBe(true)
82
+ expect(w.text()).toContain('clip.mp4')
83
+ expect(w.text()).toContain('3.4 MB')
84
+ })
85
+
86
+ it('tries again when a failed video is replaced by another item', async () => {
87
+ // MUTATION: never reset `videoFailed`. In the field preview, which keeps
88
+ // the thumbnail mounted while the selection changes, every later video then
89
+ // shows the fallback icon.
90
+ const w = mount(MediaThumbnail, { props: { item: video } })
91
+ await w.get('video').trigger('error')
92
+ expect(w.find('video').exists()).toBe(false)
93
+ await w.setProps({ item: { ...video, url: 'https://cdn.example.com/other.mp4' } })
94
+ expect(w.get('video').attributes('src')).toBe('https://cdn.example.com/other.mp4#t=0.1')
95
+ })
96
+
97
+ it('starts watching for visibility when the item becomes a video after mount', async () => {
98
+ // MUTATION: set up the IntersectionObserver only in onMounted. A video
99
+ // selected after an image then never gets a src: a permanent black box.
100
+ let fire: ((entries: Array<{ isIntersecting: boolean }>) => void) | undefined
101
+ vi.stubGlobal(
102
+ 'IntersectionObserver',
103
+ class {
104
+ constructor(cb: (entries: Array<{ isIntersecting: boolean }>) => void) {
105
+ fire = cb
106
+ }
107
+ observe() {}
108
+ disconnect() {}
109
+ }
110
+ )
111
+ const w = mount(MediaThumbnail, { props: { item: image } })
112
+ await w.setProps({ item: video })
113
+ expect(fire).toBeDefined()
114
+ fire!([{ isIntersecting: true }])
115
+ await w.vm.$nextTick()
116
+ expect(w.get('video').attributes('src')).toBe(`${video.url}#t=0.1`)
117
+ })
118
+ })
@@ -80,4 +80,31 @@ describe('useFormValidation', () => {
80
80
  expect(Object.keys(errors.value)).toHaveLength(0)
81
81
  expect(touched.value.size).toBe(0)
82
82
  })
83
+
84
+ it('validate(): required one-to-one paragraph left empty (null) → required error', () => {
85
+ // MUTATION: drop `value === null` from isEmpty. An empty one-to-one
86
+ // paragraph would then pass "required".
87
+ const { validate, touch, errors } = useFormValidation()
88
+ const field = makeField({
89
+ name: 'link', label: 'Link', field_type: 'paragraph', db_column: null,
90
+ ui_component: { component: 'paragraph-embed', ref: 'paragraph--link', rel: 'one-to-one' },
91
+ })
92
+ touch('link')
93
+ validate([field], { link: null })
94
+ expect(errors.value['link']).toBe('Link is required.')
95
+ })
96
+
97
+ it('validate(): max_items never counts a one-to-one object', () => {
98
+ // MUTATION: treat a non-array value as a one-item list in the max_items
99
+ // check. A filled one-to-one paragraph would then fail max 0.
100
+ const { validate, touch, errors } = useFormValidation()
101
+ const field = makeField({
102
+ name: 'link', label: 'Link', field_type: 'paragraph', required: false, db_column: null,
103
+ validation: { required: false, max_items: 0 },
104
+ ui_component: { component: 'paragraph-embed', ref: 'paragraph--link', rel: 'one-to-one' },
105
+ })
106
+ touch('link')
107
+ validate([field], { link: { url: 'a' } })
108
+ expect(errors.value['link']).toBeUndefined()
109
+ })
83
110
  })
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
3
+ import { defaultForField } from '../field-defaults'
4
+
5
+ const rel = (field_type: 'paragraph' | 'reference', r: 'one-to-one' | 'one-to-many' | 'many-to-many'): ParsedField =>
6
+ ({
7
+ name: 'x', label: 'X', field_type, required: false, nullable: true, order: 0,
8
+ validation: { required: false }, db_column: null,
9
+ ui_component: field_type === 'paragraph'
10
+ ? { component: 'paragraph-embed', ref: 'paragraph--p', rel: r }
11
+ : { component: 'typeahead-select', ref: 'taxonomy--t', rel: r },
12
+ }) as ParsedField
13
+
14
+ describe('defaultForField', () => {
15
+ it('starts "one" relations empty as null', () => {
16
+ // MUTATION: keep `case 'paragraph': return []`. The one-to-one editor would
17
+ // then receive an array.
18
+ expect(defaultForField(rel('paragraph', 'one-to-one'))).toBeNull()
19
+ expect(defaultForField(rel('reference', 'one-to-many'))).toBeNull()
20
+ })
21
+ it('starts "many" relations empty as []', () => {
22
+ // MUTATION: return null for every relation.
23
+ expect(defaultForField(rel('paragraph', 'one-to-many'))).toEqual([])
24
+ expect(defaultForField(rel('reference', 'many-to-many'))).toEqual([])
25
+ })
26
+ })
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { fileKind, fileName } from '../file-kind'
3
+
4
+ describe('fileKind', () => {
5
+ it('labels PDFs', () => {
6
+ // MUTATION: drop the pdf rule. A PDF then falls back to its extension "PDF"
7
+ // only by accident of its URL; with an extensionless URL it reads "FILE".
8
+ expect(fileKind('application/pdf', 'https://cdn.example.com/abc')).toMatchObject({ label: 'PDF', tone: 'red' })
9
+ })
10
+
11
+ it('labels word-processing documents, whatever their exact MIME type', () => {
12
+ // MUTATION: match only the legacy "application/msword". Modern .docx files
13
+ // (the openxml type) and ODT/RTF would fall through to their extension.
14
+ for (const mime of [
15
+ 'application/msword',
16
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
17
+ 'application/vnd.oasis.opendocument.text',
18
+ 'application/rtf',
19
+ ]) {
20
+ expect(fileKind(mime, 'https://cdn.example.com/x').label).toBe('DOC')
21
+ }
22
+ })
23
+
24
+ it('labels spreadsheets, including CSV', () => {
25
+ // MUTATION: match only the "spreadsheet" substring. CSV ("text/csv") would
26
+ // then read as plain text.
27
+ for (const mime of [
28
+ 'application/vnd.ms-excel',
29
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
30
+ 'application/vnd.oasis.opendocument.spreadsheet',
31
+ 'text/csv',
32
+ ]) {
33
+ expect(fileKind(mime, 'https://cdn.example.com/x').label).toBe('XLS')
34
+ }
35
+ })
36
+
37
+ it('labels presentations and archives', () => {
38
+ // MUTATION: drop the presentation or archive rules.
39
+ expect(fileKind('application/vnd.openxmlformats-officedocument.presentationml.presentation', 'u').label).toBe('PPT')
40
+ expect(fileKind('application/vnd.ms-powerpoint', 'u').label).toBe('PPT')
41
+ expect(fileKind('application/zip', 'u').label).toBe('ZIP')
42
+ expect(fileKind('application/gzip', 'u').label).toBe('ZIP')
43
+ expect(fileKind('application/x-7z-compressed', 'u').label).toBe('ZIP')
44
+ })
45
+
46
+ it('labels plain text and Markdown as TXT, but not CSV', () => {
47
+ // MUTATION: test the text/* rule before the CSV rule. CSV then reads TXT.
48
+ expect(fileKind('text/plain', 'u').label).toBe('TXT')
49
+ expect(fileKind('text/markdown', 'u').label).toBe('TXT')
50
+ expect(fileKind('text/csv', 'u').label).not.toBe('TXT')
51
+ })
52
+
53
+ it('falls back to the upper-cased extension of an unknown type', () => {
54
+ // MUTATION: return "FILE" for every unknown type, losing the extension hint.
55
+ expect(fileKind('application/octet-stream', 'https://cdn.example.com/media/model.glb')).toMatchObject({
56
+ label: 'GLB',
57
+ tone: 'gray',
58
+ })
59
+ })
60
+
61
+ it('ignores the query string and fragment when reading the extension', () => {
62
+ // MUTATION: take the text after the last "." of the whole URL. A signed URL
63
+ // would then produce a label from its query string.
64
+ expect(fileKind('application/octet-stream', 'https://cdn.example.com/a/b.step?X-Amz-Signature=abc.def').label).toBe('STEP')
65
+ })
66
+
67
+ it('falls back to FILE when there is no usable extension', () => {
68
+ // MUTATION: return an empty label, which renders an empty badge.
69
+ expect(fileKind('application/octet-stream', 'https://cdn.example.com/media/blob').label).toBe('FILE')
70
+ expect(fileKind('application/octet-stream', 'https://cdn.example.com/media/archive.toolongext').label).toBe('FILE')
71
+ })
72
+
73
+ it('ignores MIME parameters such as a charset', () => {
74
+ // MUTATION: compare the raw stored mime_type. "text/csv; charset=utf-8"
75
+ // then misses the CSV rule and reads TXT, and a PDF with parameters reads
76
+ // as its extension or FILE.
77
+ expect(fileKind('text/csv; charset=utf-8', 'https://cdn.example.com/x').label).toBe('XLS')
78
+ expect(fileKind('Application/PDF; name="a.pdf"', 'https://cdn.example.com/x').label).toBe('PDF')
79
+ })
80
+
81
+ it('recognises the archive MIME types browsers actually report', () => {
82
+ // MUTATION: list only application/zip. Chrome on Windows reports .zip as
83
+ // application/x-zip-compressed, which then reads as FILE.
84
+ for (const mime of ['application/x-zip-compressed', 'application/x-gzip', 'application/x-rar-compressed', 'application/vnd.rar']) {
85
+ expect(fileKind(mime, 'https://cdn.example.com/x').label).toBe('ZIP')
86
+ }
87
+ })
88
+ })
89
+
90
+ describe('fileName', () => {
91
+ it('returns the last path segment without the query string or fragment', () => {
92
+ // MUTATION: `url.split('/').pop()`. A signed URL's caption then reads
93
+ // "report.pdf?X-Amz-Signature=…".
94
+ expect(fileName('https://cdn.example.com/a/report.pdf?X-Amz-Signature=abc')).toBe('report.pdf')
95
+ expect(fileName('https://cdn.example.com/a/clip.mp4#t=3')).toBe('clip.mp4')
96
+ })
97
+
98
+ it('falls back to the whole URL when there is no path segment', () => {
99
+ // MUTATION: return '' for a URL ending in "/". The caption would be blank.
100
+ expect(fileName('https://cdn.example.com/')).toBe('https://cdn.example.com/')
101
+ })
102
+ })
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
3
+ import { initialTaxonomyForm } from '../taxonomy-form'
4
+
5
+ const base = { label: 'X', required: false, nullable: true, order: 0, validation: { required: false } }
6
+
7
+ const fields: ParsedField[] = [
8
+ {
9
+ ...base, name: 'name', field_type: 'text/plain',
10
+ db_column: { column_name: 'name', column_type: 'varchar', nullable: true },
11
+ ui_component: { component: 'text-input' },
12
+ },
13
+ {
14
+ ...base, name: 'parent', field_type: 'reference',
15
+ db_column: { column_name: 'parent', column_type: 'uuid', nullable: true },
16
+ ui_component: { component: 'typeahead-select', ref: 'taxonomy--t', rel: 'one-to-one' },
17
+ },
18
+ {
19
+ ...base, name: 'blocks', field_type: 'paragraph', db_column: null,
20
+ ui_component: { component: 'paragraph-embed', ref: 'paragraph--p', rel: 'one-to-many' },
21
+ },
22
+ {
23
+ ...base, name: 'hero', field_type: 'paragraph', db_column: null,
24
+ ui_component: { component: 'paragraph-embed', ref: 'paragraph--p', rel: 'one-to-one' },
25
+ },
26
+ {
27
+ ...base, name: 'related', field_type: 'reference',
28
+ db_column: {
29
+ column_name: '', column_type: 'uuid', nullable: true,
30
+ junction: { table_name: 'junction_taxonomy_t_related', left_column: 'taxonomy_t_id', right_column: 'taxonomy_t_id', right_table: 'taxonomy_t' },
31
+ },
32
+ ui_component: { component: 'typeahead-select', ref: 'taxonomy--t', rel: 'many-to-many' },
33
+ },
34
+ {
35
+ ...base, name: 'computed', field_type: 'programmatic',
36
+ db_column: null,
37
+ ui_component: { component: 'computed-display' },
38
+ },
39
+ ] as unknown as ParsedField[]
40
+
41
+ describe('initialTaxonomyForm', () => {
42
+ it('leaves out every field the taxonomy read does not return', () => {
43
+ // MUTATION: put every non-programmatic field into the form (the old
44
+ // initForm). The paragraph and many-to-many keys would then be sent as
45
+ // null, which the api rejects with a 422 for a "many" field and which
46
+ // erases a one-to-one paragraph.
47
+ const form = initialTaxonomyForm(fields, { name: 'Tag', parent: 'p-1' })
48
+ expect(form).not.toHaveProperty('blocks')
49
+ expect(form).not.toHaveProperty('hero')
50
+ expect(form).not.toHaveProperty('related')
51
+ expect(form).not.toHaveProperty('computed')
52
+ })
53
+
54
+ it('keeps column-backed fields, from the source or their default', () => {
55
+ // MUTATION: drop every reference field (filter on field_type instead of
56
+ // storage). A one-to-one reference has a column and must stay editable.
57
+ expect(initialTaxonomyForm(fields, { name: 'Tag', parent: 'p-1' })).toEqual({
58
+ name: 'Tag',
59
+ parent: 'p-1',
60
+ })
61
+ expect(initialTaxonomyForm(fields)).toEqual({ name: '', parent: null })
62
+ })
63
+ })
@@ -0,0 +1,31 @@
1
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
2
+ // The browser-safe subpath: core's main entry also carries Node-only code.
3
+ import { relationCardinality } from '@bobbykim/manguito-cms-core/cardinality'
4
+
5
+ // The value a new form starts each field at. A relation starts as null when it
6
+ // holds one item and [] when it holds a list (relationCardinality).
7
+ export function defaultForField(field: ParsedField): unknown {
8
+ switch (field.field_type) {
9
+ case 'text/plain':
10
+ case 'text/rich':
11
+ return ''
12
+ case 'integer':
13
+ case 'float':
14
+ return null
15
+ case 'boolean':
16
+ return false
17
+ case 'date':
18
+ return null
19
+ case 'image':
20
+ case 'video':
21
+ case 'file':
22
+ return null
23
+ case 'enum':
24
+ return ''
25
+ case 'reference':
26
+ case 'paragraph':
27
+ return relationCardinality(field) === 'many' ? [] : null
28
+ default:
29
+ return null
30
+ }
31
+ }