sanity-plugin-media 4.3.6 → 5.0.1

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 (162) hide show
  1. package/package.json +8 -17
  2. package/dist/index.cjs +0 -4721
  3. package/dist/index.cjs.map +0 -1
  4. package/dist/index.d.cts +0 -239
  5. package/dist/index.d.cts.map +0 -1
  6. package/sanity.json +0 -8
  7. package/src/__tests__/fixtures/createEpicTestStore.ts +0 -28
  8. package/src/__tests__/fixtures/listenMock.ts +0 -9
  9. package/src/__tests__/fixtures/mockSanityClient.ts +0 -84
  10. package/src/__tests__/fixtures/renderWithProviders.tsx +0 -55
  11. package/src/__tests__/fixtures/rootState.ts +0 -27
  12. package/src/__tests__/fixtures/withinDialog.ts +0 -28
  13. package/src/components/AssetGridVirtualized/index.tsx +0 -94
  14. package/src/components/AssetMetadata/index.tsx +0 -122
  15. package/src/components/AssetTableVirtualized/index.tsx +0 -73
  16. package/src/components/AutoTagInputWrapper/index.tsx +0 -85
  17. package/src/components/Browser/Browser.test.tsx +0 -45
  18. package/src/components/Browser/index.tsx +0 -90
  19. package/src/components/Browser/useBrowserInit.ts +0 -126
  20. package/src/components/ButtonAssetCopy/index.tsx +0 -65
  21. package/src/components/ButtonViewGroup/index.tsx +0 -39
  22. package/src/components/CardAsset/CardAsset.test.tsx +0 -323
  23. package/src/components/CardAsset/index.tsx +0 -290
  24. package/src/components/CardUpload/index.tsx +0 -161
  25. package/src/components/Controls/index.tsx +0 -136
  26. package/src/components/DebugControls/index.tsx +0 -80
  27. package/src/components/Dialog/index.tsx +0 -11
  28. package/src/components/DialogAssetEdit/Details.tsx +0 -181
  29. package/src/components/DialogAssetEdit/DialogAssetEdit.test.tsx +0 -216
  30. package/src/components/DialogAssetEdit/index.tsx +0 -493
  31. package/src/components/DialogConfirm/index.tsx +0 -90
  32. package/src/components/DialogSearchFacets/index.tsx +0 -42
  33. package/src/components/DialogTagCreate/DialogTagCreate.test.tsx +0 -121
  34. package/src/components/DialogTagCreate/index.tsx +0 -111
  35. package/src/components/DialogTagEdit/DialogTagEdit.test.tsx +0 -165
  36. package/src/components/DialogTagEdit/index.tsx +0 -201
  37. package/src/components/DialogTags/index.tsx +0 -45
  38. package/src/components/Dialogs/index.tsx +0 -76
  39. package/src/components/DocumentList/index.tsx +0 -62
  40. package/src/components/FileAssetPreview/index.tsx +0 -37
  41. package/src/components/FileIcon/index.tsx +0 -43
  42. package/src/components/FormBuilderTool/FormBuilderTool.test.tsx +0 -63
  43. package/src/components/FormBuilderTool/index.tsx +0 -69
  44. package/src/components/FormFieldInputLabel/index.tsx +0 -66
  45. package/src/components/FormFieldInputTags/index.tsx +0 -98
  46. package/src/components/FormFieldInputText/index.tsx +0 -41
  47. package/src/components/FormFieldInputTextarea/index.tsx +0 -43
  48. package/src/components/FormSubmitButton/index.tsx +0 -59
  49. package/src/components/Header/index.tsx +0 -80
  50. package/src/components/Image/index.tsx +0 -41
  51. package/src/components/Items/index.tsx +0 -68
  52. package/src/components/Notifications/index.tsx +0 -24
  53. package/src/components/OrderSelect/index.tsx +0 -66
  54. package/src/components/PickedBar/index.tsx +0 -77
  55. package/src/components/Progress/index.tsx +0 -38
  56. package/src/components/ReduxProvider/index.tsx +0 -96
  57. package/src/components/SearchFacet/index.tsx +0 -66
  58. package/src/components/SearchFacetNumber/index.tsx +0 -133
  59. package/src/components/SearchFacetSelect/index.tsx +0 -110
  60. package/src/components/SearchFacetString/index.tsx +0 -88
  61. package/src/components/SearchFacetTags/index.tsx +0 -121
  62. package/src/components/SearchFacets/index.tsx +0 -72
  63. package/src/components/SearchFacetsControl/index.tsx +0 -140
  64. package/src/components/TableHeader/index.tsx +0 -110
  65. package/src/components/TableHeaderItem/index.tsx +0 -61
  66. package/src/components/TableRowAsset/index.tsx +0 -419
  67. package/src/components/TableRowUpload/index.tsx +0 -164
  68. package/src/components/Tag/index.tsx +0 -200
  69. package/src/components/TagIcon/index.tsx +0 -22
  70. package/src/components/TagView/index.tsx +0 -39
  71. package/src/components/TagViewHeader/index.tsx +0 -70
  72. package/src/components/TagsPanel/index.tsx +0 -40
  73. package/src/components/TagsVirtualized/index.tsx +0 -160
  74. package/src/components/TextInputNumber/index.tsx +0 -32
  75. package/src/components/TextInputSearch/index.tsx +0 -60
  76. package/src/components/Tool/index.tsx +0 -13
  77. package/src/components/UploadDropzone/UploadDropzone.test.tsx +0 -40
  78. package/src/components/UploadDropzone/index.tsx +0 -173
  79. package/src/config/orders.ts +0 -28
  80. package/src/config/searchFacets.ts +0 -312
  81. package/src/constants.ts +0 -87
  82. package/src/contexts/AssetSourceDispatchContext.tsx +0 -38
  83. package/src/contexts/DropzoneDispatchContext.tsx +0 -32
  84. package/src/contexts/ToolOptionsContext.tsx +0 -66
  85. package/src/formSchema/index.test.ts +0 -56
  86. package/src/formSchema/index.ts +0 -39
  87. package/src/hooks/useBreakpointIndex.ts +0 -50
  88. package/src/hooks/useKeyPress.ts +0 -39
  89. package/src/hooks/usePortalPopoverProps.ts +0 -13
  90. package/src/hooks/useTypedSelector.ts +0 -7
  91. package/src/hooks/useVersionedClient.ts +0 -6
  92. package/src/index.ts +0 -5
  93. package/src/modules/assets/actions.ts +0 -42
  94. package/src/modules/assets/deleteAndUpdateEpics.test.ts +0 -87
  95. package/src/modules/assets/fetchEpic.test.ts +0 -73
  96. package/src/modules/assets/index.ts +0 -782
  97. package/src/modules/assets/reducer.test.ts +0 -91
  98. package/src/modules/assets/tagsAndListenerEpics.test.ts +0 -206
  99. package/src/modules/debug/index.ts +0 -28
  100. package/src/modules/dialog/actions.ts +0 -10
  101. package/src/modules/dialog/epics.test.ts +0 -168
  102. package/src/modules/dialog/index.ts +0 -238
  103. package/src/modules/dialog/reducer.test.ts +0 -185
  104. package/src/modules/index.ts +0 -117
  105. package/src/modules/notifications/epics.test.ts +0 -374
  106. package/src/modules/notifications/index.ts +0 -199
  107. package/src/modules/notifications/reducer.test.ts +0 -54
  108. package/src/modules/search/index.test.ts +0 -36
  109. package/src/modules/search/index.ts +0 -167
  110. package/src/modules/selected/index.ts +0 -22
  111. package/src/modules/selectors.test.ts +0 -21
  112. package/src/modules/selectors.ts +0 -17
  113. package/src/modules/tags/epics.test.ts +0 -96
  114. package/src/modules/tags/index.test.ts +0 -42
  115. package/src/modules/tags/index.ts +0 -540
  116. package/src/modules/types.ts +0 -3
  117. package/src/modules/uploads/actions.ts +0 -13
  118. package/src/modules/uploads/epics.test.ts +0 -109
  119. package/src/modules/uploads/index.test.ts +0 -59
  120. package/src/modules/uploads/index.ts +0 -272
  121. package/src/operators/checkTagName.test.ts +0 -29
  122. package/src/operators/checkTagName.ts +0 -33
  123. package/src/operators/debugThrottle.ts +0 -25
  124. package/src/plugin.tsx +0 -54
  125. package/src/schemas/tag.ts +0 -28
  126. package/src/styled/GlobalStyles/index.tsx +0 -40
  127. package/src/styled/react-select/creatable.tsx +0 -184
  128. package/src/styled/react-select/single.tsx +0 -184
  129. package/src/types/index.ts +0 -346
  130. package/src/types/sanity-ui.d.ts +0 -5
  131. package/src/utils/applyMediaTags.ts +0 -87
  132. package/src/utils/blocksToText.test.ts +0 -43
  133. package/src/utils/blocksToText.ts +0 -27
  134. package/src/utils/constructFilter.test.ts +0 -120
  135. package/src/utils/constructFilter.ts +0 -98
  136. package/src/utils/generatePreviewBlobUrl.test.ts +0 -68
  137. package/src/utils/generatePreviewBlobUrl.ts +0 -53
  138. package/src/utils/getAssetResolution.test.ts +0 -13
  139. package/src/utils/getAssetResolution.ts +0 -7
  140. package/src/utils/getDocumentAssetIds.test.ts +0 -50
  141. package/src/utils/getDocumentAssetIds.ts +0 -35
  142. package/src/utils/getSchemeColor.test.ts +0 -12
  143. package/src/utils/getSchemeColor.ts +0 -43
  144. package/src/utils/getTagSelectOptions.test.ts +0 -44
  145. package/src/utils/getTagSelectOptions.ts +0 -16
  146. package/src/utils/getUniqueDocuments.test.ts +0 -26
  147. package/src/utils/getUniqueDocuments.ts +0 -15
  148. package/src/utils/imageDprUrl.test.ts +0 -46
  149. package/src/utils/imageDprUrl.ts +0 -27
  150. package/src/utils/isSupportedAssetType.test.ts +0 -16
  151. package/src/utils/isSupportedAssetType.ts +0 -15
  152. package/src/utils/mediaField.ts +0 -73
  153. package/src/utils/sanitizeFormData.test.ts +0 -59
  154. package/src/utils/sanitizeFormData.ts +0 -26
  155. package/src/utils/typeGuards.test.ts +0 -18
  156. package/src/utils/typeGuards.ts +0 -9
  157. package/src/utils/uploadSanityAsset.test.ts +0 -29
  158. package/src/utils/uploadSanityAsset.ts +0 -97
  159. package/src/utils/withMaxConcurrency.test.ts +0 -43
  160. package/src/utils/withMaxConcurrency.ts +0 -55
  161. package/src/utils/zodFormResolver.ts +0 -17
  162. package/v2-incompatible.js +0 -11
