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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sanity-plugin-media",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "This version of `sanity-plugin-media` is for Sanity Studio V3.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sanity",
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"format": "prettier --write --cache --ignore-unknown .",
|
|
49
49
|
"link-watch": "plugin-kit link-watch",
|
|
50
50
|
"lint": "eslint .",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:watch": "vitest",
|
|
51
53
|
"prepare": "husky install",
|
|
52
54
|
"prepublishOnly": "npm run build",
|
|
53
55
|
"watch": "pkg-utils watch --strict"
|
|
@@ -87,6 +89,9 @@
|
|
|
87
89
|
"@sanity/plugin-kit": "^4.0.19",
|
|
88
90
|
"@sanity/semantic-release-preset": "^2.0.5",
|
|
89
91
|
"@sanity/vision": "^3.80.1",
|
|
92
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
93
|
+
"@testing-library/react": "^16.2.0",
|
|
94
|
+
"@testing-library/user-event": "^14.6.1",
|
|
90
95
|
"@types/is-hotkey": "^0.1.10",
|
|
91
96
|
"@types/pluralize": "^0.0.33",
|
|
92
97
|
"@types/react": "^19.0.12",
|
|
@@ -102,6 +107,7 @@
|
|
|
102
107
|
"eslint-plugin-react": "^7.37.4",
|
|
103
108
|
"eslint-plugin-react-hooks": "^4.6.2",
|
|
104
109
|
"husky": "^8.0.2",
|
|
110
|
+
"jsdom": "^25.0.1",
|
|
105
111
|
"lint-staged": "^13.0.3",
|
|
106
112
|
"prettier": "^2.8.8",
|
|
107
113
|
"prettier-plugin-packagejson": "^2.5.10",
|
|
@@ -112,7 +118,8 @@
|
|
|
112
118
|
"sanity": "^3.80.1",
|
|
113
119
|
"standard-version": "^9.5.0",
|
|
114
120
|
"styled-components": "^6.1.16",
|
|
115
|
-
"typescript": "5.8.2"
|
|
121
|
+
"typescript": "5.8.2",
|
|
122
|
+
"vitest": "^3.0.5"
|
|
116
123
|
},
|
|
117
124
|
"peerDependencies": {
|
|
118
125
|
"react": "^18.3 || ^19",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {configureStore, type AnyAction, type EnhancedStore} from '@reduxjs/toolkit'
|
|
2
|
+
import type {SanityClient} from '@sanity/client'
|
|
3
|
+
import type {Epic} from 'redux-observable'
|
|
4
|
+
import {createEpicMiddleware} from 'redux-observable'
|
|
5
|
+
import {rootReducer} from '../../modules'
|
|
6
|
+
import type {RootReducerState} from '../../modules/types'
|
|
7
|
+
import {createTestRootState} from './rootState'
|
|
8
|
+
|
|
9
|
+
export function createEpicTestStore(
|
|
10
|
+
epic: Epic<AnyAction, AnyAction, RootReducerState, {client: SanityClient}>,
|
|
11
|
+
mockClient: SanityClient,
|
|
12
|
+
preloaded?: Partial<RootReducerState>
|
|
13
|
+
): EnhancedStore<RootReducerState, AnyAction> {
|
|
14
|
+
const epicMiddleware = createEpicMiddleware<AnyAction, AnyAction, RootReducerState>({
|
|
15
|
+
dependencies: {client: mockClient}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const store = configureStore({
|
|
19
|
+
reducer: rootReducer,
|
|
20
|
+
middleware: getDefaultMiddleware =>
|
|
21
|
+
getDefaultMiddleware({serializableCheck: false, thunk: false}).concat(epicMiddleware),
|
|
22
|
+
preloadedState: createTestRootState(preloaded)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
epicMiddleware.run(epic)
|
|
26
|
+
return store
|
|
27
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {vi} from 'vitest'
|
|
2
|
+
|
|
3
|
+
/** Flat mock for client.listen() without deep callback nesting (ESLint max-nested-callbacks). */
|
|
4
|
+
export function createListenMock(): ReturnType<typeof vi.fn> {
|
|
5
|
+
const unsubscribe = vi.fn()
|
|
6
|
+
const subscribe = vi.fn()
|
|
7
|
+
subscribe.mockReturnValue({unsubscribe})
|
|
8
|
+
return vi.fn().mockReturnValue({subscribe})
|
|
9
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type {SanityClient} from '@sanity/client'
|
|
2
|
+
import {Subject, of} from 'rxjs'
|
|
3
|
+
import {vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
export type MockSanityClient = {
|
|
6
|
+
observable: {
|
|
7
|
+
fetch: ReturnType<typeof vi.fn>
|
|
8
|
+
delete: ReturnType<typeof vi.fn>
|
|
9
|
+
create: ReturnType<typeof vi.fn>
|
|
10
|
+
assets: {upload: ReturnType<typeof vi.fn>}
|
|
11
|
+
}
|
|
12
|
+
fetch: ReturnType<typeof vi.fn>
|
|
13
|
+
listen: ReturnType<typeof vi.fn>
|
|
14
|
+
patch: ReturnType<typeof vi.fn>
|
|
15
|
+
transaction: ReturnType<typeof vi.fn>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createMockSanityClient(
|
|
19
|
+
overrides: Partial<Omit<MockSanityClient, 'observable'>> & {
|
|
20
|
+
observable?: Partial<MockSanityClient['observable']> & {
|
|
21
|
+
assets?: Partial<MockSanityClient['observable']['assets']>
|
|
22
|
+
}
|
|
23
|
+
} = {}
|
|
24
|
+
): SanityClient {
|
|
25
|
+
const {observable: observableOverrides, ...restOverrides} = overrides
|
|
26
|
+
|
|
27
|
+
const observableBase: MockSanityClient['observable'] = {
|
|
28
|
+
fetch: vi.fn(() => of({items: []})),
|
|
29
|
+
delete: vi.fn(() => of({})),
|
|
30
|
+
create: vi.fn(() => of({_id: 'new'})),
|
|
31
|
+
assets: {
|
|
32
|
+
upload: vi.fn(() => of({type: 'complete', body: {document: {_id: 'up'}}}))
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const observable: MockSanityClient['observable'] = {
|
|
37
|
+
...observableBase,
|
|
38
|
+
...(observableOverrides ?? {}),
|
|
39
|
+
assets: {
|
|
40
|
+
...observableBase.assets,
|
|
41
|
+
...(observableOverrides?.assets ?? {})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const client: MockSanityClient = {
|
|
46
|
+
observable,
|
|
47
|
+
fetch: vi.fn(() => Promise.resolve(0)),
|
|
48
|
+
listen: vi.fn(() => new Subject()),
|
|
49
|
+
patch: vi.fn(),
|
|
50
|
+
transaction: vi.fn(),
|
|
51
|
+
...restOverrides
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return client as unknown as SanityClient
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function mockPatchChain(result: unknown): {
|
|
58
|
+
set: ReturnType<typeof vi.fn>
|
|
59
|
+
setIfMissing: ReturnType<typeof vi.fn>
|
|
60
|
+
commit: ReturnType<typeof vi.fn>
|
|
61
|
+
} {
|
|
62
|
+
const commit = vi.fn().mockResolvedValue(result)
|
|
63
|
+
const chain = {
|
|
64
|
+
set: vi.fn(),
|
|
65
|
+
setIfMissing: vi.fn(),
|
|
66
|
+
commit
|
|
67
|
+
}
|
|
68
|
+
chain.set.mockImplementation(() => chain)
|
|
69
|
+
chain.setIfMissing.mockImplementation(() => chain)
|
|
70
|
+
return chain
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function mockTransactionCommit(resolved: unknown = undefined): {
|
|
74
|
+
patch: ReturnType<typeof vi.fn>
|
|
75
|
+
delete: ReturnType<typeof vi.fn>
|
|
76
|
+
commit: ReturnType<typeof vi.fn>
|
|
77
|
+
} {
|
|
78
|
+
const tx = {
|
|
79
|
+
patch: vi.fn().mockReturnThis(),
|
|
80
|
+
delete: vi.fn().mockReturnThis(),
|
|
81
|
+
commit: vi.fn().mockResolvedValue(resolved)
|
|
82
|
+
}
|
|
83
|
+
return tx
|
|
84
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type {ReactElement, ReactNode} from 'react'
|
|
2
|
+
import {configureStore} from '@reduxjs/toolkit'
|
|
3
|
+
import {studioTheme, ThemeProvider, ToastProvider} from '@sanity/ui'
|
|
4
|
+
import {ColorSchemeProvider} from 'sanity'
|
|
5
|
+
import {Provider} from 'react-redux'
|
|
6
|
+
import {render} from '@testing-library/react'
|
|
7
|
+
import {AssetBrowserDispatchProvider} from '../../contexts/AssetSourceDispatchContext'
|
|
8
|
+
import {ToolOptionsProvider} from '../../contexts/ToolOptionsContext'
|
|
9
|
+
import {rootReducer} from '../../modules'
|
|
10
|
+
import type {RootReducerState} from '../../modules/types'
|
|
11
|
+
import type {MediaToolOptions} from '../../types'
|
|
12
|
+
import {createTestRootState} from './rootState'
|
|
13
|
+
import type {AssetSourceComponentProps} from 'sanity'
|
|
14
|
+
|
|
15
|
+
type Opts = {
|
|
16
|
+
onSelect?: AssetSourceComponentProps['onSelect']
|
|
17
|
+
preloaded?: Partial<RootReducerState>
|
|
18
|
+
toolOptions?: Partial<MediaToolOptions>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderWithProviders(ui: ReactElement, opts: Opts = {}) {
|
|
22
|
+
const {onSelect, preloaded, toolOptions} = opts
|
|
23
|
+
|
|
24
|
+
const store = configureStore({
|
|
25
|
+
reducer: rootReducer,
|
|
26
|
+
middleware: getDefaultMiddleware =>
|
|
27
|
+
getDefaultMiddleware({thunk: false, serializableCheck: false}),
|
|
28
|
+
preloadedState: createTestRootState(preloaded)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const options: MediaToolOptions = {
|
|
32
|
+
creditLine: {enabled: false},
|
|
33
|
+
directUploads: true,
|
|
34
|
+
...toolOptions
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const wrap = (node: ReactNode) => (
|
|
38
|
+
<Provider store={store}>
|
|
39
|
+
<ColorSchemeProvider scheme="light">
|
|
40
|
+
<ToolOptionsProvider options={options}>
|
|
41
|
+
<ThemeProvider theme={studioTheme}>
|
|
42
|
+
<ToastProvider>
|
|
43
|
+
<AssetBrowserDispatchProvider onSelect={onSelect}>
|
|
44
|
+
{node}
|
|
45
|
+
</AssetBrowserDispatchProvider>
|
|
46
|
+
</ToastProvider>
|
|
47
|
+
</ThemeProvider>
|
|
48
|
+
</ToolOptionsProvider>
|
|
49
|
+
</ColorSchemeProvider>
|
|
50
|
+
</Provider>
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return {store, ...render(wrap(ui))}
|
|
54
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {RootReducerState} from '../../modules/types'
|
|
2
|
+
import {initialState as assetsInitialState} from '../../modules/assets'
|
|
3
|
+
|
|
4
|
+
export function createTestRootState(overrides: Partial<RootReducerState> = {}): RootReducerState {
|
|
5
|
+
const base: RootReducerState = {
|
|
6
|
+
assets: {
|
|
7
|
+
...assetsInitialState,
|
|
8
|
+
assetTypes: ['file', 'image']
|
|
9
|
+
},
|
|
10
|
+
debug: {badConnection: false, enabled: false},
|
|
11
|
+
dialog: {items: []},
|
|
12
|
+
notifications: {items: []},
|
|
13
|
+
search: {facets: [], query: ''},
|
|
14
|
+
selected: {assets: [], document: undefined, documentAssetIds: []},
|
|
15
|
+
tags: {
|
|
16
|
+
allIds: [],
|
|
17
|
+
byIds: {},
|
|
18
|
+
creating: false,
|
|
19
|
+
fetchCount: -1,
|
|
20
|
+
fetching: false,
|
|
21
|
+
panelVisible: true
|
|
22
|
+
},
|
|
23
|
+
uploads: {allIds: [], byIds: {}}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {...base, ...overrides} as RootReducerState
|
|
27
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {within, type Screen} from '@testing-library/react'
|
|
2
|
+
|
|
3
|
+
/** Topmost Sanity dialog matching `name` (handles animated duplicate layers). */
|
|
4
|
+
export function getDialogRoot(name: RegExp, base: Screen): HTMLElement {
|
|
5
|
+
const dialogs = base.getAllByRole('dialog', {name})
|
|
6
|
+
const el = dialogs.at(-1)
|
|
7
|
+
if (!el) {
|
|
8
|
+
throw new Error(`No dialog found matching ${name}`)
|
|
9
|
+
}
|
|
10
|
+
return el
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Scoped queries for that dialog. */
|
|
14
|
+
export function withinDialog(name: RegExp, base: Screen) {
|
|
15
|
+
return within(getDialogRoot(name, base))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sanity `TextInput` is not always exposed as a labellable control; use the native input by `name`.
|
|
20
|
+
*/
|
|
21
|
+
export function inputByName(dialogName: RegExp, base: Screen, name: string): HTMLInputElement {
|
|
22
|
+
const root = getDialogRoot(dialogName, base)
|
|
23
|
+
const input = root.querySelector(`input[name="${name}"]`)
|
|
24
|
+
if (!input || !(input instanceof HTMLInputElement)) {
|
|
25
|
+
throw new Error(`No input name="${name}" in dialog matching ${dialogName}`)
|
|
26
|
+
}
|
|
27
|
+
return input
|
|
28
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {useToast} from '@sanity/ui'
|
|
2
|
+
import {useEffect, useRef} from 'react'
|
|
3
|
+
import {type InputProps} from 'sanity'
|
|
4
|
+
import {applyMediaTags} from '../../utils/applyMediaTags'
|
|
5
|
+
import {useToolOptions} from '../../contexts/ToolOptionsContext'
|
|
6
|
+
import useVersionedClient from '../../hooks/useVersionedClient'
|
|
7
|
+
|
|
8
|
+
type AssetValue = {
|
|
9
|
+
_type: 'image' | 'file'
|
|
10
|
+
asset?: {
|
|
11
|
+
_ref: string
|
|
12
|
+
_type: 'reference'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type AutoTagInputProps = InputProps & {
|
|
17
|
+
mediaTags?: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Input component that automatically applies media tags when an asset is selected or uploaded.
|
|
22
|
+
*
|
|
23
|
+
* Apply explicitly to image/file fields that should be auto-tagged:
|
|
24
|
+
* ```ts
|
|
25
|
+
* import {AutoTagInput} from 'sanity-plugin-media'
|
|
26
|
+
*
|
|
27
|
+
* defineField({
|
|
28
|
+
* type: 'image',
|
|
29
|
+
* options: { mediaTags: ['product'] }, // also pre-filters the media browser
|
|
30
|
+
* components: { input: AutoTagInput },
|
|
31
|
+
* })
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* Pass `mediaTags` as a prop to override or use without `options`:
|
|
35
|
+
* ```ts
|
|
36
|
+
* components: { input: (props) => <AutoTagInput {...props} mediaTags={['product']} /> }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function AutoTagInput(props: AutoTagInputProps) {
|
|
40
|
+
const {renderDefault, schemaType, value, mediaTags: mediaTagsProp} = props
|
|
41
|
+
const toast = useToast()
|
|
42
|
+
|
|
43
|
+
// Prop takes precedence; fall back to schemaType.options.mediaTags (set for browser pre-filtering)
|
|
44
|
+
const mediaTags =
|
|
45
|
+
mediaTagsProp ?? (schemaType?.options as {mediaTags?: string[]} | undefined)?.mediaTags
|
|
46
|
+
|
|
47
|
+
const client = useVersionedClient()
|
|
48
|
+
const {createTagsOnUpload} = useToolOptions()
|
|
49
|
+
|
|
50
|
+
const prevAssetRef = useRef<string | undefined>(undefined)
|
|
51
|
+
const isInitialMount = useRef(true)
|
|
52
|
+
|
|
53
|
+
const currentAssetRef = (value as AssetValue | undefined)?.asset?._ref
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (isInitialMount.current) {
|
|
57
|
+
isInitialMount.current = false
|
|
58
|
+
prevAssetRef.current = currentAssetRef
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const previousRef = prevAssetRef.current
|
|
63
|
+
prevAssetRef.current = currentAssetRef
|
|
64
|
+
|
|
65
|
+
if (!mediaTags?.length || !currentAssetRef || currentAssetRef === previousRef) return
|
|
66
|
+
|
|
67
|
+
applyMediaTags({
|
|
68
|
+
client,
|
|
69
|
+
assetId: currentAssetRef,
|
|
70
|
+
mediaTags,
|
|
71
|
+
createTagsOnUpload
|
|
72
|
+
}).catch(err => {
|
|
73
|
+
console.error('[sanity-plugin-media] Failed to apply auto-tags:', err)
|
|
74
|
+
const label = mediaTags.length === 1 ? 'tag' : 'tags'
|
|
75
|
+
toast.push({closable: true, status: 'error', title: `Failed to apply the media ${label} ${mediaTags.join(', ')}`})
|
|
76
|
+
})
|
|
77
|
+
}, [currentAssetRef, mediaTags, client, createTagsOnUpload])
|
|
78
|
+
|
|
79
|
+
return renderDefault(props as InputProps)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default AutoTagInput
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {describe, expect, it, vi, beforeEach} from 'vitest'
|
|
2
|
+
import {render, screen, waitFor} from '@testing-library/react'
|
|
3
|
+
import {studioTheme, ThemeProvider, ToastProvider} from '@sanity/ui'
|
|
4
|
+
import {ColorSchemeProvider} from 'sanity'
|
|
5
|
+
import {of} from 'rxjs'
|
|
6
|
+
import Browser from './index'
|
|
7
|
+
import {createListenMock} from '../../__tests__/fixtures/listenMock'
|
|
8
|
+
import {createMockSanityClient} from '../../__tests__/fixtures/mockSanityClient'
|
|
9
|
+
import {ToolOptionsProvider} from '../../contexts/ToolOptionsContext'
|
|
10
|
+
import useVersionedClient from '../../hooks/useVersionedClient'
|
|
11
|
+
|
|
12
|
+
vi.mock('../../hooks/useVersionedClient', () => ({
|
|
13
|
+
default: vi.fn()
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
describe('Browser', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
const fetch = vi.fn().mockReturnValue(of({items: []}))
|
|
19
|
+
vi.mocked(useVersionedClient).mockReturnValue(
|
|
20
|
+
createMockSanityClient({
|
|
21
|
+
listen: createListenMock(),
|
|
22
|
+
observable: {fetch}
|
|
23
|
+
})
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('renders Browse Assets header in tool mode', async () => {
|
|
28
|
+
render(
|
|
29
|
+
<ColorSchemeProvider scheme="light">
|
|
30
|
+
<ThemeProvider theme={studioTheme}>
|
|
31
|
+
<ToastProvider>
|
|
32
|
+
<ToolOptionsProvider options={{creditLine: {enabled: false}}}>
|
|
33
|
+
<Browser />
|
|
34
|
+
</ToolOptionsProvider>
|
|
35
|
+
</ToastProvider>
|
|
36
|
+
</ThemeProvider>
|
|
37
|
+
</ColorSchemeProvider>
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
await waitFor(() => {
|
|
41
|
+
expect(screen.getByText('Browse Assets')).toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import type {MutationEvent} from '@sanity/client'
|
|
2
1
|
import {Card, Flex, PortalProvider} from '@sanity/ui'
|
|
3
|
-
import
|
|
4
|
-
import groq from 'groq'
|
|
5
|
-
import {useEffect, useState} from 'react'
|
|
6
|
-
import {useDispatch} from 'react-redux'
|
|
2
|
+
import {useState} from 'react'
|
|
7
3
|
import {type AssetSourceComponentProps, type SanityDocument} from 'sanity'
|
|
8
|
-
import {TAG_DOCUMENT_NAME} from '../../constants'
|
|
9
4
|
import {AssetBrowserDispatchProvider} from '../../contexts/AssetSourceDispatchContext'
|
|
10
5
|
import useVersionedClient from '../../hooks/useVersionedClient'
|
|
11
|
-
import {assetsActions} from '../../modules/assets'
|
|
12
|
-
import {tagsActions} from '../../modules/tags'
|
|
13
6
|
import GlobalStyle from '../../styled/GlobalStyles'
|
|
14
7
|
import Controls from '../Controls'
|
|
15
8
|
import DebugControls from '../DebugControls'
|
|
@@ -21,6 +14,7 @@ import PickedBar from '../PickedBar'
|
|
|
21
14
|
import ReduxProvider from '../ReduxProvider'
|
|
22
15
|
import TagsPanel from '../TagsPanel'
|
|
23
16
|
import UploadDropzone from '../UploadDropzone'
|
|
17
|
+
import {useBrowserInit} from './useBrowserInit'
|
|
24
18
|
|
|
25
19
|
type Props = {
|
|
26
20
|
assetType?: AssetSourceComponentProps['assetType']
|
|
@@ -28,71 +22,20 @@ type Props = {
|
|
|
28
22
|
onClose?: AssetSourceComponentProps['onClose']
|
|
29
23
|
onSelect?: AssetSourceComponentProps['onSelect']
|
|
30
24
|
selectedAssets?: AssetSourceComponentProps['selectedAssets']
|
|
25
|
+
schemaType?: AssetSourceComponentProps['schemaType']
|
|
31
26
|
}
|
|
32
27
|
|
|
33
|
-
const BrowserContent = ({
|
|
28
|
+
const BrowserContent = ({
|
|
29
|
+
onClose,
|
|
30
|
+
schemaType
|
|
31
|
+
}: {
|
|
32
|
+
onClose?: AssetSourceComponentProps['onClose']
|
|
33
|
+
schemaType?: AssetSourceComponentProps['schemaType']
|
|
34
|
+
}) => {
|
|
34
35
|
const client = useVersionedClient()
|
|
35
36
|
const [portalElement, setPortalElement] = useState<HTMLDivElement | null>(null)
|
|
36
|
-
const dispatch = useDispatch()
|
|
37
|
-
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
const handleAssetUpdate = (update: MutationEvent) => {
|
|
40
|
-
const {documentId, result, transition} = update
|
|
41
|
-
|
|
42
|
-
if (transition === 'appear') {
|
|
43
|
-
dispatch(assetsActions.listenerCreateQueue({asset: result as Asset}))
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (transition === 'disappear') {
|
|
47
|
-
dispatch(assetsActions.listenerDeleteQueue({assetId: documentId}))
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (transition === 'update') {
|
|
51
|
-
dispatch(assetsActions.listenerUpdateQueue({asset: result as Asset}))
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const handleTagUpdate = (update: MutationEvent) => {
|
|
56
|
-
const {documentId, result, transition} = update
|
|
57
|
-
|
|
58
|
-
if (transition === 'appear') {
|
|
59
|
-
dispatch(tagsActions.listenerCreateQueue({tag: result as Tag}))
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (transition === 'disappear') {
|
|
63
|
-
dispatch(tagsActions.listenerDeleteQueue({tagId: documentId}))
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (transition === 'update') {
|
|
67
|
-
dispatch(tagsActions.listenerUpdateQueue({tag: result as Tag}))
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Fetch assets: first page
|
|
72
|
-
dispatch(assetsActions.loadPageIndex({pageIndex: 0}))
|
|
73
|
-
|
|
74
|
-
// Fetch all tags
|
|
75
|
-
dispatch(tagsActions.fetchRequest())
|
|
76
|
-
|
|
77
|
-
// Listen for asset and tag changes in published documents.
|
|
78
|
-
// Remember that Sanity listeners ignore joins, order clauses and projections!
|
|
79
|
-
// Also note that changes to the selected document (if present) will automatically re-load the media plugin
|
|
80
|
-
// due to the desk pane re-rendering.
|
|
81
|
-
const subscriptionAsset = client
|
|
82
|
-
.listen(
|
|
83
|
-
groq`*[_type in ["sanity.fileAsset", "sanity.imageAsset"] && !(_id in path("drafts.**"))]`
|
|
84
|
-
)
|
|
85
|
-
.subscribe(handleAssetUpdate)
|
|
86
|
-
|
|
87
|
-
const subscriptionTag = client
|
|
88
|
-
.listen(groq`*[_type == "${TAG_DOCUMENT_NAME}" && !(_id in path("drafts.**"))]`)
|
|
89
|
-
.subscribe(handleTagUpdate)
|
|
90
37
|
|
|
91
|
-
|
|
92
|
-
subscriptionAsset?.unsubscribe()
|
|
93
|
-
subscriptionTag?.unsubscribe()
|
|
94
|
-
}
|
|
95
|
-
}, [client, dispatch])
|
|
38
|
+
useBrowserInit(client, schemaType)
|
|
96
39
|
|
|
97
40
|
return (
|
|
98
41
|
<PortalProvider element={portalElement}>
|
|
@@ -137,7 +80,7 @@ const Browser = (props: Props) => {
|
|
|
137
80
|
>
|
|
138
81
|
<AssetBrowserDispatchProvider onSelect={props?.onSelect}>
|
|
139
82
|
<GlobalStyle />
|
|
140
|
-
<BrowserContent onClose={props?.onClose} />
|
|
83
|
+
<BrowserContent onClose={props?.onClose} schemaType={props?.schemaType} />
|
|
141
84
|
</AssetBrowserDispatchProvider>
|
|
142
85
|
</ReduxProvider>
|
|
143
86
|
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type {MutationEvent, SanityClient} from '@sanity/client'
|
|
2
|
+
import groq from 'groq'
|
|
3
|
+
import {useEffect} from 'react'
|
|
4
|
+
import {useDispatch, useSelector} from 'react-redux'
|
|
5
|
+
import type {AssetSourceComponentProps} from 'sanity'
|
|
6
|
+
import type {Dispatch} from 'redux'
|
|
7
|
+
|
|
8
|
+
import {inputs} from '../../config/searchFacets'
|
|
9
|
+
import {TAG_DOCUMENT_NAME} from '../../constants'
|
|
10
|
+
import {searchActions} from '../../modules/search'
|
|
11
|
+
import {tagsActions} from '../../modules/tags'
|
|
12
|
+
import type {RootReducerState} from '../../modules/types'
|
|
13
|
+
import type {Asset, Tag} from '../../types'
|
|
14
|
+
import {assetsActions} from '../../modules/assets'
|
|
15
|
+
|
|
16
|
+
function getMediaTagNames(schemaType?: AssetSourceComponentProps['schemaType']): string[] {
|
|
17
|
+
const mediaTags = (schemaType?.options as {mediaTags?: string[]} | undefined)?.mediaTags
|
|
18
|
+
if (!mediaTags?.length) return []
|
|
19
|
+
const unique = new Set(
|
|
20
|
+
mediaTags.map(t => t?.trim()).filter((t): t is string => Boolean(t?.length))
|
|
21
|
+
)
|
|
22
|
+
return Array.from(unique)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createAssetHandler(dispatch: Dispatch) {
|
|
26
|
+
return (update: MutationEvent) => {
|
|
27
|
+
const {documentId, result, transition} = update
|
|
28
|
+
|
|
29
|
+
switch (transition) {
|
|
30
|
+
case 'appear':
|
|
31
|
+
dispatch(assetsActions.listenerCreateQueue({asset: result as Asset}))
|
|
32
|
+
break
|
|
33
|
+
case 'disappear':
|
|
34
|
+
dispatch(assetsActions.listenerDeleteQueue({assetId: documentId}))
|
|
35
|
+
break
|
|
36
|
+
case 'update':
|
|
37
|
+
dispatch(assetsActions.listenerUpdateQueue({asset: result as Asset}))
|
|
38
|
+
break
|
|
39
|
+
default:
|
|
40
|
+
break
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createTagHandler(dispatch: Dispatch) {
|
|
46
|
+
return (update: MutationEvent) => {
|
|
47
|
+
const {documentId, result, transition} = update
|
|
48
|
+
|
|
49
|
+
switch (transition) {
|
|
50
|
+
case 'appear':
|
|
51
|
+
dispatch(tagsActions.listenerCreateQueue({tag: result as Tag}))
|
|
52
|
+
break
|
|
53
|
+
case 'disappear':
|
|
54
|
+
dispatch(tagsActions.listenerDeleteQueue({tagId: documentId}))
|
|
55
|
+
break
|
|
56
|
+
case 'update':
|
|
57
|
+
dispatch(tagsActions.listenerUpdateQueue({tag: result as Tag}))
|
|
58
|
+
break
|
|
59
|
+
default:
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useBrowserInit(
|
|
66
|
+
client: SanityClient,
|
|
67
|
+
schemaType?: AssetSourceComponentProps['schemaType']
|
|
68
|
+
): void {
|
|
69
|
+
const dispatch = useDispatch()
|
|
70
|
+
const tagsByIds = useSelector((state: RootReducerState) => state.tags.byIds)
|
|
71
|
+
const tagsFetchCount = useSelector((state: RootReducerState) => state.tags.fetchCount)
|
|
72
|
+
|
|
73
|
+
const tagNames = getMediaTagNames(schemaType)
|
|
74
|
+
const hasMediaTags = tagNames.length > 0
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!hasMediaTags) {
|
|
78
|
+
dispatch(searchActions.facetsClear())
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
dispatch(tagsActions.fetchRequest())
|
|
82
|
+
|
|
83
|
+
const assetSubscription = client
|
|
84
|
+
.listen(
|
|
85
|
+
groq`*[_type in ["sanity.fileAsset", "sanity.imageAsset"] && !(_id in path("drafts.**"))]`
|
|
86
|
+
)
|
|
87
|
+
.subscribe(createAssetHandler(dispatch))
|
|
88
|
+
|
|
89
|
+
const tagSubscription = client
|
|
90
|
+
.listen(groq`*[_type == "${TAG_DOCUMENT_NAME}" && !(_id in path("drafts.**"))]`)
|
|
91
|
+
.subscribe(createTagHandler(dispatch))
|
|
92
|
+
|
|
93
|
+
return () => {
|
|
94
|
+
assetSubscription.unsubscribe()
|
|
95
|
+
tagSubscription.unsubscribe()
|
|
96
|
+
}
|
|
97
|
+
}, [client, dispatch, hasMediaTags])
|
|
98
|
+
|
|
99
|
+
// When mediaTags are configured, wait for the tag fetch to complete then apply facets.
|
|
100
|
+
// Dispatching clear + add synchronously keeps all actions within assetsSearchEpic's
|
|
101
|
+
// 400ms debounce window, so the browser performs exactly one asset fetch on open.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!hasMediaTags || tagsFetchCount < 0) return
|
|
104
|
+
|
|
105
|
+
const tagFacetInput = inputs.tag
|
|
106
|
+
if (tagFacetInput.type !== 'searchable') return
|
|
107
|
+
|
|
108
|
+
const resolvedTags = tagNames
|
|
109
|
+
.map(name => Object.values(tagsByIds).find(item => item.tag.name.current === name))
|
|
110
|
+
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
|
111
|
+
|
|
112
|
+
dispatch(searchActions.facetsClear())
|
|
113
|
+
|
|
114
|
+
for (const tagItem of resolvedTags) {
|
|
115
|
+
dispatch(
|
|
116
|
+
searchActions.facetsAdd({
|
|
117
|
+
facet: {
|
|
118
|
+
...tagFacetInput,
|
|
119
|
+
operatorType: 'references',
|
|
120
|
+
value: {label: tagItem.tag.name.current, value: tagItem.tag._id}
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
}, [tagsFetchCount, hasMediaTags]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
126
|
+
}
|