diff --git a/apps/kitchensink-react/src/AppRoutes.tsx b/apps/kitchensink-react/src/AppRoutes.tsx index 2c98a823a..7285d9d0c 100644 --- a/apps/kitchensink-react/src/AppRoutes.tsx +++ b/apps/kitchensink-react/src/AppRoutes.tsx @@ -17,6 +17,7 @@ import {ProtectedRoute} from './ProtectedRoute' import {DashboardContextRoute} from './routes/DashboardContextRoute' import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute' import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute' +import {MediaLibraryRoute} from './routes/MediaLibraryRoute' import {PerspectivesRoute} from './routes/PerspectivesRoute' import {ProjectsRoute} from './routes/ProjectsRoute' import {ReleasesRoute} from './routes/releases/ReleasesRoute' @@ -72,6 +73,10 @@ const documentCollectionRoutes = [ path: 'presence', element: , }, + { + path: 'media-library', + element: , + }, ] const dashboardInteractionRoutes = [ diff --git a/apps/kitchensink-react/src/routes/MediaLibraryRoute.tsx b/apps/kitchensink-react/src/routes/MediaLibraryRoute.tsx new file mode 100644 index 000000000..74a18c670 --- /dev/null +++ b/apps/kitchensink-react/src/routes/MediaLibraryRoute.tsx @@ -0,0 +1,76 @@ +import {mediaLibrarySource, useQuery} from '@sanity/sdk-react' +import {Card, Spinner, Text} from '@sanity/ui' +import {type JSX, useState} from 'react' + +// for now, hardcoded. should be inferred from org later on +const MEDIA = mediaLibrarySource('mlPGY7BEqt52') + +export function MediaLibraryRoute(): JSX.Element { + const [query] = useState('*[_type == "sanity.asset"][0...10] | order(_id desc)') + const [isLoading] = useState(false) + + const {data, isPending} = useQuery({ + query, + source: MEDIA, + }) + + return ( +
+ + Media Library Query Demo + + + + This route demonstrates querying against a Sanity media library. The MediaLibraryProvider is + automatically created by SanityApp when a media library config is present. The query runs + against: https://api.sanity.io/v2025-03-24/media-libraries/mlPGY7BEqt52/query + + + +
+ + Current query: + + + {query} + +
+
+ + +
+ + Query Results: + + {(isPending || isLoading) && } +
+ +
+          {JSON.stringify(data, null, 2)}
+        
+
+
+ ) +} diff --git a/packages/core/src/_exports/index.ts b/packages/core/src/_exports/index.ts index e3df1f009..ca0fcb24f 100644 --- a/packages/core/src/_exports/index.ts +++ b/packages/core/src/_exports/index.ts @@ -58,8 +58,11 @@ export { } from '../config/handles' export { type DatasetHandle, + datasetSource, type DocumentHandle, + type DocumentSource, type DocumentTypeHandle, + mediaLibrarySource, type PerspectiveHandle, type ProjectHandle, type ReleasePerspective, diff --git a/packages/core/src/client/clientStore.test.ts b/packages/core/src/client/clientStore.test.ts index c0b912052..18462dac3 100644 --- a/packages/core/src/client/clientStore.test.ts +++ b/packages/core/src/client/clientStore.test.ts @@ -3,6 +3,7 @@ import {Subject} from 'rxjs' import {beforeEach, describe, expect, it, vi} from 'vitest' import {getAuthMethodState, getTokenState} from '../auth/authStore' +import {datasetSource, mediaLibrarySource} from '../config/sanityConfig' import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance' import {getClient, getClientState} from './clientStore' @@ -158,4 +159,120 @@ describe('clientStore', () => { subscription.unsubscribe() }) }) + + describe('source handling', () => { + it('should create client when source is provided', () => { + const source = datasetSource('source-project', 'source-dataset') + const client = getClient(instance, {apiVersion: '2024-11-12', source}) + + expect(vi.mocked(createClient)).toHaveBeenCalledWith( + expect.objectContaining({ + apiVersion: '2024-11-12', + source: expect.objectContaining({ + __sanity_internal_sourceId: { + projectId: 'source-project', + dataset: 'source-dataset', + }, + }), + }), + ) + // Client should be projectless - no projectId/dataset in config + expect(client.config()).not.toHaveProperty('projectId') + expect(client.config()).not.toHaveProperty('dataset') + expect(client.config()).toEqual( + expect.objectContaining({ + source: expect.objectContaining({ + __sanity_internal_sourceId: { + projectId: 'source-project', + dataset: 'source-dataset', + }, + }), + }), + ) + }) + + it('should create resource when source has array sourceId and be projectless', () => { + const source = mediaLibrarySource('media-lib-123') + const client = getClient(instance, {apiVersion: '2024-11-12', source}) + + expect(vi.mocked(createClient)).toHaveBeenCalledWith( + expect.objectContaining({ + '~experimental_resource': {type: 'media-library', id: 'media-lib-123'}, + 'apiVersion': '2024-11-12', + }), + ) + // Client should be projectless - no projectId/dataset in config + expect(client.config()).not.toHaveProperty('projectId') + expect(client.config()).not.toHaveProperty('dataset') + expect(client.config()).toEqual( + expect.objectContaining({ + '~experimental_resource': {type: 'media-library', id: 'media-lib-123'}, + }), + ) + }) + + it('should create projectless client when source is provided, ignoring instance config', () => { + const source = datasetSource('source-project', 'source-dataset') + const client = getClient(instance, {apiVersion: '2024-11-12', source}) + + // Client should be projectless - source takes precedence, instance config is ignored + expect(client.config()).not.toHaveProperty('projectId') + expect(client.config()).not.toHaveProperty('dataset') + expect(client.config()).toEqual( + expect.objectContaining({ + source: expect.objectContaining({ + __sanity_internal_sourceId: { + projectId: 'source-project', + dataset: 'source-dataset', + }, + }), + }), + ) + }) + + it('should warn when both source and explicit projectId/dataset are provided', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const source = datasetSource('source-project', 'source-dataset') + const client = getClient(instance, { + apiVersion: '2024-11-12', + source, + projectId: 'explicit-project', + dataset: 'explicit-dataset', + }) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.', + ) + // Client should still be projectless despite explicit projectId/dataset + expect(client.config()).not.toHaveProperty('projectId') + expect(client.config()).not.toHaveProperty('dataset') + consoleSpy.mockRestore() + }) + + it('should create different clients for different sources', () => { + const source1 = datasetSource('project-1', 'dataset-1') + const source2 = datasetSource('project-2', 'dataset-2') + const source3 = mediaLibrarySource('media-lib-1') + + const client1 = getClient(instance, {apiVersion: '2024-11-12', source: source1}) + const client2 = getClient(instance, {apiVersion: '2024-11-12', source: source2}) + const client3 = getClient(instance, {apiVersion: '2024-11-12', source: source3}) + + expect(client1).not.toBe(client2) + expect(client2).not.toBe(client3) + expect(client1).not.toBe(client3) + expect(vi.mocked(createClient)).toHaveBeenCalledTimes(3) + }) + + it('should reuse clients with identical source configurations', () => { + const source = datasetSource('same-project', 'same-dataset') + const options = {apiVersion: '2024-11-12', source} + + const client1 = getClient(instance, options) + const client2 = getClient(instance, options) + + expect(client1).toBe(client2) + expect(vi.mocked(createClient)).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/core/src/client/clientStore.ts b/packages/core/src/client/clientStore.ts index c83b266e0..78092d1d0 100644 --- a/packages/core/src/client/clientStore.ts +++ b/packages/core/src/client/clientStore.ts @@ -2,6 +2,7 @@ import {type ClientConfig, createClient, type SanityClient} from '@sanity/client import {pick} from 'lodash-es' import {getAuthMethodState, getTokenState} from '../auth/authStore' +import {type DocumentSource, SOURCE_ID} from '../config/sanityConfig' import {bindActionGlobally} from '../store/createActionBinder' import {createStateSourceAction} from '../store/createStateSourceAction' import {defineStore, type StoreContext} from '../store/defineStore' @@ -39,6 +40,7 @@ const allowedKeys = Object.keys({ 'requestTagPrefix': null, 'useProjectHostname': null, '~experimental_resource': null, + 'source': null, } satisfies Record) as (keyof ClientOptions)[] const DEFAULT_CLIENT_CONFIG: ClientConfig = { @@ -90,6 +92,11 @@ export interface ClientOptions extends Pick({ @@ -156,18 +163,42 @@ export const getClient = bindActionGlobally( const tokenFromState = state.get().token const {clients, authMethod} = state.get() + const hasSource = !!options.source + let sourceId = options.source?.[SOURCE_ID] + + let resource + if (Array.isArray(sourceId)) { + resource = {type: sourceId[0], id: sourceId[1]} + sourceId = undefined + } + const projectId = options.projectId ?? instance.config.projectId const dataset = options.dataset ?? instance.config.dataset const apiHost = options.apiHost ?? instance.config.auth?.apiHost const effectiveOptions: ClientOptions = { ...DEFAULT_CLIENT_CONFIG, - ...((options.scope === 'global' || !projectId) && {useProjectHostname: false}), + ...((options.scope === 'global' || !projectId || hasSource) && {useProjectHostname: false}), token: authMethod === 'cookie' ? undefined : (tokenFromState ?? undefined), ...options, ...(projectId && {projectId}), ...(dataset && {dataset}), ...(apiHost && {apiHost}), + ...(resource && {'~experimental_resource': resource}), + } + + // When a source is provided, don't use projectId/dataset - the client should be "projectless" + // The client code itself will ignore the non-source config, so we do this to prevent confusing the user. + // (ref: https://github.com/sanity-io/client/blob/5c23f81f5ab93a53f5b22b39845c867988508d84/src/data/dataMethods.ts#L691) + if (hasSource) { + if (options.projectId || options.dataset) { + // eslint-disable-next-line no-console + console.warn( + 'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.', + ) + } + delete effectiveOptions.projectId + delete effectiveOptions.dataset } if (effectiveOptions.token === null || typeof effectiveOptions.token === 'undefined') { diff --git a/packages/core/src/config/sanityConfig.ts b/packages/core/src/config/sanityConfig.ts index 7dbd8accd..00c63468f 100644 --- a/packages/core/src/config/sanityConfig.ts +++ b/packages/core/src/config/sanityConfig.ts @@ -81,3 +81,34 @@ export interface SanityConfig extends DatasetHandle, PerspectiveHandle { enabled: boolean } } + +export const SOURCE_ID = '__sanity_internal_sourceId' + +/** + * A document source can be used for querying. + * + * @beta + * @see datasetSource Construct a document source for a given projectId and dataset. + * @see mediaLibrarySource Construct a document source for a mediaLibraryId. + */ +export type DocumentSource = { + [SOURCE_ID]: ['media-library', string] | {projectId: string; dataset: string} +} + +/** + * Returns a document source for a projectId and dataset. + * + * @beta + */ +export function datasetSource(projectId: string, dataset: string): DocumentSource { + return {[SOURCE_ID]: {projectId, dataset}} +} + +/** + * Returns a document source for a Media Library. + * + * @beta + */ +export function mediaLibrarySource(id: string): DocumentSource { + return {[SOURCE_ID]: ['media-library', id]} +} diff --git a/packages/core/src/query/queryStore.ts b/packages/core/src/query/queryStore.ts index 995bfec2f..5f92b02d9 100644 --- a/packages/core/src/query/queryStore.ts +++ b/packages/core/src/query/queryStore.ts @@ -23,9 +23,9 @@ import { } from 'rxjs' import {getClientState} from '../client/clientStore' -import {type DatasetHandle} from '../config/sanityConfig' +import {type DatasetHandle, type DocumentSource} from '../config/sanityConfig' import {getPerspectiveState} from '../releases/getPerspectiveState' -import {bindActionByDataset} from '../store/createActionBinder' +import {bindActionBySource} from '../store/createActionBinder' import {type SanityInstance} from '../store/createSanityInstance' import { createStateSourceAction, @@ -62,6 +62,7 @@ export interface QueryOptions< DatasetHandle { query: TQuery params?: Record + source?: DocumentSource } /** @@ -160,6 +161,7 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext { return _getQueryState(...args) } -const _getQueryState = bindActionByDataset( +const _getQueryState = bindActionBySource( queryStore, createStateSourceAction({ selector: ({state, instance}: SelectorContext, options: QueryOptions) => { @@ -349,7 +352,7 @@ export function resolveQuery( export function resolveQuery(...args: Parameters): Promise { return _resolveQuery(...args) } -const _resolveQuery = bindActionByDataset( +const _resolveQuery = bindActionBySource( queryStore, ({state, instance}, {signal, ...options}: ResolveQueryOptions) => { const normalized = normalizeOptionsWithPerspective(instance, options) diff --git a/packages/core/src/store/createActionBinder.ts b/packages/core/src/store/createActionBinder.ts index 4d505707e..ad3974d03 100644 --- a/packages/core/src/store/createActionBinder.ts +++ b/packages/core/src/store/createActionBinder.ts @@ -1,4 +1,4 @@ -import {type SanityConfig} from '../config/sanityConfig' +import {type DocumentSource, type SanityConfig, SOURCE_ID} from '../config/sanityConfig' import {type SanityInstance} from './createSanityInstance' import {createStoreInstance, type StoreInstance} from './createStoreInstance' import {type StoreState} from './createStoreState' @@ -43,7 +43,9 @@ export type BoundStoreAction<_TState, TParams extends unknown[], TReturn> = ( * ) * ``` */ -export function createActionBinder(keyFn: (config: SanityConfig) => string) { +export function createActionBinder( + keyFn: (config: SanityConfig, ...params: TKeyParams) => string, +) { const instanceRegistry = new Map>() const storeRegistry = new Map>() @@ -54,12 +56,12 @@ export function createActionBinder(keyFn: (config: SanityConfig) => string) { * @param action - The action to bind * @returns A function that executes the action with a Sanity instance */ - return function bindAction( + return function bindAction( storeDefinition: StoreDefinition, action: StoreAction, ): BoundStoreAction { return function boundAction(instance: SanityInstance, ...params: TParams) { - const keySuffix = keyFn(instance.config) + const keySuffix = keyFn(instance.config, ...params) const compositeKey = storeDefinition.name + (keySuffix ? `:${keySuffix}` : '') // Get or create instance set for this composite key @@ -128,13 +130,32 @@ export function createActionBinder(keyFn: (config: SanityConfig) => string) { * fetchDocument(sanityInstance, 'doc123') * ``` */ -export const bindActionByDataset = createActionBinder(({projectId, dataset}) => { +export const bindActionByDataset = createActionBinder(({projectId, dataset}) => { if (!projectId || !dataset) { throw new Error('This API requires a project ID and dataset configured.') } return `${projectId}.${dataset}` }) +/** + * Binds an action to a store that's scoped to a specific document source. + **/ +export const bindActionBySource = createActionBinder<[{source?: DocumentSource}, ...unknown[]]>( + ({projectId, dataset}, {source}) => { + if (source) { + const id = source[SOURCE_ID] + if (!id) throw new Error('Invalid source (missing ID information)') + if (Array.isArray(id)) return id.join(':') + return `${id.projectId}.${id.dataset}` + } + + if (!projectId || !dataset) { + throw new Error('This API requires a project ID and dataset configured.') + } + return `${projectId}.${dataset}` + }, +) + /** * Binds an action to a global store that's shared across all Sanity instances * @@ -173,4 +194,4 @@ export const bindActionByDataset = createActionBinder(({projectId, dataset}) => * getCurrentUser(sanityInstance) * ``` */ -export const bindActionGlobally = createActionBinder(() => 'global') +export const bindActionGlobally = createActionBinder(() => 'global')