sanity-plugin-media 4.1.1 → 4.3.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 (66) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -3
  3. package/dist/index.d.mts +227 -56
  4. package/dist/index.d.ts +227 -56
  5. package/dist/index.js +473 -184
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +476 -187
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -2
  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/AutoTagInputWrapper/index.tsx +82 -0
  17. package/src/components/Browser/Browser.test.tsx +44 -0
  18. package/src/components/Browser/index.tsx +12 -69
  19. package/src/components/Browser/useBrowserInit.ts +126 -0
  20. package/src/components/CardAsset/CardAsset.test.tsx +322 -0
  21. package/src/components/DialogAssetEdit/Details.tsx +123 -44
  22. package/src/components/DialogAssetEdit/DialogAssetEdit.test.tsx +215 -0
  23. package/src/components/DialogAssetEdit/index.tsx +138 -30
  24. package/src/components/DialogTagCreate/DialogTagCreate.test.tsx +120 -0
  25. package/src/components/DialogTagEdit/DialogTagEdit.test.tsx +164 -0
  26. package/src/components/FormBuilderTool/FormBuilderTool.test.tsx +62 -0
  27. package/src/components/FormBuilderTool/index.tsx +1 -1
  28. package/src/components/UploadDropzone/UploadDropzone.test.tsx +39 -0
  29. package/src/contexts/ToolOptionsContext.tsx +9 -3
  30. package/src/formSchema/index.test.ts +55 -0
  31. package/src/formSchema/index.ts +28 -12
  32. package/src/hooks/useVersionedClient.ts +1 -1
  33. package/src/index.ts +4 -1
  34. package/src/modules/assets/deleteAndUpdateEpics.test.ts +86 -0
  35. package/src/modules/assets/fetchEpic.test.ts +72 -0
  36. package/src/modules/assets/reducer.test.ts +90 -0
  37. package/src/modules/assets/tagsAndListenerEpics.test.ts +205 -0
  38. package/src/modules/dialog/epics.test.ts +167 -0
  39. package/src/modules/dialog/reducer.test.ts +184 -0
  40. package/src/modules/notifications/epics.test.ts +373 -0
  41. package/src/modules/notifications/index.ts +24 -4
  42. package/src/modules/notifications/reducer.test.ts +53 -0
  43. package/src/modules/search/index.test.ts +35 -0
  44. package/src/modules/selectors.test.ts +20 -0
  45. package/src/modules/tags/epics.test.ts +95 -0
  46. package/src/modules/tags/index.test.ts +41 -0
  47. package/src/modules/uploads/epics.test.ts +108 -0
  48. package/src/modules/uploads/index.test.ts +58 -0
  49. package/src/operators/checkTagName.test.ts +28 -0
  50. package/src/types/index.ts +25 -7
  51. package/src/utils/applyMediaTags.ts +86 -0
  52. package/src/utils/blocksToText.test.ts +42 -0
  53. package/src/utils/constructFilter.test.ts +119 -0
  54. package/src/utils/generatePreviewBlobUrl.test.ts +69 -0
  55. package/src/utils/getAssetResolution.test.ts +12 -0
  56. package/src/utils/getDocumentAssetIds.test.ts +49 -0
  57. package/src/utils/getSchemeColor.test.ts +11 -0
  58. package/src/utils/getTagSelectOptions.test.ts +43 -0
  59. package/src/utils/getUniqueDocuments.test.ts +25 -0
  60. package/src/utils/imageDprUrl.test.ts +45 -0
  61. package/src/utils/isSupportedAssetType.test.ts +15 -0
  62. package/src/utils/mediaField.ts +72 -0
  63. package/src/utils/sanitizeFormData.test.ts +58 -0
  64. package/src/utils/typeGuards.test.ts +17 -0
  65. package/src/utils/uploadSanityAsset.test.ts +28 -0
  66. package/src/utils/withMaxConcurrency.test.ts +42 -0
