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.
- 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 +273 -106
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +273 -106
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -4
- 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/ReduxProvider/index.tsx +2 -1
- package/src/components/UploadDropzone/UploadDropzone.test.tsx +39 -0
- package/src/constants.ts +6 -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 +23 -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/isSupportedAssetType.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,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
|
+
})
|
package/src/types/index.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
31
|
-
description?:
|
|
46
|
+
altText?: LocalizedString
|
|
47
|
+
description?: LocalizedString
|
|
32
48
|
opt?: {
|
|
33
49
|
media?: {
|
|
34
50
|
tags?: SanityReference[]
|
|
35
51
|
}
|
|
36
52
|
}
|
|
37
|
-
title?:
|
|
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
|
|
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?:
|
|
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
|
+
})
|