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,120 @@
|
|
|
1
|
+
import userEvent from '@testing-library/user-event'
|
|
2
|
+
import {screen, waitFor} from '@testing-library/react'
|
|
3
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
4
|
+
import DialogTagCreate from './index'
|
|
5
|
+
import {renderWithProviders} from '../../__tests__/fixtures/renderWithProviders'
|
|
6
|
+
import {createTestRootState} from '../../__tests__/fixtures/rootState'
|
|
7
|
+
import {getDialogRoot, inputByName, withinDialog} from '../../__tests__/fixtures/withinDialog'
|
|
8
|
+
import {tagsActions} from '../../modules/tags'
|
|
9
|
+
|
|
10
|
+
describe('DialogTagCreate', () => {
|
|
11
|
+
it('dispatches tag create flow when form is valid', async () => {
|
|
12
|
+
const user = userEvent.setup()
|
|
13
|
+
const {store} = renderWithProviders(
|
|
14
|
+
<DialogTagCreate dialog={{id: 'dlg-1', type: 'tagCreate'}}>
|
|
15
|
+
<span />
|
|
16
|
+
</DialogTagCreate>
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const dlg = withinDialog(/create tag/i, screen)
|
|
20
|
+
await user.type(inputByName(/create tag/i, screen, 'name'), 'my-tag')
|
|
21
|
+
await user.click(dlg.getByRole('button', {name: /save and close/i}))
|
|
22
|
+
|
|
23
|
+
expect(store.getState().tags.creating).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('dispatches createRequest with a trimmed tag name', async () => {
|
|
27
|
+
const user = userEvent.setup()
|
|
28
|
+
const {store} = renderWithProviders(
|
|
29
|
+
<DialogTagCreate dialog={{id: 'dlg-1', type: 'tagCreate'}}>
|
|
30
|
+
<span />
|
|
31
|
+
</DialogTagCreate>
|
|
32
|
+
)
|
|
33
|
+
const dispatchSpy = vi.spyOn(store, 'dispatch')
|
|
34
|
+
const dlg = withinDialog(/create tag/i, screen)
|
|
35
|
+
|
|
36
|
+
await user.type(inputByName(/create tag/i, screen, 'name'), ' spaced ')
|
|
37
|
+
await user.click(dlg.getByRole('button', {name: /save and close/i}))
|
|
38
|
+
|
|
39
|
+
await waitFor(() => {
|
|
40
|
+
let createAction
|
|
41
|
+
for (const call of dispatchSpy.mock.calls) {
|
|
42
|
+
const action = call[0]
|
|
43
|
+
if (tagsActions.createRequest.match(action)) {
|
|
44
|
+
createAction = action
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
expect(createAction).toBeDefined()
|
|
49
|
+
expect(createAction?.payload).toEqual({name: 'spaced'})
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('keeps Save disabled until the name is non-empty and valid', async () => {
|
|
54
|
+
const user = userEvent.setup()
|
|
55
|
+
renderWithProviders(
|
|
56
|
+
<DialogTagCreate dialog={{id: 'dlg-1', type: 'tagCreate'}}>
|
|
57
|
+
<span />
|
|
58
|
+
</DialogTagCreate>
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(
|
|
62
|
+
withinDialog(/create tag/i, screen).getByRole('button', {name: /save and close/i})
|
|
63
|
+
).toBeDisabled()
|
|
64
|
+
|
|
65
|
+
const nameInput = inputByName(/create tag/i, screen, 'name')
|
|
66
|
+
await user.type(nameInput, 'a')
|
|
67
|
+
await user.tab()
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(
|
|
70
|
+
withinDialog(/create tag/i, screen).getByRole('button', {name: /save and close/i})
|
|
71
|
+
).not.toBeDisabled()
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('clears the entire dialog stack when the dialog close control is used', async () => {
|
|
76
|
+
const user = userEvent.setup()
|
|
77
|
+
const base = createTestRootState({
|
|
78
|
+
dialog: {
|
|
79
|
+
items: [
|
|
80
|
+
{id: 'dlg-1', type: 'tagCreate'},
|
|
81
|
+
{id: 'tags', type: 'tags'}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const {store} = renderWithProviders(
|
|
87
|
+
<DialogTagCreate dialog={{id: 'dlg-1', type: 'tagCreate'}}>
|
|
88
|
+
<span />
|
|
89
|
+
</DialogTagCreate>,
|
|
90
|
+
{preloaded: base}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const dlg = withinDialog(/create tag/i, screen)
|
|
94
|
+
await user.click(dlg.getByRole('button', {name: /close dialog/i}))
|
|
95
|
+
|
|
96
|
+
expect(store.getState().dialog.items).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('shows an error indicator beside the name when tag creation failed on the server', async () => {
|
|
100
|
+
const base = createTestRootState({
|
|
101
|
+
tags: {
|
|
102
|
+
...createTestRootState().tags,
|
|
103
|
+
creatingError: {message: 'Tag already exists', statusCode: 409}
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
renderWithProviders(
|
|
108
|
+
<DialogTagCreate dialog={{id: 'dlg-1', type: 'tagCreate'}}>
|
|
109
|
+
<span />
|
|
110
|
+
</DialogTagCreate>,
|
|
111
|
+
{preloaded: base}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(
|
|
116
|
+
getDialogRoot(/create tag/i, screen).querySelector('[data-sanity-icon="error-outline"]')
|
|
117
|
+
).toBeTruthy()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import userEvent from '@testing-library/user-event'
|
|
2
|
+
import {fireEvent, screen, waitFor} from '@testing-library/react'
|
|
3
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
4
|
+
import {Subject} from 'rxjs'
|
|
5
|
+
import DialogTagEdit from './index'
|
|
6
|
+
import {createMockSanityClient} from '../../__tests__/fixtures/mockSanityClient'
|
|
7
|
+
import {renderWithProviders} from '../../__tests__/fixtures/renderWithProviders'
|
|
8
|
+
import {createTestRootState} from '../../__tests__/fixtures/rootState'
|
|
9
|
+
import {inputByName, withinDialog} from '../../__tests__/fixtures/withinDialog'
|
|
10
|
+
import {tagsActions} from '../../modules/tags'
|
|
11
|
+
import type {Tag} from '../../types'
|
|
12
|
+
|
|
13
|
+
const tag: Tag = {
|
|
14
|
+
_id: 't1',
|
|
15
|
+
_type: 'media.tag',
|
|
16
|
+
_createdAt: '',
|
|
17
|
+
_updatedAt: '',
|
|
18
|
+
_rev: 'r1',
|
|
19
|
+
name: {_type: 'slug', current: 'alpha'}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const tagsPreloaded = {
|
|
23
|
+
allIds: ['t1'],
|
|
24
|
+
byIds: {
|
|
25
|
+
t1: {_type: 'tag' as const, tag, picked: false, updating: false}
|
|
26
|
+
},
|
|
27
|
+
creating: false,
|
|
28
|
+
fetchCount: -1,
|
|
29
|
+
fetching: false,
|
|
30
|
+
panelVisible: true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
vi.mock('../../hooks/useVersionedClient', () => ({
|
|
34
|
+
default: () =>
|
|
35
|
+
createMockSanityClient({
|
|
36
|
+
listen: vi.fn(() => new Subject())
|
|
37
|
+
})
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
describe('DialogTagEdit', () => {
|
|
41
|
+
it('dispatches updateRequest when name changes and form submits', async () => {
|
|
42
|
+
const user = userEvent.setup()
|
|
43
|
+
const {store} = renderWithProviders(
|
|
44
|
+
<DialogTagEdit dialog={{id: 'dlg-1', type: 'tagEdit', tagId: 't1'}}>
|
|
45
|
+
<span />
|
|
46
|
+
</DialogTagEdit>,
|
|
47
|
+
{
|
|
48
|
+
preloaded: {
|
|
49
|
+
tags: tagsPreloaded
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const dlg = withinDialog(/edit tag/i, screen)
|
|
55
|
+
const input = inputByName(/edit tag/i, screen, 'name')
|
|
56
|
+
await user.clear(input)
|
|
57
|
+
await user.type(input, 'beta')
|
|
58
|
+
await user.click(dlg.getByRole('button', {name: /save and close/i}))
|
|
59
|
+
|
|
60
|
+
expect(store.getState().tags.byIds.t1.updating).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('dispatches updateRequest with slug-shaped form data', async () => {
|
|
64
|
+
const user = userEvent.setup()
|
|
65
|
+
const {store} = renderWithProviders(
|
|
66
|
+
<DialogTagEdit dialog={{id: 'dlg-1', type: 'tagEdit', tagId: 't1'}}>
|
|
67
|
+
<span />
|
|
68
|
+
</DialogTagEdit>,
|
|
69
|
+
{
|
|
70
|
+
preloaded: {
|
|
71
|
+
tags: tagsPreloaded
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
const dispatchSpy = vi.spyOn(store, 'dispatch')
|
|
76
|
+
const dlg = withinDialog(/edit tag/i, screen)
|
|
77
|
+
|
|
78
|
+
const input = inputByName(/edit tag/i, screen, 'name')
|
|
79
|
+
await user.clear(input)
|
|
80
|
+
await user.type(input, 'gamma')
|
|
81
|
+
await user.click(dlg.getByRole('button', {name: /save and close/i}))
|
|
82
|
+
|
|
83
|
+
await waitFor(() => {
|
|
84
|
+
let updateAction
|
|
85
|
+
for (const call of dispatchSpy.mock.calls) {
|
|
86
|
+
const action = call[0]
|
|
87
|
+
if (tagsActions.updateRequest.match(action)) {
|
|
88
|
+
updateAction = action
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
expect(updateAction).toBeDefined()
|
|
93
|
+
expect(updateAction?.payload).toMatchObject({
|
|
94
|
+
closeDialogId: 't1',
|
|
95
|
+
formData: {
|
|
96
|
+
name: {_type: 'slug', current: 'gamma'}
|
|
97
|
+
},
|
|
98
|
+
tag
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('does not enable Save until the name is edited', () => {
|
|
104
|
+
renderWithProviders(
|
|
105
|
+
<DialogTagEdit dialog={{id: 'dlg-1', type: 'tagEdit', tagId: 't1'}}>
|
|
106
|
+
<span />
|
|
107
|
+
</DialogTagEdit>,
|
|
108
|
+
{
|
|
109
|
+
preloaded: {
|
|
110
|
+
tags: tagsPreloaded
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const dlg = withinDialog(/edit tag/i, screen)
|
|
116
|
+
expect(dlg.getByRole('button', {name: /save and close/i})).toBeDisabled()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('removes only this dialog when closed', async () => {
|
|
120
|
+
const user = userEvent.setup()
|
|
121
|
+
const base = createTestRootState({
|
|
122
|
+
dialog: {
|
|
123
|
+
items: [
|
|
124
|
+
{id: 'dlg-1', type: 'tagEdit', tagId: 't1'},
|
|
125
|
+
{id: 'tags', type: 'tags'}
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
tags: tagsPreloaded
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const {store} = renderWithProviders(
|
|
132
|
+
<DialogTagEdit dialog={{id: 'dlg-1', type: 'tagEdit', tagId: 't1'}}>
|
|
133
|
+
<span />
|
|
134
|
+
</DialogTagEdit>,
|
|
135
|
+
{preloaded: base}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const dlg = withinDialog(/edit tag/i, screen)
|
|
139
|
+
await user.click(dlg.getByRole('button', {name: /close dialog/i}))
|
|
140
|
+
|
|
141
|
+
expect(store.getState().dialog.items).toEqual([{id: 'tags', type: 'tags'}])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('opens the delete confirmation dialog when Delete is clicked', async () => {
|
|
145
|
+
const {store} = renderWithProviders(
|
|
146
|
+
<DialogTagEdit dialog={{id: 'dlg-1', type: 'tagEdit', tagId: 't1'}}>
|
|
147
|
+
<span />
|
|
148
|
+
</DialogTagEdit>,
|
|
149
|
+
{
|
|
150
|
+
preloaded: {
|
|
151
|
+
tags: tagsPreloaded
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const dlg = withinDialog(/edit tag/i, screen)
|
|
157
|
+
fireEvent.click(dlg.getByRole('button', {name: /^delete$/i}))
|
|
158
|
+
|
|
159
|
+
const confirm = store.getState().dialog.items.find(d => d.type === 'confirm')
|
|
160
|
+
expect(confirm).toBeDefined()
|
|
161
|
+
expect(confirm?.title).toMatch(/permanently delete/i)
|
|
162
|
+
expect(confirm?.headerTitle).toBe('Confirm deletion')
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {describe, expect, it, vi, beforeEach} from 'vitest'
|
|
2
|
+
import {render, screen, waitFor} from '@testing-library/react'
|
|
3
|
+
import {LayerProvider, studioTheme, ThemeProvider, ToastProvider} from '@sanity/ui'
|
|
4
|
+
import {ColorSchemeProvider} from 'sanity'
|
|
5
|
+
import {of} from 'rxjs'
|
|
6
|
+
import FormBuilderTool from './index'
|
|
7
|
+
import {createListenMock} from '../../__tests__/fixtures/listenMock'
|
|
8
|
+
import {createMockSanityClient} from '../../__tests__/fixtures/mockSanityClient'
|
|
9
|
+
import {ToolOptionsProvider} from '../../contexts/ToolOptionsContext'
|
|
10
|
+
import useVersionedClient from '../../hooks/useVersionedClient'
|
|
11
|
+
|
|
12
|
+
vi.mock('../../hooks/useVersionedClient', () => ({
|
|
13
|
+
default: vi.fn()
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
vi.mock('sanity', async importOriginal => {
|
|
17
|
+
const mod = await importOriginal<typeof import('sanity')>()
|
|
18
|
+
return {
|
|
19
|
+
...mod,
|
|
20
|
+
useFormValue: () => ({_id: 'doc-1', _type: 'article'})
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('FormBuilderTool', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
const fetch = vi.fn().mockReturnValue(of({items: []}))
|
|
27
|
+
vi.mocked(useVersionedClient).mockReturnValue(
|
|
28
|
+
createMockSanityClient({
|
|
29
|
+
listen: createListenMock(),
|
|
30
|
+
observable: {fetch}
|
|
31
|
+
})
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('renders picker header for image asset type', async () => {
|
|
36
|
+
render(
|
|
37
|
+
<ColorSchemeProvider scheme="light">
|
|
38
|
+
<ThemeProvider theme={studioTheme}>
|
|
39
|
+
<ToastProvider>
|
|
40
|
+
<LayerProvider>
|
|
41
|
+
<ToolOptionsProvider options={{creditLine: {enabled: false}}}>
|
|
42
|
+
<FormBuilderTool
|
|
43
|
+
{...({
|
|
44
|
+
assetType: 'image',
|
|
45
|
+
onClose: vi.fn(),
|
|
46
|
+
onSelect: vi.fn(),
|
|
47
|
+
schemaType: {},
|
|
48
|
+
selectedAssets: undefined
|
|
49
|
+
} as any)}
|
|
50
|
+
/>
|
|
51
|
+
</ToolOptionsProvider>
|
|
52
|
+
</LayerProvider>
|
|
53
|
+
</ToastProvider>
|
|
54
|
+
</ThemeProvider>
|
|
55
|
+
</ColorSchemeProvider>
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
await waitFor(() => {
|
|
59
|
+
expect(screen.getByText(/Insert image/i)).toBeInTheDocument()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -11,6 +11,7 @@ import {initialState as assetsInitialState} from '../../modules/assets'
|
|
|
11
11
|
// import {uploadsActions} from '../../modules/uploads'
|
|
12
12
|
import type {RootReducerState} from '../../modules/types'
|
|
13
13
|
import getDocumentAssetIds from '../../utils/getDocumentAssetIds'
|
|
14
|
+
import {isSupportedAssetType} from '../../utils/isSupportedAssetType'
|
|
14
15
|
|
|
15
16
|
type Props = {
|
|
16
17
|
assetType?: AssetSourceComponentProps['assetType']
|
|
@@ -53,7 +54,7 @@ class ReduxProvider extends Component<Props> {
|
|
|
53
54
|
preloadedState: {
|
|
54
55
|
assets: {
|
|
55
56
|
...assetsInitialState,
|
|
56
|
-
assetTypes: props?.assetType ? [props.assetType] : ['file', 'image']
|
|
57
|
+
assetTypes: isSupportedAssetType(props?.assetType) ? [props.assetType] : ['file', 'image']
|
|
57
58
|
},
|
|
58
59
|
debug: {
|
|
59
60
|
badConnection: false,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
import UploadDropzone from './index'
|
|
3
|
+
import {renderWithProviders} from '../../__tests__/fixtures/renderWithProviders'
|
|
4
|
+
import {initialState as assetsInitialState} from '../../modules/assets'
|
|
5
|
+
|
|
6
|
+
describe('UploadDropzone', () => {
|
|
7
|
+
it('still renders file input when directUploads is false (dropzone in disabled mode)', () => {
|
|
8
|
+
const {container} = renderWithProviders(
|
|
9
|
+
<UploadDropzone>
|
|
10
|
+
<div />
|
|
11
|
+
</UploadDropzone>,
|
|
12
|
+
{
|
|
13
|
+
toolOptions: {creditLine: {enabled: false}, directUploads: false},
|
|
14
|
+
preloaded: {
|
|
15
|
+
assets: {...assetsInitialState, assetTypes: ['image', 'file']}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
expect(container.querySelector('input[type="file"]')).toBeTruthy()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('enables file input when directUploads is true', () => {
|
|
24
|
+
const {container} = renderWithProviders(
|
|
25
|
+
<UploadDropzone>
|
|
26
|
+
<div />
|
|
27
|
+
</UploadDropzone>,
|
|
28
|
+
{
|
|
29
|
+
toolOptions: {creditLine: {enabled: false}, directUploads: true},
|
|
30
|
+
preloaded: {
|
|
31
|
+
assets: {...assetsInitialState, assetTypes: ['image', 'file']}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const input = container.querySelector('input[type="file"]')
|
|
37
|
+
expect(input).not.toHaveAttribute('disabled')
|
|
38
|
+
})
|
|
39
|
+
})
|
package/src/constants.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type {AssetSourceComponentProps} from 'sanity'
|
|
1
2
|
import type {
|
|
2
3
|
SearchFacetInputProps,
|
|
3
4
|
SearchFacetDivider,
|
|
@@ -6,6 +7,11 @@ import type {
|
|
|
6
7
|
} from './types'
|
|
7
8
|
import {divider, inputs} from './config/searchFacets'
|
|
8
9
|
|
|
10
|
+
export const SUPPORTED_ASSET_TYPES = [
|
|
11
|
+
'file',
|
|
12
|
+
'image'
|
|
13
|
+
] as const satisfies AssetSourceComponentProps['assetType'][]
|
|
14
|
+
|
|
9
15
|
// Sort order dropdown options
|
|
10
16
|
// null values are represented as menu dividers
|
|
11
17
|
export const ORDER_OPTIONS: ({direction: OrderDirection; field: string} | null)[] = [
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {MediaToolOptions} from '../types'
|
|
1
|
+
import type {MediaToolOptions, Locale} from '../types'
|
|
2
2
|
import {type PropsWithChildren, createContext, useContext, useMemo} from 'react'
|
|
3
3
|
import type {DropzoneOptions} from 'react-dropzone'
|
|
4
4
|
|
|
@@ -7,6 +7,7 @@ type ContextProps = {
|
|
|
7
7
|
components: MediaToolOptions['components']
|
|
8
8
|
creditLine: MediaToolOptions['creditLine']
|
|
9
9
|
directUploads: MediaToolOptions['directUploads']
|
|
10
|
+
locales?: Locale[]
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
const ToolOptionsContext = createContext<ContextProps | null>(null)
|
|
@@ -34,14 +35,16 @@ export const ToolOptionsProvider = ({options, children}: PropsWithChildren<Props
|
|
|
34
35
|
enabled: options?.creditLine?.enabled || false,
|
|
35
36
|
excludeSources: creditLineExcludeSources
|
|
36
37
|
},
|
|
37
|
-
directUploads: options?.directUploads ?? true
|
|
38
|
+
directUploads: options?.directUploads ?? true,
|
|
39
|
+
locales: options?.locales
|
|
38
40
|
}
|
|
39
41
|
}, [
|
|
40
42
|
options?.creditLine?.enabled,
|
|
41
43
|
options?.components,
|
|
42
44
|
options?.creditLine?.excludeSources,
|
|
43
45
|
options?.maximumUploadSize,
|
|
44
|
-
options?.directUploads
|
|
46
|
+
options?.directUploads,
|
|
47
|
+
options?.locales
|
|
45
48
|
])
|
|
46
49
|
|
|
47
50
|
return <ToolOptionsContext.Provider value={value}>{children}</ToolOptionsContext.Provider>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'vitest'
|
|
4
|
+
import {assetFormSchema, tagFormSchema, tagOptionSchema} from './index'
|
|
5
|
+
|
|
6
|
+
describe('tagOptionSchema', () => {
|
|
7
|
+
it('accepts trimmed non-empty label and value', () => {
|
|
8
|
+
expect(tagOptionSchema.safeParse({label: 'A', value: 'b'}).success).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('rejects empty label or value after trim', () => {
|
|
12
|
+
expect(tagOptionSchema.safeParse({label: ' ', value: 'x'}).success).toBe(false)
|
|
13
|
+
expect(tagOptionSchema.safeParse({label: 'x', value: ''}).success).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('tagFormSchema', () => {
|
|
18
|
+
it('requires non-empty name', () => {
|
|
19
|
+
expect(tagFormSchema.safeParse({name: 'x'}).success).toBe(true)
|
|
20
|
+
expect(tagFormSchema.safeParse({name: ''}).success).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('assetFormSchema', () => {
|
|
25
|
+
const base = {
|
|
26
|
+
altText: '',
|
|
27
|
+
creditLine: '',
|
|
28
|
+
description: '',
|
|
29
|
+
opt: {media: {tags: null}},
|
|
30
|
+
originalFilename: 'file.png',
|
|
31
|
+
title: ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it('accepts valid asset form payload', () => {
|
|
35
|
+
expect(assetFormSchema.safeParse(base).success).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('rejects empty originalFilename', () => {
|
|
39
|
+
expect(
|
|
40
|
+
assetFormSchema.safeParse({
|
|
41
|
+
...base,
|
|
42
|
+
originalFilename: ' '
|
|
43
|
+
}).success
|
|
44
|
+
).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('validates nested tag options', () => {
|
|
48
|
+
expect(
|
|
49
|
+
assetFormSchema.safeParse({
|
|
50
|
+
...base,
|
|
51
|
+
opt: {media: {tags: [{label: '', value: 'v'}]}}
|
|
52
|
+
}).success
|
|
53
|
+
).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|
package/src/formSchema/index.ts
CHANGED
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
import * as z from 'zod'
|
|
2
2
|
|
|
3
|
+
// Helper to generate localized string schema
|
|
4
|
+
export function localizedStringSchema(locales?: {id: string}[]) {
|
|
5
|
+
if (!locales || locales.length === 0) {
|
|
6
|
+
return z.string().trim().optional()
|
|
7
|
+
}
|
|
8
|
+
const shape: Record<string, z.ZodTypeAny> = {}
|
|
9
|
+
for (const locale of locales) {
|
|
10
|
+
shape[locale.id] = z.string().trim().optional()
|
|
11
|
+
}
|
|
12
|
+
return z.object(shape).passthrough()
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
export const tagOptionSchema = z.object({
|
|
4
16
|
label: z.string().trim().min(1, {message: 'Label cannot be empty'}),
|
|
5
17
|
value: z.string().trim().min(1, {message: 'Value cannot be empty'})
|
|
6
18
|
})
|
|
7
19
|
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
export function getAssetFormSchema(locales?: {id: string}[]) {
|
|
21
|
+
return z.object({
|
|
22
|
+
altText: localizedStringSchema(locales),
|
|
23
|
+
creditLine: localizedStringSchema(locales),
|
|
24
|
+
description: localizedStringSchema(locales),
|
|
25
|
+
opt: z.object({
|
|
26
|
+
media: z.object({
|
|
27
|
+
tags: z.array(tagOptionSchema).nullable()
|
|
28
|
+
})
|
|
29
|
+
}),
|
|
30
|
+
originalFilename: z.string().trim().min(1, {message: 'Filename cannot be empty'}),
|
|
31
|
+
title: localizedStringSchema(locales)
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const assetFormSchema = getAssetFormSchema()
|
|
20
36
|
|
|
21
37
|
export const tagFormSchema = z.object({
|
|
22
38
|
name: z.string().min(1, {message: 'Name cannot be empty'})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type {SanityClient} from '@sanity/client'
|
|
2
2
|
import {useClient} from 'sanity'
|
|
3
3
|
|
|
4
|
-
const useVersionedClient = (): SanityClient => useClient({apiVersion: '
|
|
4
|
+
const useVersionedClient = (): SanityClient => useClient({apiVersion: '2025-10-02'})
|
|
5
5
|
|
|
6
6
|
export default useVersionedClient
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
4
|
+
import {of} from 'rxjs'
|
|
5
|
+
import {
|
|
6
|
+
assetsActions,
|
|
7
|
+
assetsDeleteEpic,
|
|
8
|
+
assetsUpdateEpic,
|
|
9
|
+
initialState as assetsInitialState
|
|
10
|
+
} from './index'
|
|
11
|
+
import {createEpicTestStore} from '../../__tests__/fixtures/createEpicTestStore'
|
|
12
|
+
import {createMockSanityClient, mockPatchChain} from '../../__tests__/fixtures/mockSanityClient'
|
|
13
|
+
import type {ImageAsset} from '../../types'
|
|
14
|
+
|
|
15
|
+
const sampleAsset = {
|
|
16
|
+
_id: 'a1',
|
|
17
|
+
_type: 'sanity.imageAsset',
|
|
18
|
+
_createdAt: '',
|
|
19
|
+
_updatedAt: '',
|
|
20
|
+
_rev: 'r',
|
|
21
|
+
originalFilename: 'x.png',
|
|
22
|
+
size: 1,
|
|
23
|
+
mimeType: 'image/png',
|
|
24
|
+
url: ''
|
|
25
|
+
} as ImageAsset
|
|
26
|
+
|
|
27
|
+
describe('assetsDeleteEpic', () => {
|
|
28
|
+
it('dispatches deleteComplete when observable.delete succeeds', async () => {
|
|
29
|
+
const client = createMockSanityClient({
|
|
30
|
+
observable: {
|
|
31
|
+
delete: vi.fn(() => of({}))
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const store = createEpicTestStore(assetsDeleteEpic, client, {
|
|
36
|
+
assets: {
|
|
37
|
+
...assetsInitialState,
|
|
38
|
+
assetTypes: ['image'],
|
|
39
|
+
allIds: ['a1'],
|
|
40
|
+
byIds: {
|
|
41
|
+
a1: {_type: 'asset', asset: sampleAsset, picked: false, updating: false}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
store.dispatch(assetsActions.deleteRequest({assets: [sampleAsset]}))
|
|
47
|
+
|
|
48
|
+
await vi.waitFor(() => {
|
|
49
|
+
expect(store.getState().assets.byIds.a1).toBeUndefined()
|
|
50
|
+
expect(client.observable.delete).toHaveBeenCalled()
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('assetsUpdateEpic', () => {
|
|
56
|
+
it('commits patch and dispatches updateComplete', async () => {
|
|
57
|
+
const updated = {...sampleAsset, title: 'Updated'}
|
|
58
|
+
const chain = mockPatchChain(updated)
|
|
59
|
+
const client = createMockSanityClient({
|
|
60
|
+
patch: vi.fn(() => chain)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const store = createEpicTestStore(assetsUpdateEpic, client, {
|
|
64
|
+
assets: {
|
|
65
|
+
...assetsInitialState,
|
|
66
|
+
assetTypes: ['image'],
|
|
67
|
+
allIds: ['a1'],
|
|
68
|
+
byIds: {
|
|
69
|
+
a1: {_type: 'asset', asset: sampleAsset, picked: false, updating: false}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
store.dispatch(
|
|
75
|
+
assetsActions.updateRequest({
|
|
76
|
+
asset: sampleAsset,
|
|
77
|
+
formData: {title: 'Updated'}
|
|
78
|
+
})
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
await vi.waitFor(() => {
|
|
82
|
+
expect(chain.commit).toHaveBeenCalled()
|
|
83
|
+
expect(store.getState().assets.byIds.a1.asset.title).toBe('Updated')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
})
|