@@ -1,216 +0,0 @@
1
- import {fireEvent, screen, waitFor} from '@testing-library/react'
2
- import userEvent from '@testing-library/user-event'
3
- import {Subject} from 'rxjs'
4
- import {describe, expect, it, vi} from 'vitest'
5
-
6
- import DialogAssetEdit from './index'
7
-
8
- vi.mock('../Image', () => ({default: () => null}))
9
- vi.mock('../FileAssetPreview', () => ({default: () => null}))
10
- vi.mock('../DocumentList', () => ({default: () => null}))
11
- vi.mock('../AssetMetadata', () => ({default: () => null}))
12
- import {createMockSanityClient} from '../../__tests__/fixtures/mockSanityClient'
13
- import {renderWithProviders} from '../../__tests__/fixtures/renderWithProviders'
14
- import {createTestRootState} from '../../__tests__/fixtures/rootState'
15
- import {inputByName, withinDialog} from '../../__tests__/fixtures/withinDialog'
16
- import {assetsActions, initialState as assetsInitialState} from '../../modules/assets'
17
- import type {RootReducerState} from '../../modules/types'
18
- import type {AssetType, ImageAsset, MediaToolOptions} from '../../types'
19
-
20
- const asset = {
21
- _id: 'a1',
22
- _type: 'sanity.imageAsset',
23
- _createdAt: '',
24
- _updatedAt: '',
25
- _rev: 'r1',
26
- originalFilename: 'x.png',
27
- size: 1,
28
- mimeType: 'image/png',
29
- url: 'https://example.com/x.png',
30
- metadata: {dimensions: {width: 100, height: 100}, isOpaque: true},
31
- } as ImageAsset
32
-
33
- const assetsPreloaded = {
34
- ...assetsInitialState,
35
- assetTypes: ['image'] as AssetType[],
36
- allIds: ['a1'],
37
- byIds: {
38
- a1: {_type: 'asset' as const, asset, picked: false, updating: false},
39
- },
40
- }
41
-
42
- vi.mock('sanity', async (importOriginal) => {
43
- const actual = await importOriginal<typeof import('sanity')>()
44
- return {
45
- ...actual,
46
- WithReferringDocuments: ({children}: {children: (args: unknown) => unknown}) =>
47
- children({isLoading: false, referringDocuments: []}),
48
- useDocumentStore: () => ({}),
49
- }
50
- })
51
-
52
- vi.mock('../../hooks/useVersionedClient', () => ({
53
- default: () =>
54
- createMockSanityClient({
55
- listen: vi.fn(() => new Subject()),
56
- }),
57
- }))
58
-
59
- function renderAssetDialog(
60
- dialog: {id: string; type: 'assetEdit'; assetId: string},
61
- opts: {
62
- preloaded?: Partial<RootReducerState>
63
- toolOptions?: Partial<MediaToolOptions>
64
- } = {},
65
- ) {
66
- const {preloaded: extraPreloaded, toolOptions} = opts
67
- return renderWithProviders(
68
- <DialogAssetEdit dialog={dialog}>
69
- <span />
70
- </DialogAssetEdit>,
71
- {
72
- preloaded: {
73
- assets: assetsPreloaded,
74
- ...extraPreloaded,
75
- },
76
- toolOptions: {creditLine: {enabled: true}, ...toolOptions},
77
- },
78
- )
79
- }
80
-
81
- describe('DialogAssetEdit', () => {
82
- it('renders asset details header and details tab', () => {
83
- renderAssetDialog({
84
- id: 'dlg-1',
85
- type: 'assetEdit',
86
- assetId: 'a1',
87
- })
88
-
89
- const dlg = withinDialog(/asset details/i, screen)
90
- expect(dlg.getByText('Asset details')).toBeInTheDocument()
91
- expect(dlg.getByRole('tab', {name: 'Details'})).toBeInTheDocument()
92
- })
93
-
94
- it('keeps Save disabled until a field is edited', () => {
95
- renderAssetDialog({
96
- id: 'dlg-1',
97
- type: 'assetEdit',
98
- assetId: 'a1',
99
- })
100
-
101
- const dlg = withinDialog(/asset details/i, screen)
102
- expect(dlg.getByRole('button', {name: /save and close/i})).toBeDisabled()
103
- })
104
-
105
- it('dispatches asset update when a field changes and the form is submitted', async () => {
106
- const user = userEvent.setup()
107
- const {store} = renderAssetDialog({
108
- id: 'dlg-1',
109
- type: 'assetEdit',
110
- assetId: 'a1',
111
- })
112
- const dispatchSpy = vi.spyOn(store, 'dispatch')
113
- const dlg = withinDialog(/asset details/i, screen)
114
-
115
- await user.type(inputByName(/asset details/i, screen, 'title'), 'Hero image')
116
- await user.click(dlg.getByRole('button', {name: /save and close/i}))
117
-
118
- expect(store.getState().assets.byIds['a1']!.updating).toBe(true)
119
-
120
- await waitFor(() => {
121
- let updateAction
122
- for (const call of dispatchSpy.mock.calls) {
123
- const action = call[0]
124
- if (assetsActions.updateRequest.match(action)) {
125
- updateAction = action
126
- break
127
- }
128
- }
129
- expect(updateAction).toBeDefined()
130
- expect(updateAction?.payload).toMatchObject({
131
- asset,
132
- closeDialogId: 'a1',
133
- formData: expect.objectContaining({
134
- title: 'Hero image',
135
- originalFilename: 'x.png',
136
- }),
137
- })
138
- })
139
- })
140
-
141
- it('removes only this dialog when closed', async () => {
142
- const user = userEvent.setup()
143
- const base = createTestRootState({
144
- dialog: {
145
- items: [
146
- {id: 'dlg-1', type: 'assetEdit', assetId: 'a1'},
147
- {id: 'tags', type: 'tags'},
148
- ],
149
- },
150
- assets: assetsPreloaded,
151
- })
152
-
153
- const {store} = renderWithProviders(
154
- <DialogAssetEdit
155
- dialog={{
156
- id: 'dlg-1',
157
- type: 'assetEdit',
158
- assetId: 'a1',
159
- }}
160
- >
161
- <span />
162
- </DialogAssetEdit>,
163
- {
164
- preloaded: base,
165
- toolOptions: {creditLine: {enabled: true}},
166
- },
167
- )
168
-
169
- const dlg = withinDialog(/asset details/i, screen)
170
- await user.click(dlg.getByRole('button', {name: /close dialog/i}))
171
-
172
- expect(store.getState().dialog.items).toEqual([{id: 'tags', type: 'tags'}])
173
- })
174
-
175
- it('opens the delete confirmation dialog when Delete is clicked', async () => {
176
- const {store} = renderAssetDialog({
177
- id: 'dlg-1',
178
- type: 'assetEdit',
179
- assetId: 'a1',
180
- })
181
-
182
- const dlg = withinDialog(/asset details/i, screen)
183
- fireEvent.click(dlg.getByRole('button', {name: /^delete$/i}))
184
-
185
- await waitFor(() => {
186
- let confirm
187
- for (const d of store.getState().dialog.items) {
188
- if (d.type === 'confirm') {
189
- confirm = d
190
- break
191
- }
192
- }
193
- expect(confirm).toBeDefined()
194
- expect(confirm?.title).toMatch(/permanently delete/i)
195
- expect(confirm?.headerTitle).toBe('Confirm deletion')
196
- })
197
- })
198
-
199
- it('switches to the References tab when that tab is activated', async () => {
200
- const user = userEvent.setup()
201
- renderAssetDialog({
202
- id: 'dlg-1',
203
- type: 'assetEdit',
204
- assetId: 'a1',
205
- })
206
-
207
- const dlg = withinDialog(/asset details/i, screen)
208
- const referencesTab = dlg.getByRole('tab', {name: /references/i})
209
- expect(referencesTab).toHaveAttribute('aria-selected', 'false')
210
-
211
- await user.click(referencesTab)
212
-
213
- expect(referencesTab).toHaveAttribute('aria-selected', 'true')
214
- expect(dlg.getByRole('tab', {name: 'Details'})).toHaveAttribute('aria-selected', 'false')
215
- })
216
- })
@@ -1,493 +0,0 @@
1
- import type {MutationEvent} from '@sanity/client'
2
- import {Box, Button, Card, Flex, Stack, Tab, TabList, TabPanel, Text} from '@sanity/ui'
3
- import groq from 'groq'
4
- import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'
5
- import {type SubmitHandler, useForm} from 'react-hook-form'
6
- import {useDispatch} from 'react-redux'
7
- import {WithReferringDocuments, useColorSchemeValue, useDocumentStore} from 'sanity'
8
-
9
- import {useToolOptions} from '../../contexts/ToolOptionsContext'
10
- import {getAssetFormSchema} from '../../formSchema'
11
- import useTypedSelector from '../../hooks/useTypedSelector'
12
- import useVersionedClient from '../../hooks/useVersionedClient'
13
- import {assetsActions, selectAssetById} from '../../modules/assets'
14
- import {dialogActions} from '../../modules/dialog'
15
- import {selectTags, selectTagSelectOptions, tagsActions} from '../../modules/tags'
16
- import type {Asset, AssetFormData, DialogAssetEditProps, TagSelectOption} from '../../types'
17
- import getTagSelectOptions from '../../utils/getTagSelectOptions'
18
- import {getUniqueDocuments} from '../../utils/getUniqueDocuments'
19
- import imageDprUrl from '../../utils/imageDprUrl'
20
- import sanitizeFormData from '../../utils/sanitizeFormData'
21
- import {isFileAsset, isImageAsset} from '../../utils/typeGuards'
22
- import zodFormResolver from '../../utils/zodFormResolver'
23
- import AssetMetadata from '../AssetMetadata'
24
- import Dialog from '../Dialog'
25
- import DocumentList from '../DocumentList'
26
- import FileAssetPreview from '../FileAssetPreview'
27
- import FormSubmitButton from '../FormSubmitButton'
28
- import Image from '../Image'
29
- import Details, {type DetailsProps} from './Details'
30
-
31
- function renderDefaultDetails(props: DetailsProps) {
32
- return <Details {...props} />
33
- }
34
-
35
- type Props = {
36
- children: ReactNode
37
- dialog: DialogAssetEditProps
38
- }
39
-
40
- const DialogAssetEdit = (props: Props) => {
41
- const {
42
- children,
43
- dialog: {assetId, id, lastCreatedTag, lastRemovedTagIds},
44
- } = props
45
-
46
- const client = useVersionedClient()
47
- const scheme = useColorSchemeValue()
48
-
49
- const documentStore = useDocumentStore()
50
-
51
- const dispatch = useDispatch()
52
- const assetItem = useTypedSelector((state) => selectAssetById(state, String(assetId))) // TODO: check casting
53
- const tags = useTypedSelector(selectTags)
54
-
55
- const assetUpdatedPrev = useRef<string | undefined>(undefined)
56
-
57
- // Generate a snapshot of the current asset
58
- const [assetSnapshot, setAssetSnapshot] = useState(assetItem?.asset)
59
- const [tabSection, setTabSection] = useState<'details' | 'references'>('details')
60
-
61
- const currentAsset = assetItem ? assetItem?.asset : assetSnapshot
62
- const allTagOptions = getTagSelectOptions(tags)
63
-
64
- const assetTagOptions = useTypedSelector(selectTagSelectOptions(currentAsset))
65
-
66
- // Check if credit line options are configured
67
- const {creditLine, components: {details: CustomDetails} = {}, locales} = useToolOptions()
68
-
69
- const generateDefaultValues = useCallback(
70
- (asset?: Asset): AssetFormData => {
71
- if (locales && locales.length > 0) {
72
- const makeLocaleObj = (field?: Record<string, string> | string) => {
73
- const obj: Record<string, string> = {}
74
- for (let i = 0; i < locales.length; i++) {
75
- const locale = locales[i]!
76
- if (typeof field === 'object' && field && field[locale.id]) {
77
- obj[locale.id] = field[locale.id]!
78
- } else if (typeof field === 'string') {
79
- // Only populate the first locale to avoid spreading a legacy value
80
- // across all languages; the user should fill in other translations manually
81
- obj[locale.id] = i === 0 ? field : ''
82
- } else {
83
- obj[locale.id] = ''
84
- }
85
- }
86
- return obj
87
- }
88
- return {
89
- altText: makeLocaleObj(asset?.altText),
90
- creditLine: makeLocaleObj(asset?.creditLine),
91
- description: makeLocaleObj(asset?.description),
92
- originalFilename: asset?.originalFilename || '',
93
- opt: {media: {tags: assetTagOptions}},
94
- title: makeLocaleObj(asset?.title),
95
- }
96
- }
97
- // Normalize: if a field is a localized object but locales are disabled, pick first non-empty value
98
- const flattenField = (field: unknown): string => {
99
- if (typeof field === 'string') return field
100
- if (typeof field === 'object' && field !== null) {
101
- const values = Object.values(field as Record<string, string>)
102
- return values.find((v) => v) || ''
103
- }
104
- return ''
105
- }
106
- return {
107
- altText: flattenField(asset?.altText),
108
- creditLine: flattenField(asset?.creditLine),
109
- description: flattenField(asset?.description),
110
- originalFilename: asset?.originalFilename || '',
111
- opt: {media: {tags: assetTagOptions}},
112
- title: flattenField(asset?.title),
113
- }
114
- },
115
- [assetTagOptions, locales],
116
- )
117
-
118
- const {
119
- control,
120
- // Read the formState before render to subscribe the form state through Proxy
121
- formState: {errors, isDirty, isValid},
122
- getValues,
123
- handleSubmit,
124
- register,
125
- reset,
126
- setValue,
127
- } = useForm<AssetFormData>({
128
- defaultValues: generateDefaultValues(assetItem?.asset),
129
- mode: 'onChange',
130
- resolver: zodFormResolver<AssetFormData>(getAssetFormSchema(locales)),
131
- })
132
-
133
- const formUpdating = !assetItem || assetItem?.updating
134
-
135
- const handleClose = useCallback(() => {
136
- dispatch(dialogActions.remove({id}))
137
- }, [dispatch, id])
138
-
139
- const handleDelete = useCallback(() => {
140
- if (!assetItem?.asset) {
141
- return
142
- }
143
-
144
- dispatch(
145
- dialogActions.showConfirmDeleteAssets({
146
- assets: [assetItem],
147
- closeDialogId: assetItem?.asset._id,
148
- }),
149
- )
150
- }, [assetItem, dispatch])
151
-
152
- const handleAssetUpdate = useCallback((update: MutationEvent) => {
153
- const {result, transition} = update
154
- if (result && transition === 'update') {
155
- // Regenerate asset snapshot
156
- setAssetSnapshot(result as Asset)
157
- }
158
- }, [])
159
-
160
- const handleCreateTag = useCallback(
161
- (tagName: string) => {
162
- // Dispatch action to create new tag
163
- dispatch(
164
- tagsActions.createRequest({
165
- assetId: currentAsset?._id,
166
- name: tagName,
167
- }),
168
- )
169
- },
170
- [currentAsset?._id, dispatch],
171
- )
172
-
173
- // Detect if asset has localized fields (objects) with keys not in the configured locales
174
- const hasOrphanedLocales = useMemo(() => {
175
- if (!currentAsset) return false
176
- const isLocaleObj = (v: unknown) => 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
-
225
- // Submit react-hook-form
226
- const onSubmit: SubmitHandler<AssetFormData> = useCallback(
227
- (formData) => {
228
- if (!assetItem?.asset) {
229
- return
230
- }
231
-
232
- const sanitizedFormData = sanitizeFormData(formData)
233
-
234
- dispatch(
235
- assetsActions.updateRequest({
236
- asset: assetItem?.asset,
237
- closeDialogId: assetItem?.asset._id,
238
- formData: {
239
- ...sanitizedFormData,
240
- // Map tags to sanity references
241
- opt: {
242
- media: {
243
- ...sanitizedFormData['opt'].media,
244
- tags:
245
- sanitizedFormData['opt'].media.tags?.map((tag: TagSelectOption) => ({
246
- _ref: tag.value,
247
- _type: 'reference',
248
- _weak: true,
249
- })) || null,
250
- },
251
- },
252
- },
253
- }),
254
- )
255
- },
256
- [assetItem?.asset, dispatch],
257
- )
258
-
259
- // Listen for asset mutations and update snapshot
260
- useEffect(() => {
261
- if (!assetItem?.asset) {
262
- return undefined
263
- }
264
-
265
- // Remember that Sanity listeners ignore joins, order clauses and projections
266
- const subscriptionAsset = client
267
- .listen(groq`*[_id == $id]`, {id: assetItem?.asset._id})
268
- .subscribe(handleAssetUpdate)
269
-
270
- return () => {
271
- subscriptionAsset?.unsubscribe()
272
- }
273
- }, [assetItem?.asset, client, handleAssetUpdate])
274
-
275
- // Update tags form field (react-select) when a new _inline_ tag has been created
276
- useEffect(() => {
277
- if (lastCreatedTag) {
278
- const existingTags = (getValues('opt.media.tags') as TagSelectOption[]) || []
279
- const updatedTags = existingTags.concat([lastCreatedTag])
280
- setValue('opt.media.tags', updatedTags, {shouldDirty: true})
281
- }
282
- }, [getValues, lastCreatedTag, setValue])
283
-
284
- // Update tags form field (react-select) when an _inline_ tag has been removed elsewhere
285
- useEffect(() => {
286
- if (lastRemovedTagIds) {
287
- const existingTags = (getValues('opt.media.tags') as TagSelectOption[]) || []
288
- const updatedTags = existingTags.filter((tag) => {
289
- return !lastRemovedTagIds.includes(tag.value)
290
- })
291
-
292
- setValue('opt.media.tags', updatedTags, {shouldDirty: true})
293
- }
294
- }, [getValues, lastRemovedTagIds, setValue])
295
-
296
- // Reset react-hook-form local state on mount and every time the asset has been updated elsewhere
297
- useEffect(() => {
298
- if (assetUpdatedPrev.current !== assetItem?.asset._updatedAt) {
299
- reset(generateDefaultValues(assetItem?.asset))
300
- }
301
- assetUpdatedPrev.current = assetItem?.asset._updatedAt
302
- }, [assetItem?.asset, generateDefaultValues, reset])
303
-
304
- const Footer = () => (
305
- <Box padding={3}>
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>
344
- </Box>
345
- )
346
-
347
- if (!currentAsset) {
348
- return null
349
- }
350
-
351
- const detailsProps = {
352
- control,
353
- errors,
354
- formUpdating,
355
- register,
356
- setValue,
357
- assetTagOptions,
358
- allTagOptions,
359
- handleCreateTag,
360
- currentAsset,
361
- creditLine,
362
- locales,
363
- }
364
-
365
- return (
366
- <Dialog
367
- animate
368
- // oxlint-disable-next-line react/react-compiler
369
- footer={<Footer />}
370
- header="Asset details"
371
- id={id}
372
- onClose={handleClose}
373
- width={3}
374
- >
375
- {/*
376
- We reverse direction to ensure the download button doesn't appear (in the DOM) before other tabbable items.
377
- This ensures that the dialog doesn't scroll down to the download button (which on smaller screens, can sometimes
378
- be below the fold).
379
- */}
380
- <Flex direction={['column-reverse', 'column-reverse', 'row-reverse']}>
381
- <Box flex={1} marginTop={[5, 5, 0]} padding={4}>
382
- <WithReferringDocuments documentStore={documentStore} id={currentAsset._id}>
383
- {({isLoading, referringDocuments}) => {
384
- const uniqueReferringDocuments = getUniqueDocuments(referringDocuments)
385
- return (
386
- <>
387
- {/* Tabs */}
388
- <TabList space={2}>
389
- <Tab
390
- aria-controls="details-panel"
391
- disabled={formUpdating}
392
- id="details-tab"
393
- label="Details"
394
- onClick={() => setTabSection('details')}
395
- selected={tabSection === 'details'}
396
- size={2}
397
- />
398
- <Tab
399
- aria-controls="references-panel"
400
- disabled={formUpdating}
401
- id="references-tab"
402
- label={`References${
403
- !isLoading && Array.isArray(uniqueReferringDocuments)
404
- ? ` (${uniqueReferringDocuments.length})`
405
- : ''
406
- }`}
407
- onClick={() => setTabSection('references')}
408
- selected={tabSection === 'references'}
409
- size={2}
410
- />
411
- </TabList>
412
-
413
- {/* Form fields */}
414
- <Box as="form" marginTop={4} onSubmit={handleSubmit(onSubmit)}>
415
- {/* Deleted notification */}
416
- {!assetItem && (
417
- <Card marginBottom={3} padding={3} radius={2} shadow={1} tone="critical">
418
- <Text size={1}>This file cannot be found – it may have been deleted.</Text>
419
- </Card>
420
- )}
421
-
422
- {/* Hidden button to enable enter key submissions */}
423
- <button style={{display: 'none'}} tabIndex={-1} type="submit" />
424
-
425
- {/* Panel: details */}
426
- <TabPanel
427
- aria-labelledby="details"
428
- hidden={tabSection !== 'details'}
429
- id="details-panel"
430
- >
431
- {CustomDetails ? (
432
- <CustomDetails
433
- {...detailsProps}
434
- renderDefaultDetails={renderDefaultDetails}
435
- />
436
- ) : (
437
- <Details {...detailsProps} />
438
- )}
439
- </TabPanel>
440
-
441
- {/* Panel: References */}
442
- <TabPanel
443
- aria-labelledby="references"
444
- hidden={tabSection !== 'references'}
445
- id="references-panel"
446
- >
447
- <Box marginTop={5}>
448
- {assetItem?.asset && (
449
- <DocumentList
450
- documents={uniqueReferringDocuments}
451
- isLoading={isLoading}
452
- />
453
- )}
454
- </Box>
455
- </TabPanel>
456
- </Box>
457
- </>
458
- )
459
- }}
460
- </WithReferringDocuments>
461
- </Box>
462
-
463
- <Box flex={1} padding={4}>
464
- <Box style={{aspectRatio: '1'}}>
465
- {/* File */}
466
- {isFileAsset(currentAsset) && <FileAssetPreview asset={currentAsset} />}
467
-
468
- {/* Image */}
469
- {isImageAsset(currentAsset) && (
470
- <Image
471
- draggable={false}
472
- $scheme={scheme}
473
- $showCheckerboard={!currentAsset?.metadata?.isOpaque}
474
- src={imageDprUrl(currentAsset, {height: 600, width: 600})}
475
- />
476
- )}
477
- </Box>
478
-
479
- {/* Metadata */}
480
- {currentAsset && (
481
- <Box marginTop={4}>
482
- <AssetMetadata asset={currentAsset} item={assetItem} />
483
- </Box>
484
- )}
485
- </Box>
486
- </Flex>
487
-
488
- {children}
489
- </Dialog>
490
- )
491
- }
492
-
493
- export default DialogAssetEdit