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

369 lines (326 loc) • 11.5 kB
import {generateHelpUrl} from '@sanity/generate-help-url' import {AddIcon} from '@sanity/icons' import {type SchemaType, type SortOrderingItem} from '@sanity/types' import {DEFAULT_STUDIO_CLIENT_OPTIONS, type InitialValueTemplateItem} from 'sanity' import {type ChildResolver, type ChildResolverOptions, type ItemChild} from './ChildResolver' import {DocumentBuilder} from './Document' import { type BuildableGenericList, type GenericList, GenericListBuilder, type GenericListInput, } from './GenericList' import {HELP_URL, SerializeError} from './SerializeError' import {type Child, type SerializeOptions} from './StructureNodes' import {type StructureContext} from './types' import {resolveTypeForDocument} from './util/resolveTypeForDocument' const validateFilter = (spec: PartialDocumentList, options: SerializeOptions) => { const filter = spec.options?.filter.trim() || '' if (['*', '{'].includes(filter[0])) { throw new SerializeError( `\`filter\` cannot start with \`${filter[0]}\` - looks like you are providing a query, not a filter`, options.path, spec.id, spec.title, ).withHelpUrl(HELP_URL.QUERY_PROVIDED_FOR_FILTER) } return filter } const createDocumentChildResolverForItem = (context: StructureContext): ChildResolver => (itemId: string, options: ChildResolverOptions): ItemChild | Promise<ItemChild> | undefined => { const parentItem = options.parent as DocumentList const template = options.params?.template ? context.templates.find((tpl) => tpl.id === options.params.template) : undefined const type = template ? template.schemaType : parentItem.schemaTypeName || resolveTypeForDocument(context.getClient, itemId) return Promise.resolve(type).then((schemaType) => schemaType ? context.resolveDocumentNode({schemaType, documentId: itemId}) : new DocumentBuilder(context).id('editor').documentId(itemId).schemaType(''), ) } /** * Partial document list * * @public */ export interface PartialDocumentList extends BuildableGenericList { /** Document list options. See {@link DocumentListOptions} */ options?: DocumentListOptions /** Schema type name */ schemaTypeName?: string } /** * Interface for document list input * * @public */ export interface DocumentListInput extends GenericListInput { /** Document list options. See {@link DocumentListOptions} */ options: DocumentListOptions } /** * Interface for document list * * @public */ export interface DocumentList extends GenericList { type: 'documentList' /** Document list options. See {@link DocumentListOptions} */ options: DocumentListOptions /** Document list child. See {@link Child} */ child: Child /** Document schema type name */ schemaTypeName?: string } /** * Interface for document List options * * @public */ export interface DocumentListOptions { /** Document list filter */ filter: string /** Document list parameters */ params?: Record<string, unknown> /** Document list API version */ apiVersion?: string /** Document list API default ordering array. */ defaultOrdering?: SortOrderingItem[] } /** * Class for building document list * * @public */ export class DocumentListBuilder extends GenericListBuilder< PartialDocumentList, DocumentListBuilder > { /** Document list options. See {@link PartialDocumentList} */ protected spec: PartialDocumentList constructor( /** * Structure context. See {@link StructureContext} */ protected _context: StructureContext, spec?: DocumentListInput, ) { super() this.spec = spec || {} this.initialValueTemplatesSpecified = Boolean(spec?.initialValueTemplates) } /** Set API version * @param apiVersion - API version * @returns document list builder based on the options and API version provided. See {@link DocumentListBuilder} */ apiVersion(apiVersion: string): DocumentListBuilder { return this.clone({options: {...(this.spec.options || {filter: ''}), apiVersion}}) } /** Get API version * @returns API version */ getApiVersion(): string | undefined { return this.spec.options?.apiVersion } /** Set Document list filter * @param filter - filter * @returns document list builder based on the options and filter provided. See {@link DocumentListBuilder} */ filter(filter: string): DocumentListBuilder { return this.clone({options: {...(this.spec.options || {}), filter}}) } /** Get Document list filter * @returns filter */ getFilter(): string | undefined { return this.spec.options?.filter } /** Set Document list schema type name * @param type - schema type name. * @returns document list builder based on the schema type name provided. See {@link DocumentListBuilder} */ schemaType(type: SchemaType | string): DocumentListBuilder { const schemaTypeName = typeof type === 'string' ? type : type.name return this.clone({schemaTypeName}) } /** Get Document list schema type name * @returns schema type name */ getSchemaType(): string | undefined { return this.spec.schemaTypeName } /** Set Document list options' parameters * @param params - parameters * @returns document list builder based on the options provided. See {@link DocumentListBuilder} */ params(params: Record<string, unknown>): DocumentListBuilder { return this.clone({ options: {...(this.spec.options || {filter: ''}), params}, }) } /** Get Document list options' parameters * @returns options */ getParams(): Record<string, unknown> | undefined { return this.spec.options?.params } /** Set Document list default ordering * @param ordering - default sort ordering array. See {@link SortOrderingItem} * @returns document list builder based on ordering provided. See {@link DocumentListBuilder} */ defaultOrdering(ordering: SortOrderingItem[]): DocumentListBuilder { if (!Array.isArray(ordering)) { throw new Error('`defaultOrdering` must be an array of order clauses') } return this.clone({ options: {...(this.spec.options || {filter: ''}), defaultOrdering: ordering}, }) } /** Get Document list default ordering * @returns default ordering. See {@link SortOrderingItem} */ getDefaultOrdering(): SortOrderingItem[] | undefined { return this.spec.options?.defaultOrdering } /** Serialize Document list * @param options - serialization options. See {@link SerializeOptions} * @returns document list object based on path provided in options. See {@link DocumentList} */ serialize(options: SerializeOptions = {path: []}): DocumentList { if (typeof this.spec.id !== 'string' || !this.spec.id) { throw new SerializeError( '`id` is required for document lists', options.path, options.index, this.spec.title, ).withHelpUrl(HELP_URL.ID_REQUIRED) } if (!this.spec.options || !this.spec.options.filter) { throw new SerializeError( '`filter` is required for document lists', options.path, this.spec.id, this.spec.title, ).withHelpUrl(HELP_URL.FILTER_REQUIRED) } const hasSimpleFilter = this.spec.options?.filter === '_type == $type' if (!hasSimpleFilter && this.spec.options.filter && !this.spec.options.apiVersion) { console.warn( `No apiVersion specified for document type list with custom filter: \`${this.spec.options.filter}\`. This will be required in the future. See %s for more info.`, generateHelpUrl(HELP_URL.API_VERSION_REQUIRED_FOR_CUSTOM_FILTER), ) } return { ...super.serialize(options), type: 'documentList', schemaTypeName: this.spec.schemaTypeName, child: this.spec.child || createDocumentChildResolverForItem(this._context), options: { ...this.spec.options, // @todo: make specifying .apiVersion required when using custom (non-simple) filters in v4 apiVersion: this.spec.options.apiVersion || DEFAULT_STUDIO_CLIENT_OPTIONS.apiVersion, filter: validateFilter(this.spec, options), }, } } /** Clone Document list builder (allows for options overriding) * @param withSpec - override document list spec. See {@link PartialDocumentList} * @returns document list builder. See {@link DocumentListBuilder} */ clone(withSpec?: PartialDocumentList): DocumentListBuilder { const builder = new DocumentListBuilder(this._context) builder.spec = {...this.spec, ...(withSpec || {})} if (!this.initialValueTemplatesSpecified) { builder.spec.initialValueTemplates = inferInitialValueTemplates(this._context, builder.spec) } if (!builder.spec.schemaTypeName) { builder.spec.schemaTypeName = inferTypeName(builder.spec) } return builder } /** Get Document list spec * @returns document list spec. See {@link PartialDocumentList} */ getSpec(): PartialDocumentList { return this.spec } } function inferInitialValueTemplates( context: StructureContext, spec: PartialDocumentList, ): InitialValueTemplateItem[] | undefined { const {document} = context const {schemaTypeName, options} = spec const {filter, params} = options || {filter: '', params: {}} const typeNames = schemaTypeName ? [schemaTypeName] : Array.from(new Set(getTypeNamesFromFilter(filter, params))) if (typeNames.length === 0) { return undefined } return typeNames .flatMap((schemaType) => document.resolveNewDocumentOptions({ type: 'structure', schemaType, }), ) .map((option) => ({...option, icon: AddIcon})) } function inferTypeName(spec: PartialDocumentList): string | undefined { const {options} = spec const {filter, params} = options || {filter: '', params: {}} const typeNames = getTypeNamesFromFilter(filter, params) return typeNames.length === 1 ? typeNames[0] : undefined } /** @internal */ export function getTypeNamesFromFilter( filter: string, params: Record<string, unknown> = {}, ): string[] { let typeNames = getTypeNamesFromEqualityFilter(filter, params) if (typeNames.length === 0) { typeNames = getTypeNamesFromInTypesFilter(filter, params) } return typeNames } // From _type == "movie" || _type == $otherType function getTypeNamesFromEqualityFilter( filter: string, params: Record<string, unknown> = {}, ): string[] { const pattern = /\b_type\s*==\s*(['"].*?['"]|\$.*?(?:\s|$))|\B(['"].*?['"]|\$.*?(?:\s|$))\s*==\s*_type/g const matches: string[] = [] let match while ((match = pattern.exec(filter)) !== null) { matches.push(match[1] || match[2]) } return matches .map((candidate) => { const typeName = candidate[0] === '$' ? params[candidate.slice(1)] : candidate const normalized = ((typeName as string) || '').trim().replace(/^["']|["']$/g, '') return normalized }) .filter(Boolean) } // From _type in ["dog", "cat", $otherSpecies] function getTypeNamesFromInTypesFilter( filter: string, params: Record<string, unknown> = {}, ): string[] { const pattern = /\b_type\s+in\s+\[(.*?)\]/ const matches = filter.match(pattern) if (!matches) { return [] } return matches[1] .split(/,\s*/) .map((match) => match.trim().replace(/^["']+|["']+$/g, '')) .map((item) => (item[0] === '$' ? params[item.slice(1)] : item)) .filter(Boolean) as string[] }