UNPKG

sanity

Version:

Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches

243 lines (219 loc) • 7.96 kB
import {type ClientError, type SanityClient} from '@sanity/client' import {useMemo} from 'react' import {EMPTY, fromEvent, type Observable, of, timer} from 'rxjs' import { catchError, distinctUntilChanged, map, shareReplay, startWith, switchMap, } from 'rxjs/operators' import { type AvailabilityResponse, createHookFromObservableFactory, DEFAULT_STUDIO_CLIENT_OPTIONS, type DocumentStore, getDraftId, getPublishedId, useClient, useDocumentStore, } from 'sanity' // this is used in place of `instanceof` so the matching can be more robust and // won't have any issues with dual packages etc // https://nodejs.org/api/packages.html#dual-package-hazard function isClientError(e: unknown): e is ClientError { if (typeof e !== 'object') return false if (!e) return false return 'statusCode' in e && 'response' in e } const POLL_INTERVAL = 5000 // only fetches when the document is visible let visiblePoll$: Observable<number> const getVisiblePoll$ = () => { if (!visiblePoll$) { visiblePoll$ = fromEvent(document, 'visibilitychange').pipe( // add empty emission to have this fire on creation startWith(null), map(() => document.visibilityState === 'visible'), distinctUntilChanged(), switchMap((visible) => visible ? // using timer instead of interval since timer will emit on creation timer(0, POLL_INTERVAL) : EMPTY, ), shareReplay({refCount: true, bufferSize: 1}), ) } return visiblePoll$ } export type ReferringDocuments = { isLoading: boolean totalCount: number projectIds: string[] datasetNames: string[] hasUnknownDatasetNames: boolean internalReferences?: { totalCount: number references: Array<{_id: string; _type: string}> } crossDatasetReferences?: { totalCount: number references: Array<{ /** * The project ID of the document that is currently referencing the subject * document. Unlike `documentId` and `datasetName`, this should always be * defined. */ projectId: string /** * The ID of the document that is currently referencing the subject * document. This will be omitted if there is no access to the current * project and dataset pair (e.g. if no `sanity-project-token` were * configured) */ documentId?: string /** * The dataset name that is currently referencing the subject document. * This will be omitted if there is no access to the current project and * dataset pair (e.g. if no `sanity-project-token` were configured) */ datasetName?: string }> } } function getDocumentExistence( documentId: string, {versionedClient}: {versionedClient: SanityClient}, ): Observable<string | undefined> { const draftId = getDraftId(documentId) const publishedId = getPublishedId(documentId) const requestOptions = { uri: versionedClient.getDataUrl('doc', `${draftId},${publishedId}`), json: true, query: {excludeContent: 'true'}, tag: 'use-referring-documents.document-existence', } return versionedClient.observable.request<AvailabilityResponse>(requestOptions).pipe( map(({omitted}) => { const nonExistant = omitted.filter((doc) => doc.reason === 'existence') if (nonExistant.length === 2) { // None of the documents exist return undefined } if (nonExistant.length === 0) { // Both exist, so use the published one return publishedId } // If the draft does not exist, use the published ID, and vice versa return nonExistant.some((doc) => doc.id === draftId) ? publishedId : draftId }), ) } /** * fetches the cross-dataset references using the client observable.request * method (for that requests can be automatically cancelled) */ function fetchCrossDatasetReferences( documentId: string, context: {versionedClient: SanityClient}, ): Observable<ReferringDocuments['crossDatasetReferences']> { const {versionedClient} = context return getVisiblePoll$().pipe( switchMap(() => getDocumentExistence(documentId, context)), switchMap((checkDocumentId) => { if (!checkDocumentId) { return of({totalCount: 0, references: []}) } const currentDataset = versionedClient.config().dataset return versionedClient.observable .request({ url: `/data/references/${currentDataset}/documents/${checkDocumentId}/to?excludeInternalReferences=true&excludePaths=true`, tag: 'use-referring-documents.external', }) .pipe( catchError((e) => { // it's possible that referencing document doesn't exist yet so the // API will return a 404. In those cases, we want to catch and return // a response with no references if (isClientError(e) && e.statusCode === 404) { return of({totalCount: 0, references: []}) } throw e }), ) }), ) } const useInternalReferences = createHookFromObservableFactory( ([documentId, documentStore]: [string, DocumentStore]) => { const referencesClause = '*[references($documentId)][0...100]{_id,_type}' const totalClause = 'count(*[references($documentId)])' const fetchQuery = `{"references":${referencesClause},"totalCount":${totalClause}}` const listenQuery = '*[references($documentId)]' return documentStore.listenQuery( {fetch: fetchQuery, listen: listenQuery}, {documentId}, {tag: 'use-referring-documents', transitions: ['appear', 'disappear'], throttleTime: 5000}, ) as Observable<ReferringDocuments['internalReferences']> }, ) const useCrossDatasetReferences = createHookFromObservableFactory( ([documentId, versionedClient]: [string, SanityClient]) => { // (documentId: string, versionedClient: SanityClient) => { return getVisiblePoll$().pipe( switchMap(() => fetchCrossDatasetReferences(documentId, { versionedClient, }), ), ) }, ) export function useReferringDocuments(documentId: string): ReferringDocuments { const versionedClient = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) const documentStore = useDocumentStore() const publishedId = getPublishedId(documentId) const [internalReferences, isInternalReferencesLoading] = useInternalReferences( useMemo(() => [publishedId, documentStore], [documentStore, publishedId]), ) const [crossDatasetReferences, isCrossDatasetReferencesLoading] = useCrossDatasetReferences( useMemo(() => [publishedId, versionedClient], [publishedId, versionedClient]), ) const projectIds = useMemo(() => { return Array.from( new Set( crossDatasetReferences?.references .map((crossDatasetReference) => crossDatasetReference.projectId) .filter(Boolean), ), ).sort() }, [crossDatasetReferences?.references]) const datasetNames = useMemo(() => { return Array.from( new Set<string>( crossDatasetReferences?.references // .filter((name) => typeof name === 'string') .map((crossDatasetReference) => crossDatasetReference?.datasetName || '') .filter((datasetName) => Boolean(datasetName) && datasetName !== ''), ), ).sort() }, [crossDatasetReferences?.references]) const hasUnknownDatasetNames = useMemo(() => { return Boolean( crossDatasetReferences?.references.some( (crossDatasetReference) => typeof crossDatasetReference.datasetName !== 'string', ), ) }, [crossDatasetReferences?.references]) return { totalCount: (internalReferences?.totalCount || 0) + (crossDatasetReferences?.totalCount || 0), projectIds, datasetNames, hasUnknownDatasetNames, internalReferences, crossDatasetReferences, isLoading: isInternalReferencesLoading || isCrossDatasetReferencesLoading, } }