sanity-plugin-media 4.1.0 → 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.
Files changed (62) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +56 -4
  3. package/dist/index.d.mts +131 -57
  4. package/dist/index.d.ts +131 -57
  5. package/dist/index.js +273 -106
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +273 -106
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +11 -4
  10. package/src/__tests__/fixtures/createEpicTestStore.ts +27 -0
  11. package/src/__tests__/fixtures/listenMock.ts +9 -0
  12. package/src/__tests__/fixtures/mockSanityClient.ts +84 -0
  13. package/src/__tests__/fixtures/renderWithProviders.tsx +54 -0
  14. package/src/__tests__/fixtures/rootState.ts +27 -0
  15. package/src/__tests__/fixtures/withinDialog.ts +28 -0
  16. package/src/components/Browser/Browser.test.tsx +44 -0
  17. package/src/components/CardAsset/CardAsset.test.tsx +322 -0
  18. package/src/components/DialogAssetEdit/Details.tsx +123 -44
  19. package/src/components/DialogAssetEdit/DialogAssetEdit.test.tsx +215 -0
  20. package/src/components/DialogAssetEdit/index.tsx +138 -30
  21. package/src/components/DialogTagCreate/DialogTagCreate.test.tsx +120 -0
  22. package/src/components/DialogTagEdit/DialogTagEdit.test.tsx +164 -0
  23. package/src/components/FormBuilderTool/FormBuilderTool.test.tsx +62 -0
  24. package/src/components/ReduxProvider/index.tsx +2 -1
  25. package/src/components/UploadDropzone/UploadDropzone.test.tsx +39 -0
  26. package/src/constants.ts +6 -0
  27. package/src/contexts/ToolOptionsContext.tsx +6 -3
  28. package/src/formSchema/index.test.ts +55 -0
  29. package/src/formSchema/index.ts +28 -12
  30. package/src/hooks/useVersionedClient.ts +1 -1
  31. package/src/modules/assets/deleteAndUpdateEpics.test.ts +86 -0
  32. package/src/modules/assets/fetchEpic.test.ts +72 -0
  33. package/src/modules/assets/reducer.test.ts +90 -0
  34. package/src/modules/assets/tagsAndListenerEpics.test.ts +205 -0
  35. package/src/modules/dialog/epics.test.ts +167 -0
  36. package/src/modules/dialog/reducer.test.ts +184 -0
  37. package/src/modules/notifications/epics.test.ts +373 -0
  38. package/src/modules/notifications/index.ts +24 -4
  39. package/src/modules/notifications/reducer.test.ts +53 -0
  40. package/src/modules/search/index.test.ts +35 -0
  41. package/src/modules/selectors.test.ts +20 -0
  42. package/src/modules/tags/epics.test.ts +95 -0
  43. package/src/modules/tags/index.test.ts +41 -0
  44. package/src/modules/uploads/epics.test.ts +108 -0
  45. package/src/modules/uploads/index.test.ts +58 -0
  46. package/src/operators/checkTagName.test.ts +28 -0
  47. package/src/types/index.ts +23 -7
  48. package/src/utils/blocksToText.test.ts +42 -0
  49. package/src/utils/constructFilter.test.ts +119 -0
  50. package/src/utils/generatePreviewBlobUrl.test.ts +69 -0
  51. package/src/utils/getAssetResolution.test.ts +12 -0
  52. package/src/utils/getDocumentAssetIds.test.ts +49 -0
  53. package/src/utils/getSchemeColor.test.ts +11 -0
  54. package/src/utils/getTagSelectOptions.test.ts +43 -0
  55. package/src/utils/getUniqueDocuments.test.ts +25 -0
  56. package/src/utils/imageDprUrl.test.ts +45 -0
  57. package/src/utils/isSupportedAssetType.test.ts +15 -0
  58. package/src/utils/isSupportedAssetType.ts +15 -0
  59. package/src/utils/sanitizeFormData.test.ts +58 -0
  60. package/src/utils/typeGuards.test.ts +17 -0
  61. package/src/utils/uploadSanityAsset.test.ts +28 -0
  62. package/src/utils/withMaxConcurrency.test.ts +42 -0
