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,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
+ })
@@ -0,0 +1,15 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {isSupportedAssetType} from './isSupportedAssetType'
3
+
4
+ describe('isSupportedAssetType', () => {
5
+ it('returns true for file and image', () => {
6
+ expect(isSupportedAssetType('file')).toBe(true)
7
+ expect(isSupportedAssetType('image')).toBe(true)
8
+ })
9
+
10
+ it('returns false for unsupported or missing types', () => {
11
+ expect(isSupportedAssetType('video')).toBe(false)
12
+ expect(isSupportedAssetType('')).toBe(false)
13
+ expect(isSupportedAssetType(undefined)).toBe(false)
14
+ })
15
+ })
@@ -0,0 +1,15 @@
1
+ import {SUPPORTED_ASSET_TYPES} from '../constants'
2
+ import type {AssetType} from '../types'
3
+
4
+ /**
5
+ * Determines whether or not the provided asset type (eg 'image', 'file', 'arbitrary')
6
+ * is a supported asset type for this plugin.
7
+ *
8
+ * @param assetType - The asset type to check.
9
+ * @returns True if the asset type is supported, false otherwise.
10
+ * @internal
11
+ */
12
+ export function isSupportedAssetType(assetType?: string): assetType is AssetType {
13
+ const supported: string[] = SUPPORTED_ASSET_TYPES
14
+ return assetType ? supported.includes(assetType) : false
15
+ }
@@ -0,0 +1,58 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it} from 'vitest'
4
+ import sanitizeFormData from './sanitizeFormData'
5
+
6
+ describe('sanitizeFormData', () => {
7
+ it('maps empty string, undefined, and empty array to null', () => {
8
+ expect(
9
+ sanitizeFormData({
10
+ a: '',
11
+ b: undefined,
12
+ c: []
13
+ })
14
+ ).toEqual({
15
+ a: null,
16
+ b: null,
17
+ c: null
18
+ })
19
+ })
20
+
21
+ it('trims non-empty strings', () => {
22
+ expect(sanitizeFormData({title: ' hello '})).toEqual({title: 'hello'})
23
+ })
24
+
25
+ it('recurses into plain objects', () => {
26
+ expect(
27
+ sanitizeFormData({
28
+ opt: {
29
+ media: {
30
+ tags: []
31
+ }
32
+ }
33
+ })
34
+ ).toEqual({
35
+ opt: {
36
+ media: {
37
+ tags: null
38
+ }
39
+ }
40
+ })
41
+ })
42
+
43
+ it('preserves null and non-empty arrays', () => {
44
+ expect(
45
+ sanitizeFormData({
46
+ kept: null,
47
+ tags: [{_ref: 't1'}]
48
+ })
49
+ ).toEqual({
50
+ kept: null,
51
+ tags: [{_ref: 't1'}]
52
+ })
53
+ })
54
+
55
+ it('preserves numbers and booleans', () => {
56
+ expect(sanitizeFormData({n: 0, ok: false})).toEqual({n: 0, ok: false})
57
+ })
58
+ })
@@ -0,0 +1,17 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {isFileAsset, isImageAsset} from './typeGuards'
3
+ import type {Asset} from '../types'
4
+
5
+ describe('typeGuards', () => {
6
+ it('isFileAsset narrows sanity.fileAsset', () => {
7
+ const file = {_type: 'sanity.fileAsset'} as Asset
8
+ expect(isFileAsset(file)).toBe(true)
9
+ expect(isImageAsset(file)).toBe(false)
10
+ })
11
+
12
+ it('isImageAsset narrows sanity.imageAsset', () => {
13
+ const image = {_type: 'sanity.imageAsset'} as Asset
14
+ expect(isImageAsset(image)).toBe(true)
15
+ expect(isFileAsset(image)).toBe(false)
16
+ })
17
+ })
@@ -0,0 +1,28 @@
1
+ import {afterEach, describe, expect, it} from 'vitest'
2
+ import {firstValueFrom} from 'rxjs'
3
+ import {hashFile$} from './uploadSanityAsset'
4
+
5
+ describe('hashFile$', () => {
6
+ const cryptoRef = globalThis.crypto
7
+
8
+ afterEach(() => {
9
+ Object.defineProperty(globalThis, 'crypto', {
10
+ value: cryptoRef,
11
+ configurable: true,
12
+ writable: true
13
+ })
14
+ })
15
+
16
+ it('errors when Web Crypto is unavailable', async () => {
17
+ Object.defineProperty(globalThis, 'crypto', {
18
+ value: undefined,
19
+ configurable: true,
20
+ writable: true
21
+ })
22
+
23
+ await expect(firstValueFrom(hashFile$(new File(['x'], 'blob.bin')))).rejects.toMatchObject({
24
+ message: expect.stringMatching(/secure contexts/i),
25
+ statusCode: 500
26
+ })
27
+ })
28
+ })
@@ -0,0 +1,42 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {Observable, firstValueFrom} from 'rxjs'
3
+ import {createThrottler, withMaxConcurrency} from './withMaxConcurrency'
4
+
5
+ describe('createThrottler', () => {
6
+ it('never runs more observables concurrently than the limit', async () => {
7
+ let active = 0
8
+ let maxActive = 0
9
+ const request = createThrottler(2)
10
+
11
+ const mk = () =>
12
+ new Observable<number>(sub => {
13
+ active++
14
+ maxActive = Math.max(maxActive, active)
15
+ queueMicrotask(() => {
16
+ active--
17
+ sub.next(1)
18
+ sub.complete()
19
+ })
20
+ })
21
+
22
+ await Promise.all([
23
+ firstValueFrom(request(mk())),
24
+ firstValueFrom(request(mk())),
25
+ firstValueFrom(request(mk()))
26
+ ])
27
+
28
+ expect(maxActive).toBe(2)
29
+ })
30
+ })
31
+
32
+ describe('withMaxConcurrency', () => {
33
+ it('wraps a function so each call returns a single-value observable', async () => {
34
+ const fn = (n: number) =>
35
+ new Observable<number>(sub => {
36
+ sub.next(n)
37
+ sub.complete()
38
+ })
39
+ const wrapped = withMaxConcurrency(fn, 4)
40
+ await expect(firstValueFrom(wrapped(7))).resolves.toBe(7)
41
+ })
42
+ })