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,184 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it} from 'vitest'
4
+ import {DIALOG_ACTIONS} from './actions'
5
+ import dialogReducer, {dialogActions} from './index'
6
+ import {assetsActions} from '../assets'
7
+ import {ASSETS_ACTIONS} from '../assets/actions'
8
+ import {tagsActions} from '../tags'
9
+ import type {AssetItem, ImageAsset, Tag} from '../../types'
10
+ import {createTestRootState} from '../../__tests__/fixtures/rootState'
11
+
12
+ const sampleAsset = {
13
+ _id: 'a1',
14
+ _type: 'sanity.imageAsset',
15
+ _createdAt: '',
16
+ _updatedAt: '',
17
+ _rev: 'r1',
18
+ originalFilename: 'x.png',
19
+ size: 1,
20
+ mimeType: 'image/png',
21
+ url: 'https://example.com/x.png'
22
+ } as ImageAsset
23
+
24
+ const sampleTag: Tag = {
25
+ _id: 't1',
26
+ _type: 'media.tag',
27
+ _createdAt: '',
28
+ _updatedAt: '',
29
+ _rev: 'tr',
30
+ name: {_type: 'slug', current: 'alpha'}
31
+ }
32
+
33
+ const assetItem = (asset: ImageAsset = sampleAsset): AssetItem => ({
34
+ _type: 'asset',
35
+ asset,
36
+ picked: false,
37
+ updating: false
38
+ })
39
+
40
+ function dialogState() {
41
+ return createTestRootState().dialog
42
+ }
43
+
44
+ describe('dialog slice reducers', () => {
45
+ it('clear removes all items', () => {
46
+ const state = dialogReducer(
47
+ {...dialogState(), items: [{id: 'x', type: 'tags'}]},
48
+ dialogActions.clear()
49
+ )
50
+ expect(state.items).toEqual([])
51
+ })
52
+
53
+ it('remove filters out the dialog with the given id', () => {
54
+ const state = dialogReducer(
55
+ {
56
+ ...dialogState(),
57
+ items: [
58
+ {id: 'a', type: 'tags'},
59
+ {id: 'b', type: 'searchFacets'}
60
+ ]
61
+ },
62
+ dialogActions.remove({id: 'a'})
63
+ )
64
+ expect(state.items).toEqual([{id: 'b', type: 'searchFacets'}])
65
+ })
66
+
67
+ it('showAssetEdit appends an asset edit dialog', () => {
68
+ const state = dialogReducer(dialogState(), dialogActions.showAssetEdit({assetId: 'a1'}))
69
+ expect(state.items).toEqual([{assetId: 'a1', id: 'a1', type: 'assetEdit'}])
70
+ })
71
+
72
+ it('showSearchFacets and showTags append the expected dialogs', () => {
73
+ let state = dialogReducer(dialogState(), dialogActions.showSearchFacets())
74
+ state = dialogReducer(state, dialogActions.showTags())
75
+ expect(state.items).toEqual([
76
+ {id: 'searchFacets', type: 'searchFacets'},
77
+ {id: 'tags', type: 'tags'}
78
+ ])
79
+ })
80
+
81
+ it('inlineTagCreate sets lastCreatedTag on matching assetEdit items', () => {
82
+ const state = dialogReducer(
83
+ {
84
+ ...dialogState(),
85
+ items: [
86
+ {id: 'a1', type: 'assetEdit', assetId: 'a1'},
87
+ {id: 'a2', type: 'assetEdit', assetId: 'a2'}
88
+ ]
89
+ },
90
+ dialogActions.inlineTagCreate({assetId: 'a1', tag: sampleTag})
91
+ )
92
+ const a1 = state.items.find(i => i.type === 'assetEdit' && i.assetId === 'a1')
93
+ const a2 = state.items.find(i => i.type === 'assetEdit' && i.assetId === 'a2')
94
+ expect(a1 && 'lastCreatedTag' in a1 && a1.lastCreatedTag).toEqual({
95
+ label: 'alpha',
96
+ value: 't1'
97
+ })
98
+ expect(a2).toBeDefined()
99
+ expect(Object.prototype.hasOwnProperty.call(a2, 'lastCreatedTag')).toBe(false)
100
+ })
101
+
102
+ it('inlineTagRemove sets lastRemovedTagIds on all assetEdit items', () => {
103
+ const state = dialogReducer(
104
+ {
105
+ ...dialogState(),
106
+ items: [
107
+ {id: 'a1', type: 'assetEdit', assetId: 'a1'},
108
+ {id: 'tags', type: 'tags'}
109
+ ]
110
+ },
111
+ dialogActions.inlineTagRemove({tagIds: ['x', 'y']})
112
+ )
113
+ const edit = state.items.find(i => i.type === 'assetEdit')
114
+ expect(edit && 'lastRemovedTagIds' in edit && edit.lastRemovedTagIds).toEqual(['x', 'y'])
115
+ const tagsPanel = state.items.find(i => i.type === 'tags')
116
+ expect(tagsPanel && 'lastRemovedTagIds' in tagsPanel).toBeFalsy()
117
+ })
118
+
119
+ it('showConfirmDeleteAssets pushes a confirm dialog wired to assets deleteRequest', () => {
120
+ const item = assetItem()
121
+ const state = dialogReducer(
122
+ dialogState(),
123
+ dialogActions.showConfirmDeleteAssets({assets: [item], closeDialogId: 'a1'})
124
+ )
125
+ const confirm = state.items[0]
126
+ expect(confirm?.type).toBe('confirm')
127
+ expect(confirm && 'title' in confirm && confirm.title).toBe('Permanently delete 1 asset?')
128
+ const cb = confirm && 'confirmCallbackAction' in confirm ? confirm.confirmCallbackAction : null
129
+ expect(cb).toEqual(assetsActions.deleteRequest({assets: [sampleAsset]}))
130
+ })
131
+
132
+ it('showConfirmDeleteTag pushes a confirm dialog wired to tags deleteRequest', () => {
133
+ const state = dialogReducer(
134
+ dialogState(),
135
+ dialogActions.showConfirmDeleteTag({closeDialogId: 't1', tag: sampleTag})
136
+ )
137
+ const confirm = state.items[0]
138
+ expect(confirm?.type).toBe('confirm')
139
+ expect(confirm && 'title' in confirm && confirm.title).toMatch(/permanently delete/i)
140
+ const cb = confirm && 'confirmCallbackAction' in confirm ? confirm.confirmCallbackAction : null
141
+ expect(cb).toEqual(tagsActions.deleteRequest({tag: sampleTag}))
142
+ })
143
+
144
+ it('showConfirmAssetsTagAdd uses plural copy for multiple assets', () => {
145
+ const a2 = {...sampleAsset, _id: 'a2', originalFilename: 'y.png'} as ImageAsset
146
+ const state = dialogReducer(
147
+ dialogState(),
148
+ dialogActions.showConfirmAssetsTagAdd({
149
+ assetsPicked: [assetItem(), assetItem(a2)],
150
+ tag: sampleTag
151
+ })
152
+ )
153
+ const confirm = state.items[0]
154
+ expect(confirm && 'title' in confirm && confirm.title).toContain('2 assets')
155
+ const cb = confirm && 'confirmCallbackAction' in confirm ? confirm.confirmCallbackAction : null
156
+ expect(cb).toEqual(
157
+ ASSETS_ACTIONS.tagsAddRequest({assets: [assetItem(), assetItem(a2)], tag: sampleTag})
158
+ )
159
+ })
160
+
161
+ it('showConfirmAssetsTagRemove pushes removal confirm with tagsRemoveRequest', () => {
162
+ const state = dialogReducer(
163
+ dialogState(),
164
+ dialogActions.showConfirmAssetsTagRemove({
165
+ assetsPicked: [assetItem()],
166
+ tag: sampleTag
167
+ })
168
+ )
169
+ const confirm = state.items[0]
170
+ expect(confirm && 'tone' in confirm && confirm.tone).toBe('critical')
171
+ const cb = confirm && 'confirmCallbackAction' in confirm ? confirm.confirmCallbackAction : null
172
+ expect(cb).toEqual(ASSETS_ACTIONS.tagsRemoveRequest({assets: [assetItem()], tag: sampleTag}))
173
+ })
174
+
175
+ it('DIALOG_ACTIONS.showTagCreate appends tag create dialog', () => {
176
+ const state = dialogReducer(dialogState(), DIALOG_ACTIONS.showTagCreate())
177
+ expect(state.items).toEqual([{id: 'tagCreate', type: 'tagCreate'}])
178
+ })
179
+
180
+ it('DIALOG_ACTIONS.showTagEdit appends tag edit dialog with tag id', () => {
181
+ const state = dialogReducer(dialogState(), DIALOG_ACTIONS.showTagEdit({tagId: 't9'}))
182
+ expect(state.items).toEqual([{id: 't9', tagId: 't9', type: 'tagEdit'}])
183
+ })
184
+ })
@@ -0,0 +1,373 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it, vi} from 'vitest'
4
+ import {
5
+ notificationsAssetsDeleteCompleteEpic,
6
+ notificationsAssetsDeleteErrorEpic,
7
+ notificationsAssetsTagsAddCompleteEpic,
8
+ notificationsAssetsTagsRemoveCompleteEpic,
9
+ notificationsAssetsUpdateCompleteEpic,
10
+ notificationsAssetsUploadCompleteEpic,
11
+ notificationsGenericErrorEpic,
12
+ notificationsTagCreateCompleteEpic,
13
+ notificationsTagDeleteCompleteEpic,
14
+ notificationsTagUpdateCompleteEpic
15
+ } from './index'
16
+ import {assetsActions, initialState as assetsInitialState} from '../assets'
17
+ import {ASSETS_ACTIONS} from '../assets/actions'
18
+ import {tagsActions} from '../tags'
19
+ import {uploadsActions} from '../uploads'
20
+ import {createEpicTestStore} from '../../__tests__/fixtures/createEpicTestStore'
21
+ import {createMockSanityClient} from '../../__tests__/fixtures/mockSanityClient'
22
+ import type {AssetItem, AssetType, ImageAsset, Tag} from '../../types'
23
+
24
+ const sampleAsset = {
25
+ _id: 'a1',
26
+ _type: 'sanity.imageAsset',
27
+ _createdAt: '',
28
+ _updatedAt: '',
29
+ _rev: 'r1',
30
+ originalFilename: 'x.png',
31
+ size: 1,
32
+ mimeType: 'image/png',
33
+ url: 'https://example.com/x.png'
34
+ } as ImageAsset
35
+
36
+ const sampleAsset2 = {
37
+ ...sampleAsset,
38
+ _id: 'a2'
39
+ } as ImageAsset
40
+
41
+ const sampleTag: Tag = {
42
+ _id: 't1',
43
+ _type: 'media.tag',
44
+ _createdAt: '',
45
+ _updatedAt: '',
46
+ _rev: 'tr',
47
+ name: {_type: 'slug', current: 'alpha'}
48
+ }
49
+
50
+ const assetItem = (asset: ImageAsset): AssetItem => ({
51
+ _type: 'asset',
52
+ asset,
53
+ picked: false,
54
+ updating: false
55
+ })
56
+
57
+ function assetsWithRows(rows: Record<string, AssetItem>) {
58
+ return {
59
+ ...assetsInitialState,
60
+ assetTypes: ['image'] as AssetType[],
61
+ allIds: Object.keys(rows),
62
+ byIds: rows
63
+ }
64
+ }
65
+
66
+ describe('notificationsAssetsDeleteCompleteEpic', () => {
67
+ it('adds info notification with pluralized asset count', async () => {
68
+ const store = createEpicTestStore(
69
+ notificationsAssetsDeleteCompleteEpic,
70
+ createMockSanityClient({}),
71
+ {}
72
+ )
73
+ store.dispatch(assetsActions.deleteComplete({assetIds: ['x', 'y']}))
74
+ await vi.waitFor(() => {
75
+ expect(store.getState().notifications.items).toEqual([
76
+ {asset: undefined, status: 'info', title: '2 assets deleted'}
77
+ ])
78
+ })
79
+ })
80
+ })
81
+
82
+ describe('notificationsAssetsDeleteErrorEpic', () => {
83
+ it('adds error notification with count', async () => {
84
+ const store = createEpicTestStore(
85
+ notificationsAssetsDeleteErrorEpic,
86
+ createMockSanityClient({}),
87
+ {
88
+ assets: assetsWithRows({
89
+ a1: {...assetItem(sampleAsset), updating: true}
90
+ })
91
+ }
92
+ )
93
+ store.dispatch(assetsActions.deleteError({assetIds: ['a1'], error: {} as any}))
94
+ await vi.waitFor(() => {
95
+ const [n] = store.getState().notifications.items
96
+ expect(n.status).toBe('error')
97
+ expect(n.title).toBe(
98
+ 'Unable to delete 1 asset. Please review any asset errors and try again.'
99
+ )
100
+ })
101
+ })
102
+ })
103
+
104
+ describe('notificationsAssetsUploadCompleteEpic', () => {
105
+ it('adds info notification from upload check results count', async () => {
106
+ const store = createEpicTestStore(
107
+ notificationsAssetsUploadCompleteEpic,
108
+ createMockSanityClient({}),
109
+ {}
110
+ )
111
+ store.dispatch(
112
+ uploadsActions.checkComplete({
113
+ results: {h1: 'id1', h2: null}
114
+ })
115
+ )
116
+ await vi.waitFor(() => {
117
+ expect(store.getState().notifications.items).toEqual([
118
+ {asset: undefined, status: 'info', title: 'Uploaded 2 assets'}
119
+ ])
120
+ })
121
+ })
122
+ })
123
+
124
+ const tagsWithTagUpdating = {
125
+ allIds: ['t1'],
126
+ byIds: {
127
+ t1: {_type: 'tag' as const, tag: sampleTag, picked: false, updating: true}
128
+ },
129
+ creating: false,
130
+ fetchCount: -1,
131
+ fetching: false,
132
+ panelVisible: true
133
+ }
134
+
135
+ describe('notificationsAssetsTagsAddCompleteEpic', () => {
136
+ it('adds info notification with asset count', async () => {
137
+ const store = createEpicTestStore(
138
+ notificationsAssetsTagsAddCompleteEpic,
139
+ createMockSanityClient({}),
140
+ {
141
+ assets: assetsWithRows({
142
+ a1: {...assetItem(sampleAsset), updating: true},
143
+ a2: {...assetItem(sampleAsset2), updating: true}
144
+ }),
145
+ tags: tagsWithTagUpdating
146
+ }
147
+ )
148
+ store.dispatch(
149
+ ASSETS_ACTIONS.tagsAddComplete({
150
+ assets: [assetItem(sampleAsset), assetItem(sampleAsset2)],
151
+ tag: sampleTag
152
+ })
153
+ )
154
+ await vi.waitFor(() => {
155
+ expect(store.getState().notifications.items).toEqual([
156
+ {asset: undefined, status: 'info', title: 'Tag added to 2 assets'}
157
+ ])
158
+ })
159
+ })
160
+ })
161
+
162
+ describe('notificationsAssetsTagsRemoveCompleteEpic', () => {
163
+ it('adds info notification with asset count', async () => {
164
+ const store = createEpicTestStore(
165
+ notificationsAssetsTagsRemoveCompleteEpic,
166
+ createMockSanityClient({}),
167
+ {
168
+ assets: assetsWithRows({
169
+ a1: {...assetItem(sampleAsset), updating: true}
170
+ }),
171
+ tags: tagsWithTagUpdating
172
+ }
173
+ )
174
+ store.dispatch(
175
+ ASSETS_ACTIONS.tagsRemoveComplete({
176
+ assets: [assetItem(sampleAsset)],
177
+ tag: sampleTag
178
+ })
179
+ )
180
+ await vi.waitFor(() => {
181
+ expect(store.getState().notifications.items).toEqual([
182
+ {asset: undefined, status: 'info', title: 'Tag removed from 1 asset'}
183
+ ])
184
+ })
185
+ })
186
+ })
187
+
188
+ describe('notificationsAssetsUpdateCompleteEpic', () => {
189
+ it('batches multiple updateComplete actions into one notification after buffer window', async () => {
190
+ vi.useFakeTimers()
191
+ const store = createEpicTestStore(
192
+ notificationsAssetsUpdateCompleteEpic,
193
+ createMockSanityClient({}),
194
+ {
195
+ assets: assetsWithRows({
196
+ a1: {...assetItem(sampleAsset), updating: true},
197
+ a2: {...assetItem(sampleAsset2), updating: true}
198
+ })
199
+ }
200
+ )
201
+
202
+ store.dispatch(assetsActions.updateComplete({asset: sampleAsset}))
203
+ store.dispatch(assetsActions.updateComplete({asset: sampleAsset2}))
204
+ await vi.advanceTimersByTimeAsync(2000)
205
+
206
+ expect(store.getState().notifications.items).toEqual([
207
+ {asset: undefined, status: 'info', title: '2 assets updated'}
208
+ ])
209
+ vi.useRealTimers()
210
+ })
211
+
212
+ it('emits separate notifications when updates fall in different buffer windows', async () => {
213
+ vi.useFakeTimers()
214
+ const store = createEpicTestStore(
215
+ notificationsAssetsUpdateCompleteEpic,
216
+ createMockSanityClient({}),
217
+ {
218
+ assets: assetsWithRows({
219
+ a1: {...assetItem(sampleAsset), updating: true},
220
+ a2: {...assetItem(sampleAsset2), updating: true}
221
+ })
222
+ }
223
+ )
224
+
225
+ store.dispatch(assetsActions.updateComplete({asset: sampleAsset}))
226
+ await vi.advanceTimersByTimeAsync(2000)
227
+ store.dispatch(assetsActions.updateComplete({asset: sampleAsset2}))
228
+ await vi.advanceTimersByTimeAsync(2000)
229
+
230
+ expect(store.getState().notifications.items).toEqual([
231
+ {asset: undefined, status: 'info', title: '1 asset updated'},
232
+ {asset: undefined, status: 'info', title: '1 asset updated'}
233
+ ])
234
+ vi.useRealTimers()
235
+ })
236
+ })
237
+
238
+ describe('notificationsGenericErrorEpic', () => {
239
+ it('maps assets.updateError to error notification title', async () => {
240
+ const store = createEpicTestStore(notificationsGenericErrorEpic, createMockSanityClient({}), {
241
+ assets: assetsWithRows({
242
+ a1: {...assetItem(sampleAsset), updating: true}
243
+ })
244
+ })
245
+ store.dispatch(
246
+ assetsActions.updateError({
247
+ asset: sampleAsset,
248
+ error: {message: 'patch failed', statusCode: 500}
249
+ })
250
+ )
251
+ await vi.waitFor(() => {
252
+ expect(store.getState().notifications.items).toEqual([
253
+ {asset: undefined, status: 'error', title: 'An error occurred: patch failed'}
254
+ ])
255
+ })
256
+ })
257
+
258
+ it('maps assets.fetchError (bare HttpError payload) to error notification title', async () => {
259
+ const store = createEpicTestStore(notificationsGenericErrorEpic, createMockSanityClient({}), {
260
+ assets: {
261
+ ...assetsInitialState,
262
+ assetTypes: ['image'] as AssetType[],
263
+ fetching: true
264
+ }
265
+ })
266
+ store.dispatch(
267
+ assetsActions.fetchError({
268
+ message: 'fetch failed',
269
+ statusCode: 503
270
+ })
271
+ )
272
+ await vi.waitFor(() => {
273
+ expect(store.getState().notifications.items[0].title).toBe('An error occurred: fetch failed')
274
+ })
275
+ })
276
+
277
+ it('maps tags.createError to error notification title', async () => {
278
+ const store = createEpicTestStore(notificationsGenericErrorEpic, createMockSanityClient({}), {
279
+ tags: {
280
+ creating: true,
281
+ creatingError: undefined,
282
+ allIds: [],
283
+ byIds: {},
284
+ fetchCount: -1,
285
+ fetching: false,
286
+ panelVisible: true
287
+ } as any
288
+ })
289
+ store.dispatch(
290
+ tagsActions.createError({
291
+ name: 'n',
292
+ error: {message: 'tag create', statusCode: 400}
293
+ })
294
+ )
295
+ await vi.waitFor(() => {
296
+ expect(store.getState().notifications.items[0].title).toBe('An error occurred: tag create')
297
+ })
298
+ })
299
+
300
+ it('maps uploads.uploadError to error notification title', async () => {
301
+ const store = createEpicTestStore(notificationsGenericErrorEpic, createMockSanityClient({}), {
302
+ uploads: {allIds: ['h'], byIds: {h: {} as any}}
303
+ })
304
+ store.dispatch(
305
+ uploadsActions.uploadError({
306
+ hash: 'h',
307
+ error: {message: 'upload bad', statusCode: 413}
308
+ })
309
+ )
310
+ await vi.waitFor(() => {
311
+ expect(store.getState().notifications.items[0].title).toBe('An error occurred: upload bad')
312
+ })
313
+ })
314
+ })
315
+
316
+ describe('notificationsTagCreateCompleteEpic', () => {
317
+ it('adds tag created notification', async () => {
318
+ const store = createEpicTestStore(
319
+ notificationsTagCreateCompleteEpic,
320
+ createMockSanityClient({}),
321
+ {}
322
+ )
323
+ store.dispatch(tagsActions.createComplete({tag: sampleTag}))
324
+ await vi.waitFor(() => {
325
+ expect(store.getState().notifications.items).toEqual([
326
+ {asset: undefined, status: 'info', title: 'Tag created'}
327
+ ])
328
+ })
329
+ })
330
+ })
331
+
332
+ describe('notificationsTagDeleteCompleteEpic', () => {
333
+ it('adds tag deleted notification', async () => {
334
+ const store = createEpicTestStore(
335
+ notificationsTagDeleteCompleteEpic,
336
+ createMockSanityClient({}),
337
+ {}
338
+ )
339
+ store.dispatch(tagsActions.deleteComplete({tagId: 't1'}))
340
+ await vi.waitFor(() => {
341
+ expect(store.getState().notifications.items).toEqual([
342
+ {asset: undefined, status: 'info', title: 'Tag deleted'}
343
+ ])
344
+ })
345
+ })
346
+ })
347
+
348
+ describe('notificationsTagUpdateCompleteEpic', () => {
349
+ it('adds tag updated notification', async () => {
350
+ const store = createEpicTestStore(
351
+ notificationsTagUpdateCompleteEpic,
352
+ createMockSanityClient({}),
353
+ {
354
+ tags: {
355
+ allIds: ['t1'],
356
+ byIds: {
357
+ t1: {_type: 'tag', tag: sampleTag, picked: false, updating: true}
358
+ },
359
+ creating: false,
360
+ fetchCount: -1,
361
+ fetching: false,
362
+ panelVisible: true
363
+ }
364
+ }
365
+ )
366
+ store.dispatch(tagsActions.updateComplete({tag: sampleTag}))
367
+ await vi.waitFor(() => {
368
+ expect(store.getState().notifications.items).toEqual([
369
+ {asset: undefined, status: 'info', title: 'Tag updated'}
370
+ ])
371
+ })
372
+ })
373
+ })
@@ -1,5 +1,6 @@
1
1
  import {type PayloadAction, createSlice} from '@reduxjs/toolkit'
