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
@@ -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
  )
@@ -0,0 +1,215 @@
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 DialogAssetEdit from './index'
6
+
7
+ vi.mock('../Image', () => ({default: () => null}))
8
+ vi.mock('../FileAssetPreview', () => ({default: () => null}))
9
+ vi.mock('../DocumentList', () => ({default: () => null}))
10
+ vi.mock('../AssetMetadata', () => ({default: () => null}))
11
+ import {createMockSanityClient} from '../../__tests__/fixtures/mockSanityClient'
12
+ import {renderWithProviders} from '../../__tests__/fixtures/renderWithProviders'
13
+ import {createTestRootState} from '../../__tests__/fixtures/rootState'
14
+ import {inputByName, withinDialog} from '../../__tests__/fixtures/withinDialog'
15
+ import type {RootReducerState} from '../../modules/types'
16
+ import {assetsActions, initialState as assetsInitialState} from '../../modules/assets'
17
+ import type {AssetType, ImageAsset, MediaToolOptions} from '../../types'
18
+
19
+ const asset = {
20
+ _id: 'a1',
21
+ _type: 'sanity.imageAsset',
22
+ _createdAt: '',
23
+ _updatedAt: '',
24
+ _rev: 'r1',
25
+ originalFilename: 'x.png',
26
+ size: 1,
27
+ mimeType: 'image/png',
28
+ url: 'https://example.com/x.png',
29
+ metadata: {dimensions: {width: 100, height: 100}, isOpaque: true}
30
+ } as ImageAsset
31
+
32
+ const assetsPreloaded = {
33
+ ...assetsInitialState,
34
+ assetTypes: ['image'] as AssetType[],
35
+ allIds: ['a1'],
36
+ byIds: {
37
+ a1: {_type: 'asset' as const, asset, picked: false, updating: false}
38
+ }
39
+ }
40
+
41
+ vi.mock('sanity', async importOriginal => {
42
+ const actual = await importOriginal<typeof import('sanity')>()
43
+ return {
44
+ ...actual,
45
+ WithReferringDocuments: ({children}: {children: (args: unknown) => unknown}) =>
46
+ children({isLoading: false, referringDocuments: []}),
47
+ useDocumentStore: () => ({})
48
+ }
49
+ })
50
+
51
+ vi.mock('../../hooks/useVersionedClient', () => ({
52
+ default: () =>
53
+ createMockSanityClient({
54
+ listen: vi.fn(() => new Subject())
55
+ })
56
+ }))
57
+
58
+ function renderAssetDialog(
59
+ dialog: {id: string; type: 'assetEdit'; assetId: string},
60
+ opts: {
61
+ preloaded?: Partial<RootReducerState>
62
+ toolOptions?: Partial<MediaToolOptions>
63
+ } = {}
64
+ ) {
65
+ const {preloaded: extraPreloaded, toolOptions} = opts
66
+ return renderWithProviders(
67
+ <DialogAssetEdit dialog={dialog}>
68
+ <span />
69
+ </DialogAssetEdit>,
70
+ {
71
+ preloaded: {
72
+ assets: assetsPreloaded,
73
+ ...extraPreloaded
74
+ },
75
+ toolOptions: {creditLine: {enabled: true}, ...toolOptions}
76
+ }
77
+ )
78
+ }
79
+
80
+ describe('DialogAssetEdit', () => {
81
+ it('renders asset details header and details tab', () => {
82
+ renderAssetDialog({
83
+ id: 'dlg-1',
84
+ type: 'assetEdit',
85
+ assetId: 'a1'
86
+ })
87
+
88
+ const dlg = withinDialog(/asset details/i, screen)
89
+ expect(dlg.getByText('Asset details')).toBeInTheDocument()
90
+ expect(dlg.getByRole('tab', {name: 'Details'})).toBeInTheDocument()
91
+ })
92
+
93
+ it('keeps Save disabled until a field is edited', () => {
94
+ renderAssetDialog({
95
+ id: 'dlg-1',
96
+ type: 'assetEdit',
97
+ assetId: 'a1'
98
+ })
99
+
100
+ const dlg = withinDialog(/asset details/i, screen)
101
+ expect(dlg.getByRole('button', {name: /save and close/i})).toBeDisabled()
102
+ })
103
+
104
+ it('dispatches asset update when a field changes and the form is submitted', async () => {
105
+ const user = userEvent.setup()
106
+ const {store} = renderAssetDialog({
107
+ id: 'dlg-1',
108
+ type: 'assetEdit',
109
+ assetId: 'a1'
110
+ })
111
+ const dispatchSpy = vi.spyOn(store, 'dispatch')
112
+ const dlg = withinDialog(/asset details/i, screen)
113
+
114
+ await user.type(inputByName(/asset details/i, screen, 'title'), 'Hero image')
115
+ await user.click(dlg.getByRole('button', {name: /save and close/i}))
116
+
117
+ expect(store.getState().assets.byIds.a1.updating).toBe(true)
118
+
119
+ await waitFor(() => {
120
+ let updateAction
121
+ for (const call of dispatchSpy.mock.calls) {
122
+ const action = call[0]
123
+ if (assetsActions.updateRequest.match(action)) {
124
+ updateAction = action
125
+ break
126
+ }
127
+ }
128
+ expect(updateAction).toBeDefined()
129
+ expect(updateAction?.payload).toMatchObject({
130
+ asset,
131
+ closeDialogId: 'a1',
132
+ formData: expect.objectContaining({
133
+ title: 'Hero image',
134
+ originalFilename: 'x.png'
135
+ })
136
+ })
137
+ })
138
+ })
139
+
140
+ it('removes only this dialog when closed', async () => {
141
+ const user = userEvent.setup()
142
+ const base = createTestRootState({
143
+ dialog: {
144
+ items: [
145
+ {id: 'dlg-1', type: 'assetEdit', assetId: 'a1'},
146
+ {id: 'tags', type: 'tags'}
147
+ ]
148
+ },
149
+ assets: assetsPreloaded
150
+ })
151
+
152
+ const {store} = renderWithProviders(
153
+ <DialogAssetEdit
154
+ dialog={{
155
+ id: 'dlg-1',
156
+ type: 'assetEdit',
157
+ assetId: 'a1'
158
+ }}
159
+ >
160
+ <span />
161
+ </DialogAssetEdit>,
162
+ {
163
+ preloaded: base,
164
+ toolOptions: {creditLine: {enabled: true}}
165
+ }
166
+ )
167
+
168
+ const dlg = withinDialog(/asset details/i, screen)
169
+ await user.click(dlg.getByRole('button', {name: /close dialog/i}))
170
+
171
+ expect(store.getState().dialog.items).toEqual([{id: 'tags', type: 'tags'}])
172
+ })
173
+
174
+ it('opens the delete confirmation dialog when Delete is clicked', async () => {
175
+ const {store} = renderAssetDialog({
176
+ id: 'dlg-1',
177
+ type: 'assetEdit',
178
+ assetId: 'a1'
179
+ })
180
+
181
+ const dlg = withinDialog(/asset details/i, screen)
182
+ fireEvent.click(dlg.getByRole('button', {name: /^delete$/i}))
183
+
184
+ await waitFor(() => {
185
+ let confirm
186
+ for (const d of store.getState().dialog.items) {
187
+ if (d.type === 'confirm') {
188
+ confirm = d
189
+ break
190
+ }
191
+ }
192
+ expect(confirm).toBeDefined()
193
+ expect(confirm?.title).toMatch(/permanently delete/i)
194
+ expect(confirm?.headerTitle).toBe('Confirm deletion')
195
+ })
196
+ })
197
+
198
+ it('switches to the References tab when that tab is activated', async () => {
199
+ const user = userEvent.setup()
200
+ renderAssetDialog({
201
+ id: 'dlg-1',
202
+ type: 'assetEdit',
203
+ assetId: 'a1'
204
+ })
205
+
206
+ const dlg = withinDialog(/asset details/i, screen)
207
+ const referencesTab = dlg.getByRole('tab', {name: /references/i})
208
+ expect(referencesTab).toHaveAttribute('aria-selected', 'false')
209
+
210
+ await user.click(referencesTab)
211
+
212
+ expect(referencesTab).toHaveAttribute('aria-selected', 'true')
213
+ expect(dlg.getByRole('tab', {name: 'Details'})).toHaveAttribute('aria-selected', 'false')
214
+ })
215
+ })
@@ -1,13 +1,13 @@
1
1
  import {zodResolver} from '@hookform/resolvers/zod'
