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
184 lines (159 loc) • 5.39 kB
text/typescript
/* eslint-disable @typescript-eslint/no-shadow */
import {get} from 'lodash'
import {useEffect, useMemo, useState} from 'react'
import {isObservable, map, type Observable, of, switchMap} from 'rxjs'
import {
type DocumentStore,
getDraftId,
isRecord,
isReference,
type Previewable,
type SanityDocument,
useDocumentStore,
usePerspective,
} from 'sanity'
import {
type DocumentLocationResolver,
type DocumentLocationResolverObject,
type DocumentLocationResolvers,
type DocumentLocationsState,
type DocumentLocationsStatus,
} from './types'
import {props} from './util/props'
const INITIAL_STATE: DocumentLocationsState = {locations: []}
function getDocumentId(value: Previewable) {
if (isReference(value)) {
return value._ref
}
return '_id' in value ? value._id : undefined
}
function cleanPreviewable(id: string | undefined, previewable: Previewable) {
const clean: Record<string, unknown> = id ? {...previewable, _id: id} : {...previewable}
if (clean._type === 'reference') {
delete clean._type
delete clean._ref
delete clean._weak
delete clean._dataset
delete clean._projectId
delete clean._strengthenOnPublish
}
return clean
}
function listen(id: string, fields: string[], store: DocumentStore) {
const projection = fields.join(', ')
const query = {
fetch: `*[_id==$id][0]{${projection}}`,
// TODO: is it more performant to use `||` instead of `in`?
listen: `*[_id in [$id,$draftId]]`,
}
const params = {id, draftId: getDraftId(id)}
return store.listenQuery(query, params, {
perspective: 'drafts',
tag: 'drafts',
}) as Observable<SanityDocument | null>
}
function observeDocument(
value: Previewable | null,
paths: string[][],
store: DocumentStore,
): Observable<Record<string, unknown> | null> {
if (!value || typeof value !== 'object') {
return of(value)
}
const id = getDocumentId(value)
const currentValue = cleanPreviewable(id, value)
const headlessPaths = paths.filter((path) => !(path[0] in currentValue))
if (id && headlessPaths.length) {
const fields = [...new Set(headlessPaths.map((path: string[]) => path[0]))]
return listen(id, fields, store).pipe(
switchMap((snapshot) => {
if (snapshot) {
return observeDocument(snapshot, paths, store)
}
return of(null)
}),
)
}
const leads: Record<string, string[][]> = {}
paths.forEach((path) => {
const [head, ...tail] = path
if (!leads[head]) {
leads[head] = []
}
leads[head].push(tail)
})
const next = Object.keys(leads).reduce((res: Record<string, unknown>, head) => {
const tails = leads[head].filter((tail) => tail.length > 0)
if (tails.length === 0) {
res[head] = isRecord(value) ? (value as Record<string, unknown>)[head] : undefined
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
res[head] = observeDocument((value as any)[head], tails, store)
}
return res
}, currentValue)
return of(next).pipe(props({wait: true}))
}
function observeForLocations(
documentId: string,
resolver: DocumentLocationResolverObject<string>,
documentStore: DocumentStore,
) {
const {select} = resolver
const paths = Object.values(select).map((value) => String(value).split('.')) || []
const doc = {_type: 'reference', _ref: documentId}
return observeDocument(doc, paths, documentStore).pipe(
map((doc) => {
return Object.keys(select).reduce<Record<string, unknown>>((acc, key) => {
acc[key] = get(doc, select[key])
return acc
}, {})
}),
map(resolver.resolve),
)
}
export function useDocumentLocations(props: {
id: string
version: string | undefined
resolvers?: DocumentLocationResolver | DocumentLocationResolvers
type: string
}): {
state: DocumentLocationsState
status: DocumentLocationsStatus
} {
const {id, resolvers, type, version} = props
const documentStore = useDocumentStore()
const {perspectiveStack} = usePerspective()
const [locationsState, setLocationsState] = useState<DocumentLocationsState>(INITIAL_STATE)
const resolver = resolvers && (typeof resolvers === 'function' ? resolvers : resolvers[type])
const [locationsStatus, setLocationsStatus] = useState<DocumentLocationsStatus>(
resolver ? 'resolving' : 'empty',
)
const result = useMemo(() => {
if (!resolver) return undefined
// Original/advanced resolver which requires explicit use of Observables
if (typeof resolver === 'function') {
const params = {id, type, version, perspectiveStack}
const context = {documentStore}
const _result = resolver(params, context)
return isObservable(_result) ? _result : of(_result)
}
// Simplified resolver pattern which abstracts away Observable logic
if ('select' in resolver && 'resolve' in resolver) {
return observeForLocations(id, resolver, documentStore)
}
// Resolver is explicitly provided state
return of(resolver)
}, [documentStore, id, resolver, type, version, perspectiveStack])
useEffect(() => {
const sub = result?.subscribe((state) => {
setLocationsState(state || INITIAL_STATE)
setLocationsStatus(state ? 'resolved' : 'empty')
})
return () => sub?.unsubscribe()
}, [result])
return {
state: locationsState,
status: locationsStatus,
}
}