2
- import type {ImageAsset, MyEpic} from '../../types'
2
+ import type {AnyAction} from 'redux'
3
+ import type {HttpError, ImageAsset, MyEpic} from '../../types'
3
4
  import pluralize from 'pluralize'
4
5
  import {ofType} from 'redux-observable'
5
6
  import {of} from 'rxjs'
@@ -19,6 +20,25 @@ type NotificationsReducerState = {
19
20
  items: Notification[]
20
21
  }
21
22
 
23
+ function messageFromGenericErrorPayload(payload: unknown): string {
24
+ if (!payload || typeof payload !== 'object') {
25
+ return 'Unknown error'
26
+ }
27
+ if (
28
+ 'error' in payload &&
29
+ payload.error &&
30
+ typeof payload.error === 'object' &&
31
+ payload.error !== null &&
32
+ 'message' in payload.error
33
+ ) {
34
+ return String((payload.error as HttpError).message)
35
+ }
36
+ if ('message' in payload && typeof (payload as HttpError).message === 'string') {
37
+ return String((payload as HttpError).message)
38
+ }
39
+ return 'Unknown error'
40
+ }
41
+
22
42
  const initialState = {
23
43
  items: []
24
44
  } as NotificationsReducerState
@@ -144,12 +164,12 @@ export const notificationsGenericErrorEpic: MyEpic = action$ =>
144
164
  tagsActions.updateError.type,
