From 448a41bdbf08ffe344b5c45588602983f212b608 Mon Sep 17 00:00:00 2001 From: Ryan Bonial Date: Wed, 29 Oct 2025 21:05:43 -0600 Subject: [PATCH 1/9] feat: add asset management functions and hooks --- apps/kitchensink-react/src/AppRoutes.tsx | 10 + .../src/routes/AssetsRoute.tsx | 183 +++++++++ packages/core/src/_exports/index.ts | 17 + packages/core/src/assets/assets.ts | 382 ++++++++++++++++++ packages/react/src/_exports/sdk-react.ts | 4 + packages/react/src/hooks/assets/useAssets.ts | 41 ++ .../react/src/hooks/assets/useDeleteAsset.ts | 15 + .../hooks/assets/useLinkMediaLibraryAsset.ts | 21 + .../react/src/hooks/assets/useUploadAsset.ts | 44 ++ 9 files changed, 717 insertions(+) create mode 100644 apps/kitchensink-react/src/routes/AssetsRoute.tsx create mode 100644 packages/core/src/assets/assets.ts create mode 100644 packages/react/src/hooks/assets/useAssets.ts create mode 100644 packages/react/src/hooks/assets/useDeleteAsset.ts create mode 100644 packages/react/src/hooks/assets/useLinkMediaLibraryAsset.ts create mode 100644 packages/react/src/hooks/assets/useUploadAsset.ts diff --git a/apps/kitchensink-react/src/AppRoutes.tsx b/apps/kitchensink-react/src/AppRoutes.tsx index 2c98a823a..0cee434fe 100644 --- a/apps/kitchensink-react/src/AppRoutes.tsx +++ b/apps/kitchensink-react/src/AppRoutes.tsx @@ -1,3 +1,4 @@ +import {ResourceProvider} from '@sanity/sdk-react' import {type JSX} from 'react' import {Route, Routes} from 'react-router' @@ -14,6 +15,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 +30,14 @@ 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..fdee1ddd7 --- /dev/null +++ b/apps/kitchensink-react/src/routes/AssetsRoute.tsx @@ -0,0 +1,183 @@ +import { + type AssetDocumentBase, + useAssets, + useDeleteAsset, + useLinkMediaLibraryAsset, + useUploadAsset, +} from '@sanity/sdk-react' +import {type JSX, useCallback, useMemo, useRef, useState} from 'react' + +function AssetList({ + assets, + onDelete, +}: { + assets: AssetDocumentBase[] + onDelete: (id: string) => void +}) { + if (!assets.length) return

No assets

+ + return ( +
    + {assets.map((a) => ( +
  • +
    {a._type}
    + {a.url ? ( + {a.originalFilename + ) : ( +
    + {a.originalFilename ?? a._id} +
    + )} +
    + + Open + + +
    +
  • + ))} +
+ ) +} + +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) + const options = useMemo(() => ({assetType, order, limit}), [assetType, order, limit]) + + // Hooks + const assets = useAssets(options) + const upload = useUploadAsset() + const remove = useDeleteAsset() + const linkML = useLinkMediaLibraryAsset() + + // Upload handlers + const fileInputRef = useRef(null) + const onSelectFile = useCallback( + async (ev: React.ChangeEvent) => { + const f = ev.target.files?.[0] + if (!f) return + const kind = f.type.startsWith('image/') ? 'image' : 'file' + if (kind === 'image') { + await upload('image', f, {filename: f.name}) + } else { + await upload('file', f, {filename: f.name}) + } + if (fileInputRef.current) fileInputRef.current.value = '' + }, + [upload], + ) + + const onDelete = useCallback( + async (id: string) => { + if (!confirm('Delete this asset?')) return + await remove(id) + }, + [remove], + ) + + // 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('') + }, [linkML, mlAssetId, mlId, mlInstId]) + + return ( +
+

Assets

+ +
+ + + + +
+ +
+

Browse

+ +
+ +
+

Link Media Library Asset