@@ -0,0 +1,20 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it} from 'vitest'
4
+ import type {RootReducerState} from './types'
5
+ import {selectCombinedItems} from './selectors'
6
+
7
+ describe('selectCombinedItems', () => {
8
+ it('places upload items before asset items', () => {
9
+ const state = {
10
+ assets: {allIds: ['a1', 'a2']},
11
+ uploads: {allIds: ['u1']}
12
+ } as RootReducerState
13
+
14
+ expect(selectCombinedItems(state)).toEqual([
15
+ {id: 'u1', type: 'upload'},
16
+ {id: 'a1', type: 'asset'},
17
+ {id: 'a2', type: 'asset'}
18
+ ])
19
+ })
20
+ })
@@ -0,0 +1,95 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it, vi} from 'vitest'
4
+ import {of} from 'rxjs'
5
+ import {tagsCreateEpic, tagsDeleteEpic, tagsActions} from './index'
6
+ import {createEpicTestStore} from '../../__tests__/fixtures/createEpicTestStore'
7
+ import {
8
+ createMockSanityClient,
9
+ mockTransactionCommit
10
+ } from '../../__tests__/fixtures/mockSanityClient'
11
+ import type {Tag} from '../../types'
12
+
13
+ const sampleTag: Tag = {
14
+ _id: 't1',
15
+ _type: 'media.tag',
16
+ _createdAt: '',
17
+ _updatedAt: '',
18
+ _rev: 'tr',
19
+ name: {_type: 'slug', current: 'alpha'}
20
+ }
21
+
22
+ describe('tagsCreateEpic', () => {
23
+ it('creates tag when checkTagName passes', async () => {
24
+ const client = createMockSanityClient({
25
+ fetch: vi.fn().mockResolvedValue(0),
26
+ observable: {
27
+ create: vi.fn(() => of(sampleTag))
28
+ }
29
+ })
30
+
31
+ const store = createEpicTestStore(tagsCreateEpic, client)
32
+ store.dispatch(tagsActions.createRequest({name: 'alpha'}))
33
+
34
+ await vi.waitFor(() => {
35
+ expect(store.getState().tags.byIds.t1?.tag).toEqual(sampleTag)
36
+ expect(client.observable.create).toHaveBeenCalled()
37
+ })
38
+ })
39
+
40
+ it('dispatches createError when tag exists', async () => {
41
+ const client = createMockSanityClient({
42
+ fetch: vi.fn().mockResolvedValue(1),
43
+ observable: {
44
+ create: vi.fn(() => of(sampleTag))
45
+ }
46
+ })
47
+
48
+ const store = createEpicTestStore(tagsCreateEpic, client)
49
+ store.dispatch(tagsActions.createRequest({name: 'dup'}))
50
+
51
+ await vi.waitFor(() => {
52
+ expect(store.getState().tags.creatingError?.statusCode).toBe(409)
53
+ expect(client.observable.create).not.toHaveBeenCalled()
54
+ })
55
+ })
56
+ })
57
+
58
+ describe('tagsDeleteEpic', () => {
59
+ it('fetches referencing assets and commits transaction', async () => {
60
+ const tx = mockTransactionCommit(undefined)
61
+ const client = createMockSanityClient({
62
+ observable: {
63
+ fetch: vi.fn(() =>
64
+ of([
65
+ {_id: 'a1', _rev: 'r1', opt: {}},
66
+ {_id: 'a2', _rev: 'r2', opt: {}}
67
+ ])
68
+ )
69
+ },
70
+ transaction: vi.fn(() => tx)
71
+ })
72
+
73
+ const store = createEpicTestStore(tagsDeleteEpic, client, {
74
+ tags: {
75
+ allIds: ['t1'],
76
+ byIds: {
77
+ t1: {_type: 'tag', tag: sampleTag, picked: false, updating: false}
78
+ },
79
+ creating: false,
80
+ fetchCount: -1,
81
+ fetching: false,
82
+ panelVisible: true
83
+ }
84
+ })
85
+
86
+ store.dispatch(tagsActions.deleteRequest({tag: sampleTag}))
87
+
88
+ await vi.waitFor(() => {
89
+ expect(tx.patch).toHaveBeenCalled()
90
+ expect(tx.delete).toHaveBeenCalledWith('t1')
91
+ expect(tx.commit).toHaveBeenCalled()
92
+ expect(store.getState().tags.byIds.t1).toBeUndefined()
93
+ })
94
+ })
95
+ })
@@ -0,0 +1,41 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it} from 'vitest'
4
+ import type {Tag} from '../../types'
5
+ import tagsReducer, {tagsActions} from './index'
6
+
7
+ const sampleTag: Tag = {
8
+ _id: 'tag-1',
9
+ _type: 'media.tag',
10
+ _createdAt: '2020-01-01',
11
+ _updatedAt: '2020-01-01',
12
+ _rev: 'r1',
13
+ name: {_type: 'slug', current: 'alpha'}
14
+ }
15
+
16
+ describe('tags slice', () => {
17
+ it('createComplete adds tag', () => {
18
+ let state = tagsReducer(undefined, {type: '@@INIT'} as never)
19
+ state = tagsReducer(state, tagsActions.createComplete({tag: sampleTag}))
20
+ expect(state.allIds).toContain('tag-1')
21
+ expect(state.byIds['tag-1'].tag).toEqual(sampleTag)
22
+ expect(state.creating).toBe(false)
23
+ })
24
+
25
+ it('deleteComplete removes tag', () => {
26
+ let state = tagsReducer(undefined, {type: '@@INIT'} as never)
27
+ state = tagsReducer(state, tagsActions.createComplete({tag: sampleTag}))
28
+ state = tagsReducer(state, tagsActions.deleteComplete({tagId: 'tag-1'}))
29
+ expect(state.allIds).not.toContain('tag-1')
30
+ expect(state.byIds['tag-1']).toBeUndefined()
31
+ })
32
+
33
+ it('fetchComplete hydrates tag list', () => {
34
+ let state = tagsReducer(undefined, {type: '@@INIT'} as never)
35
+ state = tagsReducer(state, tagsActions.fetchRequest())
36
+ expect(state.fetching).toBe(true)
37
+ state = tagsReducer(state, tagsActions.fetchComplete({tags: [sampleTag]}))
38
+ expect(state.fetching).toBe(false)
39
+ expect(state.byIds['tag-1'].tag).toEqual(sampleTag)
40
+ })
41
+ })
@@ -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
+ })
@@ -8,9 +8,12 @@ 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 {assetFormSchema, tagFormSchema, tagOptionSchema} from '../formSchema'
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
+ import type {SUPPORTED_ASSET_TYPES} from '../constants'
15
+
16
+ export type AssetTypes = (typeof SUPPORTED_ASSET_TYPES)[number]
14
17
 