@@ -0,0 +1,322 @@
1
+ import type {RefObject} from 'react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import {screen} from '@testing-library/react'
4
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
5
+ import CardAsset from './index'
6
+ import {renderWithProviders} from '../../__tests__/fixtures/renderWithProviders'
7
+ import {initialState as assetsInitialState} from '../../modules/assets'
8
+ import type {AssetItem, AssetType, FileAsset, ImageAsset} from '../../types'
9
+
10
+ const SHIFT_FLAG = '__CARD_ASSET_TEST_SHIFT__'
11
+
12
+ function setShiftPressed(on: boolean) {
13
+ const g = globalThis as unknown as Record<string, boolean | undefined>
14
+ if (on) {
15
+ g[SHIFT_FLAG] = true
16
+ } else {
17
+ delete g[SHIFT_FLAG]
18
+ }
19
+ }
20
+
21
+ vi.mock('../../hooks/useKeyPress', () => ({
22
+ default: (): RefObject<boolean> =>
23
+ ({
24
+ get current() {
25
+ return Boolean((globalThis as unknown as Record<string, unknown>)[SHIFT_FLAG])
26
+ }
27
+ } as RefObject<boolean>)
28
+ }))
29
+
30
+ vi.mock('../Image', () => ({
31
+ default: () => <div data-testid="card-image" />
32
+ }))
33
+
34
+ vi.mock('../FileIcon', () => ({
35
+ default: ({extension}: {extension?: string}) => (
36
+ <div data-testid="card-file-icon" data-extension={extension ?? ''} />
37
+ )
38
+ }))
39
+
40
+ vi.mock('sanity', async importOriginal => {
41
+ const actual = await importOriginal<typeof import('sanity')>()
42
+ return {
43
+ ...actual,
44
+ useColorSchemeValue: () => 'light'
45
+ }
46
+ })
47
+
48
+ const imageAsset = {
49
+ _id: 'img-1',
50
+ _type: 'sanity.imageAsset',
51
+ _createdAt: '',
52
+ _updatedAt: '',
53
+ _rev: 'r1',
54
+ originalFilename: 'photo.png',
55
+ size: 1,
56
+ mimeType: 'image/png',
57
+ url: 'https://example.com/photo.png',
58
+ metadata: {dimensions: {width: 100, height: 100}, isOpaque: true}
59
+ } as ImageAsset
60
+
61
+ const fileAsset = {
62
+ _id: 'file-1',
63
+ _type: 'sanity.fileAsset',
64
+ _createdAt: '',
65
+ _updatedAt: '',
66
+ _rev: 'r1',
67
+ originalFilename: 'doc.pdf',
68
+ extension: 'pdf',
69
+ size: 1,
70
+ mimeType: 'application/pdf',
71
+ url: 'https://example.com/doc.pdf'
72
+ } as FileAsset
73
+
74
+ function assetItem(asset: ImageAsset | FileAsset, partial?: Partial<AssetItem>): AssetItem {
75
+ return {
76
+ _type: 'asset',
77
+ asset,
78
+ picked: false,
79
+ updating: false,
80
+ ...partial
81
+ }
82
+ }
83
+
84
+ function assetsState(byIds: Record<string, AssetItem>, extra?: Partial<typeof assetsInitialState>) {
85
+ return {
86
+ ...assetsInitialState,
87
+ assetTypes: ['file', 'image'] as AssetType[],
88
+ allIds: Object.keys(byIds),
89
+ byIds,
90
+ ...extra
91
+ }
92
+ }
93
+
94
+ function clickPreview() {
95
+ const imgs = screen.getAllByTestId('card-image')
96
+ const img = imgs.at(-1)
97
+ if (!img) {
98
+ throw new Error('card-image missing')
99
+ }
100
+ const target = img.parentElement
101
+ if (!target) {
102
+ throw new Error('preview wrapper missing')
103
+ }
104
+ return target
105
+ }
106
+
107
+ function clickFooterFilename(text: string) {
108
+ const nodes = screen.getAllByText(text)
109
+ const el = nodes.at(-1)
110
+ if (!el) {
111
+ throw new Error(`footer text missing: ${text}`)
112
+ }
113
+ return el
114
+ }
115
+
116
+ beforeEach(() => {
117
+ setShiftPressed(false)
118
+ })
119
+
120
+ describe('CardAsset', () => {
121
+ it('renders nothing when the asset id is not in the store', () => {
122
+ renderWithProviders(<CardAsset id="missing" selected={false} />, {
123
+ preloaded: {
124
+ assets: assetsState({})
125
+ }
126
+ })
127
+ expect(screen.queryAllByTestId('card-image')).toHaveLength(0)
128
+ expect(screen.queryAllByTestId('card-file-icon')).toHaveLength(0)
129
+ })
130
+
131
+ it('renders image preview and original filename for an image asset', () => {
132
+ renderWithProviders(<CardAsset id="img-1" selected={false} />, {
133
+ preloaded: {
134
+ assets: assetsState({'img-1': assetItem(imageAsset)})
135
+ }
136
+ })
137
+ expect(screen.getAllByTestId('card-image').length).toBeGreaterThan(0)
138
+ expect(screen.getAllByText('photo.png').length).toBeGreaterThan(0)
139
+ })
140
+
141
+ it('renders file icon with extension for a file asset', () => {
142
+ renderWithProviders(<CardAsset id="file-1" selected={false} />, {
143
+ preloaded: {
144
+ assets: assetsState({'file-1': assetItem(fileAsset)})
145
+ }
146
+ })
147
+ const icon = screen.getAllByTestId('card-file-icon').at(-1)!
148
+ expect(icon).toHaveAttribute('data-extension', 'pdf')
149
+ expect(screen.getAllByText('doc.pdf').length).toBeGreaterThan(0)
150
+ })
151
+
152
+ it('opens the asset edit dialog when the preview is clicked in browse mode', async () => {
153
+ const user = userEvent.setup()
154
+ const {store} = renderWithProviders(<CardAsset id="img-1" selected={false} />, {
155
+ preloaded: {
156
+ assets: assetsState({'img-1': assetItem(imageAsset)})
157
+ }
158
+ })
159
+
160
+ await user.click(clickPreview())
161
+
162
+ expect(
163
+ store.getState().dialog.items.some(d => d.type === 'assetEdit' && d.assetId === 'img-1')
164
+ ).toBe(true)
165
+ })
166
+
167
+ it('calls onSelect with the asset document id when the preview is clicked in picker mode', async () => {
168
+ const user = userEvent.setup()
169
+ const onSelect = vi.fn()
170
+ renderWithProviders(<CardAsset id="img-1" selected={false} />, {
171
+ onSelect,
172
+ preloaded: {
173
+ assets: assetsState({'img-1': assetItem(imageAsset)})
174
+ }
175
+ })
176
+
177
+ await user.click(clickPreview())
178
+
179
+ expect(onSelect).toHaveBeenCalledWith([
180
+ {
181
+ kind: 'assetDocumentId',
182
+ value: 'img-1'
183
+ }
184
+ ])
185
+ })
186
+
187
+ it('toggles pick when the footer is clicked in browse mode', async () => {
188
+ const user = userEvent.setup()
189
+ const {store} = renderWithProviders(<CardAsset id="img-1" selected={false} />, {
190
+ preloaded: {
191
+ assets: assetsState({'img-1': assetItem(imageAsset, {picked: false})})
192
+ }
193
+ })
194
+
195
+ await user.click(clickFooterFilename('photo.png'))
196
+
197
+ expect(store.getState().assets.byIds['img-1'].picked).toBe(true)
198
+ })
199
+
200
+ it('opens asset edit from the footer when in picker mode', async () => {
201
+ const user = userEvent.setup()
202
+ const onSelect = vi.fn()
203
+ const {store} = renderWithProviders(<CardAsset id="img-1" selected={false} />, {
204
+ onSelect,
205
+ preloaded: {
206
+ assets: assetsState({'img-1': assetItem(imageAsset)})
207
+ }
208
+ })
209
+
210
+ await user.click(clickFooterFilename('photo.png'))
211
+
212
+ expect(onSelect).not.toHaveBeenCalled()
213
+ expect(
214
+ store.getState().dialog.items.some(d => d.type === 'assetEdit' && d.assetId === 'img-1')
215
+ ).toBe(true)
216
+ })
217
+
218
+ it('shift-clicks on preview to unpick when the asset is already picked', async () => {
219
+ const user = userEvent.setup()
220
+ const {store} = renderWithProviders(<CardAsset id="img-1" selected={false} />, {
221
+ preloaded: {
222
+ assets: assetsState({'img-1': assetItem(imageAsset, {picked: true})})
223
+ }
224
+ })
225
+
226
+ setShiftPressed(true)
227
+ await user.click(clickPreview())
228
+ setShiftPressed(false)
229
+
230
+ expect(store.getState().assets.byIds['img-1'].picked).toBe(false)
231
+ })
232
+
233
+ it('shift-clicks on preview to pick a range when not picked and lastPicked is set', async () => {
234
+ const user = userEvent.setup()
235
+ const prevAsset = {...imageAsset, _id: 'prev-1', originalFilename: 'prev.png'} as ImageAsset
236
+ const {store} = renderWithProviders(<CardAsset id="img-1" selected={false} />, {
237
+ preloaded: {
238
+ assets: assetsState(
239
+ {
240
+ 'prev-1': assetItem(prevAsset),
241
+ 'img-1': assetItem(imageAsset, {picked: false})
242
+ },
243
+ {lastPicked: 'prev-1'}
244
+ )
245
+ }
246
+ })
247
+
248
+ setShiftPressed(true)
249
+ await user.click(clickPreview())
250
+ setShiftPressed(false)
251
+
252
+ expect(store.getState().assets.byIds['img-1'].picked).toBe(true)
253
+ expect(store.getState().assets.byIds['prev-1'].picked).toBe(true)
254
+ })
255
+
256
+ it('shift-clicks on footer to pick a range when not picked', async () => {
257
+ const user = userEvent.setup()
258
+ const anchorAsset = {
259
+ ...imageAsset,
260
+ _id: 'anchor-9',
261
+ originalFilename: 'anchor.png'
262
+ } as ImageAsset
263
+ const {store} = renderWithProviders(<CardAsset id="img-1" selected={false} />, {
264
+ preloaded: {
265
+ assets: assetsState(
266
+ {
267
+ 'anchor-9': assetItem(anchorAsset),
268
+ 'img-1': assetItem(imageAsset, {picked: false})
269
+ },
270
+ {lastPicked: 'anchor-9'}
271
+ )
272
+ }
273
+ })
274
+
275
+ setShiftPressed(true)
276
+ await user.click(clickFooterFilename('photo.png'))
277
+ setShiftPressed(false)
278
+
279
+ expect(store.getState().assets.byIds['img-1'].picked).toBe(true)
280
+ expect(store.getState().assets.byIds['anchor-9'].picked).toBe(true)
281
+ })
282
+
283
+ it('shows the selection checkmark when selected and not updating', () => {
284
+ const {container} = renderWithProviders(<CardAsset id="img-1" selected />, {
285
+ preloaded: {
286
+ assets: assetsState({'img-1': assetItem(imageAsset, {updating: false})})
287
+ }
288
+ })
289
+ expect(
290
+ container.querySelectorAll('[data-sanity-icon="checkmark-circle"]').length
291
+ ).toBeGreaterThan(0)
292
+ })
293
+
294
+ it('does not show the checkmark overlay while updating even if selected', () => {
295
+ const {container} = renderWithProviders(<CardAsset id="img-1" selected />, {
296
+ preloaded: {
297
+ assets: assetsState({'img-1': assetItem(imageAsset, {updating: true})})
298
+ }
299
+ })
300
+ expect(container.querySelectorAll('[data-sanity-icon="checkmark-circle"]')).toHaveLength(0)
301
+ })
302
+
303
+ it('shows a spinner while updating', () => {
304
+ renderWithProviders(<CardAsset id="img-1" selected={false} />, {
305
+ preloaded: {
306
+ assets: assetsState({'img-1': assetItem(imageAsset, {updating: true})})
307
+ }
308
+ })
309
+ expect(document.body.querySelectorAll('[data-ui="Spinner"]').length).toBeGreaterThan(0)
310
+ })
311
+
312
+ it('shows a warning icon when the asset item has an error', () => {
313
+ const {container} = renderWithProviders(<CardAsset id="img-1" selected={false} />, {
314
+ preloaded: {
315
+ assets: assetsState({'img-1': assetItem(imageAsset, {error: 'Upload failed'})})
316
+ }
317
+ })
318
+ expect(
319
+ container.querySelectorAll('[data-sanity-icon="warning-filled"]').length
320
+ ).toBeGreaterThan(0)
321
+ })
322
+ })
@@ -1,11 +1,23 @@
1
- import {Stack} from '@sanity/ui'
2
- import type {Asset, AssetFormData, TagSelectOption} from '../../types'
1
+ import {Card, Stack, Tab, TabList, TabPanel} from '@sanity/ui'
2
+ import {useState} from 'react'
3
3
  import {type Control, type FieldErrors, type UseFormRegister} from 'react-hook-form'