+
+ setMlAssetId(e.target.value)} + /> + setMlId(e.target.value)} + /> + setMlInstId(e.target.value)} + /> + +
+
+
+ ) +} + +export default AssetsRoute diff --git a/packages/core/src/_exports/index.ts b/packages/core/src/_exports/index.ts index e3df1f009..2e8193265 100644 --- a/packages/core/src/_exports/index.ts +++ b/packages/core/src/_exports/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable simple-import-sort/exports */ import {type SanityProject as _SanityProject} from '@sanity/client' /** @@ -139,6 +140,22 @@ export { export {getPerspectiveState} from '../releases/getPerspectiveState' export type {ReleaseDocument} from '../releases/releasesStore' export {getActiveReleasesState} from '../releases/releasesStore' +export { + type AssetDocumentBase, + type AssetKind, + type AssetQueryOptions, + type ImageAssetId, + type LinkMediaLibraryAssetOptions, + type UploadAssetOptions, + buildImageUrlFromId, + deleteAsset, + getAssetDownloadUrl, + getAssetsState, + isImageAssetId, + linkMediaLibraryAsset, + resolveAssets, + uploadAsset, +} from '../assets/assets' export {createSanityInstance, type SanityInstance} from '../store/createSanityInstance' export {type Selector, type StateSource} from '../store/createStateSourceAction' export {getUsersKey, parseUsersKey} from '../users/reducers' diff --git a/packages/core/src/assets/assets.ts b/packages/core/src/assets/assets.ts new file mode 100644 index 000000000..bd574597b --- /dev/null +++ b/packages/core/src/assets/assets.ts @@ -0,0 +1,382 @@ +import { + type AssetMetadataType, + type SanityAssetDocument, + type SanityImageAssetDocument, + type UploadBody, +} from '@sanity/client' +import {type SanityDocument} from '@sanity/types' +import {switchMap} from 'rxjs' + +import {getClient, getClientState} from '../client/clientStore' +import {type DatasetHandle} from '../config/sanityConfig' +import {type SanityInstance} from '../store/createSanityInstance' +import {createFetcherStore} from '../utils/createFetcherStore' +import {hashString} from '../utils/hashString' + +const API_VERSION = 'v2025-10-29' +const IMAGE_ASSET_ID_PATTERN = + /^image-(?[A-Za-z0-9_-]+)-(?\d+x\d+)-(?[a-z]+)$/ + +/** + * Template-literal type for Sanity image asset IDs, eg: + * `image--x-` + * @public + */ +export type ImageAssetId = + `image-${string}-${number}x${number}-${Lowercase}` + +/** + * Runtime validator and type guard for image asset IDs + * @public + */ +export function isImageAssetId(value: string): value is ImageAssetId { + return IMAGE_ASSET_ID_PATTERN.test(value) +} + +/** + * Supported asset kinds for uploads + * @public + */ +export type AssetKind = 'image' | 'file' + +/** + * Options when uploading an asset + * + * These map to the underlying Sanity Assets API. Most fields are optional and will be + * stored on the created asset document. + * + * @public + */ +export interface UploadAssetOptions { + /** Optional filename to associate with the uploaded asset */ + filename?: string + /** Optional MIME type if it cannot be inferred */ + contentType?: string + /** Optional title for the asset */ + title?: string + /** Optional description for the asset */ + description?: string + /** Optional freeform label */ + label?: string + /** Optional credit line */ + creditLine?: string + /** Optional metadata keys to extract on upload (mapped to client `extract`) */ + meta?: AssetMetadataType[] + /** Name of the external source the asset originates from */ + sourceName?: string + /** Identifier of the asset in the external source */ + sourceId?: string + /** URL of the asset in the external source */ + sourceUrl?: string +} + +/** + * Upload an asset to the current project/dataset. + * + * - Pass `kind` as `'image'` or `'file'`. + * - `body` accepts the common upload types (eg. `File`, `Blob`, `Buffer`, `NodeJS.ReadableStream`). + * - Returns the created asset document. + * + * Example (browser): + * ```ts + * const file = input.files?.[0]! + * const image = await uploadAsset(instance, 'image', file, {filename: file.name}) + * ``` + * + * Example (node): + * ```ts + * import {createReadStream} from 'node:fs' + * + * const rs = createReadStream('/path/photo.jpg') + * const image = await uploadAsset(instance, 'image', rs, {filename: 'photo.jpg'}) + * ``` + * + * @public + */ +export async function uploadAsset( + instance: SanityInstance, + kind: 'image', + body: UploadBody, + options?: UploadAssetOptions, +): Promise +export async function uploadAsset( + instance: SanityInstance, + kind: 'file', + body: UploadBody, + options?: UploadAssetOptions, +): Promise +export async function uploadAsset( + instance: SanityInstance, + kind: AssetKind, + body: UploadBody, + options?: UploadAssetOptions, +): Promise { + const client = getClient(instance, {apiVersion: API_VERSION}) + // Map public options to client upload options + const clientOptions = { + filename: options?.filename, + contentType: options?.contentType, + // `meta` maps to client `extract` + ...(options?.meta ? {extract: options.meta} : {}), + title: options?.title, + description: options?.description, + label: options?.label, + creditLine: options?.creditLine, + tag: 'sdk.upload-asset', + ...(options?.sourceName && options?.sourceId + ? { + source: { + name: options.sourceName, + id: options.sourceId, + ...(options?.sourceUrl ? {url: options.sourceUrl} : {}), + }, + } + : {}), + } + const assetType: 'image' | 'file' = kind + return await client.assets.upload(assetType, body, clientOptions) +} + +/** + * Delete an asset by its asset document ID. + * + * Pass the asset document `_id` (eg. `image-abc123-2000x1200-jpg`). + * + * @public + */ +export async function deleteAsset( + instance: SanityInstance, + assetDocumentId: string, +): Promise { + const client = getClient(instance, {apiVersion: API_VERSION}) + await client.delete(assetDocumentId) +} + +/** + * Append a download query parameter to an asset URL so that browsers download the file + * using a preferred filename. + * + * If `filename` is omitted, the CDN uses the original filename (or the asset ID if absent). + * + * @public + */ +export function getAssetDownloadUrl(url: string, filename?: string): string { + const param = typeof filename === 'string' && filename.length > 0 ? filename : '' + const joiner = url.includes('?') ? '&' : '?' + return `${url}${joiner}dl=${encodeURIComponent(param)}` +} + +/** + * Build a CDN URL for an image asset from its asset ID. + * + * Expects `image-{hash}-{width}x{height}-{format}`. Throws for invalid input. + * + * @public + */ +export function buildImageUrlFromId( + instance: SanityInstance, + assetId: ImageAssetId, + projectId?: string, + dataset?: string, +): string +export function buildImageUrlFromId( + instance: SanityInstance, + assetId: string, + projectId?: string, + dataset?: string, +): string { + const resolvedProject = projectId ?? instance.config.projectId + const resolvedDataset = dataset ?? instance.config.dataset + + if (!resolvedProject || !resolvedDataset) { + throw new Error('A projectId and dataset are required to build an image URL from an asset ID.') + } + + const match = assetId.match(IMAGE_ASSET_ID_PATTERN) + if (!match?.groups) { + throw new Error( + `Invalid asset ID \`${assetId}\`. Expected: image-{assetName}-{width}x{height}-{format}`, + ) + } + + const {assetName, dimensions, format} = match.groups as { + assetName: string + dimensions: string + format: string + } + return `https://cdn.sanity.io/images/${resolvedProject}/${resolvedDataset}/${assetName}-${dimensions}.${format}` +} + +/** + * Asset document subset returned by default asset queries + * @public + */ +export interface AssetDocumentBase { + _id: string + _type: 'sanity.imageAsset' | 'sanity.fileAsset' + url?: string + path?: string + originalFilename?: string + size?: number + metadata?: unknown +} + +/** + * Options for querying assets + * @public + */ +export interface AssetQueryOptions< + TDataset extends string = string, + TProjectId extends string = string, +> extends DatasetHandle { + /** Restrict to a specific asset type. Defaults to both */ + assetType?: 'image' | 'file' | 'all' + /** Additional GROQ filter expression appended with && (...) */ + where?: string + /** GROQ params */ + params?: Record + /** Optional ordering, eg: `_createdAt desc` */ + order?: string + /** Limit number of results */ + limit?: number + /** Optional projection body inside \{...\}. Omit to return full documents */ + projection?: string +} + +function buildAssetsGroq( + options: Omit, +): string { + const typeNames = + options.assetType === 'image' + ? ['sanity.imageAsset'] + : options.assetType === 'file' + ? ['sanity.fileAsset'] + : ['sanity.imageAsset', 'sanity.fileAsset'] + + const typeFilter = `_type in [${typeNames.map((t) => `"${t}"`).join(', ')}]` + const where = options.where ? `${typeFilter} && (${options.where})` : typeFilter + + let groq = `*[${where}]` + if (options.order) groq += ` | order(${options.order})` + if (typeof options.limit === 'number') groq += `[0...${options.limit}]` + if (options.projection) groq += `{${options.projection}}` + return groq +} + +const assetsStore = createFetcherStore<[AssetQueryOptions], AssetDocumentBase[]>({ + name: 'Assets', + getKey: (instance, options) => { + const projectId = options.projectId ?? instance.config.projectId + const dataset = options.dataset ?? instance.config.dataset + if (!projectId || !dataset) { + throw new Error('A projectId and dataset are required to query assets.') + } + const groq = buildAssetsGroq(options) + const paramsKey = hashString(JSON.stringify(options.params || {})) + return `${projectId}.${dataset}:assets:${hashString(groq)}:${paramsKey}` + }, + fetcher: (instance) => (options) => { + const projectId = options.projectId ?? instance.config.projectId + const dataset = options.dataset ?? instance.config.dataset + if (!projectId || !dataset) { + throw new Error('A projectId and dataset are required to query assets.') + } + + const groq = buildAssetsGroq(options) + return getClientState(instance, { + apiVersion: API_VERSION, + projectId, + dataset, + useProjectHostname: true, + }).observable.pipe( + switchMap((client) => + client.observable.fetch(groq, options.params ?? {}, { + tag: 'sdk.assets', + }), + ), + ) + }, +}) + +/** + * Returns a StateSource for an asset query. + * + * Example: + * ```ts + * const state = getAssetsState(instance, {assetType: 'image', limit: 50}) + * state.subscribe(images => { + * // render + * }) + * ``` + * + * @public + */ +export const getAssetsState = assetsStore.getState +/** + * Resolves an asset query one-time (Promise-based). + * + * Example: + * ```ts + * const files = await resolveAssets(instance, {assetType: 'file', order: '_createdAt desc'}) + * ``` + * + * @public + */ +export const resolveAssets = assetsStore.resolveState + +/** + * Options for linking a Media Library asset to a dataset + * @public + */ +export interface LinkMediaLibraryAssetOptions< + TDataset extends string = string, + TProjectId extends string = string, +> extends DatasetHandle { + assetId: string + mediaLibraryId: string + assetInstanceId: string + /** Optional request tag */ + tag?: string +} + +/** + * Link a Media Library asset to a local dataset, creating a linked asset document. + * + * Example: + * ```ts + * await linkMediaLibraryAsset(instance, { + * assetId: 'mlAssetId', + * mediaLibraryId: 'mediaLib1', + * assetInstanceId: 'mlInst1', + * }) + * ``` + * + * @public + */ +export async function linkMediaLibraryAsset( + instance: SanityInstance, + options: LinkMediaLibraryAssetOptions, +): Promise { + const projectId = options.projectId ?? instance.config.projectId + const dataset = options.dataset ?? instance.config.dataset + if (!projectId || !dataset) { + throw new Error('A projectId and dataset are required to link a Media Library asset.') + } + + const client = getClient(instance, { + apiVersion: API_VERSION, + projectId, + dataset, + useProjectHostname: true, + }) + return await client.request({ + uri: `/assets/media-library-link/${dataset}`, + method: 'POST', + tag: options.tag ?? 'sdk.link-media-library', + body: { + assetId: options.assetId, + mediaLibraryId: options.mediaLibraryId, + assetInstanceId: options.assetInstanceId, + }, + }) +} diff --git a/packages/react/src/_exports/sdk-react.ts b/packages/react/src/_exports/sdk-react.ts index 88c38a770..1bec4857a 100644 --- a/packages/react/src/_exports/sdk-react.ts +++ b/packages/react/src/_exports/sdk-react.ts @@ -6,6 +6,10 @@ export {SanityApp, type SanityAppProps} from '../components/SanityApp' export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider' export {ComlinkTokenRefreshProvider} from '../context/ComlinkTokenRefresh' export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider' +export {useAssets} from '../hooks/assets/useAssets' +export {useDeleteAsset} from '../hooks/assets/useDeleteAsset' +export {useLinkMediaLibraryAsset} from '../hooks/assets/useLinkMediaLibraryAsset' +export {useUploadAsset} from '../hooks/assets/useUploadAsset' export {useAuthState} from '../hooks/auth/useAuthState' export {useAuthToken} from '../hooks/auth/useAuthToken' export {useCurrentUser} from '../hooks/auth/useCurrentUser' 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.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.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.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 + } +} From bcff6996c3e18c6a6829c03d5905e5639595c9c3 Mon Sep 17 00:00:00 2001 From: Ryan Bonial Date: Wed, 29 Oct 2025 21:16:15 -0600 Subject: [PATCH 2/9] chore: fix exports --- packages/core/src/assets/assets.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/src/assets/assets.ts b/packages/core/src/assets/assets.ts index bd574597b..7a20be7aa 100644 --- a/packages/core/src/assets/assets.ts +++ b/packages/core/src/assets/assets.ts @@ -99,12 +99,14 @@ export async function uploadAsset( body: UploadBody, options?: UploadAssetOptions, ): Promise +/** @public */ export async function uploadAsset( instance: SanityInstance, kind: 'file', body: UploadBody, options?: UploadAssetOptions, ): Promise +/** @public */ export async function uploadAsset( instance: SanityInstance, kind: AssetKind, @@ -179,6 +181,13 @@ export function buildImageUrlFromId( projectId?: string, dataset?: string, ): string +/** + * Build a CDN URL for an image asset from its asset ID. + * + * Expects `image-{hash}-{width}x{height}-{format}`. Throws for invalid input. + * + * @public + */ export function buildImageUrlFromId( instance: SanityInstance, assetId: string, From 2b1a5e77a0e7a11f044aaec6268adf78a896ff66 Mon Sep 17 00:00:00 2001 From: Ryan Bonial Date: Thu, 30 Oct 2025 08:40:43 -0600 Subject: [PATCH 3/9] chore: fix export --- apps/kitchensink-react/src/AppRoutes.tsx | 2 +- apps/kitchensink-react/src/routes/AssetsRoute.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/kitchensink-react/src/AppRoutes.tsx b/apps/kitchensink-react/src/AppRoutes.tsx index 0cee434fe..9ad6197b7 100644 --- a/apps/kitchensink-react/src/AppRoutes.tsx +++ b/apps/kitchensink-react/src/AppRoutes.tsx @@ -15,7 +15,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 {AssetsRoute} from './routes/AssetsRoute' import {DashboardContextRoute} from './routes/DashboardContextRoute' import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute' import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute' diff --git a/apps/kitchensink-react/src/routes/AssetsRoute.tsx b/apps/kitchensink-react/src/routes/AssetsRoute.tsx index fdee1ddd7..a13c2a086 100644 --- a/apps/kitchensink-react/src/routes/AssetsRoute.tsx +++ b/apps/kitchensink-react/src/routes/AssetsRoute.tsx @@ -179,5 +179,3 @@ export function AssetsRoute(): JSX.Element { ) } - -export default AssetsRoute From 3ac7342732c29cae5e10559071d7ac66aaf3a5fb Mon Sep 17 00:00:00 2001 From: Ryan Bonial Date: Thu, 30 Oct 2025 09:54:16 -0600 Subject: [PATCH 4/9] chore: add tests --- packages/core/src/assets/assets.test.ts | 231 ++++++++++++++++++ .../react/src/hooks/assets/useAssets.test.tsx | 231 ++++++++++++++++++ .../src/hooks/assets/useDeleteAsset.test.tsx | 72 ++++++ .../assets/useLinkMediaLibraryAsset.test.tsx | 83 +++++++ .../src/hooks/assets/useUploadAsset.test.tsx | 121 +++++++++ 5 files changed, 738 insertions(+) create mode 100644 packages/core/src/assets/assets.test.ts create mode 100644 packages/react/src/hooks/assets/useAssets.test.tsx create mode 100644 packages/react/src/hooks/assets/useDeleteAsset.test.tsx create mode 100644 packages/react/src/hooks/assets/useLinkMediaLibraryAsset.test.tsx create mode 100644 packages/react/src/hooks/assets/useUploadAsset.test.tsx diff --git a/packages/core/src/assets/assets.test.ts b/packages/core/src/assets/assets.test.ts new file mode 100644 index 000000000..022763d6c --- /dev/null +++ b/packages/core/src/assets/assets.test.ts @@ -0,0 +1,231 @@ +import {type SanityClient} from '@sanity/client' +import {of} from 'rxjs' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {getClient, getClientState} from '../client/clientStore' +import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance' +import {type StateSource} from '../store/createStateSourceAction' +import { + type AssetDocumentBase, + buildImageUrlFromId, + deleteAsset, + getAssetDownloadUrl, + getAssetsState, + isImageAssetId, + linkMediaLibraryAsset, + resolveAssets, + uploadAsset, +} from './assets' + +vi.mock('../client/clientStore', () => ({ + getClient: vi.fn(), + getClientState: vi.fn(), +})) + +describe('assets', () => { + let instance: SanityInstance + + beforeEach(() => { + vi.resetAllMocks() + instance = createSanityInstance({projectId: 'proj', dataset: 'ds'}) + }) + + it('isImageAssetId validates expected pattern', () => { + expect(isImageAssetId('image-abc_123-800x600-jpg')).toBe(true) + expect(isImageAssetId('image-someHash-1x2-png')).toBe(true) + expect(isImageAssetId('image-abc-800-600-jpg')).toBe(false) + expect(isImageAssetId('file-abc-800x600-jpg')).toBe(false) + expect(isImageAssetId('image-abc-800x600-JPG')).toBe(false) + }) + + describe('buildImageUrlFromId', () => { + it('builds a CDN URL from a valid asset id', () => { + const url = buildImageUrlFromId(instance, 'image-somehash-1024x768-png') + expect(url).toBe('https://cdn.sanity.io/images/proj/ds/somehash-1024x768.png') + }) + + it('supports explicit projectId/dataset overrides', () => { + const url = buildImageUrlFromId(instance, 'image-x-10x20-webp', 'p2', 'd2') + expect(url).toBe('https://cdn.sanity.io/images/p2/d2/x-10x20.webp') + }) + + it('throws for invalid asset id', () => { + // @ts-expect-error - invalid asset id + expect(() => buildImageUrlFromId(instance, 'image-x-10-20-webp')).toThrow(/Invalid asset ID/) + }) + + it('throws when projectId/dataset are missing', () => { + const empty = createSanityInstance({}) + expect(() => buildImageUrlFromId(empty, 'image-x-1x1-png')).toThrow( + /projectId and dataset are required/, + ) + empty.dispose() + }) + }) + + describe('getAssetDownloadUrl', () => { + it('appends dl param with empty value when filename omitted', () => { + expect(getAssetDownloadUrl('https://cdn.sanity.io/file.png')).toBe( + 'https://cdn.sanity.io/file.png?dl=', + ) + }) + + it('appends dl param with encoded filename', () => { + expect(getAssetDownloadUrl('https://cdn.sanity.io/file.png', 'nice name.png')).toBe( + 'https://cdn.sanity.io/file.png?dl=nice%20name.png', + ) + }) + + it('uses & when URL already has query parameters', () => { + expect(getAssetDownloadUrl('https://cdn.sanity.io/file.png?w=100', 'x')).toBe( + 'https://cdn.sanity.io/file.png?w=100&dl=x', + ) + }) + }) + + describe('upload/delete asset', () => { + it('uploadAsset forwards to client with mapped options and tag', async () => { + const body = Buffer.from('data') + const uploaded = {_id: 'image-abc-1x1-png', _type: 'sanity.imageAsset'} + const upload = vi.fn().mockResolvedValue(uploaded) + vi.mocked(getClient).mockReturnValue({ + assets: {upload}, + } as unknown as SanityClient) + + const result = await uploadAsset(instance, 'image', body, { + filename: 'photo.png', + contentType: 'image/png', + meta: ['palette'], + title: 't', + description: 'd', + label: 'l', + creditLine: 'c', + sourceName: 'ext', + sourceId: '42', + sourceUrl: 'https://example.com/asset/42', + }) + + expect(upload).toHaveBeenCalledTimes(1) + expect(upload).toHaveBeenCalledWith( + 'image', + body, + expect.objectContaining({ + filename: 'photo.png', + contentType: 'image/png', + extract: ['palette'], + title: 't', + description: 'd', + label: 'l', + creditLine: 'c', + tag: 'sdk.upload-asset', + source: { + name: 'ext', + id: '42', + url: 'https://example.com/asset/42', + }, + }), + ) + expect(result).toBe(uploaded) + }) + + it('uploadAsset omits source when incomplete', async () => { + const body = Buffer.from('x') + const upload = vi.fn().mockResolvedValue({_id: 'file-1', _type: 'sanity.fileAsset'}) + vi.mocked(getClient).mockReturnValue({assets: {upload}} as unknown as SanityClient) + + await uploadAsset(instance, 'file', body, {sourceName: 'ext'}) + + const options = vi.mocked(upload).mock.calls[0][2] + expect(options.source).toBeUndefined() + }) + + it('deleteAsset forwards to client.delete', async () => { + const del = vi.fn().mockResolvedValue(undefined) + vi.mocked(getClient).mockReturnValue({delete: del} as unknown as SanityClient) + + await deleteAsset(instance, 'image-abc-1x1-png') + expect(del).toHaveBeenCalledWith('image-abc-1x1-png') + }) + }) + + describe('linkMediaLibraryAsset', () => { + it('throws when projectId/dataset are missing', async () => { + const empty = createSanityInstance({}) + await expect( + linkMediaLibraryAsset(empty, { + assetId: 'a', + mediaLibraryId: 'm', + assetInstanceId: 'i', + }), + ).rejects.toThrow(/projectId and dataset are required/) + empty.dispose() + }) + + it('posts to media-library link endpoint with correct body', async () => { + const request = vi.fn().mockResolvedValue({_id: 'linkedAsset', _type: 'sanity.fileAsset'}) + vi.mocked(getClient).mockReturnValue({request} as unknown as SanityClient) + + const doc = await linkMediaLibraryAsset(instance, { + projectId: 'p1', + dataset: 'd1', + assetId: 'asset-1', + mediaLibraryId: 'lib-1', + assetInstanceId: 'inst-1', + tag: 'custom-tag', + }) + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + uri: '/assets/media-library-link/d1', + method: 'POST', + tag: 'custom-tag', + body: { + assetId: 'asset-1', + mediaLibraryId: 'lib-1', + assetInstanceId: 'inst-1', + }, + }), + ) + expect(doc).toEqual({_id: 'linkedAsset', _type: 'sanity.fileAsset'}) + }) + }) + + describe('assetsStore integration', () => { + it('resolveAssets fetches via client observable when configured', async () => { + const data: AssetDocumentBase[] = [{_id: 'image-1', _type: 'sanity.imageAsset', url: 'u'}] + const fetch = vi.fn().mockReturnValue(of(data)) + vi.mocked(getClientState).mockReturnValue({ + observable: of({observable: {fetch}} as unknown as SanityClient), + } as StateSource) + + const result = await resolveAssets(instance, { + projectId: 'p', + dataset: 'd', + assetType: 'image', + where: 'defined(url)', + limit: 1, + params: {x: 1}, + order: '_createdAt desc', + }) + + expect(fetch).toHaveBeenCalledTimes(1) + const [groq, params, options] = vi.mocked(fetch).mock.calls[0] + expect(typeof groq).toBe('string') + expect(groq).toContain('sanity.imageAsset') + expect(groq).toContain('defined(url)') + expect(groq).toContain('order(_createdAt desc)') + expect(groq).toContain('[0...1]') + expect(params).toEqual({x: 1}) + expect(options).toEqual(expect.objectContaining({tag: 'sdk.assets'})) + expect(result).toEqual(data) + }) + + it('getAssetsState throws without projectId/dataset', () => { + const empty = createSanityInstance({}) + expect(() => getAssetsState(empty, {assetType: 'all'}).getCurrent()).toThrow( + /projectId and dataset are required/, + ) + empty.dispose() + }) + }) +}) diff --git a/packages/react/src/hooks/assets/useAssets.test.tsx b/packages/react/src/hooks/assets/useAssets.test.tsx new file mode 100644 index 000000000..2f4dfcd83 --- /dev/null +++ b/packages/react/src/hooks/assets/useAssets.test.tsx @@ -0,0 +1,231 @@ +import {type AssetDocumentBase, getAssetsState, resolveAssets, type StateSource} from '@sanity/sdk' +import {act, render, screen} from '@testing-library/react' +import {useState} from 'react' +import {type Observable, Subject} from 'rxjs' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {ResourceProvider} from '../../context/ResourceProvider' +import {useAssets} from './useAssets' + +vi.mock('@sanity/sdk', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + getAssetsState: vi.fn(), + resolveAssets: vi.fn(), + } +}) + +describe('useAssets', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('renders assets immediately when available', () => { + const assets: AssetDocumentBase[] = [ + {_id: 'image-1', _type: 'sanity.imageAsset', url: 'u1'}, + {_id: 'file-1', _type: 'sanity.fileAsset', url: 'u2'}, + ] + + vi.mocked(getAssetsState).mockReturnValue({ + getCurrent: vi.fn().mockReturnValue(assets), + subscribe: vi.fn(), + get observable(): Observable { + throw new Error('Not implemented') + }, + } as unknown as StateSource) + + function TestComponent() { + const data = useAssets({assetType: 'all'}) + return
{data.map((a) => a._id).join(',')}
+ } + + render( + Loading...

}> + +
, + ) + + expect(screen.getByTestId('output').textContent).toBe('image-1,file-1') + }) + + it('suspends until assets are resolved via Suspense', async () => { + const ref = {current: undefined as AssetDocumentBase[] | undefined} + const getCurrent = vi.fn(() => ref.current) + const storeChanged$ = new Subject() + + vi.mocked(getAssetsState).mockReturnValue({ + getCurrent, + subscribe: vi.fn((cb) => { + const sub = storeChanged$.subscribe(cb) + return () => sub.unsubscribe() + }), + get observable(): Observable { + throw new Error('Not implemented') + }, + } as unknown as StateSource) + + let resolvePromise: () => void + vi.mocked(resolveAssets).mockReturnValue( + new Promise((resolve) => { + resolvePromise = () => { + ref.current = [{_id: 'image-1', _type: 'sanity.imageAsset'}] + storeChanged$.next() + resolve(ref.current) + } + }), + ) + + function TestComponent() { + const data = useAssets({assetType: 'image'}) + return
{data.map((a) => a._id).join(',')}
+ } + + render( + Loading...} + > + + , + ) + + expect(screen.getByTestId('fallback')).toBeInTheDocument() + + await act(async () => { + resolvePromise() + }) + + expect(screen.getByTestId('output').textContent).toBe('image-1') + }) + + it('re-subscribes and updates when options change', async () => { + const ref = {current: undefined as AssetDocumentBase[] | undefined} + const getCurrent = vi.fn(() => ref.current) + const storeChanged$ = new Subject() + + vi.mocked(getAssetsState).mockImplementation((_instance, options) => { + if (options?.assetType === 'image') { + return { + getCurrent: vi.fn().mockReturnValue([{_id: 'image-1', _type: 'sanity.imageAsset'}]), + subscribe: vi.fn(), + get observable(): Observable { + throw new Error('Not implemented') + }, + } as unknown as StateSource + } + + return { + getCurrent, + subscribe: vi.fn((cb) => { + const sub = storeChanged$.subscribe(cb) + return () => sub.unsubscribe() + }), + get observable(): Observable { + throw new Error('Not implemented') + }, + } as unknown as StateSource + }) + + let resolvePromise: () => void + vi.mocked(resolveAssets).mockReturnValue( + new Promise((resolve) => { + resolvePromise = () => { + ref.current = [{_id: 'file-1', _type: 'sanity.fileAsset'}] + storeChanged$.next() + resolve(ref.current) + } + }), + ) + + function Wrapper() { + const [kind, setKind] = useState<'image' | 'file'>('image') + const data = useAssets({assetType: kind}) + return ( +
+
{data.map((a) => a._id).join(',')}
+ +
+ ) + } + + 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/useDeleteAsset.test.tsx b/packages/react/src/hooks/assets/useDeleteAsset.test.tsx new file mode 100644 index 000000000..26e7fd2c2 --- /dev/null +++ b/packages/react/src/hooks/assets/useDeleteAsset.test.tsx @@ -0,0 +1,72 @@ +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) + }) +}) 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/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) + }) +}) From 105ba0401b79557044a10441aa92bea29f031989 Mon Sep 17 00:00:00 2001 From: Ryan Bonial Date: Thu, 30 Oct 2025 10:23:14 -0600 Subject: [PATCH 5/9] chore: style asset example --- apps/kitchensink-react/src/AppRoutes.tsx | 6 +- .../src/routes/AssetsRoute.tsx | 304 ++++++++++++------ 2 files changed, 202 insertions(+), 108 deletions(-) diff --git a/apps/kitchensink-react/src/AppRoutes.tsx b/apps/kitchensink-react/src/AppRoutes.tsx index 9ad6197b7..b68a47861 100644 --- a/apps/kitchensink-react/src/AppRoutes.tsx +++ b/apps/kitchensink-react/src/AppRoutes.tsx @@ -33,7 +33,11 @@ const documentCollectionRoutes = [ { path: 'assets', element: ( - + Loading assets...} + > ), diff --git a/apps/kitchensink-react/src/routes/AssetsRoute.tsx b/apps/kitchensink-react/src/routes/AssetsRoute.tsx index a13c2a086..9aef9a0f1 100644 --- a/apps/kitchensink-react/src/routes/AssetsRoute.tsx +++ b/apps/kitchensink-react/src/routes/AssetsRoute.tsx @@ -5,8 +5,12 @@ import { useLinkMediaLibraryAsset, useUploadAsset, } from '@sanity/sdk-react' +import {Button, Card, Flex, Label, Stack, Text} from '@sanity/ui' import {type JSX, useCallback, useMemo, useRef, useState} from 'react' +import {DocumentGridLayout} from '../components/DocumentGridLayout/DocumentGridLayout' +import {PageLayout} from '../components/PageLayout' + function AssetList({ assets, onDelete, @@ -14,53 +18,65 @@ function AssetList({ assets: AssetDocumentBase[] onDelete: (id: string) => void }) { - if (!assets.length) return

No assets

+ 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} -
    - )} -
    - - Open - - -
    +
  • + + + + {a._type} + + {a.url ? ( + {a.originalFilename + ) : ( + + + {a.originalFilename ?? a._id} + + + )} + + {a.originalFilename ?? a._id} + + +
  • ))} -
