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
327 lines (285 loc) • 8.95 kB
text/typescript
import {
type ArraySchemaType,
type MultiFieldSet,
type ObjectField,
type ObjectSchemaType,
type Path,
type SchemaType,
} from '@sanity/types'
import {getItemKeySegment, pathsAreEqual, pathToString} from '../../paths'
import {getArrayDiffItemType} from '../../schema/helpers'
import {
type ArrayDiff,
type ChangeNode,
type ChangeTitlePath,
type Diff,
type DiffComponent,
type FieldChangeNode,
type ItemDiff,
type ObjectDiff,
} from '../../types'
import {hasPTMemberType} from '../../types/portableText/diff/helpers'
import {getValueError} from '../../validation'
import {isFieldChange} from '../helpers'
import {resolveDiffComponent} from '../resolve/resolveDiffComponent'
interface DiffContext {
itemDiff?: ItemDiff
parentDiff?: ArrayDiff | ObjectDiff
parentSchema?: ArraySchemaType | ObjectSchemaType
fieldFilter?: string[]
}
export function buildChangeList(
schemaType: SchemaType,
diff: Diff,
path: Path = [],
titlePath: ChangeTitlePath = [],
context: DiffContext = {},
): ChangeNode[] {
const diffComponent = resolveDiffComponent(schemaType, context.parentSchema)
if (!diffComponent) {
if (schemaType.jsonType === 'object' && diff.type === 'object') {
return buildObjectChangeList(schemaType as ObjectSchemaType, diff, path, titlePath, context)
}
if (schemaType.jsonType === 'array' && diff.type === 'array') {
return buildArrayChangeList(schemaType, diff, path, titlePath)
}
}
return getFieldChange(schemaType, diff, path, titlePath, context)
}
export function buildObjectChangeList(
schemaType: ObjectSchemaType,
diff: ObjectDiff,
path: Path = [],
titlePath: ChangeTitlePath = [],
diffContext: DiffContext = {},
): ChangeNode[] {
const changes: ChangeNode[] = []
const childContext: DiffContext = {...diffContext, parentSchema: schemaType}
const fieldSets =
schemaType.fieldsets || schemaType.fields.map((field) => ({single: true, field}))
for (const fieldSet of fieldSets) {
if (fieldSet.single) {
changes.push(...buildFieldChange(fieldSet.field, diff, path, titlePath, childContext))
} else {
changes.push(
...buildFieldsetChangeList(fieldSet as MultiFieldSet, diff, path, titlePath, childContext),
)
}
}
if (changes.length < 2) {
return changes
}
return [
{
type: 'group',
key: pathToString(path) || 'root',
path,
titlePath,
changes: reduceTitlePaths(changes, titlePath.length),
schemaType,
},
]
}
export function buildFieldChange(
field: ObjectField,
diff: ObjectDiff,
path: Path,
titlePath: ChangeTitlePath,
diffContext: DiffContext & {fieldFilter?: string[]} = {},
): ChangeNode[] {
const {fieldFilter, ...context} = diffContext
const fieldDiff = diff.fields[field.name]
if (!fieldDiff || !fieldDiff.isChanged || (fieldFilter && !fieldFilter.includes(field.name))) {
return []
}
const fieldPath = path.concat([field.name])
const fieldTitlePath = titlePath.concat([field.type.title || field.name])
return buildChangeList(field.type as any, fieldDiff, fieldPath, fieldTitlePath, context)
}
export function buildFieldsetChangeList(
fieldSet: MultiFieldSet,
diff: ObjectDiff,
path: Path,
titlePath: ChangeTitlePath,
diffContext: DiffContext & {fieldFilter?: string[]} = {},
): ChangeNode[] {
const {fields, name, title, readOnly, hidden} = fieldSet
const {fieldFilter, ...context} = diffContext
const fieldSetHidden = hidden
const fieldsetReadOnly = readOnly
const fieldSetTitlePath = titlePath.concat([title || name])
const changes: ChangeNode[] = []
for (const field of fields) {
const fieldDiff = diff.fields[field.name]
if (!fieldDiff || !fieldDiff.isChanged || (fieldFilter && !fieldFilter.includes(field.name))) {
continue
}
const fieldPath = path.concat([field.name])
const fieldTitlePath = fieldSetTitlePath.concat([field.type.title || field.name])
changes.push(
...buildChangeList(
{
readOnly: fieldsetReadOnly,
hidden: fieldSetHidden,
...field.type,
} as any,
fieldDiff,
fieldPath,
fieldTitlePath,
context,
),
)
}
if (changes.length < 2) {
return changes
}
return [
{
type: 'group',
key: pathToString(path) || 'root',
fieldsetName: name,
path,
titlePath: fieldSetTitlePath,
changes: reduceTitlePaths(changes, fieldSetTitlePath.length),
readOnly: fieldsetReadOnly,
hidden: fieldSetHidden,
},
]
}
export function buildArrayChangeList(
schemaType: ArraySchemaType,
diff: ArrayDiff,
path: Path = [],
titlePath: ChangeTitlePath = [],
): ChangeNode[] {
const changedOrMoved = diff.items.filter(
(item) =>
(item.hasMoved && item.fromIndex !== item.toIndex) || item.diff.action !== 'unchanged',
)
if (changedOrMoved.length === 0) {
return []
}
const isPortableText = hasPTMemberType(schemaType)
const list: ChangeNode[] = []
const changes = changedOrMoved.reduce((acc, itemDiff) => {
const memberTypes = getArrayDiffItemType(itemDiff.diff, schemaType)
const memberType = memberTypes.toType || memberTypes.fromType
if (!memberType) {
// eslint-disable-next-line no-console
console.warn('Could not determine schema type for item at %s', pathToString(path))
return acc
}
const segment =
getItemKeySegment(itemDiff.diff.fromValue) ||
getItemKeySegment(itemDiff.diff.toValue) ||
diff.items.indexOf(itemDiff)
const itemPath = path.concat(segment)
const itemContext: DiffContext = {itemDiff, parentDiff: diff, parentSchema: schemaType}
const itemTitlePath = titlePath.concat({
hasMoved: itemDiff.hasMoved,
toIndex: itemDiff.toIndex,
fromIndex: itemDiff.fromIndex,
annotation:
itemDiff.diff.action === 'unchanged' ? itemDiff.annotation : itemDiff.diff.annotation,
})
const attachItemDiff = (change: ChangeNode): ChangeNode => {
if (change.type === 'field' && pathsAreEqual(itemPath, change.path)) {
change.itemDiff = itemDiff
}
return change
}
const children = buildChangeList(
memberType,
itemDiff.diff,
itemPath,
itemTitlePath,
itemContext,
).map(attachItemDiff)
if (isPortableText) {
children.filter(isFieldChange).forEach((field, index, siblings) => {
field.showHeader = siblings.length === 1
field.showIndex = itemDiff.fromIndex !== itemDiff.toIndex && itemDiff.hasMoved
})
}
if (children.length === 0) {
// This can happen when there are no changes to the actual element, it's just been moved
acc.push(...getFieldChange(memberType, itemDiff.diff, itemPath, itemTitlePath, itemContext))
} else {
acc.push(...children)
}
return acc
}, list)
if (changes.length > 1) {
return [
{
type: 'group',
key: pathToString(path) || 'root',
path,
titlePath,
changes: reduceTitlePaths(changes, titlePath.length),
schemaType,
},
]
}
return changes
}
function getFieldChange(
schemaType: SchemaType,
diff: Diff,
path: Path,
titlePath: ChangeTitlePath,
{itemDiff, parentDiff, parentSchema}: DiffContext = {},
): FieldChangeNode[] {
const {fromValue, toValue, type} = diff
// Treat undefined => [] as no change
if (type === 'array' && isEmpty(fromValue) && isEmpty(toValue)) {
return []
}
let error
if (typeof fromValue !== 'undefined') {
error = getValueError(fromValue, schemaType)
}
if (!error && typeof toValue !== 'undefined') {
error = getValueError(toValue, schemaType)
}
let showHeader = true
let component: DiffComponent | undefined
const diffComponent = resolveDiffComponent(schemaType, parentSchema)
if (diffComponent && typeof diffComponent === 'function') {
// Just a diff component with default options
component = diffComponent
} else if (diffComponent) {
// Diff component with options
component = (diffComponent as any).component
showHeader =
typeof (diffComponent as any).showHeader === 'undefined'
? showHeader
: (diffComponent as any).showHeader
}
return [
{
type: 'field',
diff,
path,
error,
itemDiff,
parentDiff,
titlePath,
schemaType,
showHeader,
showIndex: true,
key: pathToString(path) || 'root',
diffComponent: error ? undefined : component,
parentSchema,
},
]
}
function reduceTitlePaths(changes: ChangeNode[], byLength = 1): ChangeNode[] {
return changes.map((change) => {
change.titlePath = change.titlePath.slice(byLength)
return change
})
}
function isEmpty(item: unknown): boolean {
return (Array.isArray(item) && item.length === 0) || item === null || typeof item === 'undefined'
}