diff --git a/apps/kitchensink-react/src/AppRoutes.tsx b/apps/kitchensink-react/src/AppRoutes.tsx index 2c98a823a..c4c9b45a5 100644 --- a/apps/kitchensink-react/src/AppRoutes.tsx +++ b/apps/kitchensink-react/src/AppRoutes.tsx @@ -14,6 +14,7 @@ import {SearchRoute} from './DocumentCollection/SearchRoute' import {PresenceRoute} from './Presence/PresenceRoute' import {ProjectAuthHome} from './ProjectAuthentication/ProjectAuthHome' import {ProtectedRoute} from './ProtectedRoute' +import {AssetsRoute} from './routes/AssetsRoute' import {DashboardContextRoute} from './routes/DashboardContextRoute' import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute' import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute' @@ -28,6 +29,10 @@ const documentCollectionRoutes = [ path: 'users', element: , }, + { + path: 'assets', + element: , + }, { path: 'document-list', element: , diff --git a/apps/kitchensink-react/src/routes/AssetsRoute.tsx b/apps/kitchensink-react/src/routes/AssetsRoute.tsx new file mode 100644 index 000000000..49d330bb2 --- /dev/null +++ b/apps/kitchensink-react/src/routes/AssetsRoute.tsx @@ -0,0 +1,305 @@ +import { + type AssetDocumentBase, + createAssetHandle, + useAssets, + useDeleteAsset, + useLinkMediaLibraryAsset, + useUploadAsset, +} from '@sanity/sdk-react' +import {Button, Card, Flex, Label, Stack, Text} from '@sanity/ui' +import {type JSX, useCallback, useEffect, useMemo, useRef, useState} from 'react' + +import {DocumentGridLayout} from '../components/DocumentGridLayout/DocumentGridLayout' +import {PageLayout} from '../components/PageLayout' + +function AssetList({ + assets, + onDelete, +}: { + assets: AssetDocumentBase[] + onDelete: (id: string) => void +}) { + if (!assets.length) + return ( + + + No assets + + Upload a file to get started. + + + + ) + + return ( + + {assets.map((a) => ( +
  • + + + + {a._type} + + {a.url ? ( + {a.originalFilename + ) : ( + + + {a.originalFilename ?? a._id} + + + )} + + {a.originalFilename ?? a._id} + + +
  • + ))} +
    + ) +} + +export function AssetsRoute(): JSX.Element { + // Query controls + const [assetType, setAssetType] = useState<'all' | 'image' | 'file'>('all') + const [order, setOrder] = useState('_createdAt desc') + const [limit, setLimit] = useState(24) + // Bump this to force a re-fetch of assets + const [refresh, setRefresh] = useState(0) + const [isUploading, setIsUploading] = useState(false) + const requeryTimeoutsRef = useRef([]) + const options = useMemo( + () => ({ + assetType, + order, + limit, + params: {refresh}, // add refresh to params to cache bust + projectId: 'vo1ysemo', + dataset: 'production', + }), + [assetType, order, limit, refresh], + ) + + // Hooks + const assets = useAssets(options) + const upload = useUploadAsset() + const remove = useDeleteAsset() + const linkML = useLinkMediaLibraryAsset() + + // Cleanup any scheduled re-queries on unmount + useEffect(() => { + return () => { + requeryTimeoutsRef.current.forEach((id) => clearTimeout(id)) + requeryTimeoutsRef.current = [] + } + }, []) + + const triggerRequeryBurst = useCallback(() => { + setRefresh((r) => r + 1) + const t1 = window.setTimeout(() => setRefresh((r) => r + 1), 600) + const t2 = window.setTimeout(() => setRefresh((r) => r + 1), 1500) + requeryTimeoutsRef.current.push(t1, t2) + }, []) + + // Upload handlers + const fileInputRef = useRef(null) + const onSelectFile = useCallback( + async (ev: React.ChangeEvent) => { + const f = ev.target.files?.[0] + if (!f) return + setIsUploading(true) + const kind = f.type.startsWith('image/') ? 'image' : 'file' + try { + if (kind === 'image') { + await upload('image', f, { + filename: f.name, + projectId: options.projectId, + dataset: options.dataset, + }) + } else { + await upload('file', f, { + filename: f.name, + projectId: options.projectId, + dataset: options.dataset, + }) + } + // trigger re-queries so the new asset appears once indexed + triggerRequeryBurst() + } finally { + if (fileInputRef.current) fileInputRef.current.value = '' + setIsUploading(false) + } + }, + [upload, triggerRequeryBurst, options.projectId, options.dataset], + ) + + const onDelete = useCallback( + async (id: string) => { + if (!confirm('Delete this asset?')) return + await remove( + createAssetHandle({assetId: id, projectId: options.projectId, dataset: options.dataset}), + ) + // re-query after deletion + triggerRequeryBurst() + }, + [remove, triggerRequeryBurst, options.projectId, options.dataset], + ) + + // Media Library link form + const [mlAssetId, setMlAssetId] = useState('') + const [mlId, setMlId] = useState('') + const [mlInstId, setMlInstId] = useState('') + const onLinkMl = useCallback(async () => { + if (!mlAssetId || !mlId || !mlInstId) return + await linkML({assetId: mlAssetId, mediaLibraryId: mlId, assetInstanceId: mlInstId}) + setMlAssetId('') + setMlId('') + setMlInstId('') + // re-query after linking + triggerRequeryBurst() + }, [linkML, mlAssetId, mlId, mlInstId, triggerRequeryBurst]) + + return ( + + + +
    +
    + + +
    +
    + + setOrder(e.target.value)} + placeholder="_createdAt desc" + style={{ + width: '100%', + border: '1px solid #ccc', + padding: '8px', + borderRadius: '4px', + }} + /> +
    +
    + + setLimit(parseInt(e.target.value || '0', 10))} + style={{ + width: '100%', + border: '1px solid #ccc', + padding: '8px', + borderRadius: '4px', + }} + /> +
    +
    + + + {isUploading && ( + + Uploading... + + )} +
    +
    +
    + + + + Browse + + + + + + + + Link Media Library Asset + + + setMlAssetId(e.target.value)} + /> + setMlId(e.target.value)} + /> + setMlInstId(e.target.value)} + /> + + + ) + } + + render( + Loading...

    }> + +
    , + ) + + // Initially for images + expect(screen.getByTestId('output').textContent).toBe('image-1') + + // Change to files, trigger suspense and then resolve + act(() => { + screen.getByTestId('toggle').dispatchEvent(new MouseEvent('click', {bubbles: true})) + }) + + await act(async () => { + resolvePromise() + }) + + expect(screen.getByTestId('output').textContent).toBe('file-1') + }) + + it('calls unsubscribe on unmount', () => { + const unsubscribe = vi.fn() + const subscribe = vi.fn().mockImplementation((_cb: () => void) => { + // Return cleanup + return unsubscribe + }) + + vi.mocked(getAssetsState).mockReturnValue({ + getCurrent: vi.fn().mockReturnValue([]), + subscribe, + get observable(): Observable { + throw new Error('Not implemented') + }, + } as unknown as StateSource) + + function TestComponent() { + useAssets() + return null + } + + const {unmount} = render( + Loading...

    }> + +
    , + ) + + expect(subscribe).toHaveBeenCalled() + unmount() + expect(unsubscribe).toHaveBeenCalled() + }) + + it('throws when state source getCurrent throws (error path)', () => { + vi.mocked(getAssetsState).mockReturnValue({ + getCurrent: vi.fn(() => { + throw new Error('failed') + }), + subscribe: vi.fn(), + get observable(): Observable { + throw new Error('Not implemented') + }, + } as unknown as StateSource) + + function TestComponent() { + const data = useAssets() + return
    {data.length}
    + } + + expect(() => + render( + Loading...

    }> + +
    , + ), + ).toThrow('failed') + }) +}) diff --git a/packages/react/src/hooks/assets/useAssets.ts b/packages/react/src/hooks/assets/useAssets.ts new file mode 100644 index 000000000..ad9d14bb5 --- /dev/null +++ b/packages/react/src/hooks/assets/useAssets.ts @@ -0,0 +1,41 @@ +import { + type AssetDocumentBase, + type AssetQueryOptions, + getAssetsState, + resolveAssets, + type SanityInstance, + type StateSource, +} from '@sanity/sdk' +import {identity} from 'rxjs' + +import {createStateSourceHook} from '../helpers/createStateSourceHook' + +type UseAssets = (options?: AssetQueryOptions) => AssetDocumentBase[] + +/** + * @public + * Returns assets from your dataset based on flexible query options. + * + * Examples: + * ```tsx + * // Images only + * const images = useAssets({assetType: 'image', limit: 50}) + * + * // Files ordered by creation + * const files = useAssets({assetType: 'file', order: '_createdAt desc'}) + * + * // Filter (GROQ) with params + * const results = useAssets({where: 'size > $min', params: {min: 1024}}) + * ``` + */ +export const useAssets: UseAssets = createStateSourceHook({ + getState: (instance: SanityInstance, options?: AssetQueryOptions) => + getAssetsState(instance, (options ?? {}) as AssetQueryOptions) as unknown as StateSource< + AssetDocumentBase[] + >, + shouldSuspend: (instance: SanityInstance, options?: AssetQueryOptions) => + getAssetsState(instance, (options ?? {}) as AssetQueryOptions).getCurrent() === undefined, + suspender: (instance: SanityInstance, options?: AssetQueryOptions) => + resolveAssets(instance, (options ?? {}) as AssetQueryOptions), + getConfig: identity as (options?: AssetQueryOptions) => AssetQueryOptions | undefined, +}) diff --git a/packages/react/src/hooks/assets/useDeleteAsset.test.tsx b/packages/react/src/hooks/assets/useDeleteAsset.test.tsx new file mode 100644 index 000000000..c3c94e3e4 --- /dev/null +++ b/packages/react/src/hooks/assets/useDeleteAsset.test.tsx @@ -0,0 +1,91 @@ +import {deleteAsset} from '@sanity/sdk' +import {renderHook} from '@testing-library/react' +import {describe, expect, it, vi} from 'vitest' + +import {ResourceProvider} from '../../context/ResourceProvider' +import {useDeleteAsset} from './useDeleteAsset' + +vi.mock('@sanity/sdk', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + deleteAsset: vi.fn(), + } +}) + +describe('useDeleteAsset', () => { + it('calls deleteAsset with the asset document id', async () => { + vi.mocked(deleteAsset).mockResolvedValue(undefined) + + const {result} = renderHook(() => useDeleteAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + await result.current('image-abc-1x1-png') + + expect(deleteAsset).toHaveBeenCalledWith( + expect.objectContaining({config: expect.objectContaining({projectId: 'p'})}), + 'image-abc-1x1-png', + ) + }) + + it('propagates errors from delete', async () => { + vi.mocked(deleteAsset).mockRejectedValue(new Error('nope')) + + const {result} = renderHook(() => useDeleteAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + await expect(result.current('file-1')).rejects.toThrow('nope') + }) + + it('returns new function when instance changes', () => { + const {result, unmount} = renderHook(() => useDeleteAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + const first = result.current + unmount() + + const {result: result2} = renderHook(() => useDeleteAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + expect(result2.current).not.toBe(first) + }) + + it('calls deleteAsset when given an AssetHandle', async () => { + vi.mocked(deleteAsset).mockResolvedValue(undefined) + + const {result} = renderHook(() => useDeleteAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + await result.current({assetId: 'image-abc', projectId: 'p2', dataset: 'd2'}) + + expect(deleteAsset).toHaveBeenCalledWith( + expect.objectContaining({config: expect.objectContaining({projectId: 'p'})}), + {assetId: 'image-abc', projectId: 'p2', dataset: 'd2'}, + ) + }) +}) diff --git a/packages/react/src/hooks/assets/useDeleteAsset.ts b/packages/react/src/hooks/assets/useDeleteAsset.ts new file mode 100644 index 000000000..61c1b2046 --- /dev/null +++ b/packages/react/src/hooks/assets/useDeleteAsset.ts @@ -0,0 +1,15 @@ +import {deleteAsset} from '@sanity/sdk' + +import {createCallbackHook} from '../helpers/createCallbackHook' + +/** + * @public + * Provides a stable callback to delete an asset by its asset document ID. + * + * Example: + * ```tsx + * const remove = useDeleteAsset() + * await remove('image-abc123-2000x1200-jpg') + * ``` + */ +export const useDeleteAsset = createCallbackHook(deleteAsset) diff --git a/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.test.tsx b/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.test.tsx new file mode 100644 index 000000000..4a5380bef --- /dev/null +++ b/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.test.tsx @@ -0,0 +1,83 @@ +import {linkMediaLibraryAsset, type SanityDocument} from '@sanity/sdk' +import {renderHook} from '@testing-library/react' +import {describe, expect, it, vi} from 'vitest' + +import {ResourceProvider} from '../../context/ResourceProvider' +import {useLinkMediaLibraryAsset} from './useLinkMediaLibraryAsset' + +vi.mock('@sanity/sdk', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + linkMediaLibraryAsset: vi.fn(), + } +}) + +describe('useLinkMediaLibraryAsset', () => { + it('calls linkMediaLibraryAsset with provided options', async () => { + vi.mocked(linkMediaLibraryAsset).mockResolvedValue({ + _id: 'doc', + _type: 'any', + } as unknown as SanityDocument) + + const {result} = renderHook(() => useLinkMediaLibraryAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + const out = await result.current({ + assetId: 'a', + mediaLibraryId: 'm', + assetInstanceId: 'i', + tag: 't', + }) + + expect(out).toEqual({_id: 'doc', _type: 'any'}) + expect(linkMediaLibraryAsset).toHaveBeenCalledWith( + expect.objectContaining({config: expect.objectContaining({projectId: 'p'})}), + {assetId: 'a', mediaLibraryId: 'm', assetInstanceId: 'i', tag: 't'}, + ) + }) + + it('propagates errors from link operation', async () => { + vi.mocked(linkMediaLibraryAsset).mockRejectedValue(new Error('fail')) + + const {result} = renderHook(() => useLinkMediaLibraryAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + await expect( + result.current({assetId: 'a', mediaLibraryId: 'm', assetInstanceId: 'i'}), + ).rejects.toThrow('fail') + }) + + it('returns new function when instance changes', () => { + const {result, unmount} = renderHook(() => useLinkMediaLibraryAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + const first = result.current + unmount() + + const {result: result2} = renderHook(() => useLinkMediaLibraryAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + expect(result2.current).not.toBe(first) + }) +}) diff --git a/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.ts b/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.ts new file mode 100644 index 000000000..8bc646048 --- /dev/null +++ b/packages/react/src/hooks/assets/useLinkMediaLibraryAsset.ts @@ -0,0 +1,21 @@ +import {linkMediaLibraryAsset, type LinkMediaLibraryAssetOptions} from '@sanity/sdk' + +import {createCallbackHook} from '../helpers/createCallbackHook' + +/** + * @public + * Provides a stable callback to link a Media Library asset to your dataset. + * + * Example: + * ```tsx + * const linkMlAsset = useLinkMediaLibraryAsset() + * await linkMlAsset({ + * assetId: 'mlAssetId', + * mediaLibraryId: 'mediaLib1', + * assetInstanceId: 'mlInst1', + * }) + * ``` + */ +export const useLinkMediaLibraryAsset = createCallbackHook( + (instance, options: LinkMediaLibraryAssetOptions) => linkMediaLibraryAsset(instance, options), +) diff --git a/packages/react/src/hooks/assets/useUploadAsset.test.tsx b/packages/react/src/hooks/assets/useUploadAsset.test.tsx new file mode 100644 index 000000000..3c6c32daa --- /dev/null +++ b/packages/react/src/hooks/assets/useUploadAsset.test.tsx @@ -0,0 +1,121 @@ +import {type SanityAssetDocument, type SanityImageAssetDocument} from '@sanity/client' +import {uploadAsset} from '@sanity/sdk' +import {renderHook} from '@testing-library/react' +import {describe, expect, it, vi} from 'vitest' + +import {ResourceProvider} from '../../context/ResourceProvider' +import {useUploadAsset} from './useUploadAsset' + +vi.mock('@sanity/sdk', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + uploadAsset: vi.fn(), + } +}) + +describe('useUploadAsset', () => { + it('returns a function and uploads image assets', async () => { + const uploaded = { + _id: 'image-abc-1x1-png', + _type: 'sanity.imageAsset', + assetId: 'abc', + extension: 'png', + size: 1, + url: 'u', + path: 'p', + mimeType: 'image/png', + } as unknown as SanityImageAssetDocument + vi.mocked(uploadAsset).mockResolvedValue(uploaded) + + const {result} = renderHook(() => useUploadAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + const fn = result.current + const body = Buffer.from('x') + const res = await fn('image', body, {filename: 'a.png'}) + expect(res).toBe(uploaded) + expect(uploadAsset).toHaveBeenCalledWith( + expect.objectContaining({config: expect.objectContaining({projectId: 'p'})}), + 'image', + body, + {filename: 'a.png'}, + ) + }) + + it('uploads file assets and forwards options', async () => { + const uploaded = { + _id: 'file-abc', + _type: 'sanity.fileAsset', + assetId: 'abc', + extension: 'pdf', + size: 10, + url: 'u', + path: 'p', + mimeType: 'application/pdf', + } as unknown as SanityAssetDocument + vi.mocked(uploadAsset).mockResolvedValue(uploaded) + + const {result} = renderHook(() => useUploadAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + const out = await result.current('file', Buffer.from('f'), {title: 'T'}) + expect(out).toBe(uploaded) + expect(uploadAsset).toHaveBeenCalledWith( + expect.objectContaining({config: expect.objectContaining({projectId: 'p'})}), + 'file', + expect.any(Buffer), + {title: 'T'}, + ) + }) + + it('propagates errors from upload', async () => { + const err = new Error('boom') + vi.mocked(uploadAsset).mockRejectedValue(err) + + const {result} = renderHook(() => useUploadAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + await expect(result.current('image', Buffer.from('x'))).rejects.toThrow('boom') + }) + + it('returns new function when instance changes (cleanup/remount)', async () => { + vi.mocked(uploadAsset).mockResolvedValue({} as SanityImageAssetDocument) + + const {result, unmount} = renderHook(() => useUploadAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + const first = result.current + unmount() + + const {result: result2} = renderHook(() => useUploadAsset(), { + wrapper: ({children}) => ( + + {children} + + ), + }) + + expect(result2.current).not.toBe(first) + }) +}) diff --git a/packages/react/src/hooks/assets/useUploadAsset.ts b/packages/react/src/hooks/assets/useUploadAsset.ts new file mode 100644 index 000000000..73d678a73 --- /dev/null +++ b/packages/react/src/hooks/assets/useUploadAsset.ts @@ -0,0 +1,44 @@ +import { + type SanityAssetDocument, + type SanityImageAssetDocument, + type UploadBody, +} from '@sanity/client' +import {uploadAsset, type UploadAssetOptions} from '@sanity/sdk' +import {useCallback} from 'react' + +import {useSanityInstance} from '../context/useSanityInstance' + +/** + * @public + * Returns a stable callback for uploading assets. + * + * Overloads ensure proper return typing based on `kind`. + * + * @example + * ```tsx + * const upload = useUploadAsset() + * const onDrop = async (file: File) => { + * const image = await upload('image', file, {filename: file.name}) + * } + * ``` + */ +export function useUploadAsset(): { + (kind: 'image', body: UploadBody, options?: UploadAssetOptions): Promise + (kind: 'file', body: UploadBody, options?: UploadAssetOptions): Promise +} { + const instance = useSanityInstance() + return useCallback( + (kind: 'image' | 'file', body: UploadBody, options?: UploadAssetOptions) => + kind === 'image' + ? uploadAsset(instance, 'image', body, options) + : uploadAsset(instance, 'file', body, options), + [instance], + ) as unknown as { + ( + kind: 'image', + body: UploadBody, + options?: UploadAssetOptions, + ): Promise + (kind: 'file', body: UploadBody, options?: UploadAssetOptions): Promise + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54f5661a1..b25e23638 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,6 +353,9 @@ importers: '@sanity/diff-patch': specifier: ^6.0.0 version: 6.0.0 + '@sanity/image-url': + specifier: ^1.2.0 + version: 1.2.0 '@sanity/json-match': specifier: ^1.0.5 version: 1.0.5 @@ -2797,6 +2800,10 @@ packages: resolution: {integrity: sha512-JHumVRxzzaZAJyOimntdukA9TjjzsJiaiq/uUBdTknMLCNvtM6KQ5OCp6W5fIdY78uyFxtQjz+MPXwK8WBIxWg==} engines: {node: '>=10.0.0'} + '@sanity/image-url@1.2.0': + resolution: {integrity: sha512-pYRhti+lDi22it+npWXkEGuYyzbXJLF+d0TYLiyWbKu46JHhYhTDKkp6zmGu4YKF5cXUjT6pnUjFsaf2vlB9nQ==} + engines: {node: '>=10.0.0'} + '@sanity/import@3.38.2': resolution: {integrity: sha512-7KUEiksAjr+Ub+xbWbIIrNlfEesmqJcBW+n7zOr65TN8lS9WarTzIClDjbeZ13yYMS9e9FOKIzXVJC8UFwIseA==} engines: {node: '>=18'} @@ -10461,6 +10468,8 @@ snapshots: '@sanity/image-url@1.1.0': {} + '@sanity/image-url@1.2.0': {} + '@sanity/import@3.38.2(@types/react@19.1.2)': dependencies: '@sanity/asset-utils': 2.2.1