@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.
- package/dist/assets/{ChangePasswordView-BILytTnH.js → ChangePasswordView-D-B7xv5c.js} +1 -1
- package/dist/assets/ContentFormView-Bfee-uVL.js +1 -0
- package/dist/assets/{ContentListView-CWHYBV23.js → ContentListView-BpgXHLa6.js} +1 -1
- package/dist/assets/{ContentSettingsView-B9BHm-xg.js → ContentSettingsView-CuuxtUQh.js} +1 -1
- package/dist/assets/{LoginView-8tF3SdN4.js → LoginView-BdqDyYJ6.js} +1 -1
- package/dist/assets/{MediaDetailView-HfRoV5W8.js → MediaDetailView-DmKNv8bW.js} +1 -1
- package/dist/assets/MediaLibraryView-OGPHji7F.js +1 -0
- package/dist/assets/MediaThumbnail-q7pxungQ.js +1 -0
- package/dist/assets/TaxonomyFormView-Dzaz47pa.js +1 -0
- package/dist/assets/{TaxonomyListView-DKnsY_ab.js → TaxonomyListView-kaf2LX5v.js} +1 -1
- package/dist/assets/{UserFormView-DhH0m-Vq.js → UserFormView-KlTIvn2s.js} +1 -1
- package/dist/assets/{UserListView-Dk3Jv2Z8.js → UserListView-_HSsX1vW.js} +1 -1
- package/dist/assets/{ComputedDisplay-Df_HvjzX.js → field-defaults-m6kJphku.js} +2 -2
- package/dist/assets/{index-ivedPYqQ.css → index-C4qBpxpk.css} +1 -1
- package/dist/assets/{index-C-iAN6wp.js → index-DHBhcS6-.js} +2 -2
- package/dist/assets/{useNotification-kY57Myug.js → useNotification-B8AtuEWL.js} +1 -1
- package/dist/codegen/index.cjs +7 -0
- package/dist/codegen/index.js +7 -0
- package/dist/index.html +2 -2
- package/package.json +3 -3
- package/src/components/fields/MediaUpload.vue +5 -19
- package/src/components/fields/ParagraphEmbed.vue +108 -45
- package/src/components/fields/ReferenceSelect.vue +2 -2
- package/src/components/fields/__tests__/ParagraphEmbed.test.ts +64 -0
- package/src/components/fields/__tests__/ReferenceSelect.test.ts +19 -0
- package/src/components/shared/MediaSelectModal.vue +4 -21
- package/src/components/shared/MediaThumbnail.vue +108 -0
- package/src/components/shared/__tests__/MediaThumbnail.test.ts +118 -0
- package/src/composables/__tests__/useFormValidation.test.ts +27 -0
- package/src/utils/__tests__/field-defaults.test.ts +26 -0
- package/src/utils/__tests__/file-kind.test.ts +102 -0
- package/src/utils/__tests__/taxonomy-form.test.ts +63 -0
- package/src/utils/field-defaults.ts +31 -0
- package/src/utils/file-kind.ts +83 -0
- package/src/utils/taxonomy-form.ts +28 -0
- package/src/views/content/ContentFormView.vue +1 -32
- package/src/views/media/MediaLibraryView.vue +7 -22
- package/src/views/taxonomy/TaxonomyFormView.vue +2 -27
- package/dist/assets/ContentFormView-BsTU-zzP.js +0 -1
- package/dist/assets/MediaLibraryView-COQmdQPB.js +0 -1
- package/dist/assets/TaxonomyFormView-BibZ2qjc.js +0 -1
- 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(() =>
|
|
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
|
|
193
|
+
:aria-label="`Select ${item.alt ?? fileName(item.url)}`"
|
|
192
194
|
@click="selectedItem = item"
|
|
193
195
|
>
|
|
194
|
-
|
|
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
|
+
}
|