4
-
4
+ import type {Asset, AssetFormData, Locale, TagSelectOption} from '../../types'
5
5
  import FormFieldInputTags from '../FormFieldInputTags'
6
6
  import FormFieldInputText from '../FormFieldInputText'
7
7
  import FormFieldInputTextarea from '../FormFieldInputTextarea'
8
8
 
9
+ type LocalizedErrors = Record<string, {message?: string} | undefined>
10
+
11
+ // When locales are not configured, extract a plain string from a potentially localized field
12
+ function toStringField(value: unknown): string | undefined {
13
+ if (typeof value === 'string') return value
14
+ if (typeof value === 'object' && value !== null) {
15
+ const found = Object.values(value as Record<string, string>).find(v => v)
16
+ return found || undefined
17
+ }
18
+ return undefined
19
+ }
20
+
9
21
  export type DetailsProps = {
10
22
  formUpdating: boolean
11
23
  handleCreateTag: (title: string) => void
@@ -19,6 +31,7 @@ export type DetailsProps = {
19
31
  enabled: boolean
20
32
  excludeSources?: string | string[] | undefined
21
33
  }
34
+ locales?: Locale[]
22
35
  }
23
36
 
24
37
  export default function Details({
@@ -30,8 +43,11 @@ export default function Details({
30
43
  allTagOptions,
31
44
  assetTagOptions,
32
45
  currentAsset,
33
- creditLine
46
+ creditLine,
47
+ locales
34
48
  }: DetailsProps) {
49
+ const hasLocales = locales && locales.length > 0
50
+ const [activeLocaleTab, setActiveLocaleTab] = useState(0)
35
51
  return (
36
52
  <Stack space={3}>
37
53
  {/* Tags */}
@@ -55,46 +71,109 @@ export default function Details({
55
71
  name="originalFilename"
56
72
  value={currentAsset?.originalFilename}
57
73
  />
58
- {/* Title */}
59
- <FormFieldInputText
60
- {...register('title')}
61
- disabled={formUpdating}
62
- error={errors?.title?.message}
63
- label="Title"
64
- name="title"
65
- value={currentAsset?.title}
66
- />
67
- {/* Alt text */}
68
- <FormFieldInputText
69
- {...register('altText')}
70
- disabled={formUpdating}
71
- error={errors?.altText?.message}
72
- label="Alt Text"
73
- name="altText"
74
- value={currentAsset?.altText}
75
- />
76
- {/* Description */}
77
- <FormFieldInputTextarea
78
- {...register('description')}
79
- disabled={formUpdating}
80
- error={errors?.description?.message}
81
- label="Description"
82
- name="description"
83
- rows={5}
84
- value={currentAsset?.description}
85
- />
86
- {/* CreditLine */}
87
- {creditLine?.enabled && (
88
- <FormFieldInputText
89
- {...register('creditLine')}
90
- error={errors?.creditLine?.message}
91
- label="Credit"
92
- name="creditLine"
93
- value={currentAsset?.creditLine}
94
- disabled={
95
- formUpdating || creditLine?.excludeSources?.includes(currentAsset?.source?.name)
96
- }
97
- />
74
+ {/* Localized fields grouped by language */}
75
+ {hasLocales ? (
76
+ <Card marginTop={2} shadow={1} padding={3} radius={1}>
77
+ <Stack space={2}>
78
+ <TabList space={2}>
79
+ {locales.map((locale, idx) => (
80
+ <Tab
81
+ key={locale.id}
82
+ id={`locale-tab-${locale.id}`}
83
+ aria-controls={`locale-panel-${locale.id}`}
84
+ selected={activeLocaleTab === idx}
85
+ onClick={() => setActiveLocaleTab(idx)}
86
+ label={locale.title}
87
+ />
88
+ ))}
89
+ </TabList>
90
+ {locales.map((locale, idx) => (
91
+ <TabPanel
92
+ key={locale.id}
93
+ id={`locale-panel-${locale.id}`}
94
+ aria-labelledby={`locale-tab-${locale.id}`}
95
+ hidden={activeLocaleTab !== idx}
96
+ >
97
+ <Stack space={3}>
98
+ <FormFieldInputText
99
+ {...register(`title.${locale.id}` as const)}
100
+ disabled={formUpdating}
101
+ error={(errors?.title as LocalizedErrors)?.[locale.id]?.message}
102
+ label="Title"
103
+ name={`title.${locale.id}`}
104
+ />
105
+ <FormFieldInputText
106
+ {...register(`altText.${locale.id}` as const)}
107
+ disabled={formUpdating}
108
+ error={(errors?.altText as LocalizedErrors)?.[locale.id]?.message}
109
+ label="Alt Text"
110
+ name={`altText.${locale.id}`}
111
+ />
112
+ <FormFieldInputTextarea
113
+ {...register(`description.${locale.id}` as const)}
114
+ disabled={formUpdating}
115
+ error={(errors?.description as LocalizedErrors)?.[locale.id]?.message}
116
+ label="Description"
117
+ name={`description.${locale.id}`}
118
+ rows={5}
119
+ />
120
+ {creditLine?.enabled && (
121
+ <FormFieldInputText
122
+ {...register(`creditLine.${locale.id}` as const)}
123
+ error={(errors?.creditLine as LocalizedErrors)?.[locale.id]?.message}
124
+ label="Credit"
125
+ name={`creditLine.${locale.id}`}
126
+ disabled={
127
+ formUpdating ||
128
+ creditLine?.excludeSources?.includes(currentAsset?.source?.name)
129
+ }
130
+ />
131
+ )}
132
+ </Stack>
133
+ </TabPanel>
134
+ ))}
135
+ </Stack>
136
+ </Card>
137
+ ) : (
138
+ <>
139
+ <FormFieldInputText
140
+ {...register('title')}
141
+ disabled={formUpdating}
142
+ error={errors?.title?.message}
143
+ label="Title"
144
+ name="title"
145
+ value={toStringField(currentAsset?.title)}
146
+ />
147
+ <FormFieldInputText
148
+ {...register('altText')}
149
+ disabled={formUpdating}
150
+ error={errors?.altText?.message}
151
+ label="Alt Text"
152
+ name="altText"
153
+ value={toStringField(currentAsset?.altText)}
154
+ />
155
+ <FormFieldInputTextarea
156
+ {...register('description')}
157
+ disabled={formUpdating}
158
+ error={errors?.description?.message}
159
+ label="Description"
160
+ name="description"
161
+ rows={5}
162
+ value={toStringField(currentAsset?.description)}
163
+ />
164
+ {creditLine?.enabled && (
165
+ <FormFieldInputText
166
+ {...register('creditLine')}
167
+ error={errors?.creditLine?.message}
168
+ label="Credit"
169
+ name="creditLine"
170
+ value={toStringField(currentAsset?.creditLine)}
171
+ disabled={
172
+ formUpdating || creditLine?.excludeSources?.includes(currentAsset?.source?.name)
173
+ }
174
+ />
175
+ )}
176
+ </>
98
177
  )}
99
178
  </Stack>
100
179
  )