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

1,064 lines (936 loc) 33.7 kB
/* 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) }