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