sanity-plugin-media 4.1.1 → 4.2.0
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/LICENSE +1 -1
- package/README.md +56 -4
- package/dist/index.d.mts +131 -57
- package/dist/index.d.ts +131 -57
- package/dist/index.js +259 -98
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +259 -98
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -2
- package/src/__tests__/fixtures/createEpicTestStore.ts +27 -0
- package/src/__tests__/fixtures/listenMock.ts +9 -0
- package/src/__tests__/fixtures/mockSanityClient.ts +84 -0
- package/src/__tests__/fixtures/renderWithProviders.tsx +54 -0
- package/src/__tests__/fixtures/rootState.ts +27 -0
- package/src/__tests__/fixtures/withinDialog.ts +28 -0
- package/src/components/Browser/Browser.test.tsx +44 -0
- package/src/components/CardAsset/CardAsset.test.tsx +322 -0
- package/src/components/DialogAssetEdit/Details.tsx +123 -44
- package/src/components/DialogAssetEdit/DialogAssetEdit.test.tsx +215 -0
- package/src/components/DialogAssetEdit/index.tsx +138 -30
- package/src/components/DialogTagCreate/DialogTagCreate.test.tsx +120 -0
- package/src/components/DialogTagEdit/DialogTagEdit.test.tsx +164 -0
- package/src/components/FormBuilderTool/FormBuilderTool.test.tsx +62 -0
- package/src/components/UploadDropzone/UploadDropzone.test.tsx +39 -0
- package/src/contexts/ToolOptionsContext.tsx +6 -3
- package/src/formSchema/index.test.ts +55 -0
- package/src/formSchema/index.ts +28 -12
- package/src/hooks/useVersionedClient.ts +1 -1
- package/src/modules/assets/deleteAndUpdateEpics.test.ts +86 -0
- package/src/modules/assets/fetchEpic.test.ts +72 -0
- package/src/modules/assets/reducer.test.ts +90 -0
- package/src/modules/assets/tagsAndListenerEpics.test.ts +205 -0
- package/src/modules/dialog/epics.test.ts +167 -0
- package/src/modules/dialog/reducer.test.ts +184 -0
- package/src/modules/notifications/epics.test.ts +373 -0
- package/src/modules/notifications/index.ts +24 -4
- package/src/modules/notifications/reducer.test.ts +53 -0
- package/src/modules/search/index.test.ts +35 -0
- package/src/modules/selectors.test.ts +20 -0
- package/src/modules/tags/epics.test.ts +95 -0
- package/src/modules/tags/index.test.ts +41 -0
- package/src/modules/uploads/epics.test.ts +108 -0
- package/src/modules/uploads/index.test.ts +58 -0
- package/src/operators/checkTagName.test.ts +28 -0
- package/src/types/index.ts +20 -7
- package/src/utils/blocksToText.test.ts +42 -0
- package/src/utils/constructFilter.test.ts +119 -0
- package/src/utils/generatePreviewBlobUrl.test.ts +69 -0
- package/src/utils/getAssetResolution.test.ts +12 -0
- package/src/utils/getDocumentAssetIds.test.ts +49 -0
- package/src/utils/getSchemeColor.test.ts +11 -0
- package/src/utils/getTagSelectOptions.test.ts +43 -0
- package/src/utils/getUniqueDocuments.test.ts +25 -0
- package/src/utils/imageDprUrl.test.ts +45 -0
- package/src/utils/isSupportedAssetType.test.ts +15 -0
- package/src/utils/sanitizeFormData.test.ts +58 -0
- package/src/utils/typeGuards.test.ts +17 -0
- package/src/utils/uploadSanityAsset.test.ts +28 -0
- package/src/utils/withMaxConcurrency.test.ts +42 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
|
|
4
|
+
import {of} from 'rxjs'
|
|
5
|
+
import {uploadsAssetStartEpic, uploadsCheckRequestEpic, uploadsActions} from './index'
|
|
6
|
+
import {createEpicTestStore} from '../../__tests__/fixtures/createEpicTestStore'
|
|
7
|
+
import {createMockSanityClient} from '../../__tests__/fixtures/mockSanityClient'
|
|
8
|
+
import {initialState as assetsInitialState} from '../assets'
|
|
9
|
+
import type {SanityImageAssetDocument} from '@sanity/client'
|
|
10
|
+
|
|
11
|
+
vi.mock('../../utils/generatePreviewBlobUrl', () => ({
|
|
12
|
+
generatePreviewBlobUrl$: () => of('blob:http://preview')
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const uploadedAsset = {
|
|
16
|
+
_id: 'asset-new',
|
|
17
|
+
_type: 'sanity.imageAsset',
|
|
18
|
+
sha1hash: 'deadbeef',
|
|
19
|
+
_createdAt: '',
|
|
20
|
+
_updatedAt: '',
|
|
21
|
+
_rev: 'r',
|
|
22
|
+
originalFilename: 'f.png',
|
|
23
|
+
mimeType: 'image/png',
|
|
24
|
+
size: 10,
|
|
25
|
+
url: ''
|
|
26
|
+
} as SanityImageAssetDocument
|
|
27
|
+
|
|
28
|
+
vi.mock('../../utils/uploadSanityAsset', () => ({
|
|
29
|
+
uploadAsset$: () =>
|
|
30
|
+
of({
|
|
31
|
+
type: 'complete' as const,
|
|
32
|
+
asset: uploadedAsset
|
|
33
|
+
}),
|
|
34
|
+
hashFile$: () => of('deadbeef'),
|
|
35
|
+
withMaxConcurrency: (fn: unknown) => fn
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
describe('uploadsAssetStartEpic', () => {
|
|
39
|
+
it('dispatches preview, progress path, and uploadComplete', async () => {
|
|
40
|
+
const client = createMockSanityClient()
|
|
41
|
+
|
|
42
|
+
const store = createEpicTestStore(uploadsAssetStartEpic, client)
|
|
43
|
+
|
|
44
|
+
const file = new File(['x'], 'f.png', {type: 'image/png'})
|
|
45
|
+
const uploadItem = {
|
|
46
|
+
_type: 'upload' as const,
|
|
47
|
+
assetType: 'image' as const,
|
|
48
|
+
hash: 'deadbeef',
|
|
49
|
+
name: 'f.png',
|
|
50
|
+
size: file.size,
|
|
51
|
+
status: 'queued' as const
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
store.dispatch(uploadsActions.uploadStart({file, uploadItem}))
|
|
55
|
+
|
|
56
|
+
await vi.waitFor(() => {
|
|
57
|
+
expect(store.getState().uploads.byIds.deadbeef?.objectUrl).toBe('blob:http://preview')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
await vi.waitFor(() => {
|
|
61
|
+
expect(store.getState().assets.byIds['asset-new']).toBeDefined()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('uploadsCheckRequestEpic', () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.useFakeTimers()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
vi.useRealTimers()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('after delay, fetches sha hashes and dispatches checkComplete + insertUploads', async () => {
|
|
76
|
+
const client = createMockSanityClient({
|
|
77
|
+
observable: {
|
|
78
|
+
fetch: vi.fn(() => of(['hh']))
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const store = createEpicTestStore(uploadsCheckRequestEpic, client, {
|
|
83
|
+
assets: {...assetsInitialState, assetTypes: ['image']}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const asset = {
|
|
87
|
+
_id: 'id-1',
|
|
88
|
+
_type: 'sanity.imageAsset',
|
|
89
|
+
sha1hash: 'hh',
|
|
90
|
+
_createdAt: '',
|
|
91
|
+
_updatedAt: '',
|
|
92
|
+
_rev: 'r',
|
|
93
|
+
originalFilename: 'f.png',
|
|
94
|
+
mimeType: 'image/png',
|
|
95
|
+
size: 1,
|
|
96
|
+
url: ''
|
|
97
|
+
} as SanityImageAssetDocument
|
|
98
|
+
|
|
99
|
+
store.dispatch(uploadsActions.checkRequest({assets: [asset]}))
|
|
100
|
+
|
|
101
|
+
await vi.advanceTimersByTimeAsync(1100)
|
|
102
|
+
|
|
103
|
+
await vi.waitFor(() => {
|
|
104
|
+
expect(client.observable.fetch).toHaveBeenCalled()
|
|
105
|
+
expect(store.getState().uploads.byIds.hh).toBeUndefined()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'vitest'
|
|
4
|
+
import type {UploadItem} from '../../types'
|
|
5
|
+
import uploadsReducer, {uploadsActions} from './index'
|
|
6
|
+
|
|
7
|
+
describe('uploads slice', () => {
|
|
8
|
+
it('uploadStart adds item to queue', () => {
|
|
9
|
+
let state = uploadsReducer(undefined, {type: '@@INIT'} as never)
|
|
10
|
+
const uploadItem = {
|
|
11
|
+
_type: 'upload',
|
|
12
|
+
assetType: 'image',
|
|
13
|
+
hash: 'abc',
|
|
14
|
+
name: 'x.png',
|
|
15
|
+
size: 1,
|
|
16
|
+
status: 'queued'
|
|
17
|
+
} as UploadItem
|
|
18
|
+
|
|
19
|
+
state = uploadsReducer(
|
|
20
|
+
state,
|
|
21
|
+
uploadsActions.uploadStart({
|
|
22
|
+
file: new File([], 'x.png'),
|
|
23
|
+
uploadItem
|
|
24
|
+
})
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
expect(state.allIds).toEqual(['abc'])
|
|
28
|
+
expect(state.byIds.abc).toMatchObject({hash: 'abc', status: 'queued'})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('uploadProgress updates percent and status', () => {
|
|
32
|
+
let state = uploadsReducer(undefined, {type: '@@INIT'} as never)
|
|
33
|
+
const uploadItem = {
|
|
34
|
+
_type: 'upload',
|
|
35
|
+
assetType: 'image',
|
|
36
|
+
hash: 'h1',
|
|
37
|
+
name: 'x.png',
|
|
38
|
+
size: 1,
|
|
39
|
+
status: 'queued',
|
|
40
|
+
percent: 0
|
|
41
|
+
} as UploadItem
|
|
42
|
+
|
|
43
|
+
state = uploadsReducer(
|
|
44
|
+
state,
|
|
45
|
+
uploadsActions.uploadStart({file: new File([], 'x.png'), uploadItem})
|
|
46
|
+
)
|
|
47
|
+
state = uploadsReducer(
|
|
48
|
+
state,
|
|
49
|
+
uploadsActions.uploadProgress({
|
|
50
|
+
uploadHash: 'h1',
|
|
51
|
+
event: {percent: 42, stage: 'upload'} as any
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
expect(state.byIds.h1.percent).toBe(42)
|
|
56
|
+
expect(state.byIds.h1.status).toBe('uploading')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
4
|
+
import {firstValueFrom, of} from 'rxjs'
|
|
5
|
+
import type {SanityClient} from '@sanity/client'
|
|
6
|
+
import checkTagName from './checkTagName'
|
|
7
|
+
|
|
8
|
+
describe('checkTagName', () => {
|
|
9
|
+
it('errors with 409 when a tag with the same slug exists', async () => {
|
|
10
|
+
const client = {
|
|
11
|
+
fetch: vi.fn().mockResolvedValue(1)
|
|
12
|
+
} as unknown as SanityClient
|
|
13
|
+
|
|
14
|
+
await expect(
|
|
15
|
+
firstValueFrom(of(null).pipe(checkTagName(client, 'existing')))
|
|
16
|
+
).rejects.toMatchObject({statusCode: 409, message: 'Tag already exists'})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('emits true when name is available', async () => {
|
|
20
|
+
const client = {
|
|
21
|
+
fetch: vi.fn().mockResolvedValue(0)
|
|
22
|
+
} as unknown as SanityClient
|
|
23
|
+
|
|
24
|
+
await expect(firstValueFrom(of(null).pipe(checkTagName(client, 'fresh-name')))).resolves.toBe(
|
|
25
|
+
true
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
})
|
package/src/types/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
import type {ComponentType, JSX} from 'react'
|
|
9
9
|
import type {Epic} from 'redux-observable'
|
|
10
10
|
import * as z from 'zod'
|
|
11
|
-
import {
|
|
11
|
+
import {getAssetFormSchema, tagFormSchema, tagOptionSchema} from '../formSchema'
|
|
12
12
|
import type {RootReducerState} from '../modules/types'
|
|
13
13
|
import type {DetailsProps} from '../components/DialogAssetEdit/Details'
|
|
14
14
|
import type {SUPPORTED_ASSET_TYPES} from '../constants'
|
|
@@ -22,22 +22,35 @@ export type MediaToolOptions = {
|
|
|
22
22
|
DetailsProps & {renderDefaultDetails: (props: DetailsProps) => JSX.Element}
|
|
23
23
|
>
|
|
24
24
|
}
|
|
25
|
-
creditLine
|
|
25
|
+
creditLine?: {
|
|
26
26
|
enabled: boolean
|
|
27
27
|
excludeSources?: string | string[]
|
|
28
28
|
}
|
|
29
29
|
directUploads?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Optional locales following Sanity recommended scheme: [{ id, title }]
|
|
32
|
+
* https://www.sanity.io/docs/studio/localization#k4da239411955
|
|
33
|
+
*/
|
|
34
|
+
locales?: Locale[]
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
export type Locale = {
|
|
38
|
+
title: string
|
|
39
|
+
id: string
|
|
40
|
+
[key: string]: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type LocalizedString = string | Record<string, string>
|
|
44
|
+
|
|
32
45
|
type CustomFields = {
|
|
33
|
-
altText?:
|
|
34
|
-
description?:
|
|
46
|
+
altText?: LocalizedString
|
|
47
|
+
description?: LocalizedString
|
|
35
48
|
opt?: {
|
|
36
49
|
media?: {
|
|
37
50
|
tags?: SanityReference[]
|
|
38
51
|
}
|
|
39
52
|
}
|
|
40
|
-
title?:
|
|
53
|
+
title?: LocalizedString
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
type SearchFacetInputCommon = {
|
|
@@ -51,7 +64,7 @@ type SearchFacetInputCommon = {
|
|
|
51
64
|
|
|
52
65
|
export type Asset = FileAsset | ImageAsset
|
|
53
66
|
|
|
54
|
-
export type AssetFormData = z.infer<typeof
|
|
67
|
+
export type AssetFormData = z.infer<ReturnType<typeof getAssetFormSchema>>
|
|
55
68
|
|
|
56
69
|
export type AssetItem = {
|
|
57
70
|
_type: 'asset'
|
|
@@ -167,7 +180,7 @@ export type FileAsset = SanityAssetDocument &
|
|
|
167
180
|
export type ImageAsset = SanityImageAssetDocument &
|
|
168
181
|
CustomFields & {
|
|
169
182
|
_type: 'sanity.imageAsset'
|
|
170
|
-
creditLine?:
|
|
183
|
+
creditLine?: LocalizedString
|
|
171
184
|
}
|
|
172
185
|
|
|
173
186
|
export type MarkDef = {_key: string; _type: string}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
import blocksToText from './blocksToText'
|
|
3
|
+
import type {Block} from '../types'
|
|
4
|
+
|
|
5
|
+
describe('blocksToText', () => {
|
|
6
|
+
it('returns plain strings unchanged', () => {
|
|
7
|
+
expect(blocksToText('hello')).toBe('hello')
|
|
8
|
+
expect(blocksToText('')).toBe('')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('returns empty string for non-array non-string input', () => {
|
|
12
|
+
expect(blocksToText(null as unknown as string)).toBe('')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('joins block children text with blank lines between blocks', () => {
|
|
16
|
+
const blocks: Block[] = [
|
|
17
|
+
{
|
|
18
|
+
_type: 'block',
|
|
19
|
+
_key: 'a',
|
|
20
|
+
markDefs: [],
|
|
21
|
+
children: [{_key: 'a1', text: 'Line one', marks: []}]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
_type: 'block',
|
|
25
|
+
_key: 'b',
|
|
26
|
+
markDefs: [],
|
|
27
|
+
children: [{_key: 'b1', text: 'Line two', marks: []}]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
expect(blocksToText(blocks)).toBe('Line one\n\nLine two')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('removes non-block nodes by default', () => {
|
|
34
|
+
const blocks = [{_type: 'image', _key: 'i', markDefs: [], children: []}] as unknown as Block[]
|
|
35
|
+
expect(blocksToText(blocks)).toBe('')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('keeps placeholder for non-block nodes when nonTextBehavior is not remove', () => {
|
|
39
|
+
const blocks = [{_type: 'image', _key: 'i', markDefs: [], children: []}] as unknown as Block[]
|
|
40
|
+
expect(blocksToText(blocks, {nonTextBehavior: 'keep'})).toBe('[image block]')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'vitest'
|
|
4
|
+
import {inputs} from '../config/searchFacets'
|
|
5
|
+
import type {SearchFacetInputProps} from '../types'
|
|
6
|
+
import constructFilter from './constructFilter'
|
|
7
|
+
|
|
8
|
+
describe('constructFilter', () => {
|
|
9
|
+
it('includes base filter that excludes drafts and restricts asset types', () => {
|
|
10
|
+
const q = constructFilter({
|
|
11
|
+
assetTypes: ['image', 'file'],
|
|
12
|
+
searchFacets: [],
|
|
13
|
+
searchQuery: undefined
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
expect(q).toContain('_type in ["sanity.imageAsset","sanity.fileAsset"]')
|
|
17
|
+
expect(q).toContain('!(_id in path("drafts.**"))')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('limits to a single asset type in picker mode', () => {
|
|
21
|
+
const q = constructFilter({
|
|
22
|
+
assetTypes: ['image'],
|
|
23
|
+
searchFacets: [],
|
|
24
|
+
searchQuery: undefined
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(q).toContain('_type in ["sanity.imageAsset"]')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('appends text search on trimmed query', () => {
|
|
31
|
+
const q = constructFilter({
|
|
32
|
+
assetTypes: ['file', 'image'],
|
|
33
|
+
searchFacets: [],
|
|
34
|
+
searchQuery: ' hello '
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(q).toContain(
|
|
38
|
+
"[_id, altText, assetId, creditLine, description, originalFilename, title, url] match '*hello*'"
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('composes number facet with field modifier (size / KB)', () => {
|
|
43
|
+
const q = constructFilter({
|
|
44
|
+
assetTypes: ['image'],
|
|
45
|
+
searchFacets: [{...inputs.size, value: 500} as SearchFacetInputProps],
|
|
46
|
+
searchQuery: undefined
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(q.replace(/\s+/g, ' ')).toContain('round(size / 1000) > 500')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('composes searchable tag facet (references)', () => {
|
|
53
|
+
const facet = {
|
|
54
|
+
...inputs.tag,
|
|
55
|
+
operatorType: 'references' as const,
|
|
56
|
+
value: {label: 'T', value: 'tag-id-1'}
|
|
57
|
+
} as SearchFacetInputProps
|
|
58
|
+
|
|
59
|
+
const q = constructFilter({
|
|
60
|
+
assetTypes: ['image', 'file'],
|
|
61
|
+
searchFacets: [facet],
|
|
62
|
+
searchQuery: undefined
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(q).toContain("references('tag-id-1')")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('composes select facet (inUse)', () => {
|
|
69
|
+
const q = constructFilter({
|
|
70
|
+
assetTypes: ['image', 'file'],
|
|
71
|
+
searchFacets: [structuredClone(inputs.inUse)],
|
|
72
|
+
searchQuery: undefined
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
expect(q).toContain('count(*[references(^._id)]) > 0')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('AND-joins base filter, search text, and multiple facets', () => {
|
|
79
|
+
const q = constructFilter({
|
|
80
|
+
assetTypes: ['image', 'file'],
|
|
81
|
+
searchFacets: [{...inputs.title}, {...inputs.inUse}],
|
|
82
|
+
searchQuery: 'x'
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const parts = q.split(' && ')
|
|
86
|
+
expect(parts.length).toBeGreaterThanOrEqual(4)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('matches snapshot for stable GROQ shape (apiVersion / filter regressions)', () => {
|
|
90
|
+
const q = constructFilter({
|
|
91
|
+
assetTypes: ['file', 'image'],
|
|
92
|
+
searchFacets: [
|
|
93
|
+
{...inputs.size, value: 100} as SearchFacetInputProps,
|
|
94
|
+
{
|
|
95
|
+
...inputs.tag,
|
|
96
|
+
operatorType: 'references',
|
|
97
|
+
value: {label: 'Example', value: 'abc123'}
|
|
98
|
+
} as SearchFacetInputProps
|
|
99
|
+
],
|
|
100
|
+
searchQuery: 'portrait'
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const normalized = q.replace(/\s+/g, ' ').trim()
|
|
104
|
+
|
|
105
|
+
expect(normalized).toBe(
|
|
106
|
+
'_type in ["sanity.fileAsset","sanity.imageAsset"] && !(_id in path("drafts.**")) && [_id, altText, assetId, creditLine, description, originalFilename, title, url] match \'*portrait*\' && round(size / 1000) > 100 && references(\'abc123\')'
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('omits text search fragment when searchQuery is undefined', () => {
|
|
111
|
+
const q = constructFilter({
|
|
112
|
+
assetTypes: ['image', 'file'],
|
|
113
|
+
searchFacets: [],
|
|
114
|
+
searchQuery: undefined
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(q).not.toContain('match ')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
2
|
+
import {firstValueFrom} from 'rxjs'
|
|
3
|
+
import {generatePreviewBlobUrl$} from './generatePreviewBlobUrl'
|
|
4
|
+
|
|
5
|
+
describe('generatePreviewBlobUrl$', () => {
|
|
6
|
+
const origCreateElement = document.createElement.bind(document)
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
class MockImage {
|
|
10
|
+
onload: (() => void) | null = null
|
|
11
|
+
width = 400
|
|
12
|
+
height = 200
|
|
13
|
+
private _src = ''
|
|
14
|
+
get src() {
|
|
15
|
+
return this._src
|
|
16
|
+
}
|
|
17
|
+
set src(v: string) {
|
|
18
|
+
this._src = v
|
|
19
|
+
queueMicrotask(() => this.onload?.())
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
vi.stubGlobal('Image', MockImage)
|
|
23
|
+
|
|
24
|
+
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
|
|
25
|
+
if (tagName === 'canvas') {
|
|
26
|
+
const el = origCreateElement('canvas')
|
|
27
|
+
vi.spyOn(el, 'getContext').mockReturnValue({
|
|
28
|
+
drawImage: vi.fn()
|
|
29
|
+
} as unknown as CanvasRenderingContext2D)
|
|
30
|
+
/* eslint-disable callback-return, consistent-return -- HTMLCanvasElement#toBlob sync test stub */
|
|
31
|
+
el.toBlob = function toBlob(cb: ((blob: Blob | null) => void) | null | undefined) {
|
|
32
|
+
if (cb) {
|
|
33
|
+
cb(new Blob(['x'], {type: 'image/jpeg'}))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/* eslint-enable callback-return, consistent-return */
|
|
37
|
+
return el
|
|
38
|
+
}
|
|
39
|
+
return origCreateElement(tagName)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const createObjectURL = vi.fn(() => 'blob:mock-preview')
|
|
43
|
+
const revokeObjectURL = vi.fn()
|
|
44
|
+
Object.defineProperty(URL, 'createObjectURL', {
|
|
45
|
+
configurable: true,
|
|
46
|
+
writable: true,
|
|
47
|
+
value: createObjectURL
|
|
48
|
+
})
|
|
49
|
+
Object.defineProperty(URL, 'revokeObjectURL', {
|
|
50
|
+
configurable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
value: revokeObjectURL
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.unstubAllGlobals()
|
|
58
|
+
vi.restoreAllMocks()
|
|
59
|
+
delete (URL as Partial<typeof URL> & {createObjectURL?: unknown}).createObjectURL
|
|
60
|
+
delete (URL as Partial<typeof URL> & {revokeObjectURL?: unknown}).revokeObjectURL
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('emits a blob URL when canvas preview succeeds', async () => {
|
|
64
|
+
const url = await firstValueFrom(
|
|
65
|
+
generatePreviewBlobUrl$(new File(['x'], 'photo.jpg', {type: 'image/jpeg'}))
|
|
66
|
+
)
|
|
67
|
+
expect(url).toBe('blob:mock-preview')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
import getAssetResolution from './getAssetResolution'
|
|
3
|
+
import type {ImageAsset} from '../types'
|
|
4
|
+
|
|
5
|
+
describe('getAssetResolution', () => {
|
|
6
|
+
it('formats width x height with px suffix', () => {
|
|
7
|
+
const asset = {
|
|
8
|
+
metadata: {dimensions: {width: 1920, height: 1080}}
|
|
9
|
+
} as ImageAsset
|
|
10
|
+
expect(getAssetResolution(asset)).toBe('1920x1080px')
|
|
11
|
+
})
|
|
12
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'vitest'
|
|
4
|
+
import getDocumentAssetIds from './getDocumentAssetIds'
|
|
5
|
+
|
|
6
|
+
describe('getDocumentAssetIds', () => {
|
|
7
|
+
it('returns empty array for document without asset refs', () => {
|
|
8
|
+
expect(getDocumentAssetIds({_id: 'doc1', _type: 'post'} as any)).toEqual([])
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('collects asset _ref from nested portable text–like structures', () => {
|
|
12
|
+
const doc = {
|
|
13
|
+
_id: 'doc1',
|
|
14
|
+
_type: 'post',
|
|
15
|
+
body: [
|
|
16
|
+
{
|
|
17
|
+
_type: 'block',
|
|
18
|
+
asset: {_type: 'reference', _ref: 'image-asset-1'}
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
} as any
|
|
22
|
+
|
|
23
|
+
expect(getDocumentAssetIds(doc)).toEqual(['image-asset-1'])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('dedupes and sorts refs', () => {
|
|
27
|
+
const doc = {
|
|
28
|
+
_id: 'doc1',
|
|
29
|
+
_type: 'post',
|
|
30
|
+
modules: [
|
|
31
|
+
{image: {asset: {_type: 'reference', _ref: 'b'}}},
|
|
32
|
+
{image: {asset: {_type: 'reference', _ref: 'a'}}},
|
|
33
|
+
{image: {asset: {_type: 'reference', _ref: 'b'}}}
|
|
34
|
+
]
|
|
35
|
+
} as any
|
|
36
|
+
|
|
37
|
+
expect(getDocumentAssetIds(doc)).toEqual(['a', 'b'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('ignores reference nodes that are not asset references', () => {
|
|
41
|
+
const doc = {
|
|
42
|
+
_id: 'doc1',
|
|
43
|
+
_type: 'post',
|
|
44
|
+
author: {_type: 'reference', _ref: 'person-1'}
|
|
45
|
+
} as any
|
|
46
|
+
|
|
47
|
+
expect(getDocumentAssetIds(doc)).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
import {getSchemeColor} from './getSchemeColor'
|
|
3
|
+
|
|
4
|
+
describe('getSchemeColor', () => {
|
|
5
|
+
it('returns a hex or theme string for light and dark schemes', () => {
|
|
6
|
+
expect(getSchemeColor('light', 'bg')).toMatch(/^#/)
|
|
7
|
+
expect(getSchemeColor('dark', 'bg')).toMatch(/^#/)
|
|
8
|
+
expect(getSchemeColor('light', 'spotBlue')).toBeTruthy()
|
|
9
|
+
expect(getSchemeColor('dark', 'inputEnabledBorder')).toBeTruthy()
|
|
10
|
+
})
|
|
11
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
import getTagSelectOptions from './getTagSelectOptions'
|
|
3
|
+
import type {Tag, TagItem} from '../types'
|
|
4
|
+
|
|
5
|
+
function tagItem(partial: Partial<TagItem> & Pick<TagItem, 'tag'>): TagItem {
|
|
6
|
+
return {
|
|
7
|
+
_type: 'tag',
|
|
8
|
+
picked: false,
|
|
9
|
+
updating: false,
|
|
10
|
+
...partial
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const makeTag = (id: string, name: string): Tag => ({
|
|
15
|
+
_id: id,
|
|
16
|
+
_type: 'media.tag',
|
|
17
|
+
_createdAt: '',
|
|
18
|
+
_updatedAt: '',
|
|
19
|
+
_rev: 'r1',
|
|
20
|
+
name: {_type: 'slug', current: name}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('getTagSelectOptions', () => {
|
|
24
|
+
it('maps tag items to label/value options', () => {
|
|
25
|
+
const tags = [tagItem({tag: makeTag('t1', 'alpha')}), tagItem({tag: makeTag('t2', 'beta')})]
|
|
26
|
+
expect(getTagSelectOptions(tags)).toEqual([
|
|
27
|
+
{label: 'alpha', value: 't1'},
|
|
28
|
+
{label: 'beta', value: 't2'}
|
|
29
|
+
])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns an empty array for an empty list', () => {
|
|
33
|
+
expect(getTagSelectOptions([])).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('skips items without a tag', () => {
|
|
37
|
+
const tags = [
|
|
38
|
+
tagItem({tag: makeTag('t1', 'ok')}),
|
|
39
|
+
{_type: 'tag', tag: undefined, picked: false, updating: false} as unknown as TagItem
|
|
40
|
+
]
|
|
41
|
+
expect(getTagSelectOptions(tags)).toEqual([{label: 'ok', value: 't1'}])
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
import type {SanityDocument} from '@sanity/client'
|
|
3
|
+
import {getUniqueDocuments} from './getUniqueDocuments'
|
|
4
|
+
|
|
5
|
+
describe('getUniqueDocuments', () => {
|
|
6
|
+
it('drops published documents when a drafts.* sibling exists', () => {
|
|
7
|
+
const docs: SanityDocument[] = [
|
|
8
|
+
{_id: 'drafts.post1', _type: 'post'} as SanityDocument,
|
|
9
|
+
{_id: 'post1', _type: 'post'} as SanityDocument
|
|
10
|
+
]
|
|
11
|
+
expect(getUniqueDocuments(docs)).toEqual([{_id: 'drafts.post1', _type: 'post'}])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('keeps published-only and draft-only ids', () => {
|
|
15
|
+
const docs: SanityDocument[] = [
|
|
16
|
+
{_id: 'onlyPub', _type: 'x'} as SanityDocument,
|
|
17
|
+
{_id: 'drafts.onlyDraft', _type: 'x'} as SanityDocument
|
|
18
|
+
]
|
|
19
|
+
expect(getUniqueDocuments(docs)).toEqual(docs)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns an empty array for an empty list', () => {
|
|
23
|
+
expect(getUniqueDocuments([])).toEqual([])
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {afterEach, describe, expect, it} from 'vitest'
|
|
2
|
+
import imageDprUrl from './imageDprUrl'
|
|
3
|
+
import type {ImageAsset} from '../types'
|
|
4
|
+
|
|
5
|
+
const asset = {
|
|
6
|
+
_id: 'a1',
|
|
7
|
+
_type: 'sanity.imageAsset',
|
|
8
|
+
_createdAt: '',
|
|
9
|
+
_updatedAt: '',
|
|
10
|
+
_rev: 'r1',
|
|
11
|
+
originalFilename: 'x.png',
|
|
12
|
+
size: 1,
|
|
13
|
+
mimeType: 'image/png',
|
|
14
|
+
url: 'https://cdn.test/image.png',
|
|
15
|
+
metadata: {dimensions: {width: 100, height: 100}, isOpaque: true}
|
|
16
|
+
} as ImageAsset
|
|
17
|
+
|
|
18
|
+
describe('imageDprUrl', () => {
|
|
19
|
+
const dpr = window.devicePixelRatio
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
Object.defineProperty(window, 'devicePixelRatio', {value: dpr, configurable: true})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('scales width by devicePixelRatio and sets fit=max', () => {
|
|
26
|
+
Object.defineProperty(window, 'devicePixelRatio', {value: 2, configurable: true})
|
|
27
|
+
const url = imageDprUrl(asset, {width: 400})
|
|
28
|
+
expect(url).toBe('https://cdn.test/image.png?fit=max&w=800')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('includes height when provided, scaled by dpr', () => {
|
|
32
|
+
Object.defineProperty(window, 'devicePixelRatio', {value: 2, configurable: true})
|
|
33
|
+
const url = imageDprUrl(asset, {width: 300, height: 200})
|
|
34
|
+
expect(url).toBe('https://cdn.test/image.png?fit=max&w=600&h=400')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('uses multiplier 1 when devicePixelRatio is missing', () => {
|
|
38
|
+
Object.defineProperty(window, 'devicePixelRatio', {
|
|
39
|
+
value: undefined as unknown as number,
|
|
40
|
+
configurable: true
|
|
41
|
+
})
|
|
42
|
+
const url = imageDprUrl(asset, {width: 100})
|
|
43
|
+
expect(url).toBe('https://cdn.test/image.png?fit=max&w=100')
|
|
44
|
+
})
|
|
45
|
+
})
|