+ ) } @@ -69,7 +85,13 @@ export function AssetsRoute(): JSX.Element { const [assetType, setAssetType] = useState<'all' | 'image' | 'file'>('all') const [order, setOrder] = useState('_createdAt desc') const [limit, setLimit] = useState(24) - const options = useMemo(() => ({assetType, order, limit}), [assetType, order, limit]) + // Bump this to force a re-fetch of assets + const [refresh, setRefresh] = useState(0) + const [isUploading, setIsUploading] = useState(false) + const options = useMemo( + () => ({assetType, order, limit, params: {refresh}}), + [assetType, order, limit, refresh], + ) // Hooks const assets = useAssets(options) @@ -83,13 +105,20 @@ export function AssetsRoute(): JSX.Element { async (ev: React.ChangeEvent) => { const f = ev.target.files?.[0] if (!f) return + setIsUploading(true) const kind = f.type.startsWith('image/') ? 'image' : 'file' - if (kind === 'image') { - await upload('image', f, {filename: f.name}) - } else { - await upload('file', f, {filename: f.name}) + try { + if (kind === 'image') { + await upload('image', f, {filename: f.name}) + } else { + await upload('file', f, {filename: f.name}) + } + // trigger a refresh so the new asset appears + setRefresh((r) => r + 1) + } finally { + if (fileInputRef.current) fileInputRef.current.value = '' + setIsUploading(false) } - if (fileInputRef.current) fileInputRef.current.value = '' }, [upload], ) @@ -98,6 +127,8 @@ export function AssetsRoute(): JSX.Element { async (id: string) => { if (!confirm('Delete this asset?')) return await remove(id) + // refresh after deletion + setRefresh((r) => r + 1) }, [remove], ) @@ -112,70 +143,129 @@ export function AssetsRoute(): JSX.Element { setMlAssetId('') setMlId('') setMlInstId('') + // refresh after linking + setRefresh((r) => r + 1) }, [linkML, mlAssetId, mlId, mlInstId]) return ( -
-

Assets

- -
- - - - -
+
+ + +
+
+ + 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

- -
+ + + Browse + + + -
-

Link Media Library Asset

-
- setMlAssetId(e.target.value)} - /> - setMlId(e.target.value)} - /> - setMlInstId(e.target.value)} - /> - -
-
- + + + + Link Media Library Asset + + + setMlAssetId(e.target.value)} + /> + setMlId(e.target.value)} + /> + setMlInstId(e.target.value)} + /> +