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.
- package/LICENSE +1 -1
- package/README.md +107 -3
- package/dist/index.d.mts +227 -56
- package/dist/index.d.ts +227 -56
- package/dist/index.js +473 -184
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +476 -187
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -2
- package/src/__tests__/fixtures/createEpicTestStore.ts +27 -0
- package/src/__tests__/fixtures/listenMock.ts +9 -0
- package/src/__tests__/fixtures/mockSanityClient.ts +84 -0
- package/src/__tests__/fixtures/renderWithProviders.tsx +54 -0
- package/src/__tests__/fixtures/rootState.ts +27 -0
- package/src/__tests__/fixtures/withinDialog.ts +28 -0
- package/src/components/AutoTagInputWrapper/index.tsx +82 -0
- package/src/components/Browser/Browser.test.tsx +44 -0
- package/src/components/Browser/index.tsx +12 -69
- package/src/components/Browser/useBrowserInit.ts +126 -0
- package/src/components/CardAsset/CardAsset.test.tsx +322 -0
- package/src/components/DialogAssetEdit/Details.tsx +123 -44
- package/src/components/DialogAssetEdit/DialogAssetEdit.test.tsx +215 -0
- package/src/components/DialogAssetEdit/index.tsx +138 -30
- package/src/components/DialogTagCreate/DialogTagCreate.test.tsx +120 -0
- package/src/components/DialogTagEdit/DialogTagEdit.test.tsx +164 -0
- package/src/components/FormBuilderTool/FormBuilderTool.test.tsx +62 -0
- package/src/components/FormBuilderTool/index.tsx +1 -1
- package/src/components/UploadDropzone/UploadDropzone.test.tsx +39 -0
- package/src/contexts/ToolOptionsContext.tsx +9 -3
- package/src/formSchema/index.test.ts +55 -0
- package/src/formSchema/index.ts +28 -12
- package/src/hooks/useVersionedClient.ts +1 -1
- package/src/index.ts +4 -1
- package/src/modules/assets/deleteAndUpdateEpics.test.ts +86 -0
- package/src/modules/assets/fetchEpic.test.ts +72 -0
- package/src/modules/assets/reducer.test.ts +90 -0
- package/src/modules/assets/tagsAndListenerEpics.test.ts +205 -0
- package/src/modules/dialog/epics.test.ts +167 -0
- package/src/modules/dialog/reducer.test.ts +184 -0
- package/src/modules/notifications/epics.test.ts +373 -0
- package/src/modules/notifications/index.ts +24 -4
- package/src/modules/notifications/reducer.test.ts +53 -0
- package/src/modules/search/index.test.ts +35 -0
- package/src/modules/selectors.test.ts +20 -0
- package/src/modules/tags/epics.test.ts +95 -0
- package/src/modules/tags/index.test.ts +41 -0
- package/src/modules/uploads/epics.test.ts +108 -0
- package/src/modules/uploads/index.test.ts +58 -0
- package/src/operators/checkTagName.test.ts +28 -0
- package/src/types/index.ts +25 -7
- package/src/utils/applyMediaTags.ts +86 -0
- package/src/utils/blocksToText.test.ts +42 -0
- package/src/utils/constructFilter.test.ts +119 -0
- package/src/utils/generatePreviewBlobUrl.test.ts +69 -0
- package/src/utils/getAssetResolution.test.ts +12 -0
- package/src/utils/getDocumentAssetIds.test.ts +49 -0
- package/src/utils/getSchemeColor.test.ts +11 -0
- package/src/utils/getTagSelectOptions.test.ts +43 -0
- package/src/utils/getUniqueDocuments.test.ts +25 -0
- package/src/utils/imageDprUrl.test.ts +45 -0
- package/src/utils/isSupportedAssetType.test.ts +15 -0
- package/src/utils/mediaField.ts +72 -0
- package/src/utils/sanitizeFormData.test.ts +58 -0
- package/src/utils/typeGuards.test.ts +17 -0
- package/src/utils/uploadSanityAsset.test.ts +28 -0
- package/src/utils/withMaxConcurrency.test.ts +42 -0
|
@@ -0,0 +1,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 {
|
|
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(
|
|
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
|
-
<
|
|
219
|
-
{
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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 (
|
|
@@ -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
|
+
})
|