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,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 {
|
|
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:
|
|
148
|
-
const
|
|
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
|
|
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
|
+
})
|