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.
Files changed (59) 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 +259 -98
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +259 -98
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -2
  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/UploadDropzone/UploadDropzone.test.tsx +39 -0
  25. package/src/contexts/ToolOptionsContext.tsx +6 -3
  26. package/src/formSchema/index.test.ts +55 -0
  27. package/src/formSchema/index.ts +28 -12
  28. package/src/hooks/useVersionedClient.ts +1 -1
  29. package/src/modules/assets/deleteAndUpdateEpics.test.ts +86 -0
  30. package/src/modules/assets/fetchEpic.test.ts +72 -0
  31. package/src/modules/assets/reducer.test.ts +90 -0
  32. package/src/modules/assets/tagsAndListenerEpics.test.ts +205 -0
  33. package/src/modules/dialog/epics.test.ts +167 -0
  34. package/src/modules/dialog/reducer.test.ts +184 -0
  35. package/src/modules/notifications/epics.test.ts +373 -0
  36. package/src/modules/notifications/index.ts +24 -4
  37. package/src/modules/notifications/reducer.test.ts +53 -0
  38. package/src/modules/search/index.test.ts +35 -0
  39. package/src/modules/selectors.test.ts +20 -0
  40. package/src/modules/tags/epics.test.ts +95 -0
  41. package/src/modules/tags/index.test.ts +41 -0
  42. package/src/modules/uploads/epics.test.ts +108 -0
  43. package/src/modules/uploads/index.test.ts +58 -0
  44. package/src/operators/checkTagName.test.ts +28 -0
  45. package/src/types/index.ts +20 -7
  46. package/src/utils/blocksToText.test.ts +42 -0
  47. package/src/utils/constructFilter.test.ts +119 -0
  48. package/src/utils/generatePreviewBlobUrl.test.ts +69 -0
  49. package/src/utils/getAssetResolution.test.ts +12 -0
  50. package/src/utils/getDocumentAssetIds.test.ts +49 -0
  51. package/src/utils/getSchemeColor.test.ts +11 -0
  52. package/src/utils/getTagSelectOptions.test.ts +43 -0
  53. package/src/utils/getUniqueDocuments.test.ts +25 -0
  54. package/src/utils/imageDprUrl.test.ts +45 -0
  55. package/src/utils/isSupportedAssetType.test.ts +15 -0
  56. package/src/utils/sanitizeFormData.test.ts +58 -0
  57. package/src/utils/typeGuards.test.ts +17 -0
  58. package/src/utils/uploadSanityAsset.test.ts +28 -0
  59. 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
+ })
@@ -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 {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
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?: string
34
- description?: string
46
+ altText?: LocalizedString
47
+ description?: LocalizedString
35
48
  opt?: {
36
49
  media?: {
37
50
  tags?: SanityReference[]
38
51
  }
39
52
  }
40
- title?: string
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 assetFormSchema>
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?: string
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
+ })