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
1,064 lines (936 loc) • 33.7 kB
text/typescript
/* eslint-disable max-statements */
/* eslint-disable camelcase, no-else-return */
import {
type ArraySchemaType,
type BooleanSchemaType,
type CurrentUser,
isArrayOfObjectsSchemaType,
isArraySchemaType,
isObjectSchemaType,
type NumberSchemaType,
type ObjectField,
type ObjectSchemaType,
type Path,
type StringSchemaType,
type ValidationMarker,
} from '@sanity/types'
import {resolveTypeName} from '@sanity/util/content'
import {isEqual, pathFor, startsWith, toString, trimChildPath} from '@sanity/util/paths'
import {castArray, isEqual as _isEqual, pick} from 'lodash'
import {type FIXME} from '../../FIXME'
import {type FormNodePresence} from '../../presence'
import {EMPTY_ARRAY, isRecord} from '../../util'
import {getFieldLevel} from '../studio/inputResolver/helpers'
import {resolveConditionalProperty} from './conditional-property'
import {ALL_FIELDS_GROUP, MAX_FIELD_DEPTH} from './constants'
import {
type FieldSetMember,
type HiddenField,
type ObjectArrayFormNode,
type PrimitiveFormNode,
type StateTree,
} from './types'
import {type FormFieldGroup} from './types/fieldGroup'
import {type FieldError} from './types/memberErrors'
import {
type ArrayOfObjectsMember,
type ArrayOfPrimitivesMember,
type FieldMember,
type ObjectMember,
} from './types/members'
import {
type ArrayOfObjectsFormNode,
type ArrayOfPrimitivesFormNode,
type ObjectFormNode,
} from './types/nodes'
import {getCollapsedWithDefaults} from './utils/getCollapsibleOptions'
import {getItemType, getPrimitiveItemType} from './utils/getItemType'
type PrimitiveSchemaType = BooleanSchemaType | NumberSchemaType | StringSchemaType
function isFieldEnabledByGroupFilter(
// the groups config for the "enclosing object" type
groupsConfig: FormFieldGroup[],
fieldGroup: string | string[] | undefined,
selectedGroup: FormFieldGroup,
) {
if (selectedGroup.name === ALL_FIELDS_GROUP.name) {
return true
}
// "all fields" is not the selected group and the field has no group config, so it should be hidden
if (fieldGroup === undefined) {
return false
}
// if there's no group config for the object type, all fields are visible
if (groupsConfig.length === 0 && selectedGroup.name === ALL_FIELDS_GROUP.name) {
return true
}
return castArray(fieldGroup).includes(selectedGroup.name)
}
function isAcceptedObjectValue(value: any): value is Record<string, unknown> | undefined {
return typeof value === 'undefined' || isRecord(value)
}
function isValidArrayOfObjectsValue(value: any): value is unknown[] | undefined {
return typeof value === 'undefined' || Array.isArray(value)
}
function isValidArrayOfPrimitivesValue(
value: any,
): value is (boolean | number | string)[] | undefined {
return typeof value === 'undefined' || Array.isArray(value)
}
function everyItemIsObject(value: unknown[]): value is object[] {
return value.length === 0 || value.every((item) => isRecord(item))
}
function findDuplicateKeyEntries(array: {_key: string}[]) {
const seenKeys = new Set<string>()
return array.reduce((acc: [index: number, key: string][], item, index) => {
if (seenKeys.has(item._key)) {
acc.push([index, item._key])
}
seenKeys.add(item._key)
return acc
}, [])
}
function hasKey<T extends object>(value: T): value is T & {_key: string} {
return '_key' in value
}
function everyItemHasKey<T extends object>(array: T[]): array is (T & {_key: string})[] {
return array?.every((item) => isRecord(item) && hasKey(item))
}
function isChangedValue(value: any, comparisonValue: any) {
// changes panel is not being able to identify changes in array of objects
// (especially when it comes to unpublished changes)
// the main issue it fixes is in instances where the array removes a last item but instead of turning
// "undefined" it returns an empty array (and so the change indicator remains active when it shouldn't)
if (
(Array.isArray(value) && typeof comparisonValue === 'undefined') ||
(Array.isArray(comparisonValue) && typeof value === 'undefined')
) {
return false
}
if (value && !comparisonValue) {
return true
}
return !_isEqual(value, comparisonValue)
}
/*
* Takes a field in context of a parent object and returns prepared props for it
*/
function prepareFieldMember(props: {
field: ObjectField
parent: RawState<ObjectSchemaType, unknown> & {
groups: FormFieldGroup[]
selectedGroup: FormFieldGroup
}
index: number
}): ObjectMember | HiddenField | null {
const {parent, field, index} = props
const fieldPath = pathFor([...parent.path, field.name])
const fieldLevel = getFieldLevel(field.type, parent.level + 1)
const parentValue = parent.value
const parentComparisonValue = parent.comparisonValue
if (!isAcceptedObjectValue(parentValue)) {
// Note: we validate each field, before passing it recursively to this function so getting this error means that the
// ´prepareFormState´ function itself has been called with a non-object value
throw new Error('Unexpected non-object value')
}
const normalizedFieldGroupNames = field.group ? castArray(field.group) : []
const inSelectedGroup = isFieldEnabledByGroupFilter(
parent.groups,
field.group,
parent.selectedGroup,
)
if (isObjectSchemaType(field.type)) {
const fieldValue = parentValue?.[field.name]
const fieldComparisonValue = isRecord(parentComparisonValue)
? parentComparisonValue?.[field.name]
: undefined
if (!isAcceptedObjectValue(fieldValue)) {
return {
kind: 'error',
key: field.name,
fieldName: field.name,
error: {
type: 'INCOMPATIBLE_TYPE',
expectedSchemaType: field.type,
resolvedValueType: resolveTypeName(fieldValue),
value: fieldValue,
},
}
}
const conditionalPropertyContext = {
value: fieldValue,
parent: parent.value,
document: parent.document,
currentUser: parent.currentUser,
}
const hidden = resolveConditionalProperty(field.type.hidden, conditionalPropertyContext)
if (hidden) {
return {
kind: 'hidden',
key: `field-${field.name}`,
name: field.name,
index: index,
}
}
// readonly is inherited
const readOnly =
parent.readOnly || resolveConditionalProperty(field.type.readOnly, conditionalPropertyContext)
// todo: consider requiring a _type annotation for object values on fields as well
// if (resolvedValueType !== field.type.name) {
// return {
// kind: 'error',
// key: field.name,
// error: {
// type: 'TYPE_ANNOTATION_MISMATCH',
// expectedSchemaType: field.type,
// resolvedValueType,
// },
// }
// }
const fieldGroupState = parent.fieldGroupState?.children?.[field.name]
const scopedCollapsedPaths = parent.collapsedPaths?.children?.[field.name]
const scopedCollapsedFieldsets = parent.collapsedFieldSets?.children?.[field.name]
const inputState = prepareObjectInputState({
schemaType: field.type,
currentUser: parent.currentUser,
parent: parent.value,
document: parent.document,
value: fieldValue,
changed: isChangedValue(fieldValue, fieldComparisonValue),
comparisonValue: fieldComparisonValue,
presence: parent.presence,
validation: parent.validation,
fieldGroupState,
path: fieldPath,
level: fieldLevel,
focusPath: parent.focusPath,
openPath: parent.openPath,
collapsedPaths: scopedCollapsedPaths,
collapsedFieldSets: scopedCollapsedFieldsets,
readOnly,
changesOpen: parent.changesOpen,
})
if (inputState === null) {
// if inputState is null is either because we reached max field depth or if it has no visible members
return null
}
const defaultCollapsedState = getCollapsedWithDefaults(field.type.options as FIXME, fieldLevel)
const collapsed = scopedCollapsedPaths
? scopedCollapsedPaths.value
: defaultCollapsedState.collapsed
return {
kind: 'field',
key: `field-${field.name}`,
name: field.name,
index: index,
inSelectedGroup,
groups: normalizedFieldGroupNames,
open: startsWith(fieldPath, parent.openPath),
field: inputState,
collapsed,
collapsible: defaultCollapsedState.collapsible,
}
} else if (isArraySchemaType(field.type)) {
const fieldValue = parentValue?.[field.name] as unknown[] | undefined
const fieldComparisonValue = isRecord(parentComparisonValue)
? parentComparisonValue?.[field.name]
: undefined
if (isArrayOfObjectsSchemaType(field.type)) {
const hasValue = typeof fieldValue !== 'undefined'
if (hasValue && !isValidArrayOfObjectsValue(fieldValue)) {
const resolvedValueType = resolveTypeName(fieldValue)
return {
kind: 'error',
key: field.name,
fieldName: field.name,
error: {
type: 'INCOMPATIBLE_TYPE',
expectedSchemaType: field.type,
resolvedValueType,
value: fieldValue,
},
}
}
if (hasValue && !everyItemIsObject(fieldValue)) {
return {
kind: 'error',
key: field.name,
fieldName: field.name,
error: {
type: 'MIXED_ARRAY',
schemaType: field.type,
value: fieldValue,
},
}
}
if (hasValue && !everyItemHasKey(fieldValue)) {
return {
kind: 'error',
key: field.name,
fieldName: field.name,
error: {
type: 'MISSING_KEYS',
value: fieldValue,
schemaType: field.type,
},
}
}
const duplicateKeyEntries = hasValue ? findDuplicateKeyEntries(fieldValue) : []
if (duplicateKeyEntries.length > 0) {
return {
kind: 'error',
key: field.name,
fieldName: field.name,
error: {
type: 'DUPLICATE_KEYS',
duplicates: duplicateKeyEntries,
schemaType: field.type,
},
}
}
const fieldGroupState = parent.fieldGroupState?.children?.[field.name]
const scopedCollapsedPaths = parent.collapsedPaths?.children?.[field.name]
const scopedCollapsedFieldSets = parent.collapsedFieldSets?.children?.[field.name]
const readOnly =
parent.readOnly ||
resolveConditionalProperty(field.type.readOnly, {
value: fieldValue,
parent: parent.value,
document: parent.document,
currentUser: parent.currentUser,
})
const fieldState = prepareArrayOfObjectsInputState({
schemaType: field.type,
parent: parent.value,
currentUser: parent.currentUser,
document: parent.document,
value: fieldValue,
changed: isChangedValue(fieldValue, fieldComparisonValue),
comparisonValue: fieldComparisonValue as FIXME,
fieldGroupState,
focusPath: parent.focusPath,
openPath: parent.openPath,
presence: parent.presence,
validation: parent.validation,
collapsedPaths: scopedCollapsedPaths,
collapsedFieldSets: scopedCollapsedFieldSets,
level: fieldLevel,
path: fieldPath,
readOnly,
})
if (fieldState === null) {
return null
}
return {
kind: 'field',
key: `field-${field.name}`,
name: field.name,
index: index,
open: startsWith(fieldPath, parent.openPath),
inSelectedGroup,
groups: normalizedFieldGroupNames,
collapsible: false,
collapsed: false,
// note: this is what we actually end up passing down as to the next input component
field: fieldState,
}
} else {
// array of primitives
if (!isValidArrayOfPrimitivesValue(fieldValue)) {
const resolvedValueType = resolveTypeName(fieldValue)
return {
kind: 'error',
key: field.name,
fieldName: field.name,
error: {
type: 'INCOMPATIBLE_TYPE',
expectedSchemaType: field.type,
resolvedValueType,
value: fieldValue,
},
}
}
const fieldGroupState = parent.fieldGroupState?.children?.[field.name]
const scopedCollapsedPaths = parent.collapsedPaths?.children?.[field.name]
const scopedCollapsedFieldSets = parent.collapsedFieldSets?.children?.[field.name]
const readOnly =
parent.readOnly ||
resolveConditionalProperty(field.type.readOnly, {
value: fieldValue,
parent: parent.value,
document: parent.document,
currentUser: parent.currentUser,
})
const fieldState = prepareArrayOfPrimitivesInputState({
changed: isChangedValue(fieldValue, fieldComparisonValue),
comparisonValue: fieldComparisonValue as FIXME,
schemaType: field.type,
parent: parent.value,
currentUser: parent.currentUser,
document: parent.document,
value: fieldValue,
fieldGroupState,
focusPath: parent.focusPath,
openPath: parent.openPath,
presence: parent.presence,
validation: parent.validation,
collapsedPaths: scopedCollapsedPaths,
collapsedFieldSets: scopedCollapsedFieldSets,
level: fieldLevel,
path: fieldPath,
readOnly,
})
if (fieldState === null) {
return null
}
return {
kind: 'field',
key: `field-${field.name}`,
name: field.name,
index: index,
inSelectedGroup,
groups: normalizedFieldGroupNames,
open: startsWith(fieldPath, parent.openPath),
// todo: consider support for collapsible arrays
collapsible: false,
collapsed: false,
// note: this is what we actually end up passing down as to the next input component
field: fieldState,
}
}
} else {
// primitive fields
const fieldValue = parentValue?.[field.name] as undefined | boolean | string | number
const fieldComparisonValue = isRecord(parentComparisonValue)
? parentComparisonValue?.[field.name]
: undefined
const conditionalPropertyContext = {
value: fieldValue,
parent: parent.value,
document: parent.document,
currentUser: parent.currentUser,
}
// note: we *only* want to call the conditional props here, as it's handled by the prepare<Object|Array>InputProps otherwise
const hidden = resolveConditionalProperty(field.type.hidden, conditionalPropertyContext)
if (hidden) {
return null
}
const readOnly =
parent.readOnly || resolveConditionalProperty(field.type.readOnly, conditionalPropertyContext)
const fieldState = preparePrimitiveInputState({
...parent,
comparisonValue: fieldComparisonValue,
value: fieldValue as boolean | string | number | undefined,
schemaType: field.type as PrimitiveSchemaType,
path: fieldPath,
readOnly,
})
return {
kind: 'field',
key: `field-${field.name}`,
name: field.name,
index: index,
open: startsWith(fieldPath, parent.openPath),
inSelectedGroup,
groups: normalizedFieldGroupNames,
// todo: consider support for collapsible primitive fields
collapsible: false,
collapsed: false,
field: fieldState,
}
}
}
interface RawState<SchemaType, T> {
schemaType: SchemaType
value?: T
comparisonValue?: T | null
changed?: boolean
document: FIXME_SanityDocument
currentUser: Omit<CurrentUser, 'role'> | null
parent?: unknown
hidden?: boolean
readOnly?: boolean
path: Path
openPath: Path
focusPath: Path
presence: FormNodePresence[]
validation: ValidationMarker[]
fieldGroupState?: StateTree<string>
collapsedPaths?: StateTree<boolean>
collapsedFieldSets?: StateTree<boolean>
// nesting level
level: number
changesOpen?: boolean
}
function prepareObjectInputState<T>(
props: RawState<ObjectSchemaType, T>,
enableHiddenCheck?: false,
): ObjectFormNode
function prepareObjectInputState<T>(
props: RawState<ObjectSchemaType, T>,
enableHiddenCheck?: true,
): ObjectFormNode | null
function prepareObjectInputState<T>(
props: RawState<ObjectSchemaType, T>,
enableHiddenCheck = true,
): ObjectFormNode | null {
if (props.level === MAX_FIELD_DEPTH) {
return null
}
const conditionalPropertyContext = {
value: props.value,
parent: props.parent,
document: props.document,
currentUser: props.currentUser,
}
// readonly is inherited
const readOnly =
props.readOnly ||
resolveConditionalProperty(props.schemaType.readOnly, conditionalPropertyContext)
const schemaTypeGroupConfig = props.schemaType.groups || []
const defaultGroupName = (schemaTypeGroupConfig.find((g) => g.default) || ALL_FIELDS_GROUP)?.name
const groups = [ALL_FIELDS_GROUP, ...schemaTypeGroupConfig].flatMap((group): FormFieldGroup[] => {
const groupHidden = resolveConditionalProperty(group.hidden, conditionalPropertyContext)
const isSelected = group.name === (props.fieldGroupState?.value || defaultGroupName)
// Set the "all-fields" group as selected when review changes is open to enable review of all
// fields and changes together. When review changes is closed - switch back to the selected tab.
const selected = props.changesOpen ? group.name === ALL_FIELDS_GROUP.name : isSelected
// Also disable non-selected groups when review changes is open
const disabled = props.changesOpen ? !selected : false
return groupHidden
? []
: [
{
disabled,
icon: group?.icon,
name: group.name,
selected,
title: group.title,
},
]
})
const selectedGroup = groups.find((group) => group.selected)!
// note: this is needed because not all object types gets a ´fieldsets´ property during schema parsing.
// ideally members should be normalized as part of the schema parsing and not here
const normalizedSchemaMembers: typeof props.schemaType.fieldsets = props.schemaType.fieldsets
? props.schemaType.fieldsets
: props.schemaType.fields.map((field) => ({single: true, field}))
// create a members array for the object
const members = normalizedSchemaMembers.flatMap(
(fieldSet, index): (ObjectMember | HiddenField)[] => {
// "single" means not part of a fieldset
if (fieldSet.single) {
const field = fieldSet.field
const fieldMember = prepareFieldMember({
field: field,
parent: {...props, readOnly, groups, selectedGroup},
index,
})
return fieldMember ? [fieldMember] : []
}
// it's an actual fieldset
const fieldsetFieldNames = fieldSet.fields.map((f) => f.name)
const fieldsetHidden = resolveConditionalProperty(fieldSet.hidden, {
currentUser: props.currentUser,
document: props.document,
parent: props.value,
value: pick(props.value, fieldsetFieldNames),
})
const fieldsetReadOnly = resolveConditionalProperty(fieldSet.readOnly, {
currentUser: props.currentUser,
document: props.document,
parent: props.value,
value: pick(props.value, fieldsetFieldNames),
})
const fieldsetMembers = fieldSet.fields.flatMap(
(field): (FieldMember | FieldError | HiddenField)[] => {
if (fieldsetHidden) {
return [
{
kind: 'hidden',
key: `field-${field.name}`,
name: field.name,
index: index,
},
]
}
const fieldMember = prepareFieldMember({
field: field,
parent: {...props, readOnly: readOnly || fieldsetReadOnly, groups, selectedGroup},
index,
}) as FieldMember | FieldError | HiddenField
return fieldMember ? [fieldMember] : []
},
)
const defaultCollapsedState = getCollapsedWithDefaults(fieldSet.options, props.level)
const collapsed =
(props.collapsedFieldSets?.children || {})[fieldSet.name]?.value ??
defaultCollapsedState.collapsed
return [
{
kind: 'fieldSet',
key: `fieldset-${fieldSet.name}`,
_inSelectedGroup: isFieldEnabledByGroupFilter(groups, fieldSet.group, selectedGroup),
groups: fieldSet.group ? castArray(fieldSet.group) : [],
fieldSet: {
path: pathFor(props.path.concat(fieldSet.name)),
name: fieldSet.name,
title: fieldSet.title,
description: fieldSet.description,
hidden: false,
level: props.level + 1,
members: fieldsetMembers.filter(
(member): member is FieldMember => member.kind !== 'hidden',
),
collapsible: defaultCollapsedState?.collapsible,
collapsed,
columns: fieldSet?.options?.columns,
},
},
]
},
)
const hasFieldGroups = schemaTypeGroupConfig.length > 0
const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path))
const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY
const validation = props.validation
.filter((item) => isEqual(item.path, props.path))
.map((v) => ({level: v.level, message: v.message, path: v.path}))
const visibleMembers = members.filter(
(member): member is ObjectMember => member.kind !== 'hidden',
)
// Return null here only when enableHiddenCheck, or we end up with array members that have 'item: null' when they
// really should not be. One example is when a block object inside the PT-input have a type with one single hidden field.
// Then it should still be possible to see the member item, even though all of it's fields are null.
if (visibleMembers.length === 0 && enableHiddenCheck) {
return null
}
const visibleGroups = hasFieldGroups
? groups.flatMap((group) => {
// The "all fields" group is always visible
if (group.name === ALL_FIELDS_GROUP.name) {
return group
}
const hasVisibleMembers = visibleMembers.some((member) => {
if (member.kind === 'error') {
return false
}
if (member.kind === 'field') {
return member.groups.includes(group.name)
}
return (
member.groups.includes(group.name) ||
member.fieldSet.members.some(
(fieldsetMember) =>
fieldsetMember.kind !== 'error' && fieldsetMember.groups.includes(group.name),
)
)
})
return hasVisibleMembers ? group : []
})
: []
const filtereredMembers = visibleMembers.flatMap(
(member): (FieldError | FieldMember | FieldSetMember)[] => {
if (member.kind === 'error') {
return [member]
}
if (member.kind === 'field') {
return member.inSelectedGroup ? [member] : []
}
const filteredFieldsetMembers: ObjectMember[] = member.fieldSet.members.filter(
(fieldsetMember) => fieldsetMember.kind !== 'field' || fieldsetMember.inSelectedGroup,
)
return filteredFieldsetMembers.length > 0
? [
{
...member,
fieldSet: {...member.fieldSet, members: filteredFieldsetMembers},
} as FieldSetMember,
]
: []
},
)
const node = {
value: props.value as Record<string, unknown> | undefined,
changed: isChangedValue(props.value, props.comparisonValue),
schemaType: props.schemaType,
readOnly,
path: props.path,
id: toString(props.path),
level: props.level,
focused: isEqual(props.path, props.focusPath),
focusPath: trimChildPath(props.path, props.focusPath),
presence,
validation,
// this is currently needed by getExpandOperations which needs to know about hidden members
// (e.g. members not matching current group filter) in order to determine what to expand
members: filtereredMembers,
groups: visibleGroups,
}
Object.defineProperty(node, '_allMembers', {
value: members,
enumerable: false,
})
return node
}
function prepareArrayOfPrimitivesInputState<T extends (boolean | string | number)[]>(
props: RawState<ArraySchemaType, T>,
): ArrayOfPrimitivesFormNode | null {
if (props.level === MAX_FIELD_DEPTH) {
return null
}
const conditionalPropertyContext = {
comparisonValue: props.comparisonValue,
value: props.value,
parent: props.parent,
document: props.document,
currentUser: props.currentUser,
}
const hidden = resolveConditionalProperty(props.schemaType.hidden, conditionalPropertyContext)
if (hidden) {
return null
}
const readOnly =
props.readOnly ||
resolveConditionalProperty(props.schemaType.readOnly, conditionalPropertyContext)
// Todo: improve error handling at the parent level so that the value here is either undefined or an array
const items = Array.isArray(props.value) ? props.value : []
const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path))
const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY
const validation = props.validation
.filter((item) => isEqual(item.path, props.path))
.map((v) => ({level: v.level, message: v.message, path: v.path}))
const members = items.flatMap((item, index) =>
prepareArrayOfPrimitivesMember({arrayItem: item, parent: props, index}),
)
return {
// checks for changes not only on the array itself, but also on any of its items
changed: props.changed || members.some((m) => m.kind === 'item' && m.item.changed),
value: props.value as T,
readOnly,
schemaType: props.schemaType,
focused: isEqual(props.path, props.focusPath),
focusPath: trimChildPath(props.path, props.focusPath),
path: props.path,
id: toString(props.path),
level: props.level,
validation,
presence,
members,
}
}
function prepareArrayOfObjectsInputState<T extends {_key: string}[]>(
props: RawState<ArraySchemaType, T>,
): ArrayOfObjectsFormNode | null {
if (props.level === MAX_FIELD_DEPTH) {
return null
}
const conditionalPropertyContext = {
value: props.value,
parent: props.parent,
document: props.document,
currentUser: props.currentUser,
}
const hidden = resolveConditionalProperty(props.schemaType.hidden, conditionalPropertyContext)
if (hidden) {
return null
}
const readOnly =
props.readOnly ||
resolveConditionalProperty(props.schemaType.readOnly, conditionalPropertyContext)
// Todo: improve error handling at the parent level so that the value here is either undefined or an array
const items = Array.isArray(props.value) ? props.value : []
const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path))
const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY
const validation = props.validation
.filter((item) => isEqual(item.path, props.path))
.map((v) => ({level: v.level, message: v.message, path: v.path}))
const members = items.flatMap((item, index) =>
prepareArrayOfObjectsMember({
arrayItem: item,
parent: props,
index,
}),
)
return {
// checks for changes not only on the array itself, but also on any of its items
changed: props.changed || members.some((m) => m.kind === 'item' && m.item.changed),
value: props.value as T,
readOnly,
schemaType: props.schemaType,
focused: isEqual(props.path, props.focusPath),
focusPath: trimChildPath(props.path, props.focusPath),
path: props.path,
id: toString(props.path),
level: props.level,
validation,
presence,
members,
}
}
/*
* Takes a field in context of a parent object and returns prepared props for it
*/
function prepareArrayOfObjectsMember(props: {
arrayItem: {_key: string}
parent: RawState<ArraySchemaType, unknown>
index: number
}): ArrayOfObjectsMember {
const {arrayItem, parent, index} = props
const itemType = getItemType(parent.schemaType, arrayItem) as ObjectSchemaType
const key = arrayItem._key
if (!itemType) {
const itemTypeName = resolveTypeName(arrayItem)
return {
kind: 'error',
key,
index,
error: {
type: 'INVALID_ITEM_TYPE',
resolvedValueType: itemTypeName,
value: arrayItem,
validTypes: parent.schemaType.of,
},
}
}
const itemPath = pathFor([...parent.path, {_key: key}])
const itemLevel = parent.level + 1
const conditionalPropertyContext = {
value: parent.value,
parent: props.parent,
document: parent.document,
currentUser: parent.currentUser,
}
const readOnly =
parent.readOnly ||
resolveConditionalProperty(parent.schemaType.readOnly, conditionalPropertyContext)
const fieldGroupState = parent.fieldGroupState?.children?.[key]
const scopedCollapsedPaths = parent.collapsedPaths?.children?.[key]
const scopedCollapsedFieldsets = parent.collapsedFieldSets?.children?.[key]
const comparisonValue =
(Array.isArray(parent.comparisonValue) &&
parent.comparisonValue.find((i) => i._key === arrayItem._key)) ||
undefined
const itemState = prepareObjectInputState(
{
schemaType: itemType,
level: itemLevel,
document: parent.document,
value: arrayItem,
comparisonValue,
changed: isChangedValue(arrayItem, comparisonValue),
path: itemPath,
focusPath: parent.focusPath,
openPath: parent.openPath,
currentUser: parent.currentUser,
collapsedPaths: scopedCollapsedPaths,
collapsedFieldSets: scopedCollapsedFieldsets,
presence: parent.presence,
validation: parent.validation,
fieldGroupState,
readOnly,
},
false,
) as ObjectArrayFormNode
const defaultCollapsedState = getCollapsedWithDefaults(itemType.options, itemLevel)
const collapsed = scopedCollapsedPaths?.value ?? defaultCollapsedState.collapsed
return {
kind: 'item',
key,
index,
open: startsWith(itemPath, parent.openPath),
collapsed: collapsed,
collapsible: true,
parentSchemaType: parent.schemaType,
item: itemState,
}
}
/*
* Takes a field in contet of a parent object and returns prepared props for it
*/
function prepareArrayOfPrimitivesMember(props: {
arrayItem: unknown
parent: RawState<ArraySchemaType, unknown>
index: number
}): ArrayOfPrimitivesMember {
const {arrayItem, parent, index} = props
const itemType = getPrimitiveItemType(parent.schemaType, arrayItem)
const itemPath = pathFor([...parent.path, index])
const itemValue = (parent.value as unknown[] | undefined)?.[index] as string | boolean | number
const itemComparisonValue = (parent.comparisonValue as unknown[] | undefined)?.[index] as
| string
| boolean
| number
const itemLevel = parent.level + 1
// Best effort attempt to make a stable key for each item in the array
// Since items may be reordered and change at any time, there's no way to reliably address each item uniquely
// This is a "best effort"-attempt at making sure we don't re-use internal state for item inputs
// when items are added to or removed from the array
const key = `${itemType?.name || 'invalid-type'}-${String(index)}`
if (!itemType) {
return {
kind: 'error',
key,
index,
error: {
type: 'INVALID_ITEM_TYPE',
validTypes: parent.schemaType.of,
resolvedValueType: resolveTypeName(itemType),
value: itemValue,
},
}
}
const readOnly =
parent.readOnly ||
resolveConditionalProperty(itemType.readOnly, {
value: itemValue,
parent: parent.value,
document: parent.document,
currentUser: parent.currentUser,
})
const item = preparePrimitiveInputState({
...parent,
path: itemPath,
schemaType: itemType as PrimitiveSchemaType,
level: itemLevel,
value: itemValue,
comparisonValue: itemComparisonValue,
readOnly,
})
return {
kind: 'item',
key,
index,
parentSchemaType: parent.schemaType,
open: isEqual(itemPath, parent.openPath),
item,
}
}
function preparePrimitiveInputState<SchemaType extends PrimitiveSchemaType>(
props: RawState<SchemaType, unknown>,
): PrimitiveFormNode {
const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path))
const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY
const validation = props.validation
.filter((item) => isEqual(item.path, props.path))
.map((v) => ({level: v.level, message: v.message, path: v.path}))
return {
schemaType: props.schemaType,
changed: isChangedValue(props.value, props.comparisonValue),
value: props.value,
level: props.level,
id: toString(props.path),
readOnly: props.readOnly,
focused: isEqual(props.path, props.focusPath),
path: props.path,
presence,
validation,
} as PrimitiveFormNode
}
/** @internal */
export type FIXME_SanityDocument = Record<string, unknown>
/** @internal */
export function prepareFormState<T extends FIXME_SanityDocument>(
props: RawState<ObjectSchemaType, T>,
): ObjectFormNode | null {
return prepareObjectInputState(props)
}