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
text/typescript
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[]
}