145
165
  uploadsActions.uploadError.type
146
166
  ),
147
- mergeMap((action: {payload: {error: {message: string}}}) => {
148
- const error = action.payload?.error
167
+ mergeMap((action: AnyAction) => {
168
+ const title = `An error occurred: ${messageFromGenericErrorPayload(action.payload)}`
149
169
  return of(
150
170
  notificationsSlice.actions.add({
151
171
  status: 'error',
152
- title: `An error occured: ${error.message}`
172
+ title
153
173
  })
154
174
  )
155
175
  })
@@ -0,0 +1,53 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it} from 'vitest'
4
+ import notificationsReducer, {notificationsActions} from './index'
5
+ import type {ImageAsset} from '../../types'
6
+ import {createTestRootState} from '../../__tests__/fixtures/rootState'
7
+
8
+ const sampleAsset = {
9
+ _id: 'a1',
10
+ _type: 'sanity.imageAsset',
11
+ _createdAt: '',
12
+ _updatedAt: '',
13
+ _rev: 'r1',
14
+ originalFilename: 'x.png',
15
+ size: 1,
16
+ mimeType: 'image/png',
17
+ url: 'https://example.com/x.png'
18
+ } as ImageAsset
19
+
20
+ describe('notificationsReducer', () => {
21
+ it('starts with no items', () => {
22
+ const state = notificationsReducer(undefined, {type: '@@init'})
23
+ expect(state.items).toEqual([])
24
+ })
25
+
26
+ it('add appends a notification with asset, status, and title', () => {
27
+ const prev = createTestRootState().notifications
28
+ const next = notificationsReducer(
29
+ prev,
30
+ notificationsActions.add({
31
+ asset: sampleAsset,
32
+ status: 'success',
33
+ title: 'Done'
34
+ })
35
+ )
36
+ expect(next.items).toHaveLength(1)
37
+ expect(next.items[0]).toEqual({
38
+ asset: sampleAsset,
39
+ status: 'success',
40
+ title: 'Done'
41
+ })
42
+ })
43
+
44
+ it('add allows partial payloads', () => {
45
+ let state = createTestRootState().notifications
46
+ state = notificationsReducer(state, notificationsActions.add({status: 'error', title: 'X'}))
47
+ state = notificationsReducer(state, notificationsActions.add({title: 'Y'}))
48
+ expect(state.items).toEqual([
49
+ {asset: undefined, status: 'error', title: 'X'},
50
+ {asset: undefined, status: undefined, title: 'Y'}
51
+ ])
52
+ })
53
+ })
@@ -0,0 +1,35 @@
1
+ // @vitest-environment node
2
+
3
+ import {describe, expect, it} from 'vitest'
4
+ import searchReducer, {searchActions} from './index'
5
+ import {inputs} from '../../config/searchFacets'
6
+
7
+ describe('search slice', () => {
8
+ it('facetsAdd assigns an id and appends facet', () => {
9
+ let state = searchReducer(undefined, {type: '@@INIT'} as never)
10
+ state = searchReducer(state, searchActions.facetsAdd({facet: {...inputs.title}}))
11
+ expect(state.facets).toHaveLength(1)
12
+ expect(state.facets[0].name).toBe('title')
13
+ expect(state.facets[0].id).toBeDefined()
14
+ })
15
+
16
+ it('querySet updates search string', () => {
17
+ let state = searchReducer(undefined, {type: '@@INIT'} as never)
18
+ state = searchReducer(state, searchActions.querySet({searchQuery: 'cats'}))
19
+ expect(state.query).toBe('cats')
20
+ })
21
+
22
+ it('facetsClear removes all facets', () => {
23
+ let state = searchReducer(undefined, {type: '@@INIT'} as never)
24
+ state = searchReducer(state, searchActions.facetsAdd({facet: {...inputs.title}}))
25
+ state = searchReducer(state, searchActions.facetsClear())
26
+ expect(state.facets).toHaveLength(0)
27
+ })
28
+
29
+ it('facetsRemoveByName filters facets', () => {
30
+ let state = searchReducer(undefined, {type: '@@INIT'} as never)
31
+ state = searchReducer(state, searchActions.facetsAdd({facet: {...inputs.title}}))
32
+ state = searchReducer(state, searchActions.facetsRemoveByName({facetName: 'title'}))
33
+ expect(state.facets).toHaveLength(0)
34
+ })
35
+ })