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,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
+ })
@@ -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 const assetFormSchema = z.object({
9
- altText: z.string().trim().optional(),
10
- creditLine: z.string().trim().optional(),
11
- description: z.string().trim().optional(),
12
- opt: z.object({
13
- media: z.object({
14
- tags: z.array(tagOptionSchema).nullable()
15
- })
16
- }),
17
- originalFilename: z.string().trim().min(1, {message: 'Filename cannot be empty'}),
18
- title: z.string().trim().optional()
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: '2022-10-01'})
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
+ })