15
18
  export type MediaToolOptions = {
16
19
  maximumUploadSize?: number
@@ -19,22 +22,35 @@ export type MediaToolOptions = {
19
22
  DetailsProps & {renderDefaultDetails: (props: DetailsProps) => JSX.Element}
20
23
  >
21
24
  }
22
- creditLine: {
25
+ creditLine?: {
23
26
  enabled: boolean
24
27
  excludeSources?: string | string[]
25
28
  }
26
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[]
35
+ }
36
+
37
+ export type Locale = {
38
+ title: string
39
+ id: string
40
+ [key: string]: unknown
27
41
  }
28
42
 
43
+ type LocalizedString = string | Record<string, string>
44
+
29
45
  type CustomFields = {
30
- altText?: string
31
- description?: string
46
+ altText?: LocalizedString
47
+ description?: LocalizedString
32
48
  opt?: {
33
49
  media?: {
34
50
  tags?: SanityReference[]
35
51
  }
36
52
  }
37
- title?: string
53
+ title?: LocalizedString
38
54
  }
39
55
 
40
56
  type SearchFacetInputCommon = {
@@ -48,7 +64,7 @@ type SearchFacetInputCommon = {
48
64
 
49
65
  export type Asset = FileAsset | ImageAsset
50
66
 
51
- export type AssetFormData = z.infer<typeof assetFormSchema>
67
+ export type AssetFormData = z.infer<ReturnType<typeof getAssetFormSchema>>
52
68
 
53
69
  export type AssetItem = {
54
70
  _type: 'asset'
@@ -164,7 +180,7 @@ export type FileAsset = SanityAssetDocument &
164
180
  export type ImageAsset = SanityImageAssetDocument &
165
181
  CustomFields & {
166
182
  _type: 'sanity.imageAsset'
167
- creditLine?: string
183
+ creditLine?: LocalizedString
168
184
  }
169
185
 
170
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
+ })