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
302 lines (270 loc) • 9.41 kB
text/typescript
import {
isTitledListValue,
type PrepareViewOptions,
type PreviewValue,
type SchemaType,
type TitledListValue,
} from '@sanity/types'
import {debounce, flatten, get, isPlainObject, pick, uniqBy} from 'lodash'
import {isRecord} from '../../util'
import {INVALID_PREVIEW_FALLBACK} from '../constants'
import {type PreviewableType} from '../types'
import {keysOf} from './keysOf'
import {extractTextFromBlocks, isPortableTextPreviewValue} from './portableText'
const PRESERVE_KEYS = ['_id', '_type', '_upload', '_createdAt', '_updatedAt']
const EMPTY: never[] = []
type SelectedValue = Record<string, unknown>
export type PrepareInvocationResult = {
selectedValue?: SelectedValue
returnValue: null | PreviewValue
errors: Error[]
}
const errorCollector = (() => {
let errorsByType: Record<string, {error: Error; type: SchemaType; value: SelectedValue}[]> = {}
return {
add: (type: SchemaType, value: SelectedValue, error: Error) => {
if (!errorsByType[type.name]) {
errorsByType[type.name] = []
}
errorsByType[type.name].push({error: error, type: type, value})
},
getAll() {
return errorsByType
},
clear() {
errorsByType = {}
},
}
})()
const reportErrors = debounce(() => {
/* eslint-disable no-console */
const errorsByType = errorCollector.getAll()
const uniqueErrors = flatten(
Object.keys(errorsByType).map((typeName) => {
const entries = errorsByType[typeName]
return uniqBy(entries, (entry) => entry.error.message)
}),
)
const errorCount = uniqueErrors.length
if (errorCount === 0) {
return
}
console.groupCollapsed(
`%cHeads up! Got ${
errorCount === 1 ? 'error' : `${errorCount} errors`
} while preparing data for preview. Click for details.`,
'color: #ff7e7c',
)
Object.keys(errorsByType).forEach((typeName) => {
const entries = errorsByType[typeName]
const first = entries[0]
console.group(`Check the preview config for schema type "${typeName}": %o`, first.type.preview)
const uniqued = uniqBy(entries, (entry) => entry.error.message)
uniqued.forEach((entry) => {
if ((entry.error as any).type === 'returnValueError') {
const hasPrepare = typeof entry.type.preview?.prepare === 'function'
const {value, error} = entry
console.log(
`Encountered an invalid ${
hasPrepare
? 'return value when calling prepare(%o)'
: 'value targeted by preview.select'
}:`,
value,
)
console.error(error)
}
if ((entry.error as any).type === 'prepareError') {
const {value, error} = entry
console.log('Encountered an error when calling prepare(%o):', value)
console.error(error)
}
})
console.groupEnd()
})
console.groupEnd()
errorCollector.clear()
/* eslint-enable no-console */
}, 1000)
const isRenderable =
(fieldName: string) =>
(value: unknown): Error[] => {
const type = typeof value
if (
value === null ||
type === 'undefined' ||
type === 'string' ||
type === 'number' ||
type === 'boolean'
) {
return EMPTY
}
return [
assignType(
'returnValueError',
new Error(
`The "${fieldName}" field should be a string, number, boolean, undefined or null, instead saw ${inspect(
value,
)}`,
),
),
]
}
const FIELD_NAME_VALIDATORS: Record<string, (value: unknown) => Error[]> = {
media: () => {
// not sure how to validate media as it would possibly involve executing a function and check the
// return value
return EMPTY
},
title: isRenderable('title'),
subtitle: isRenderable('subtitle'),
description: isRenderable('description'),
imageUrl: isRenderable('imageUrl'),
date: isRenderable('date'),
}
function inspect(val: unknown, prefixType = true): string {
if (isRecord(val)) {
const keys = Object.keys(val)
const ellipse = keys.length > 3 ? '...' : ''
const prefix = `object with keys `
return `${prefixType ? prefix : ''}{${keys.slice(0, 3).join(', ')}${ellipse}}`
}
if (Array.isArray(val)) {
const ellipse = val.length > 3 ? '...' : ''
const prefix = `array with `
return `${prefixType ? prefix : ''}[${val.map((v) => inspect(v, false))}${ellipse}]`
}
return `the ${typeof val} ${val}`
}
function validateFieldValue(fieldName: string, fieldValue: unknown) {
if (typeof fieldValue === 'undefined') {
return EMPTY
}
const validator = FIELD_NAME_VALIDATORS[fieldName]
return (validator && validator(fieldValue)) || EMPTY
}
function assignType(type: string, error: Error) {
return Object.assign(error, {type})
}
function validatePreparedValue(preparedValue: PreviewValue | null) {
if (!isPlainObject(preparedValue) || preparedValue === null) {
return [
assignType(
'returnValueError',
new Error(
`Invalid return value. Expected a plain object with at least a 'title' field, instead saw ${inspect(
preparedValue,
)}`,
),
),
]
}
return Object.entries(preparedValue).reduce<Error[]>((acc, [fieldName, fieldValue]) => {
return [...acc, ...validateFieldValue(fieldName, fieldValue)]
}, EMPTY)
}
function validateReturnedPreview(result: PrepareInvocationResult) {
return {
...result,
errors: [...(result.errors || []), ...validatePreparedValue(result.returnValue)],
}
}
function defaultPrepare(value: SelectedValue) {
return keysOf(value).reduce((acc: SelectedValue, fieldName: keyof SelectedValue) => {
const val = value[fieldName]
return {
...acc,
[fieldName]: isPortableTextPreviewValue(val) ? extractTextFromBlocks(val) : val,
}
}, {})
}
export function invokePrepare(
type: PreviewableType,
value: SelectedValue,
viewOptions: PrepareViewOptions = {},
): PrepareInvocationResult {
const prepare = type.preview?.prepare
try {
return {
returnValue: prepare
? (prepare(value, viewOptions) as Record<string, unknown>)
: defaultPrepare(value),
errors: EMPTY,
}
} catch (error) {
return {
returnValue: null,
errors: [assignType('prepareError', error)],
}
}
}
function withErrors(
result: {errors: Error[]},
type: SchemaType,
selectedValue: SelectedValue,
): PreviewValue {
result.errors.forEach((error) => errorCollector.add(type, selectedValue, error))
reportErrors()
return INVALID_PREVIEW_FALLBACK
}
interface EnumListOptions {
list: TitledListValue[] | unknown[]
}
function hasEnumListOptions(
type: SchemaType,
): type is SchemaType & {options: SchemaType['options'] & EnumListOptions} {
const options = type.options && typeof type.options === 'object' ? type.options : false
if (!options || !('list' in options)) {
return false
}
const listOptions = (options as EnumListOptions).list
return Array.isArray(listOptions)
}
function getListOptions(type: SchemaType): TitledListValue[] | undefined {
if (!hasEnumListOptions(type)) {
return undefined
}
const listOptions = type.options.list as EnumListOptions['list']
return listOptions.map((option) =>
isTitledListValue(option) ? option : ({title: option, value: option} as TitledListValue),
)
}
/** @internal */
export function prepareForPreview(
rawValue: unknown,
type: SchemaType,
viewOptions: PrepareViewOptions = {},
): PreviewValue & {_createdAt?: string; _updatedAt?: string} {
const hasCustomPrepare = typeof type.preview?.prepare === 'function'
const selection: Record<string, string> = type.preview?.select || {}
const targetKeys = Object.keys(selection)
const selectedValue = targetKeys.reduce<Record<string, unknown>>((acc, key) => {
// Find the field the value belongs to
const typeWithFields = 'fields' in type ? type : null
const targetFieldName = selection[key]
const valueField = typeWithFields?.fields?.find((f) => f.name === targetFieldName)
const listOptions = valueField && getListOptions(valueField.type)
// If the user has _not_ specified a `prepare()` function for the preview, and the
// field type has an `options.list`, we want to use the title of the selected item
// as the preview value. If, however, there _is_ a custom `prepare()`, we leave this
// mapping up to the user to perform should they want to. This is both to maintain
// backwards compatiblity, but also to allow using the raw value for prepare operations
if (!hasCustomPrepare && listOptions) {
// Find the selected option that matches the raw value
const selectedOption =
listOptions && listOptions.find((opt) => opt.value === get(rawValue, selection[key]))
acc[key] = selectedOption ? selectedOption.value : get(rawValue, selection[key])
} else {
acc[key] = get(rawValue, selection[key])
}
return acc
}, {})
const prepareResult = invokePrepare(type, selectedValue, viewOptions)
if (prepareResult.errors.length > 0) {
return withErrors(prepareResult, type, selectedValue)
}
const returnValueResult = validateReturnedPreview(invokePrepare(type, selectedValue, viewOptions))
return returnValueResult.errors.length > 0
? withErrors(returnValueResult, type, selectedValue)
: {...pick(rawValue, PRESERVE_KEYS), ...prepareResult.returnValue}
}