From 3bf70962b4f2eb052311a0c66dc376498cae2954 Mon Sep 17 00:00:00 2001 From: Rico Kahler Date: Tue, 17 Sep 2024 13:30:41 -0500 Subject: [PATCH] perf(core): memoize `prepareFormState` (#7498) * perf(core): memoize prepareFormState * refactor(core): update root form state options * fix(core): fix memoization bugs * test(core): finish tests for every form option * fix(core): fix stale `fieldGroupState` memo result * refactor(core): return named function for readability * docs(core): add JSDoc for `getId` * refactor(core): mark sub-functions of `PrepareFormState` as internal * fix: don't use symbols in weakmaps for firefox compat --- .../tests/formBuilder/utils/TestForm.tsx | 5 +- .../form/store/__tests__/collapsible.test.ts | 20 +- .../form/store/__tests__/equality.test.ts | 16 +- .../form/store/__tests__/formState.test.ts | 761 +++++++ .../store/__tests__/members.hidden.test.ts | 47 +- .../src/core/form/store/__tests__/shared.ts | 19 +- .../createCallbackResolver.ts | 197 ++ .../sanity/src/core/form/store/formState.ts | 1808 ++++++++++------- packages/sanity/src/core/form/store/index.ts | 1 - .../sanity/src/core/form/store/types/state.ts | 2 +- .../src/core/form/store/useFormState.ts | 165 +- .../__tests__/immutableReconcile.test.ts | 163 +- .../core/form/store/utils/createMemoizer.ts | 42 + .../sanity/src/core/form/store/utils/getId.ts | 41 + .../form/store/utils/immutableReconcile.ts | 157 +- .../src/core/form/studio/FormBuilder.test.tsx | 18 +- .../tasksFormBuilder/useTasksFormBuilder.ts | 9 +- .../panes/document/DocumentPaneProvider.tsx | 5 +- packages/sanity/test/form/renderInput.tsx | 7 +- 19 files changed, 2434 insertions(+), 1049 deletions(-) create mode 100644 packages/sanity/src/core/form/store/__tests__/formState.test.ts create mode 100644 packages/sanity/src/core/form/store/conditional-property/createCallbackResolver.ts create mode 100644 packages/sanity/src/core/form/store/utils/createMemoizer.ts create mode 100644 packages/sanity/src/core/form/store/utils/getId.ts diff --git a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx index 60d134cfc03..742c063ac92 100644 --- a/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx +++ b/packages/sanity/playwright-ct/tests/formBuilder/utils/TestForm.tsx @@ -157,7 +157,8 @@ export function TestForm(props: TestFormProps) { validateStaticDocument(document, workspace, (result) => setValidation(result)) }, [document, workspace]) - const formState = useFormState(schemaType, { + const formState = useFormState({ + schemaType, focusPath, collapsedPaths, collapsedFieldSets, @@ -166,7 +167,7 @@ export function TestForm(props: TestFormProps) { openPath, presence: presenceFromProps, validation, - value: document, + documentValue: document, }) const formStateRef = useRef(formState) diff --git a/packages/sanity/src/core/form/store/__tests__/collapsible.test.ts b/packages/sanity/src/core/form/store/__tests__/collapsible.test.ts index 2d96e78ffac..4dceb7e077b 100644 --- a/packages/sanity/src/core/form/store/__tests__/collapsible.test.ts +++ b/packages/sanity/src/core/form/store/__tests__/collapsible.test.ts @@ -1,9 +1,9 @@ -import {describe, expect, it, test} from '@jest/globals' +import {beforeEach, describe, expect, it, test} from '@jest/globals' import {Schema} from '@sanity/schema' import {type ObjectSchemaType, type Path} from '@sanity/types' import {pathToString} from '../../../field' -import {prepareFormState} from '../formState' +import {createPrepareFormState, type PrepareFormState} from '../formState' import {type FieldMember, type ObjectFormNode} from '../types' import {isObjectFormNode} from '../types/asserters' import {DEFAULT_PROPS} from './shared' @@ -81,6 +81,12 @@ function getBookType(fieldOptions: { }).get('book') } +let prepareFormState!: PrepareFormState + +beforeEach(() => { + prepareFormState = createPrepareFormState() +}) + test("doesn't make primitive fields collapsed even if they are configured to be", () => { // Note: the schema validation should possibly enforce this // Note2: We might want to support making all kinds of fields collapsible, even primitive fields @@ -93,7 +99,7 @@ test("doesn't make primitive fields collapsed even if they are configured to be" const result = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + documentValue: {_id: 'foo', _type: 'book'}, }) expect(result).not.toBe(null) @@ -121,7 +127,7 @@ describe('collapsible object fields', () => { const result = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + documentValue: {_id: 'foo', _type: 'book'}, }) expect(result).not.toBe(null) @@ -141,7 +147,7 @@ describe('collapsible object fields', () => { const result = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + documentValue: {_id: 'foo', _type: 'book'}, }) expect(result).not.toBe(null) @@ -160,7 +166,7 @@ describe('collapsible object fields', () => { const result = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + value: {_id: 'foo', _type: 'book'}, }) expect(result).not.toBe(null) @@ -184,7 +190,7 @@ describe('collapsible object fields', () => { const result = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + documentValue: {_id: 'foo', _type: 'book'}, }) expect(result).not.toBe(null) diff --git a/packages/sanity/src/core/form/store/__tests__/equality.test.ts b/packages/sanity/src/core/form/store/__tests__/equality.test.ts index bfb227f4d4a..b458360c2c3 100644 --- a/packages/sanity/src/core/form/store/__tests__/equality.test.ts +++ b/packages/sanity/src/core/form/store/__tests__/equality.test.ts @@ -1,8 +1,8 @@ -import {expect, test} from '@jest/globals' +import {beforeEach, expect, test} from '@jest/globals' import {Schema} from '@sanity/schema' import {type ConditionalProperty} from '@sanity/types' -import {prepareFormState} from '../formState' +import {createPrepareFormState, type PrepareFormState} from '../formState' import {DEFAULT_PROPS} from './shared' function getBookType(properties: { @@ -73,20 +73,26 @@ function getBookType(properties: { }).get('book') } +let prepareFormState!: PrepareFormState + +beforeEach(() => { + prepareFormState = createPrepareFormState() +}) + test('it doesnt return new object equalities given the same input', () => { - const document = {_id: 'test', _type: 'foo'} + const documentValue = {_id: 'test', _type: 'foo'} const bookType = getBookType({}) const state1 = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document, + documentValue, }) const state2 = prepareFormState({ ...DEFAULT_PROPS, schemaType: bookType, - document, + documentValue, }) expect(state1).not.toBe(null) expect(state2).not.toBe(null) diff --git a/packages/sanity/src/core/form/store/__tests__/formState.test.ts b/packages/sanity/src/core/form/store/__tests__/formState.test.ts new file mode 100644 index 00000000000..b902457a10c --- /dev/null +++ b/packages/sanity/src/core/form/store/__tests__/formState.test.ts @@ -0,0 +1,761 @@ +import {beforeEach, describe, expect, jest, test} from '@jest/globals' +import { + type CurrentUser, + defineField, + defineType, + isIndexTuple, + isKeySegment, + type ObjectSchemaType, + type Path, +} from '@sanity/types' +import {startsWith, toString} from '@sanity/util/paths' + +import {createSchema} from '../../../schema/createSchema' +import { + createPrepareFormState, + type PrepareFormState, + type RootFormStateOptions, +} from '../formState' +import {type FieldsetState} from '../types/fieldsetState' +import { + type ArrayOfObjectsItemMember, + type ArrayOfPrimitivesItemMember, + type FieldMember, +} from '../types/members' +import { + type ArrayOfObjectsFormNode, + type ArrayOfPrimitivesFormNode, + type ObjectFormNode, + type PrimitiveFormNode, +} from '../types/nodes' +import {type StateTree} from '../types/state' + +let prepareFormState!: PrepareFormState + +type RemoveFirstChar = S extends `${infer _}${infer R}` ? R : S + +beforeEach(() => { + prepareFormState = createPrepareFormState({ + decorators: { + prepareArrayOfObjectsInputState: jest.fn, + prepareArrayOfObjectsMember: jest.fn, + prepareArrayOfPrimitivesInputState: jest.fn, + prepareArrayOfPrimitivesMember: jest.fn, + prepareFieldMember: jest.fn, + prepareObjectInputState: jest.fn, + preparePrimitiveInputState: jest.fn, + }, + }) +}) +const schema = createSchema({ + name: 'default', + types: [ + defineType({ + name: 'testDocument', + type: 'document', + groups: [ + {name: 'groupA', title: 'Group A'}, + {name: 'groupB', title: 'Group B'}, + ], + fields: [ + defineField({ + name: 'title', + type: 'string', + validation: (Rule) => Rule.required(), + group: 'groupA', + }), + defineField({ + name: 'simpleObject', + type: 'object', + group: 'groupA', + fields: [ + {name: 'field1', type: 'string'}, + {name: 'field2', type: 'number'}, + ], + }), + defineField({ + name: 'arrayOfPrimitives', + type: 'array', + of: [{type: 'string'}], + group: 'groupB', + }), + defineField({ + name: 'arrayOfObjects', + type: 'array', + of: [ + { + type: 'object', + name: 'arrayObject', + fields: [ + defineField({name: 'objectTitle', type: 'string'}), + defineField({name: 'objectValue', type: 'number'}), + ], + }, + ], + group: 'groupB', + }), + defineField({ + name: 'nestedObject', + type: 'object', + group: 'groupA', + fields: [ + defineField({name: 'nestedField1', type: 'string'}), + defineField({ + name: 'nestedObject', + type: 'object', + fields: [ + defineField({ + name: 'deeplyNestedField', + type: 'string', + }), + ], + }), + defineField({name: 'nestedArray', type: 'array', of: [{type: 'string'}]}), + ], + }), + defineField({ + name: 'conditionalField', + type: 'string', + hidden: ({document}) => !document?.title, + }), + defineField({ + name: 'fieldsetField1', + type: 'string', + fieldset: 'testFieldset', + group: 'groupB', + }), + defineField({ + name: 'fieldsetField2', + type: 'number', + fieldset: 'testFieldset', + group: 'groupB', + }), + ], + fieldsets: [ + { + name: 'testFieldset', + options: {collapsible: true, collapsed: false}, + }, + ], + }), + ], +}) + +const schemaType = schema.get('testDocument') as ObjectSchemaType + +const currentUser: Omit = { + email: 'rico@sanity.io', + id: 'exampleId', + name: 'Rico Kahler', + roles: [], +} + +const documentValue = { + _type: 'testDocument', + title: 'Example Test Document', + simpleObject: { + field1: 'Simple Object String', + field2: 42, + }, + arrayOfPrimitives: ['First string', 'Second string', 'Third string'], + arrayOfObjects: [ + { + _type: 'arrayObject', + _key: 'object0', + objectTitle: 'First Object', + objectValue: 10, + }, + { + _type: 'arrayObject', + _key: 'object1', + objectTitle: 'Second Object', + objectValue: 20, + }, + ], + nestedObject: { + nestedField1: 'Nested Field Value', + nestedObject: { + deeplyNestedField: 'Deeply Nested Value', + }, + nestedArray: ['Nested Array Item 1', 'Nested Array Item 2'], + }, + conditionalField: 'This field is visible', + fieldsetField1: 'Fieldset String Value', + fieldsetField2: 99, +} + +function setAtPath(path: Path): StateTree { + const [first, ...rest] = path + if (typeof first === 'undefined') { + return {value: true} + } + + if (isIndexTuple(first)) return {} + + const key = typeof first === 'object' && '_key' in first ? first._key : first + + return { + children: { + [key]: setAtPath(rest), + }, + } +} + +function updateDocumentAtPath(path: Path, value: any): unknown { + const [first, ...rest] = path + if (isIndexTuple(first)) throw new Error('Unexpected index tuple') + + if (typeof first === 'undefined') return 'CHANGED' + if (typeof first === 'string') { + return {...value, [first]: updateDocumentAtPath(rest, value?.[first])} + } + + if (Array.isArray(value)) { + const index = isKeySegment(first) ? value.findIndex((item) => item?._key === first._key) : first + + return [ + ...value.slice(0, index), + updateDocumentAtPath(rest, value[index]), + ...value.slice(index + 1), + ] + } + + return updateDocumentAtPath(rest, []) +} + +type FormNode = + | ObjectFormNode + | ArrayOfObjectsFormNode + | ArrayOfPrimitivesFormNode + | PrimitiveFormNode + +type FormTraversalResult = [ + FormNode, + { + member?: FieldMember | ArrayOfObjectsItemMember | ArrayOfPrimitivesItemMember + fieldset?: FieldsetState + }, +] + +function* traverseForm( + formNode: FormNode | null, + parent?: FormTraversalResult[1], +): Generator { + if (!formNode) return + + yield [formNode, parent ?? {}] + + if (!('members' in formNode)) return + + for (const member of formNode.members) { + switch (member.kind) { + case 'field': { + yield* traverseForm(member.field as FormNode, {member}) + continue + } + case 'fieldSet': { + for (const fieldsetMember of member.fieldSet.members) { + if (fieldsetMember.kind === 'error') continue + yield* traverseForm(fieldsetMember.field as FormNode, { + member: fieldsetMember, + fieldset: member.fieldSet, + }) + } + continue + } + case 'item': { + yield* traverseForm(member.item as FormNode, {member}) + continue + } + default: { + continue + } + } + } +} + +const rootFormNodeOptions: Partial<{ + [K in keyof RootFormStateOptions]: { + deriveInput: (path: Path) => RootFormStateOptions[K] + assertOutput: (node: FormTraversalResult) => void + } +}> = { + focusPath: { + deriveInput: (path) => path, + assertOutput: ([node]) => expect(node.focused).toBe(true), + }, + openPath: { + deriveInput: (path) => path, + assertOutput: ([_node, {member}]) => expect(member?.open).toBe(true), + }, + validation: { + deriveInput: (path) => [{path, level: 'error', message: 'example marker'}], + assertOutput: ([node]) => + expect(node.validation).toEqual([ + {path: node.path, level: 'error', message: 'example marker'}, + ]), + }, + presence: { + deriveInput: (path) => [ + { + path, + lastActiveAt: '2024-09-12T21:59:08.362Z', + sessionId: 'exampleSession', + user: {id: 'exampleUser'}, + }, + ], + assertOutput: ([node]) => + expect(node.presence).toEqual([ + { + path: node.path, + lastActiveAt: '2024-09-12T21:59:08.362Z', + sessionId: 'exampleSession', + user: {id: 'exampleUser'}, + }, + ]), + }, + documentValue: { + deriveInput: (path) => updateDocumentAtPath(path, documentValue), + assertOutput: ([node]) => expect(node.value).toBe('CHANGED'), + }, + comparisonValue: { + deriveInput: (path) => updateDocumentAtPath(path, documentValue), + assertOutput: ([node]) => expect(node.changed).toBe(true), + }, + readOnly: { + deriveInput: (path) => setAtPath(path), + assertOutput: ([node]) => expect(node.readOnly).toBe(true), + }, +} + +const paths: { + path: Path + expectedCalls: {[K in RemoveFirstChar]: number} +}[] = [ + { + path: ['title'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 8, + prepareObjectInputState: 1, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['simpleObject', 'field1'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['arrayOfPrimitives', 1], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 1, + prepareArrayOfPrimitivesMember: 3, + prepareFieldMember: 8, + prepareObjectInputState: 1, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['arrayOfObjects', {_key: 'object1'}, 'objectTitle'], + expectedCalls: { + prepareArrayOfObjectsInputState: 1, + prepareArrayOfObjectsMember: 2, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['nestedObject', 'nestedField1'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 11, + prepareObjectInputState: 2, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['nestedObject', 'nestedObject', 'deeplyNestedField'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 12, + prepareObjectInputState: 3, + preparePrimitiveInputState: 1, + }, + }, + { + path: ['nestedObject', 'nestedArray', 0], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 1, + prepareArrayOfPrimitivesMember: 2, + prepareFieldMember: 11, + prepareObjectInputState: 2, + preparePrimitiveInputState: 1, + }, + }, +] + +const defaultOptions: RootFormStateOptions = { + currentUser, + focusPath: [], + openPath: [], + presence: [], + schemaType, + validation: [], + changesOpen: false, + collapsedFieldSets: {}, + collapsedPaths: {}, + documentValue, + comparisonValue: documentValue, + fieldGroupState: {}, + hidden: undefined, + readOnly: undefined, +} + +describe.each( + Object.entries(rootFormNodeOptions).map(([property, {deriveInput, assertOutput}]) => ({ + property, + deriveInput, + assertOutput, + })), +)('$property', ({property, deriveInput, assertOutput}) => { + test.each(paths)('$path', ({path, expectedCalls}) => { + const initialFormState = prepareFormState(defaultOptions) + const initialNodes = new Set(Array.from(traverseForm(initialFormState)).map(([node]) => node)) + + // reset toHaveBeenCalledTimes amount + jest.clearAllMocks() + + const updatedFormState = prepareFormState({ + ...defaultOptions, + ...{[property]: deriveInput(path)}, + }) + const updatedNodes = Array.from(traverseForm(updatedFormState)).reverse() + + const differentNodes = updatedNodes.filter(([node]) => !initialNodes.has(node)) + expect(differentNodes).not.toHaveLength(0) + + assertOutput(differentNodes[0]) + + for (const [differentNode] of differentNodes) { + expect(startsWith(differentNode.path, path)).toBe(true) + } + + expect(prepareFormState._prepareArrayOfObjectsInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsInputState, + ) + expect(prepareFormState._prepareArrayOfObjectsMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsMember, + ) + expect(prepareFormState._prepareArrayOfPrimitivesInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesInputState, + ) + expect(prepareFormState._prepareArrayOfPrimitivesMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesMember, + ) + expect(prepareFormState._prepareFieldMember).toHaveBeenCalledTimes( + expectedCalls.prepareFieldMember, + ) + expect(prepareFormState._prepareObjectInputState).toHaveBeenCalledTimes( + expectedCalls.prepareObjectInputState, + ) + expect(prepareFormState._preparePrimitiveInputState).toHaveBeenCalledTimes( + expectedCalls.preparePrimitiveInputState, + ) + }) +}) + +describe('hidden', () => { + const pathsToTest: { + path: Path + expectedCalls: {[K in RemoveFirstChar]: number} + }[] = [ + { + path: ['title'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 8, + prepareObjectInputState: 1, + preparePrimitiveInputState: 0, + }, + }, + { + path: ['simpleObject', 'field1'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 0, + }, + }, + { + path: ['arrayOfPrimitives'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 1, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 8, + prepareObjectInputState: 1, + preparePrimitiveInputState: 0, + }, + }, + { + path: ['arrayOfObjects', {_key: 'object1'}, 'objectTitle'], + expectedCalls: { + prepareArrayOfObjectsInputState: 1, + prepareArrayOfObjectsMember: 2, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 0, + }, + }, + ] + + test.each(pathsToTest)('$path', ({path, expectedCalls}) => { + const hidden = setAtPath(path) + + const initialFormState = prepareFormState(defaultOptions) + const initialNodes = new Set(Array.from(traverseForm(initialFormState)).map(([node]) => node)) + + // reset toHaveBeenCalledTimes amount + jest.clearAllMocks() + + const updatedFormState = prepareFormState({ + ...defaultOptions, + hidden, + }) + const updatedNodes = Array.from(traverseForm(updatedFormState)).reverse() + const differentNodes = updatedNodes.filter(([node]) => !initialNodes.has(node)) + + expect(differentNodes).not.toHaveLength(0) + for (const [differentNode] of differentNodes) { + expect(startsWith(differentNode.path, path)).toBe(true) + } + + // Verify memoization: functions should be called only for affected nodes + expect(prepareFormState._prepareArrayOfObjectsInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsInputState, + ) + expect(prepareFormState._prepareArrayOfObjectsMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsMember, + ) + expect(prepareFormState._prepareArrayOfPrimitivesInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesInputState, + ) + expect(prepareFormState._prepareArrayOfPrimitivesMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesMember, + ) + expect(prepareFormState._prepareFieldMember).toHaveBeenCalledTimes( + expectedCalls.prepareFieldMember, + ) + expect(prepareFormState._prepareObjectInputState).toHaveBeenCalledTimes( + expectedCalls.prepareObjectInputState, + ) + expect(prepareFormState._preparePrimitiveInputState).toHaveBeenCalledTimes( + expectedCalls.preparePrimitiveInputState, + ) + }) +}) + +describe('collapsedPaths', () => { + const pathsToTest: { + path: Path + expectedCalls: {[K in RemoveFirstChar]: number} + }[] = [ + { + path: ['simpleObject'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 0, + }, + }, + { + path: ['arrayOfObjects', {_key: 'object1'}], + expectedCalls: { + prepareArrayOfObjectsInputState: 1, + prepareArrayOfObjectsMember: 2, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 10, + prepareObjectInputState: 2, + preparePrimitiveInputState: 0, + }, + }, + { + path: ['nestedObject', 'nestedObject'], + expectedCalls: { + prepareArrayOfObjectsInputState: 0, + prepareArrayOfObjectsMember: 0, + prepareArrayOfPrimitivesInputState: 0, + prepareArrayOfPrimitivesMember: 0, + prepareFieldMember: 12, + prepareObjectInputState: 3, + preparePrimitiveInputState: 0, + }, + }, + ] + + test.each(pathsToTest)('$path', ({path, expectedCalls}) => { + const collapsedPaths = setAtPath(path) + + // Prepare initial form state + prepareFormState(defaultOptions) + + // reset toHaveBeenCalledTimes amount + jest.clearAllMocks() + + // Prepare updated form state with collapsedPaths set + const updatedFormState = prepareFormState({ + ...defaultOptions, + collapsedPaths, + }) + + // Traverse updated form state + const updatedNodes = Array.from(traverseForm(updatedFormState)) + + // Find the member at the path + const memberAtPath = updatedNodes.find( + ([node, {member}]) => toString(node.path) === toString(path) && member !== undefined, + ) + + expect(memberAtPath).toBeDefined() + const member = memberAtPath![1].member + expect(member && 'collapsed' in member && member.collapsed).toBe(true) + + // Verify memoization: functions should be called only for affected nodes + expect(prepareFormState._prepareArrayOfObjectsInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsInputState, + ) + expect(prepareFormState._prepareArrayOfObjectsMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfObjectsMember, + ) + expect(prepareFormState._prepareArrayOfPrimitivesInputState).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesInputState, + ) + expect(prepareFormState._prepareArrayOfPrimitivesMember).toHaveBeenCalledTimes( + expectedCalls.prepareArrayOfPrimitivesMember, + ) + expect(prepareFormState._prepareFieldMember).toHaveBeenCalledTimes( + expectedCalls.prepareFieldMember, + ) + expect(prepareFormState._prepareObjectInputState).toHaveBeenCalledTimes( + expectedCalls.prepareObjectInputState, + ) + expect(prepareFormState._preparePrimitiveInputState).toHaveBeenCalledTimes( + expectedCalls.preparePrimitiveInputState, + ) + }) +}) + +describe('collapsedFieldSets', () => { + test('collapsedFieldSets', () => { + const fieldsetName = 'testFieldset' + const path = [fieldsetName] // Use the fieldset name directly + const collapsedFieldSets = setAtPath(path) + + // Prepare initial form state + prepareFormState(defaultOptions) + + jest.clearAllMocks() + + // Prepare updated form state with collapsedFieldSets set + const updatedFormState = prepareFormState({ + ...defaultOptions, + collapsedFieldSets, + }) + + // Traverse updated form state + const updatedNodes = Array.from(traverseForm(updatedFormState)) + + // Find the fieldset member + const fieldsetNode = updatedNodes.find( + ([_node, {fieldset}]) => fieldset?.name === fieldsetName, + )! + + const [, {fieldset}] = fieldsetNode + + expect(fieldset?.collapsed).toBe(true) + + // Verify memoization: functions should be called only for affected nodes + expect(prepareFormState._prepareArrayOfObjectsInputState).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareArrayOfObjectsMember).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareArrayOfPrimitivesInputState).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareArrayOfPrimitivesMember).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareFieldMember).toHaveBeenCalledTimes(8) + expect(prepareFormState._prepareObjectInputState).toHaveBeenCalledTimes(1) + expect(prepareFormState._preparePrimitiveInputState).toHaveBeenCalledTimes(0) + }) +}) + +describe('fieldGroupState', () => { + test('fieldGroupState', () => { + const initialFormState = prepareFormState(defaultOptions) + const initialNodes = new Set(Array.from(traverseForm(initialFormState)).map(([node]) => node)) + + // Reset call counts + jest.clearAllMocks() + + const updatedFormState = prepareFormState({ + ...defaultOptions, + fieldGroupState: {value: 'groupA'}, + }) + + const updatedNodes = Array.from(traverseForm(updatedFormState)).reverse() + expect(updatedNodes.length).toBeGreaterThan(1) + const differentNodes = updatedNodes.filter(([node]) => !initialNodes.has(node)) + expect(differentNodes.length).toBeGreaterThan(0) + expect(differentNodes.length).toBeLessThan(updatedNodes.length) + + expect(updatedFormState?.members.map((i) => i.key)).toEqual([ + 'field-title', + 'field-simpleObject', + 'field-nestedObject', + ]) + + // Verify memoization: functions should be called only for affected nodes + expect(prepareFormState._prepareArrayOfObjectsInputState).toHaveBeenCalledTimes(1) + expect(prepareFormState._prepareArrayOfObjectsMember).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareArrayOfPrimitivesInputState).toHaveBeenCalledTimes(1) + expect(prepareFormState._prepareArrayOfPrimitivesMember).toHaveBeenCalledTimes(0) + expect(prepareFormState._prepareFieldMember).toHaveBeenCalledTimes(8) + expect(prepareFormState._prepareObjectInputState).toHaveBeenCalledTimes(3) + expect(prepareFormState._preparePrimitiveInputState).toHaveBeenCalledTimes(4) + }) +}) diff --git a/packages/sanity/src/core/form/store/__tests__/members.hidden.test.ts b/packages/sanity/src/core/form/store/__tests__/members.hidden.test.ts index cf10a6568d7..deac7747412 100644 --- a/packages/sanity/src/core/form/store/__tests__/members.hidden.test.ts +++ b/packages/sanity/src/core/form/store/__tests__/members.hidden.test.ts @@ -1,12 +1,13 @@ -import {expect, test} from '@jest/globals' +import {beforeEach, expect, test} from '@jest/globals' import {Schema} from '@sanity/schema' import {type ConditionalProperty, type ObjectSchemaType} from '@sanity/types' -import {prepareFormState} from '../formState' -import {DEFAULT_PROPS} from './shared' - -// eslint-disable-next-line no-empty-function,@typescript-eslint/no-empty-function -const noop = () => {} +import { + createCallbackResolver, + type RootCallbackResolver, +} from '../conditional-property/createCallbackResolver' +import {createPrepareFormState, type PrepareFormState} from '../formState' +import {DEFAULT_PROPS, MOCK_USER} from './shared' function getBookType(properties: { root?: {hidden?: ConditionalProperty; readOnly?: ConditionalProperty} @@ -76,14 +77,26 @@ function getBookType(properties: { }).get('book') } +let prepareFormState!: PrepareFormState +let prepareHiddenState!: RootCallbackResolver<'hidden'> + +beforeEach(() => { + prepareFormState = createPrepareFormState() + prepareHiddenState = createCallbackResolver({property: 'hidden'}) +}) + test('it omits the hidden member field from the members array', () => { - const bookType: ObjectSchemaType = getBookType({ + const schemaType: ObjectSchemaType = getBookType({ subtitle: {hidden: () => true}, }) + + const documentValue = {_id: 'foo', _type: 'book'} const result = prepareFormState({ ...DEFAULT_PROPS, - schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + hidden: prepareHiddenState({currentUser: MOCK_USER, documentValue, schemaType}), + schemaType, + documentValue, + comparisonValue: documentValue, }) expect(result).not.toBe(null) @@ -95,13 +108,15 @@ test('it omits the hidden member field from the members array', () => { }) test('it omits nested hidden members from the members array', () => { - const bookType = getBookType({ + const schemaType = getBookType({ author: {hidden: () => true}, }) + const documentValue = {_id: 'foo', _type: 'book'} const result = prepareFormState({ ...DEFAULT_PROPS, - schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, + schemaType: schemaType, + hidden: prepareHiddenState({currentUser: MOCK_USER, documentValue: documentValue, schemaType}), + documentValue, }) expect(result).not.toBe(null) @@ -114,14 +129,16 @@ test('it omits nested hidden members from the members array', () => { test('it "upward propagates" hidden fields', () => { // If the hidden callback for every field of an object type returns true, the whole object should be hidden - const bookType = getBookType({ + const schemaType = getBookType({ authorFirstName: {hidden: () => true}, authorLastName: {hidden: () => true}, }) + const document = {_id: 'foo', _type: 'book'} const result = prepareFormState({ - schemaType: bookType, - document: {_id: 'foo', _type: 'book'}, ...DEFAULT_PROPS, + schemaType, + value: document, + hidden: prepareHiddenState({currentUser: MOCK_USER, documentValue: document, schemaType}), }) expect(result).not.toBe(null) if (result === null) { diff --git a/packages/sanity/src/core/form/store/__tests__/shared.ts b/packages/sanity/src/core/form/store/__tests__/shared.ts index 237e38210f3..9489c37bf93 100644 --- a/packages/sanity/src/core/form/store/__tests__/shared.ts +++ b/packages/sanity/src/core/form/store/__tests__/shared.ts @@ -1,21 +1,14 @@ -// eslint-disable-next-line no-empty-function,@typescript-eslint/no-empty-function -const noop = () => {} - export const MOCK_USER = {id: 'bjoerge', email: 'bjoerge@gmail.com', name: 'Bjørge', roles: []} export const DEFAULT_PROPS = { validation: [], presence: [], focusPath: [], - path: [], - hidden: false, - readOnly: false, currentUser: MOCK_USER, openPath: [], - onSetCollapsedField: noop, - onSetCollapsedFieldSet: noop, - onSetActiveFieldGroupAtPath: noop, - onChange: noop, - onBlur: noop, - onFocus: noop, - level: 0, + comparisonValue: undefined, + hidden: undefined, + readOnly: undefined, + fieldGroupState: undefined, + collapsedPaths: undefined, + collapsedFieldSets: undefined, } diff --git a/packages/sanity/src/core/form/store/conditional-property/createCallbackResolver.ts b/packages/sanity/src/core/form/store/conditional-property/createCallbackResolver.ts new file mode 100644 index 00000000000..7e1e4de5fae --- /dev/null +++ b/packages/sanity/src/core/form/store/conditional-property/createCallbackResolver.ts @@ -0,0 +1,197 @@ +import {type CurrentUser, isKeyedObject, type SchemaType} from '@sanity/types' + +import {EMPTY_ARRAY} from '../../../util/empty' +import {MAX_FIELD_DEPTH} from '../constants' +import {type StateTree} from '../types/state' +import {getId} from '../utils/getId' +import {getItemType} from '../utils/getItemType' +import {immutableReconcile} from '../utils/immutableReconcile' +import { + type ConditionalPropertyCallbackContext, + resolveConditionalProperty, +} from './resolveConditionalProperty' + +interface ResolveCallbackStateOptions { + property: 'readOnly' | 'hidden' + value: unknown + parent: unknown + document: unknown + currentUser: Omit | null + schemaType: SchemaType + level: number +} + +function resolveCallbackState({ + value, + parent, + document, + currentUser, + schemaType, + level, + property, +}: ResolveCallbackStateOptions): StateTree | undefined { + const context: ConditionalPropertyCallbackContext = { + value, + parent, + document: document as ConditionalPropertyCallbackContext['document'], + currentUser, + } + const selfValue = resolveConditionalProperty(schemaType[property], context) + + // we don't have to calculate the children if the current value is true + // because readOnly and hidden inherit. If the parent is readOnly or hidden + // then its children are assumed to also be readOnly or hidden respectively. + if (selfValue || level === MAX_FIELD_DEPTH) { + return {value: selfValue} + } + + const children: Record> = {} + + if (schemaType.jsonType === 'object') { + // 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 schemaType.fieldsets = schemaType.fieldsets + ? schemaType.fieldsets + : schemaType.fields.map((field) => ({single: true, field})) + + for (const fieldset of normalizedSchemaMembers) { + if (fieldset.single) { + const childResult = resolveCallbackState({ + currentUser, + document, + parent: value, + value: (value as any)?.[fieldset.field.name], + schemaType: fieldset.field.type, + level: level + 1, + property, + }) + if (!childResult) continue + + children[fieldset.field.name] = childResult + continue + } + + const fieldsetValue = resolveConditionalProperty(fieldset.hidden, context) + if (fieldsetValue) { + children[`fieldset:${fieldset.name}`] = { + value: fieldsetValue, + } + } + + for (const field of fieldset.fields) { + const childResult = resolveCallbackState({ + currentUser, + document, + parent: value, + value: (value as any)?.[field.name], + schemaType: field.type, + level: level + 1, + property, + }) + if (!childResult) continue + + children[field.name] = childResult + } + } + + for (const group of schemaType.groups ?? EMPTY_ARRAY) { + // should only be true for `'hidden'` + if (property in group) { + const groupResult = resolveConditionalProperty(group[property as 'hidden'], context) + if (!groupResult) continue + + children[`group:${group.name}`] = {value: groupResult} + } + } + } + + if (schemaType.jsonType === 'array' && Array.isArray(value)) { + if (value.every(isKeyedObject)) { + for (const item of value) { + const itemType = getItemType(schemaType, item) + if (!itemType) continue + + const childResult = resolveCallbackState({ + currentUser, + document, + level: level + 1, + value: item, + parent: value, + schemaType: itemType, + property, + }) + if (!childResult) continue + + children[item._key] = childResult + } + } + } + + if (Object.keys(children).length) return {children} + return undefined +} + +export interface CreateCallbackResolverOptions { + property: TProperty +} + +export type ResolveRootCallbackStateOptions = { + documentValue: unknown + currentUser: Omit | null + schemaType: SchemaType +} & {[K in TProperty]?: boolean} + +export type RootCallbackResolver = ( + options: ResolveRootCallbackStateOptions, +) => StateTree | undefined + +export function createCallbackResolver({ + property, +}: CreateCallbackResolverOptions): RootCallbackResolver { + const stableTrue = {value: true} + let last: {serializedHash: string; result: StateTree | undefined} | null = null + + function callbackResult({ + currentUser, + documentValue, + schemaType, + ...options + }: ResolveRootCallbackStateOptions) { + const hash = { + currentUser: getId(currentUser), + schemaType: getId(schemaType), + document: getId(documentValue), + } + const serializedHash = JSON.stringify(hash) + + if (property in options) { + if (options[property] === true) { + return stableTrue + } + } + + if (last?.serializedHash === serializedHash) return last.result + + const result = immutableReconcile( + last?.result ?? null, + resolveCallbackState({ + currentUser, + document: documentValue, + level: 0, + parent: null, + schemaType, + value: documentValue, + property, + }), + ) + + last = { + result, + serializedHash, + } + + return result + } + + return callbackResult +} diff --git a/packages/sanity/src/core/form/store/formState.ts b/packages/sanity/src/core/form/store/formState.ts index 6862208ddcd..0968ab4db91 100644 --- a/packages/sanity/src/core/form/store/formState.ts +++ b/packages/sanity/src/core/form/store/formState.ts @@ -1,3 +1,5 @@ +/* eslint-disable complexity */ +/* eslint-disable max-nested-callbacks */ /* eslint-disable max-statements */ /* eslint-disable camelcase, no-else-return */ @@ -7,6 +9,7 @@ import { type CurrentUser, isArrayOfObjectsSchemaType, isArraySchemaType, + isKeyedObject, isObjectSchemaType, type NumberSchemaType, type ObjectField, @@ -17,13 +20,12 @@ import { } 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 {castArray, isEqual as _isEqual} from 'lodash' import {type FIXME} from '../../FIXME' import {type FormNodePresence} from '../../presence' -import {EMPTY_ARRAY, isRecord} from '../../util' +import {EMPTY_ARRAY, EMPTY_OBJECT, 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, @@ -45,11 +47,72 @@ import { type ArrayOfPrimitivesFormNode, type ObjectFormNode, } from './types/nodes' +import {createMemoizer, type FunctionDecorator} from './utils/createMemoizer' import {getCollapsedWithDefaults} from './utils/getCollapsibleOptions' +import {getId} from './utils/getId' import {getItemType, getPrimitiveItemType} from './utils/getItemType' type PrimitiveSchemaType = BooleanSchemaType | NumberSchemaType | StringSchemaType +interface FormStateOptions { + schemaType: TSchemaType + path: Path + value?: T + comparisonValue?: T | null + changed?: boolean + currentUser: Omit | null + hidden?: true | StateTree | undefined + readOnly?: true | StateTree | undefined + openPath: Path + focusPath: Path + presence: FormNodePresence[] + validation: ValidationMarker[] + fieldGroupState?: StateTree + collapsedPaths?: StateTree + collapsedFieldSets?: StateTree + // nesting level + level: number + changesOpen?: boolean +} + +type PrepareFieldMember = (props: { + field: ObjectField + parent: FormStateOptions & { + groups: FormFieldGroup[] + selectedGroup: FormFieldGroup + } + index: number +}) => ObjectMember | HiddenField | null + +type PrepareObjectInputState = ( + props: FormStateOptions, + enableHiddenCheck?: boolean, +) => ObjectFormNode | null + +type PrepareArrayOfPrimitivesInputState = ( + props: FormStateOptions, +) => ArrayOfPrimitivesFormNode | null + +type PrepareArrayOfObjectsInputState = ( + props: FormStateOptions, +) => ArrayOfObjectsFormNode | null + +type PrepareArrayOfObjectsMember = (props: { + arrayItem: {_key: string} + parent: FormStateOptions + index: number +}) => ArrayOfObjectsMember + +type PrepareArrayOfPrimitivesMember = (props: { + arrayItem: unknown + parent: FormStateOptions + index: number +}) => ArrayOfPrimitivesMember + +type PreparePrimitiveInputState = ( + props: FormStateOptions, +) => PrimitiveFormNode + function isFieldEnabledByGroupFilter( // the groups config for the "enclosing object" type groupsConfig: FormFieldGroup[], @@ -128,149 +191,269 @@ function isChangedValue(value: any, comparisonValue: any) { 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 & { - 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') +export interface CreatePrepareFormStateOptions { + decorators?: { + prepareFieldMember?: FunctionDecorator + prepareObjectInputState?: FunctionDecorator + prepareArrayOfPrimitivesInputState?: FunctionDecorator + prepareArrayOfObjectsInputState?: FunctionDecorator + prepareArrayOfObjectsMember?: FunctionDecorator + prepareArrayOfPrimitivesMember?: FunctionDecorator + preparePrimitiveInputState?: FunctionDecorator } +} - const normalizedFieldGroupNames = field.group ? castArray(field.group) : [] - const inSelectedGroup = isFieldEnabledByGroupFilter( - parent.groups, - field.group, - parent.selectedGroup, - ) +export interface RootFormStateOptions { + schemaType: ObjectSchemaType + documentValue: unknown + comparisonValue: unknown + currentUser: Omit | null + hidden: boolean | StateTree | undefined + readOnly: boolean | StateTree | undefined + openPath: Path + focusPath: Path + presence: FormNodePresence[] + validation: ValidationMarker[] + fieldGroupState: StateTree | undefined + collapsedPaths: StateTree | undefined + collapsedFieldSets: StateTree | undefined + changesOpen?: boolean +} - if (isObjectSchemaType(field.type)) { - const fieldValue = parentValue?.[field.name] - const fieldComparisonValue = isRecord(parentComparisonValue) - ? parentComparisonValue?.[field.name] - : undefined +export interface PrepareFormState { + (options: RootFormStateOptions): ObjectFormNode | null + + /** @internal */ + _prepareFieldMember: PrepareFieldMember + /** @internal */ + _prepareObjectInputState: PrepareObjectInputState + /** @internal */ + _prepareArrayOfPrimitivesInputState: PrepareArrayOfPrimitivesInputState + /** @internal */ + _prepareArrayOfObjectsInputState: PrepareArrayOfObjectsInputState + /** @internal */ + _prepareArrayOfObjectsMember: PrepareArrayOfObjectsMember + /** @internal */ + _prepareArrayOfPrimitivesMember: PrepareArrayOfPrimitivesMember + /** @internal */ + _preparePrimitiveInputState: PreparePrimitiveInputState +} - if (!isAcceptedObjectValue(fieldValue)) { +export function createPrepareFormState({ + decorators = {}, +}: CreatePrepareFormStateOptions = {}): PrepareFormState { + const memoizePrepareFieldMember = createMemoizer({ + decorator: decorators.prepareFieldMember, + getPath: ({parent, field}) => [...parent.path, field.name], + hashInput: ({parent, field}) => { + const path = [...parent.path, field.name] return { - kind: 'error', - key: field.name, - fieldName: field.name, - error: { - type: 'INCOMPATIBLE_TYPE', - expectedSchemaType: field.type, - resolvedValueType: resolveTypeName(fieldValue), - value: fieldValue, - }, + changesOpen: parent.changesOpen, + presence: parent.presence.filter((p) => startsWith(path, p.path)), + validation: parent.validation.filter((v) => startsWith(path, v.path)), + focusPath: startsWith(path, parent.focusPath) ? parent.focusPath : [], + openPath: startsWith(path, parent.openPath) ? parent.openPath : [], + value: getId((parent.value as any)?.[field.name]), + comparisonValue: getId((parent.comparisonValue as any)?.[field.name]), + collapsedFieldSets: getId(parent.collapsedFieldSets?.children?.[field.name]), + collapsedPaths: getId(parent.collapsedPaths?.children?.[field.name]), + currentUser: getId(parent.currentUser), + fieldGroupState: getId(parent.fieldGroupState), + hidden: + parent.hidden === true || + parent.hidden?.value || + getId(parent.hidden?.children?.[field.name]), + readOnly: + parent.readOnly === true || + parent.readOnly?.value || + getId(parent.readOnly?.children?.[field.name]), + schemaType: getId(parent.schemaType), } - } + }, + }) - const conditionalPropertyContext = { - value: fieldValue, - parent: parent.value, - document: parent.document, - currentUser: parent.currentUser, - } - const hidden = resolveConditionalProperty(field.type.hidden, conditionalPropertyContext) + const memoizePrepareObjectInputState = createMemoizer({ + decorator: decorators.prepareObjectInputState, + getPath: ({path}) => path, + hashInput: (state) => ({ + changesOpen: state.changesOpen, + presence: state.presence.filter((p) => startsWith(state.path, p.path)), + validation: state.validation.filter((v) => startsWith(state.path, v.path)), + focusPath: startsWith(state.path, state.focusPath) ? state.focusPath : [], + openPath: startsWith(state.path, state.openPath) ? state.openPath : [], + value: getId(state.value), + comparisonValue: getId(state.comparisonValue), + collapsedFieldSets: getId(state.collapsedFieldSets), + collapsedPaths: state.collapsedPaths, + currentUser: getId(state.currentUser), + fieldGroupState: getId(state.fieldGroupState), + hidden: state.hidden === true || state.hidden?.value || getId(state.hidden), + readOnly: state.readOnly === true || state.readOnly?.value || getId(state.readOnly), + schemaType: getId(state.schemaType), + }), + }) + + const memoizePrepareArrayOfPrimitivesInputState = + createMemoizer({ + decorator: decorators.prepareArrayOfPrimitivesInputState, + getPath: ({path}) => path, + hashInput: (state) => ({ + changesOpen: state.changesOpen, + presence: state.presence.filter((p) => startsWith(state.path, p.path)), + validation: state.validation.filter((v) => startsWith(state.path, v.path)), + focusPath: startsWith(state.path, state.focusPath) ? state.focusPath : [], + openPath: startsWith(state.path, state.openPath) ? state.openPath : [], + value: getId(state.value), + comparisonValue: getId(state.comparisonValue), + collapsedFieldSets: getId(state.collapsedFieldSets), + collapsedPaths: state.collapsedPaths, + currentUser: getId(state.currentUser), + fieldGroupState: getId(state.fieldGroupState), + hidden: state.hidden === true || state.hidden?.value || getId(state.hidden), + readOnly: state.readOnly === true || state.readOnly?.value || getId(state.readOnly), + schemaType: getId(state.schemaType), + }), + }) + + const memoizePrepareArrayOfObjectsInputState = createMemoizer({ + decorator: decorators.prepareArrayOfObjectsInputState, + getPath: ({path}) => path, + hashInput: (state) => ({ + changesOpen: state.changesOpen, + presence: state.presence.filter((p) => startsWith(state.path, p.path)), + validation: state.validation.filter((v) => startsWith(state.path, v.path)), + focusPath: startsWith(state.path, state.focusPath) ? state.focusPath : [], + openPath: startsWith(state.path, state.openPath) ? state.openPath : [], + value: getId(state.value), + comparisonValue: getId(state.comparisonValue), + collapsedFieldSets: getId(state.collapsedFieldSets), + collapsedPaths: state.collapsedPaths, + currentUser: getId(state.currentUser), + fieldGroupState: getId(state.fieldGroupState), + hidden: state.hidden === true || state.hidden?.value || getId(state.hidden), + readOnly: state.readOnly === true || state.readOnly?.value || getId(state.readOnly), + schemaType: getId(state.schemaType), + }), + }) + + const memoizePrepareArrayOfObjectsMember = createMemoizer({ + decorator: decorators.prepareArrayOfObjectsMember, + getPath: ({parent, arrayItem}) => [...parent.path, {_key: arrayItem._key}], + hashInput: ({parent, arrayItem}) => { + const comparisonValue = Array.isArray(parent.comparisonValue) + ? parent.comparisonValue.find((item) => isKeyedObject(item) && item._key === arrayItem._key) + : undefined + + const key = arrayItem._key + const path: Path = [...parent.path, {_key: key}] - if (hidden) { return { - kind: 'hidden', - key: `field-${field.name}`, - name: field.name, - index: index, + changesOpen: parent.changesOpen, + presence: parent.presence.filter((p) => startsWith(path, p.path)), + validation: parent.validation.filter((v) => startsWith(path, v.path)), + focusPath: startsWith(path, parent.focusPath) ? parent.focusPath : [], + openPath: startsWith(path, parent.openPath) ? parent.openPath : [], + value: getId(arrayItem), + comparisonValue: getId(comparisonValue), + collapsedFieldSets: getId(parent.collapsedFieldSets?.children?.[key]), + collapsedPaths: getId(parent.collapsedPaths?.children?.[key]), + currentUser: getId(parent.currentUser), + fieldGroupState: getId(parent.fieldGroupState?.children?.[key]), + hidden: + parent.hidden === true || parent.hidden?.value || getId(parent.hidden?.children?.[key]), + readOnly: + parent.readOnly === true || + parent.readOnly?.value || + getId(parent.readOnly?.children?.[key]), + schemaType: getId(parent.schemaType), } - } - - // 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 memoizePrepareArrayOfPrimitivesMember = createMemoizer({ + decorator: decorators.prepareArrayOfPrimitivesMember, + getPath: ({parent, index}) => [...parent.path, index], + hashInput: ({parent, index, arrayItem}) => { + const comparisonValue = Array.isArray(parent.comparisonValue) + ? parent.comparisonValue[index] + : undefined - const defaultCollapsedState = getCollapsedWithDefaults(field.type.options as FIXME, fieldLevel) - const collapsed = scopedCollapsedPaths - ? scopedCollapsedPaths.value - : defaultCollapsedState.collapsed + const path: Path = [...parent.path, index] - return { - kind: 'field', - key: `field-${field.name}`, - name: field.name, - index: index, + return { + changesOpen: parent.changesOpen, + presence: parent.presence.filter((p) => startsWith(path, p.path)), + validation: parent.validation.filter((v) => startsWith(path, v.path)), + focusPath: startsWith(path, parent.focusPath) ? parent.focusPath : [], + openPath: startsWith(path, parent.openPath) ? parent.openPath : [], + collapsedFieldSets: getId(parent.collapsedFieldSets?.children?.[index]), + collapsedPaths: getId(parent.collapsedPaths?.children?.[index]), + currentUser: getId(parent.currentUser), + fieldGroupState: getId(parent.fieldGroupState?.children?.[index]), + hidden: + parent.hidden === true || parent.hidden?.value || getId(parent.hidden?.children?.[index]), + readOnly: + parent.readOnly === true || + parent.readOnly?.value || + getId(parent.readOnly?.children?.[index]), + schemaType: getId(parent.schemaType), + value: `${arrayItem}`, + comparisonValue: `${comparisonValue}`, + } + }, + }) - inSelectedGroup, - groups: normalizedFieldGroupNames, + const memoizePreparePrimitiveInputState = createMemoizer({ + decorator: decorators.preparePrimitiveInputState, + getPath: ({path}) => path, + hashInput: (state) => ({ + changesOpen: state.changesOpen, + presence: state.presence.filter((p) => startsWith(state.path, p.path)), + validation: state.validation.filter((v) => startsWith(state.path, v.path)), + focusPath: startsWith(state.path, state.focusPath) ? state.focusPath : [], + openPath: startsWith(state.path, state.openPath) ? state.openPath : [], + value: getId(state.value), + comparisonValue: getId(state.comparisonValue), + collapsedFieldSets: getId(state.collapsedFieldSets), + collapsedPaths: state.collapsedPaths, + currentUser: getId(state.currentUser), + fieldGroupState: getId(state.fieldGroupState), + hidden: state.hidden === true || state.hidden?.value || getId(state.hidden), + readOnly: state.readOnly === true || state.readOnly?.value || getId(state.readOnly), + schemaType: getId(state.schemaType), + }), + }) - open: startsWith(fieldPath, parent.openPath), - field: inputState, - collapsed, - collapsible: defaultCollapsedState.collapsible, + /* + * Takes a field in context of a parent object and returns prepared props for it + */ + const prepareFieldMember = memoizePrepareFieldMember(function _prepareFieldMember(props) { + const {field, index, parent} = 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') } - } 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) + 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, @@ -278,787 +461,844 @@ function prepareFieldMember(props: { error: { type: 'INCOMPATIBLE_TYPE', expectedSchemaType: field.type, - resolvedValueType, + resolvedValueType: resolveTypeName(fieldValue), value: fieldValue, }, } } - if (hasValue && !everyItemIsObject(fieldValue)) { - return { - kind: 'error', - key: field.name, - fieldName: field.name, - error: { - type: 'MIXED_ARRAY', - schemaType: field.type, - value: fieldValue, - }, - } - } + const hidden = + parent.hidden === true || + parent?.hidden?.value || + parent.hidden?.children?.[field.name]?.value - if (hasValue && !everyItemHasKey(fieldValue)) { + if (hidden) { return { - kind: 'error', - key: field.name, - fieldName: field.name, - error: { - type: 'MISSING_KEYS', - value: fieldValue, - schemaType: field.type, - }, + kind: 'hidden', + key: `field-${field.name}`, + name: field.name, + index: index, } } - 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, - }, - } - } + // 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 readOnly = - parent.readOnly || - resolveConditionalProperty(field.type.readOnly, { - value: fieldValue, - parent: parent.value, - document: parent.document, - currentUser: parent.currentUser, - }) - - const fieldState = prepareArrayOfObjectsInputState({ + const scopedCollapsedFieldsets = parent.collapsedFieldSets?.children?.[field.name] + const scopedHidden = + parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[field.name] + const scopedReadOnly = + parent.readOnly === true || + parent.readOnly?.value || + parent.readOnly?.children?.[field.name] + + const inputState = prepareObjectInputState({ schemaType: field.type, - parent: parent.value, currentUser: parent.currentUser, - document: parent.document, value: fieldValue, changed: isChangedValue(fieldValue, fieldComparisonValue), - comparisonValue: fieldComparisonValue as FIXME, + comparisonValue: fieldComparisonValue, + presence: parent.presence, + validation: parent.validation, fieldGroupState, + path: fieldPath, + level: fieldLevel, focusPath: parent.focusPath, openPath: parent.openPath, - presence: parent.presence, - validation: parent.validation, collapsedPaths: scopedCollapsedPaths, - collapsedFieldSets: scopedCollapsedFieldSets, - level: fieldLevel, - path: fieldPath, - readOnly, + collapsedFieldSets: scopedCollapsedFieldsets, + hidden: scopedHidden, + readOnly: scopedReadOnly, + changesOpen: parent.changesOpen, }) - if (fieldState === null) { + 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, fieldLevel) + const collapsed = scopedCollapsedPaths + ? scopedCollapsedPaths.value + : defaultCollapsedState.collapsed + 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, + open: startsWith(fieldPath, parent.openPath), + field: inputState, + collapsed, + collapsible: defaultCollapsedState.collapsible, } - } else { - // array of primitives - if (!isValidArrayOfPrimitivesValue(fieldValue)) { - const resolvedValueType = resolveTypeName(fieldValue) + } 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, + }, + } + } - 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, + }, + } } - } - const fieldGroupState = parent.fieldGroupState?.children?.[field.name] - const scopedCollapsedPaths = parent.collapsedPaths?.children?.[field.name] - const scopedCollapsedFieldSets = parent.collapsedFieldSets?.children?.[field.name] + 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 readOnly = - parent.readOnly || - resolveConditionalProperty(field.type.readOnly, { + const fieldGroupState = parent.fieldGroupState?.children?.[field.name] + const scopedCollapsedPaths = parent.collapsedPaths?.children?.[field.name] + const scopedCollapsedFieldSets = parent.collapsedFieldSets?.children?.[field.name] + const scopedHidden = + parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[field.name] + const scopedReadOnly = + parent.readOnly === true || + parent.readOnly?.value || + parent.readOnly?.children?.[field.name] + + const fieldState = prepareArrayOfObjectsInputState({ + schemaType: field.type, + currentUser: parent.currentUser, value: fieldValue, - parent: parent.value, - document: parent.document, + 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: scopedReadOnly, + hidden: scopedHidden, + changesOpen: parent.changesOpen, + }) + + 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 scopedHidden = + parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[field.name] + const scopedReadOnly = + parent.readOnly === true || + parent.readOnly?.value || + parent.readOnly?.children?.[field.name] + + const fieldState = prepareArrayOfPrimitivesInputState({ + changed: isChangedValue(fieldValue, fieldComparisonValue), + comparisonValue: fieldComparisonValue as FIXME, + schemaType: field.type, currentUser: parent.currentUser, + value: fieldValue, + fieldGroupState, + focusPath: parent.focusPath, + openPath: parent.openPath, + presence: parent.presence, + validation: parent.validation, + collapsedPaths: scopedCollapsedPaths, + collapsedFieldSets: scopedCollapsedFieldSets, + level: fieldLevel, + path: fieldPath, + readOnly: scopedReadOnly, + hidden: scopedHidden, + changesOpen: parent.changesOpen, }) - 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 + } - if (fieldState === 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 + + // note: we *only* want to call the conditional props here, as it's handled by the prepareInputProps otherwise + const hidden = + parent.hidden === true || + parent.hidden?.value || + parent.hidden?.children?.[field.name]?.value + + if (hidden) { return null } + const scopedHidden = + parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[field.name] + const scopedReadOnly = + parent.readOnly === true || + parent.readOnly?.value || + parent.readOnly?.children?.[field.name] + + const fieldState = preparePrimitiveInputState({ + ...parent, + comparisonValue: fieldComparisonValue, + value: fieldValue as boolean | string | number | undefined, + schemaType: field.type as PrimitiveSchemaType, + path: fieldPath, + readOnly: scopedReadOnly, + hidden: scopedHidden, + }) + return { kind: 'field', key: `field-${field.name}`, name: field.name, index: index, + open: startsWith(fieldPath, parent.openPath), inSelectedGroup, groups: normalizedFieldGroupNames, - open: startsWith(fieldPath, parent.openPath), - - // todo: consider support for collapsible arrays + // todo: consider support for collapsible primitive fields 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 prepareInputProps otherwise - const hidden = resolveConditionalProperty(field.type.hidden, conditionalPropertyContext) + }) - if (hidden) { + const prepareObjectInputState = memoizePrepareObjectInputState(function _prepareObjectInputState( + props, + enableHiddenCheck = true, + ) { + if (props.level === MAX_FIELD_DEPTH) { 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, - }) + const readOnly = props.readOnly === true || props.readOnly?.value + + 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 = + props.hidden === true || + props.hidden?.value || + props.hidden?.children?.[`group:${group.name}`]?.value + 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, + i18n: group.i18n, + }, + ] + }, + ) - return { - kind: 'field', - key: `field-${field.name}`, - name: field.name, - index: index, - open: startsWith(fieldPath, parent.openPath), + const selectedGroup = groups.find((group) => group.selected)! - inSelectedGroup, - groups: normalizedFieldGroupNames, + // 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})) - // todo: consider support for collapsible primitive fields - collapsible: false, - collapsed: false, - field: fieldState, - } - } -} + // 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 -interface RawState { - schemaType: SchemaType - value?: T - comparisonValue?: T | null - changed?: boolean - document: FIXME_SanityDocument - currentUser: Omit | null - parent?: unknown - hidden?: boolean - readOnly?: boolean - path: Path - openPath: Path - focusPath: Path - presence: FormNodePresence[] - validation: ValidationMarker[] - fieldGroupState?: StateTree - collapsedPaths?: StateTree - collapsedFieldSets?: StateTree - // nesting level - level: number - changesOpen?: boolean -} - -function prepareObjectInputState( - props: RawState, - enableHiddenCheck?: false, -): ObjectFormNode -function prepareObjectInputState( - props: RawState, - enableHiddenCheck?: true, -): ObjectFormNode | null -function prepareObjectInputState( - props: RawState, - 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, - } + const fieldMember = prepareFieldMember({ + field: field, + parent: {...props, groups, selectedGroup}, + index, + }) - // readonly is inherited - const readOnly = - props.readOnly || - resolveConditionalProperty(props.schemaType.readOnly, conditionalPropertyContext) + return fieldMember ? [fieldMember] : [] + } - const schemaTypeGroupConfig = props.schemaType.groups || [] - const defaultGroupName = (schemaTypeGroupConfig.find((g) => g.default) || ALL_FIELDS_GROUP)?.name + // it's an actual fieldset + const fieldsetHidden = + props.hidden === true || + props.hidden?.value || + props.hidden?.children?.[`fieldset:${fieldSet.name}`]?.value + + 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, groups, selectedGroup}, + index, + }) as FieldMember | FieldError | HiddenField + + return fieldMember ? [fieldMember] : [] + }, + ) - const groups = [ALL_FIELDS_GROUP, ...schemaTypeGroupConfig].flatMap((group): FormFieldGroup[] => { - const groupHidden = resolveConditionalProperty(group.hidden, conditionalPropertyContext) - const isSelected = group.name === (props.fieldGroupState?.value || defaultGroupName) + const defaultCollapsedState = getCollapsedWithDefaults(fieldSet.options, props.level) - // 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 + const collapsed = + (props.collapsedFieldSets?.children || {})[fieldSet.name]?.value ?? + defaultCollapsedState.collapsed - return groupHidden - ? [] - : [ + return [ { - disabled, - icon: group?.icon, - name: group.name, - selected, - title: group.title, - i18n: group.i18n, + 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 selectedGroup = groups.find((group) => group.selected)! + const hasFieldGroups = schemaTypeGroupConfig.length > 0 - // 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})) + const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path)) + const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY - // 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 validation = props.validation + .filter((item) => isEqual(item.path, props.path)) + .map((v) => ({level: v.level, message: v.message, path: v.path})) - const fieldMember = prepareFieldMember({ - field: field, - parent: {...props, readOnly, groups, selectedGroup}, - index, - }) + const visibleMembers = members.filter( + (member): member is ObjectMember => member.kind !== 'hidden', + ) - return fieldMember ? [fieldMember] : [] - } + // 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 + } - // 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 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 fieldsetReadOnly = resolveConditionalProperty(fieldSet.readOnly, { - currentUser: props.currentUser, - document: props.document, - parent: props.value, - value: pick(props.value, fieldsetFieldNames), - }) + const filtereredMembers = visibleMembers.flatMap( + (member): (FieldError | FieldMember | FieldSetMember)[] => { + if (member.kind === 'error') { + return [member] + } + if (member.kind === 'field') { + return member.inSelectedGroup ? [member] : [] + } - const fieldsetMembers = fieldSet.fields.flatMap( - (field): (FieldMember | FieldError | HiddenField)[] => { - if (fieldsetHidden) { - return [ + const filteredFieldsetMembers: ObjectMember[] = member.fieldSet.members.filter( + (fieldsetMember) => fieldsetMember.kind !== 'field' || fieldsetMember.inSelectedGroup, + ) + return filteredFieldsetMembers.length > 0 + ? [ { - kind: 'hidden', - key: `field-${field.name}`, - name: field.name, - index: index, - }, + ...member, + fieldSet: {...member.fieldSet, members: filteredFieldsetMembers}, + } as FieldSetMember, ] - } - const fieldMember = prepareFieldMember({ - field: field, - parent: {...props, readOnly: readOnly || fieldsetReadOnly, groups, selectedGroup}, - index, - }) as FieldMember | FieldError | HiddenField + : [] + }, + ) - return fieldMember ? [fieldMember] : [] - }, - ) + const node = { + value: props.value as Record | 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 + }) + + const prepareArrayOfPrimitivesInputState = memoizePrepareArrayOfPrimitivesInputState( + function _prepareArrayOfPrimitivesInputState(props) { + if (props.level === MAX_FIELD_DEPTH) { + return null + } - const defaultCollapsedState = getCollapsedWithDefaults(fieldSet.options, props.level) + if (props.hidden === true || props.hidden?.value) { + return null + } - const collapsed = - (props.collapsedFieldSets?.children || {})[fieldSet.name]?.value ?? - defaultCollapsedState.collapsed + // 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 : [] - 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 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, + readOnly: props.readOnly === true || props.readOnly?.value, + 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, + } }, ) - 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 prepareArrayOfObjectsInputState = memoizePrepareArrayOfObjectsInputState( + function _prepareArrayOfObjectsInputState(props) { + if (props.level === MAX_FIELD_DEPTH) { + return null + } - const visibleMembers = members.filter( - (member): member is ObjectMember => member.kind !== 'hidden', - ) + if (props.hidden === true || props.hidden?.value) { + return null + } - // 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 - } + // 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 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) - } + 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 ( - member.groups.includes(group.name) || - member.fieldSet.members.some( - (fieldsetMember) => - fieldsetMember.kind !== 'error' && fieldsetMember.groups.includes(group.name), - ) - ) - }) - return hasVisibleMembers ? group : [] - }) - : [] + const members = items.flatMap((item, index) => + prepareArrayOfObjectsMember({ + arrayItem: item, + parent: props, + index, + }), + ) - const filtereredMembers = visibleMembers.flatMap( - (member): (FieldError | FieldMember | FieldSetMember)[] => { - if (member.kind === 'error') { - return [member] - } - if (member.kind === 'field') { - return member.inSelectedGroup ? [member] : [] + 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, + readOnly: props.readOnly === true || props.readOnly?.value, + 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, } - - 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 | 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 -} + /* + * Takes a field in context of a parent object and returns prepared props for it + */ + const prepareArrayOfObjectsMember = memoizePrepareArrayOfObjectsMember( + function _prepareArrayOfObjectsMember(props) { + const {arrayItem, parent, index} = props -function prepareArrayOfPrimitivesInputState( - props: RawState, -): ArrayOfPrimitivesFormNode | null { - if (props.level === MAX_FIELD_DEPTH) { - return null - } + const itemType = getItemType(parent.schemaType, arrayItem) as ObjectSchemaType - const conditionalPropertyContext = { - comparisonValue: props.comparisonValue, - value: props.value, - parent: props.parent, - document: props.document, - currentUser: props.currentUser, - } + const key = arrayItem._key - const hidden = resolveConditionalProperty(props.schemaType.hidden, conditionalPropertyContext) + if (!itemType) { + const itemTypeName = resolveTypeName(arrayItem) + return { + kind: 'error', + key, + index, + error: { + type: 'INVALID_ITEM_TYPE', + resolvedValueType: itemTypeName, + value: arrayItem, + validTypes: parent.schemaType.of, + }, + } + } - if (hidden) { - return null - } + const itemPath = pathFor([...parent.path, {_key: key}]) + const itemLevel = parent.level + 1 - const readOnly = - props.readOnly || - resolveConditionalProperty(props.schemaType.readOnly, conditionalPropertyContext) + const fieldGroupState = parent.fieldGroupState?.children?.[key] + const scopedCollapsedPaths = parent.collapsedPaths?.children?.[key] + const scopedCollapsedFieldsets = parent.collapsedFieldSets?.children?.[key] - // 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 scopedHidden = + parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[key] + const scopedReadOnly = + parent.readOnly === true || parent.readOnly?.value || parent.readOnly?.children?.[key] - 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, - } -} + const comparisonValue = + (Array.isArray(parent.comparisonValue) && + parent.comparisonValue.find((i) => i._key === arrayItem._key)) || + undefined -function prepareArrayOfObjectsInputState( - props: RawState, -): ArrayOfObjectsFormNode | null { - if (props.level === MAX_FIELD_DEPTH) { - return null - } + const itemState = prepareObjectInputState( + { + schemaType: itemType, + level: itemLevel, + 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: scopedReadOnly, + hidden: scopedHidden, + }, + false, + ) as ObjectArrayFormNode - const conditionalPropertyContext = { - value: props.value, - parent: props.parent, - document: props.document, - currentUser: props.currentUser, - } - const hidden = resolveConditionalProperty(props.schemaType.hidden, conditionalPropertyContext) + 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, + } + }, + ) - if (hidden) { - return null - } + /* + * Takes a field in contet of a parent object and returns prepared props for it + */ + const prepareArrayOfPrimitivesMember = memoizePrepareArrayOfPrimitivesMember( + function _prepareArrayOfPrimitivesMember(props) { + 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 scopedHidden = + // parent.hidden === true || parent.hidden?.value || parent.hidden?.children?.[field.name] + const scopedReadOnly = + parent.readOnly === true || parent.readOnly?.value || parent.readOnly?.children?.[index] - const readOnly = - props.readOnly || - resolveConditionalProperty(props.schemaType.readOnly, conditionalPropertyContext) + const item = preparePrimitiveInputState({ + ...parent, + path: itemPath, + schemaType: itemType as PrimitiveSchemaType, + level: itemLevel, + value: itemValue, + comparisonValue: itemComparisonValue, + readOnly: scopedReadOnly, + }) - // 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 : [] + return { + kind: 'item', + key, + index, + parentSchemaType: parent.schemaType, + open: isEqual(itemPath, parent.openPath), + item, + } + }, + ) - 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 preparePrimitiveInputState = memoizePreparePrimitiveInputState( + function _preparePrimitiveInputState(props) { + const filteredPresence = props.presence.filter((item) => isEqual(item.path, props.path)) + const presence = filteredPresence.length ? filteredPresence : EMPTY_ARRAY - const members = items.flatMap((item, index) => - prepareArrayOfObjectsMember({ - arrayItem: item, - parent: props, - index, - }), + 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 === true || props.readOnly?.value, + focused: isEqual(props.path, props.focusPath), + path: props.path, + presence, + validation, + } as PrimitiveFormNode + }, ) - 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, + function prepareFormState({ + collapsedFieldSets, + collapsedPaths, + comparisonValue, + currentUser, + documentValue, + fieldGroupState, + focusPath, + hidden, + openPath, + presence, 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, + schemaType, 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 - 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, + changesOpen, + }: RootFormStateOptions): ObjectFormNode | null { + return prepareObjectInputState({ + collapsedFieldSets, + collapsedPaths, 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, + currentUser, + value: documentValue, 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 - 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, + focusPath, + hidden: hidden === false ? EMPTY_OBJECT : hidden, + openPath, + presence, + readOnly: readOnly === false ? EMPTY_OBJECT : readOnly, + schemaType, + validation, + changesOpen, + level: 0, + path: [], }) - - 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( - props: RawState, -): 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 + prepareFormState._prepareFieldMember = prepareFieldMember + prepareFormState._prepareFieldMember = prepareFieldMember + prepareFormState._prepareObjectInputState = prepareObjectInputState + prepareFormState._prepareArrayOfPrimitivesInputState = prepareArrayOfPrimitivesInputState + prepareFormState._prepareArrayOfObjectsInputState = prepareArrayOfObjectsInputState + prepareFormState._prepareArrayOfObjectsMember = prepareArrayOfObjectsMember + prepareFormState._prepareArrayOfPrimitivesMember = prepareArrayOfPrimitivesMember + prepareFormState._preparePrimitiveInputState = preparePrimitiveInputState -/** @internal */ -export function prepareFormState( - props: RawState, -): ObjectFormNode | null { - return prepareObjectInputState(props) + return prepareFormState } diff --git a/packages/sanity/src/core/form/store/index.ts b/packages/sanity/src/core/form/store/index.ts index 8abc6d4de7c..68c063ea9f5 100644 --- a/packages/sanity/src/core/form/store/index.ts +++ b/packages/sanity/src/core/form/store/index.ts @@ -1,5 +1,4 @@ export {resolveConditionalProperty} from './conditional-property' -export type {FIXME_SanityDocument} from './formState' // eslint-disable-line camelcase export * from './stateTreeHelper' export * from './types' export * from './useFormState' diff --git a/packages/sanity/src/core/form/store/types/state.ts b/packages/sanity/src/core/form/store/types/state.ts index 81fcdff61ef..a9ca23ecbe3 100644 --- a/packages/sanity/src/core/form/store/types/state.ts +++ b/packages/sanity/src/core/form/store/types/state.ts @@ -2,7 +2,7 @@ * @hidden * @beta */ export interface StateTree { - value: T | undefined + value?: T | undefined children?: { [key: string]: StateTree } diff --git a/packages/sanity/src/core/form/store/useFormState.ts b/packages/sanity/src/core/form/store/useFormState.ts index 9d997e0cd24..a57912da68b 100644 --- a/packages/sanity/src/core/form/store/useFormState.ts +++ b/packages/sanity/src/core/form/store/useFormState.ts @@ -1,14 +1,13 @@ /* eslint-disable camelcase */ import {type ObjectSchemaType, type Path, type ValidationMarker} from '@sanity/types' -import {pathFor} from '@sanity/util/paths' -import {useLayoutEffect, useMemo, useRef} from 'react' +import {useMemo} from 'react' import {type FormNodePresence} from '../../presence' import {useCurrentUser} from '../../store' -import {type FIXME_SanityDocument, prepareFormState} from './formState' +import {createCallbackResolver} from './conditional-property/createCallbackResolver' +import {createPrepareFormState} from './formState' import {type ObjectFormNode, type StateTree} from './types' -import {type DocumentFormNode} from './types/nodes' import {immutableReconcile} from './utils/immutableReconcile' /** @internal */ @@ -17,82 +16,138 @@ export type FormState< S extends ObjectSchemaType = ObjectSchemaType, > = ObjectFormNode +/** @internal */ +export interface UseFormStateOptions { + schemaType: ObjectSchemaType + documentValue: unknown + comparisonValue: unknown + openPath: Path + focusPath: Path + presence: FormNodePresence[] + validation: ValidationMarker[] + fieldGroupState?: StateTree | undefined + collapsedFieldSets?: StateTree | undefined + collapsedPaths?: StateTree | undefined + readOnly?: boolean + changesOpen?: boolean +} + /** @internal */ export function useFormState< T extends {[key in string]: unknown} = {[key in string]: unknown}, S extends ObjectSchemaType = ObjectSchemaType, ->( - schemaType: ObjectSchemaType, - { - comparisonValue, - value, - fieldGroupState, - collapsedFieldSets, - collapsedPaths, - focusPath, - openPath, - presence, - validation, - readOnly, - changesOpen, - }: { - fieldGroupState?: StateTree | undefined - collapsedFieldSets?: StateTree | undefined - collapsedPaths?: StateTree | undefined - value: Partial - comparisonValue: Partial | null - openPath: Path - focusPath: Path - presence: FormNodePresence[] - validation: ValidationMarker[] - changesOpen?: boolean - readOnly?: boolean - }, -): FormState | null { +>({ + comparisonValue, + documentValue, + fieldGroupState, + collapsedFieldSets, + collapsedPaths, + focusPath, + openPath, + presence, + validation, + readOnly: inputReadOnly, + changesOpen, + schemaType, +}: UseFormStateOptions): FormState | null { // note: feel free to move these state pieces out of this hook const currentUser = useCurrentUser() - const prev = useRef(null) + const prepareHiddenState = useMemo(() => createCallbackResolver({property: 'hidden'}), []) + const prepareReadOnlyState = useMemo(() => createCallbackResolver({property: 'readOnly'}), []) + const prepareFormState = useMemo(() => createPrepareFormState(), []) - useLayoutEffect(() => { - prev.current = null - }, [schemaType]) + const reconcileFieldGroupState = useMemo(() => { + let last: StateTree | undefined + return (state: StateTree | undefined) => { + const result = immutableReconcile(last ?? null, state) + last = result + return result + } + }, []) + + const reconciledFieldGroupState = useMemo(() => { + return reconcileFieldGroupState(fieldGroupState) + }, [fieldGroupState, reconcileFieldGroupState]) + + const reconcileCollapsedPaths = useMemo(() => { + let last: StateTree | undefined + return (state: StateTree | undefined) => { + const result = immutableReconcile(last ?? null, state) + last = result + return result + } + }, []) + const reconciledCollapsedPaths = useMemo( + () => reconcileCollapsedPaths(collapsedPaths), + [collapsedPaths, reconcileCollapsedPaths], + ) + + const reconcileCollapsedFieldsets = useMemo(() => { + let last: StateTree | undefined + return (state: StateTree | undefined) => { + const result = immutableReconcile(last ?? null, state) + last = result + return result + } + }, []) + const reconciledCollapsedFieldsets = useMemo( + () => reconcileCollapsedFieldsets(collapsedFieldSets), + [collapsedFieldSets, reconcileCollapsedFieldsets], + ) + + const {hidden, readOnly} = useMemo(() => { + return { + hidden: prepareHiddenState({ + currentUser, + documentValue: documentValue, + schemaType, + }), + readOnly: prepareReadOnlyState({ + currentUser, + documentValue: documentValue, + schemaType, + readOnly: inputReadOnly, + }), + } + }, [ + prepareHiddenState, + currentUser, + documentValue, + schemaType, + prepareReadOnlyState, + inputReadOnly, + ]) return useMemo(() => { - // console.time('derive form state') - const next = prepareFormState({ + return prepareFormState({ schemaType, - document: value, - fieldGroupState, - collapsedFieldSets, - collapsedPaths, - value, + fieldGroupState: reconciledFieldGroupState, + collapsedFieldSets: reconciledCollapsedFieldsets, + collapsedPaths: reconciledCollapsedPaths, + documentValue, comparisonValue, focusPath, openPath, readOnly, - path: pathFor([]), - level: 0, + hidden, currentUser, presence, validation, changesOpen, - }) as ObjectFormNode // TODO: remove type cast - - const reconciled = immutableReconcile(prev.current, next) - prev.current = reconciled - // console.timeEnd('derive form state') - return reconciled + }) as ObjectFormNode }, [ + prepareFormState, schemaType, - value, - fieldGroupState, - collapsedFieldSets, - collapsedPaths, + reconciledFieldGroupState, + reconciledCollapsedFieldsets, + reconciledCollapsedPaths, + documentValue, comparisonValue, focusPath, openPath, readOnly, + hidden, currentUser, presence, validation, diff --git a/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts b/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts index 90796f09109..36cef64f4f7 100644 --- a/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts +++ b/packages/sanity/src/core/form/store/utils/__tests__/immutableReconcile.test.ts @@ -1,132 +1,106 @@ -import {expect, test} from '@jest/globals' +import {beforeEach, expect, jest, test} from '@jest/globals' +import {defineField, defineType} from '@sanity/types' -import {immutableReconcile} from '../immutableReconcile' +import {createSchema} from '../../../../schema/createSchema' +import {createImmutableReconcile} from '../immutableReconcile' + +const immutableReconcile = createImmutableReconcile({decorator: jest.fn}) + +beforeEach(() => { + ;(immutableReconcile as jest.Mock).mockClear() +}) test('it preserves previous value if shallow equal', () => { const prev = {test: 'hi'} const next = {test: 'hi'} - expect(immutableReconcile(prev, next)).toBe(prev) + const reconciled = immutableReconcile(prev, next) + expect(reconciled).toBe(prev) + expect(immutableReconcile).toHaveBeenCalledTimes(2) }) test('it preserves previous value if deep equal', () => { const prev = {arr: [{foo: 'bar'}]} const next = {arr: [{foo: 'bar'}]} - expect(immutableReconcile(prev, next)).toBe(prev) + const reconciled = immutableReconcile(prev, next) + expect(reconciled).toBe(prev) + expect(immutableReconcile).toHaveBeenCalledTimes(4) }) test('it preserves previous nodes that are deep equal', () => { const prev = {arr: [{foo: 'bar'}], x: 1} const next = {arr: [{foo: 'bar'}]} - expect(immutableReconcile(prev, next).arr).toBe(prev.arr) + const reconciled = immutableReconcile(prev, next) + expect(reconciled.arr).toBe(prev.arr) }) test('it keeps equal objects in arrays', () => { const prev = {arr: ['foo', {greet: 'hello'}, {other: []}], x: 1} const next = {arr: ['bar', {greet: 'hello'}, {other: []}]} - expect(immutableReconcile(prev, next).arr).not.toBe(prev.arr) - expect(immutableReconcile(prev, next).arr[1]).toBe(prev.arr[1]) - expect(immutableReconcile(prev, next).arr[2]).toBe(prev.arr[2]) -}) - -test('it handles changing cyclic structures', () => { - const createObject = (differentiator: string) => { - // will be different if differentiator is different - const root: Record = {id: 'root'} - - // will be different if differentiator is different - root.a = {id: 'a'} - - // will be different if differentiator is different - root.a.b = {id: 'b', diff: differentiator} - - // cycle - root.a.b.a = root.a - // will never be different - root.a.b.c = {id: 'c'} - - return root - } - - const prev = createObject('previous') - const next = createObject('next') - const reconciled = immutableReconcile(prev, next) - expect(prev).not.toBe(reconciled) - expect(next).not.toBe(reconciled) - - // A sub object of root has changed, creating new object - expect(next.a).not.toBe(reconciled.a) - - // A sub-object of root.a has changed, creating new object - expect(next.a.b).not.toBe(reconciled.a.b) - - // root.a.b.c is has not changed, therefore reuse. - expect(next.a.b.c).not.toBe(reconciled.a.b.c) - - expect(prev.a.b.c).toBe(reconciled.a.b.c) - - // The new reconcile will retain reconcilable objects also within loops. - expect(prev.a.b.a.b.c).toBe(reconciled.a.b.a.b.c) - - // This is because it retains the loop. - expect(reconciled.a).toBe(reconciled.a.b.a) - expect(prev.a.b.c).toBe(reconciled.a.b.a.b.c) + expect(reconciled.arr).not.toBe(prev.arr) + expect(reconciled.arr[1]).toBe(prev.arr[1]) + expect(reconciled.arr[2]).toBe(prev.arr[2]) + expect(immutableReconcile).toHaveBeenCalledTimes(7) }) -test('it handles non-changing cyclic structures', () => { - const cyclic: Record = {test: 'foo'} - cyclic.self = cyclic - +test('keeps the previous values where they deep equal to the next', () => { const prev = { - cyclic, - arr: [ - {cyclic, value: 'old'}, - {cyclic, value: 'unchanged'}, - ], - other: {cyclic, value: 'unchanged'}, + test: 'hi', + array: ['aloha', {foo: 'bar'}], + object: { + x: {y: 'CHANGE'}, + keep: {foo: 'bar'}, + }, } const next = { - cyclic, - arr: [ - {cyclic, value: 'new'}, - {cyclic, value: 'unchanged'}, - ], - other: {cyclic, value: 'unchanged'}, + test: 'hi', + array: ['aloha', {foo: 'bar'}], + object: { + x: {y: 'CHANGED'}, + keep: {foo: 'bar'}, + }, + new: ['foo', 'bar'], } const reconciled = immutableReconcile(prev, next) - expect(reconciled.arr).not.toBe(prev.arr) - expect(reconciled.arr[1]).toBe(prev.arr[1]) - expect(reconciled.other).toBe(prev.other) + + expect(reconciled).not.toBe(prev) + expect(reconciled).not.toBe(next) + + expect(reconciled.array).toBe(prev.array) + expect(reconciled.object.keep).toBe(prev.object.keep) + expect(immutableReconcile).toHaveBeenCalledTimes(11) }) -test('keeps the previous values where they deep equal to the next', () => { +test('skips reconciling if the previous sub-values are already referentially equal', () => { + const keep = {foo: 'bar'} const prev = { test: 'hi', - array: ['aloha', {foo: 'bar'}], + array: ['aloha', keep], object: { x: {y: 'CHANGE'}, - keep: {foo: 'bar'}, + keep, }, } const next = { test: 'hi', - array: ['aloha', {foo: 'bar'}], + array: ['aloha', keep], object: { x: {y: 'CHANGED'}, - keep: {foo: 'bar'}, + keep, }, new: ['foo', 'bar'], } - const result = immutableReconcile(prev, next) + const reconciled = immutableReconcile(prev, next) - expect(result).not.toBe(prev) - expect(result).not.toBe(next) + expect(reconciled).not.toBe(prev) + expect(reconciled).not.toBe(next) - expect(result.array).toBe(prev.array) - expect(result.object.keep).toBe(prev.object.keep) + expect(reconciled.array).toBe(prev.array) + expect(reconciled.object.keep).toBe(prev.object.keep) + expect(immutableReconcile).toHaveBeenCalledTimes(9) }) test('does not mutate any of its input', () => { @@ -172,6 +146,33 @@ test('returns new array when previous and next has different length', () => { expect(immutableReconcile(lessItems, moreItems)).not.toBe(lessItems) }) +test('does not reconcile schema type values', () => { + const schema = createSchema({ + name: 'default', + types: [ + defineType({ + name: 'myType', + type: 'document', + fields: [defineField({name: 'myString', type: 'string'})], + }), + defineType({ + name: 'myOtherType', + type: 'document', + fields: [defineField({name: 'myString2', type: 'string'})], + }), + ], + }) + const schemaType = schema.get('myType')! + const otherSchemaType = schema.get('myOtherType')! + + const prev = {schemaType} + const next = {schemaType: otherSchemaType} + + const reconciled = immutableReconcile(prev, next) + expect(reconciled.schemaType).toBe(otherSchemaType) + expect(immutableReconcile).toHaveBeenCalledTimes(2) +}) + test('returns latest non-enumerable value', () => { const prev = {enumerable: true} const next = {enumerable: true} diff --git a/packages/sanity/src/core/form/store/utils/createMemoizer.ts b/packages/sanity/src/core/form/store/utils/createMemoizer.ts new file mode 100644 index 00000000000..e440148db11 --- /dev/null +++ b/packages/sanity/src/core/form/store/utils/createMemoizer.ts @@ -0,0 +1,42 @@ +import {type Path} from '@sanity/types' +import {toString} from '@sanity/util/paths' + +export type FunctionDecorator unknown> = ( + fn: TFunction, +) => TFunction + +export interface MemoizerOptions unknown> { + getPath: (...args: Parameters) => Path + hashInput: (...args: Parameters) => unknown + decorator: ((fn: TFunction) => TFunction) | undefined +} + +function identity(t: T) { + return t +} + +export function createMemoizer unknown>({ + getPath, + hashInput, + decorator = identity, +}: MemoizerOptions): FunctionDecorator { + const cache = new Map}>() + + function memoizer(fn: TFunction): TFunction { + function memoizedFn(...args: Parameters) { + const path = toString(getPath(...args)) + const hashed = hashInput(...args) + const serializedHash = JSON.stringify(hashed) + const cached = cache.get(path) + if (serializedHash === cached?.serializedHash) return cached.result + + const result = fn(...args) as ReturnType + cache.set(path, {serializedHash, result}) + return result + } + + return decorator(memoizedFn as TFunction) + } + + return memoizer +} diff --git a/packages/sanity/src/core/form/store/utils/getId.ts b/packages/sanity/src/core/form/store/utils/getId.ts new file mode 100644 index 00000000000..745b867c526 --- /dev/null +++ b/packages/sanity/src/core/form/store/utils/getId.ts @@ -0,0 +1,41 @@ +import {nanoid} from 'nanoid' + +const idCache = new WeakMap() +const undefinedKey = {key: 'GetIdUndefined'} +const nullKey = {key: 'GetIdNull'} + +/** + * Generates a stable ID for various types of values, including `undefined`, `null`, objects, functions, and symbols. + * + * - **Primitives (string, number, boolean):** The value itself is used as the ID. + * - **Undefined and null:** Special symbols (`undefinedKey` and `nullKey`) are used to generate unique IDs. + * - **Objects and functions:** An ID is generated using the `nanoid` library and cached in a `WeakMap` for stable future retrieval. + * + * This function is used to reconcile inputs in `prepareFormState` immutably, allowing IDs to be generated and cached based + * on the reference of the object. This ensures that memoization functions can use these IDs for consistent hashing without + * recalculating on each call, as the inputs themselves are immutably edited. + * + * @internal + */ +export function getId(value: unknown): string { + switch (typeof value) { + case 'undefined': { + return getId(undefinedKey) + } + case 'function': + case 'object': + case 'symbol': { + if (value === null) return getId(nullKey) + + const cached = idCache.get(value as object) + if (cached) return cached + + const id = nanoid() + idCache.set(value as object, id) + return id + } + default: { + return `${value}` + } + } +} diff --git a/packages/sanity/src/core/form/store/utils/immutableReconcile.ts b/packages/sanity/src/core/form/store/utils/immutableReconcile.ts index b233a743c4b..1a65981c43e 100644 --- a/packages/sanity/src/core/form/store/utils/immutableReconcile.ts +++ b/packages/sanity/src/core/form/store/utils/immutableReconcile.ts @@ -1,76 +1,97 @@ -/** - * Reconciles two versions of a state tree by iterating over the next and deep comparing against the next towards the previous. - * Wherever identical values are found, the previous value is kept, preserving object identities for arrays and objects where possible - * @param previous - the previous value - * @param next - the next/current value - */ -export function immutableReconcile(previous: unknown, next: T): T { - return _immutableReconcile(previous, next, new WeakMap()) +import {type SchemaType} from '@sanity/types' + +function isPlainObject(obj: unknown): boolean { + return obj !== null && typeof obj === 'object' && obj.constructor === Object +} + +function isSchemaType(obj: unknown): obj is SchemaType { + if (typeof obj !== 'object') return false + if (!obj) return false + if (!('jsonType' in obj) || typeof obj.jsonType !== 'string') return false + if (!('name' in obj) || typeof obj.name !== 'string') return false + return true } -function _immutableReconcile( - previous: unknown, - next: T, - /** - * Keep track of visited nodes to prevent infinite recursion in case of circular structures - */ - parents: WeakMap, -): T { - if (previous === next) return previous as T - - if (parents.has(next)) { - return parents.get(next) - } - - // eslint-disable-next-line no-eq-null - if (previous == null || next == null) return next - - const prevType = typeof previous - const nextType = typeof next - - // Different types - if (prevType !== nextType) return next - - if (Array.isArray(next)) { - assertType(previous) - assertType(next) - - let allEqual = previous.length === next.length - const result: unknown[] = [] - parents.set(next, result) - for (let index = 0; index < next.length; index++) { - const nextItem = _immutableReconcile(previous[index], next[index], parents) - - if (nextItem !== previous[index]) { - allEqual = false +interface ImmutableReconcile { + (prev: T | null, curr: T): T +} + +export interface CreateImmutableReconcileOptions { + decorator?: (fn: ImmutableReconcile) => ImmutableReconcile +} + +function identity(t: T) { + return t +} + +export function createImmutableReconcile({ + decorator = identity, +}: CreateImmutableReconcileOptions = {}): (prev: T | null, curr: T) => T { + const immutableReconcile = decorator(function _immutableReconcile(prev: T | null, curr: T): T { + if (prev === curr) return curr + if (prev === null) return curr + if (typeof prev !== 'object' || typeof curr !== 'object') return curr + + if (Array.isArray(prev) && Array.isArray(curr)) { + if (prev.length !== curr.length) return curr + + const reconciled = curr.map((item, index) => immutableReconcile(prev[index], item)) + if (reconciled.every((item, index) => item === prev[index])) return prev + return reconciled as T + } + + // skip these, they're recursive structures and will cause stack overflows + // they're stable anyway + if (isSchemaType(prev) || isSchemaType(curr)) return curr + + // skip these as well + if (!isPlainObject(prev) || !isPlainObject(curr)) return curr + + const prevObj = prev as Record + const currObj = curr as Record + + const reconciled: Record = {} + let changed = false + + const enumerableKeys = new Set(Object.keys(currObj)) + + for (const key of Object.getOwnPropertyNames(currObj)) { + if (key in prevObj) { + const reconciledValue = immutableReconcile(prevObj[key], currObj[key]) + if (enumerableKeys.has(key)) { + reconciled[key] = reconciledValue + } else { + Object.defineProperty(reconciled, key, { + value: reconciledValue, + enumerable: false, + }) + } + changed = changed || reconciledValue !== prevObj[key] + } else { + if (enumerableKeys.has(key)) { + reconciled[key] = currObj[key] + } else { + Object.defineProperty(reconciled, key, { + value: currObj[key], + enumerable: false, + }) + } + changed = true } - result[index] = nextItem } - parents.set(next, allEqual ? previous : result) - return (allEqual ? previous : result) as any - } - - if (typeof next === 'object') { - assertType>(previous) - assertType>(next) - - const nextKeys = Object.getOwnPropertyNames(next) - let allEqual = Object.getOwnPropertyNames(previous).length === nextKeys.length - const result: Record = {} - parents.set(next, result) - for (const key of nextKeys) { - const nextValue = _immutableReconcile(previous[key], next[key]!, parents) - if (nextValue !== previous[key]) { - allEqual = false + + // Check if any keys were removed + for (const key of Object.getOwnPropertyNames(prevObj)) { + if (!(key in currObj)) { + changed = true + break } - result[key] = nextValue } - parents.set(next, allEqual ? previous : result) - return (allEqual ? previous : result) as T - } - return next + + return changed ? (reconciled as T) : prev + }) + + return immutableReconcile } -// just some typescript trickery get type assertion -// eslint-disable-next-line @typescript-eslint/no-empty-function, no-empty-function -function assertType(value: unknown): asserts value is T {} +export const immutableReconcile = createImmutableReconcile() diff --git a/packages/sanity/src/core/form/studio/FormBuilder.test.tsx b/packages/sanity/src/core/form/studio/FormBuilder.test.tsx index f486e994f4d..f9bf868b7e9 100644 --- a/packages/sanity/src/core/form/studio/FormBuilder.test.tsx +++ b/packages/sanity/src/core/form/studio/FormBuilder.test.tsx @@ -54,7 +54,7 @@ describe('FormBuilder', () => { const focusPath: Path = [] const openPath: Path = [] - const value = {_id: 'test', _type: 'test'} + const documentValue = {_id: 'test', _type: 'test'} const onChange = jest.fn() const onFieldGroupSelect = jest.fn() @@ -79,9 +79,10 @@ describe('FormBuilder', () => { const patchChannel = useMemo(() => createPatchChannel(), []) - const formState = useFormState(schemaType, { - value, - comparisonValue: value, + const formState = useFormState({ + schemaType, + documentValue, + comparisonValue: documentValue, focusPath, collapsedPaths: undefined, collapsedFieldSets: undefined, @@ -150,7 +151,7 @@ describe('FormBuilder', () => { const focusPath: Path = [] const openPath: Path = [] - const value = {_id: 'test', _type: 'test'} + const documentValue = {_id: 'test', _type: 'test'} const onChange = jest.fn() const onFieldGroupSelect = jest.fn() @@ -175,9 +176,10 @@ describe('FormBuilder', () => { const patchChannel = useMemo(() => createPatchChannel(), []) - const formState = useFormState(schemaType, { - value, - comparisonValue: value, + const formState = useFormState({ + schemaType, + documentValue, + comparisonValue: documentValue, focusPath, collapsedPaths: undefined, collapsedFieldSets: undefined, diff --git a/packages/sanity/src/core/tasks/components/form/tasksFormBuilder/useTasksFormBuilder.ts b/packages/sanity/src/core/tasks/components/form/tasksFormBuilder/useTasksFormBuilder.ts index adbdee18434..3bef232bf03 100644 --- a/packages/sanity/src/core/tasks/components/form/tasksFormBuilder/useTasksFormBuilder.ts +++ b/packages/sanity/src/core/tasks/components/form/tasksFormBuilder/useTasksFormBuilder.ts @@ -93,11 +93,12 @@ export function useTasksFormBuilder(options: TasksFormBuilderOptions): TasksForm const connectionState = useConnectionState(documentId, documentType) const editState = useEditState(documentId, documentType) - const value = editState?.draft || editState?.published || initialValue + const documentValue = editState?.draft || editState?.published || initialValue - const formState = useFormState(tasksSchemaType, { - value: value, - comparisonValue: value, + const formState = useFormState({ + schemaType: tasksSchemaType, + documentValue, + comparisonValue: documentValue, readOnly: false, changesOpen: false, presence, diff --git a/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx b/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx index 85ef855ccb9..f15f16fffcf 100644 --- a/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx +++ b/packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx @@ -542,8 +542,9 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => { schemaType, ]) - const formState = useFormState(schemaType!, { - value: displayed, + const formState = useFormState({ + schemaType: schemaType!, + documentValue: displayed, readOnly, comparisonValue: compareValue, focusPath, diff --git a/packages/sanity/test/form/renderInput.tsx b/packages/sanity/test/form/renderInput.tsx index 3395e71d554..8cc6b9bae25 100644 --- a/packages/sanity/test/form/renderInput.tsx +++ b/packages/sanity/test/form/renderInput.tsx @@ -125,9 +125,10 @@ export async function renderInput(props: { if (!docType) throw new Error(`no document type: test`) - const formState = useFormState(docType, { - comparisonValue: documentValue as any, - value: documentValue as any, + const formState = useFormState({ + schemaType: docType, + comparisonValue: documentValue, + documentValue, focusPath, collapsedPaths: undefined, collapsedFieldSets: undefined,