2
2
  import type {MutationEvent} from '@sanity/client'
3
- import {Box, Button, Card, Flex, Tab, TabList, TabPanel, Text} from '@sanity/ui'
3
+ import {Box, Button, Card, Flex, Stack, Tab, TabList, TabPanel, Text} from '@sanity/ui'
4
4
  import type {Asset, AssetFormData, DialogAssetEditProps, TagSelectOption} from '../../types'
5
5
  import groq from 'groq'
6
- import {type ReactNode, useCallback, useEffect, useRef, useState} from 'react'
6
+ import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'
7
7
  import {type SubmitHandler, useForm} from 'react-hook-form'
8
8
  import {useDispatch} from 'react-redux'
9
9
  import {WithReferringDocuments, useColorSchemeValue, useDocumentStore} from 'sanity'
10
- import {assetFormSchema} from '../../formSchema'
10
+ import {getAssetFormSchema} from '../../formSchema'
11
11
  import useTypedSelector from '../../hooks/useTypedSelector'
12
12
  import useVersionedClient from '../../hooks/useVersionedClient'
13
13
  import {assetsActions, selectAssetById} from '../../modules/assets'
@@ -63,20 +63,55 @@ const DialogAssetEdit = (props: Props) => {
63
63
  const assetTagOptions = useTypedSelector(selectTagSelectOptions(currentAsset))
64
64
 
65
65
  // Check if credit line options are configured
66
- const {creditLine, components: {details: CustomDetails} = {}} = useToolOptions()
66
+ const {creditLine, components: {details: CustomDetails} = {}, locales} = useToolOptions()
67
67
 
68
68
  const generateDefaultValues = useCallback(
69
69
  (asset?: Asset): AssetFormData => {
70
+ if (locales && locales.length > 0) {
71
+ const makeLocaleObj = (field?: Record<string, string> | string) => {
72
+ const obj: Record<string, string> = {}
73
+ for (let i = 0; i < locales.length; i++) {
74
+ const locale = locales[i]
75
+ if (typeof field === 'object' && field && field[locale.id]) {
76
+ obj[locale.id] = field[locale.id]
77
+ } else if (typeof field === 'string') {
78
+ // Only populate the first locale to avoid spreading a legacy value
79
+ // across all languages; the user should fill in other translations manually
80
+ obj[locale.id] = i === 0 ? field : ''
81
+ } else {
82
+ obj[locale.id] = ''
83
+ }
84
+ }
85
+ return obj
86
+ }
87
+ return {
88
+ altText: makeLocaleObj(asset?.altText),
89
+ creditLine: makeLocaleObj(asset?.creditLine),
90
+ description: makeLocaleObj(asset?.description),
91
+ originalFilename: asset?.originalFilename || '',
92
+ opt: {media: {tags: assetTagOptions}},
93
+ title: makeLocaleObj(asset?.title)
94
+ }
95
+ }
96
+ // Normalize: if a field is a localized object but locales are disabled, pick first non-empty value
97
+ const flattenField = (field: unknown): string => {
98
+ if (typeof field === 'string') return field
99
+ if (typeof field === 'object' && field !== null) {
100
+ const values = Object.values(field as Record<string, string>)
101
+ return values.find(v => v) || ''
102
+ }
103
+ return ''
104
+ }
70
105
  return {
71
- altText: asset?.altText || '',
72
- creditLine: asset?.creditLine || '',
73
- description: asset?.description || '',
106
+ altText: flattenField(asset?.altText),
107
+ creditLine: flattenField(asset?.creditLine),
108
+ description: flattenField(asset?.description),
74
109
  originalFilename: asset?.originalFilename || '',
75
110
  opt: {media: {tags: assetTagOptions}},
76
- title: asset?.title || ''
111
+ title: flattenField(asset?.title)
77
112
  }
78
113
  },
79
- [assetTagOptions]
114
+ [assetTagOptions, locales]
80
115
  )
81
116
 
82
117
  const {
@@ -91,7 +126,7 @@ const DialogAssetEdit = (props: Props) => {
91
126
  } = useForm<AssetFormData>({
92
127
  defaultValues: generateDefaultValues(assetItem?.asset),
93
128
  mode: 'onChange',
94
- resolver: zodResolver(assetFormSchema)
129
+ resolver: zodResolver(getAssetFormSchema(locales))
95
130
  })
96
131
 
97
132
  const formUpdating = !assetItem || assetItem?.updating
@@ -134,6 +169,59 @@ const DialogAssetEdit = (props: Props) => {
134
169
  [currentAsset?._id, dispatch]
135
170
  )
136
171
 
172
+ // Detect if asset has localized fields (objects) with keys not in the configured locales
173
+ const hasOrphanedLocales = useMemo(() => {
174
+ if (!currentAsset) return false
175
+ const isLocaleObj = (v: unknown) =>
176
+ typeof v === 'object' && v !== null && !Array.isArray(v)
177
+ const fields = [
178
+ currentAsset.title,
179
+ currentAsset.altText,
180
+ currentAsset.description,
181
+ ...(currentAsset._type === 'sanity.imageAsset' ? [currentAsset.creditLine] : [])
182
+ ]
183
+ const anyLocalized = fields.some(f => isLocaleObj(f))
184
+ if (!anyLocalized) return false
185
+ if (!locales || locales.length === 0) return true
186
+ const configuredIds = new Set(locales.map(l => l.id))
187
+ return fields.some(f => {
188
+ if (!isLocaleObj(f)) return false
189
+ return Object.keys(f as object).some(k => !configuredIds.has(k))
190
+ })
191
+ }, [currentAsset, locales])
192
+
193
+ const handleCleanupLocales = useCallback(async () => {
194
+ if (!currentAsset) return
195
+
196
+ const cleanField = (field: unknown): unknown => {
197
+ if (typeof field !== 'object' || field === null || Array.isArray(field)) return field
198
+ const obj = field as Record<string, string>
199
+ if (!locales || locales.length === 0) {
200
+ // Pick the first non-empty value sorted by key for determinism
201
+ const sorted = Object.keys(obj).sort()
202
+ return sorted.map(k => obj[k]).find(v => v) || ''
203
+ }
204
+ const configuredIds = new Set(locales.map(l => l.id))
205
+ const cleaned: Record<string, string> = {}
206
+ for (const [key, val] of Object.entries(obj)) {
207
+ if (configuredIds.has(key)) cleaned[key] = val
208
+ }
209
+ return cleaned
210
+ }
211
+
212
+ await client
213
+ .patch(currentAsset._id)
214
+ .set({
215
+ title: cleanField(currentAsset.title),
216
+ altText: cleanField(currentAsset.altText),
217
+ description: cleanField(currentAsset.description),
218
+ ...(currentAsset._type === 'sanity.imageAsset' && {
219
+ creditLine: cleanField(currentAsset.creditLine)
220
+ })
221
+ })
222
+ .commit()
223
+ }, [client, currentAsset, locales])
224
+
137
225
  // Submit react-hook-form
138
226
  const onSubmit: SubmitHandler<AssetFormData> = useCallback(
139
227
  formData => {
@@ -215,25 +303,44 @@ const DialogAssetEdit = (props: Props) => {
215
303
 
216
304
  const Footer = () => (
217
305
  <Box padding={3}>
218
- <Flex justify="space-between">
219
- {/* Delete button */}
220
- <Button
221
- disabled={formUpdating}
222
- fontSize={1}
223
- mode="bleed"
224
- onClick={handleDelete}
225
- text="Delete"
226
- tone="critical"
227
- />
228
-
229
- {/* Submit button */}
230
- <FormSubmitButton
231
- disabled={formUpdating || !isDirty || !isValid}
232
- isValid={isValid}
233
- lastUpdated={currentAsset?._updatedAt}
234
- onClick={handleSubmit(onSubmit)}
235
- />
236
- </Flex>
306
+ <Stack space={3}>
307
+ {hasOrphanedLocales && (
308
+ <Card padding={3} radius={2} shadow={1} tone="caution">
309
+ <Flex align="center" justify="space-between" gap={3}>
310
+ <Text size={1}>
311
+ This asset has localized fields that are no longer configured. Clean them up to
312
+ avoid validation errors.
313
+ </Text>
314
+ <Button
315
+ fontSize={1}
316
+ mode="ghost"
317
+ onClick={handleCleanupLocales}
318
+ text="Cleanup localized fields"
319
+ tone="caution"
320
+ />
321
+ </Flex>
322
+ </Card>
323
+ )}
324
+ <Flex justify="space-between">
325
+ {/* Delete button */}
326
+ <Button
327
+ disabled={formUpdating}
328
+ fontSize={1}
329
+ mode="bleed"
330
+ onClick={handleDelete}
331
+ text="Delete"
332
+ tone="critical"
333
+ />
334
+
335
+ {/* Submit button */}
336
+ <FormSubmitButton
337
+ disabled={formUpdating || !isDirty || !isValid || hasOrphanedLocales}
338
+ isValid={isValid}
339
+ lastUpdated={currentAsset?._updatedAt}
340
+ onClick={handleSubmit(onSubmit)}
341
+ />
342
+ </Flex>
343
+ </Stack>
237
344
  </Box>
238
345
  )
239
346
 
@@ -251,7 +358,8 @@ const DialogAssetEdit = (props: Props) => {
251
358
  allTagOptions,
252
359
  handleCreateTag,
253
360
  currentAsset,
254
- creditLine
361
+ creditLine,
362
+ locales
255
363
  }
256
364
 
257
365
  return (