From 6dd4e2d3abdae3eff668c33ad8af2dc8b6f9070c Mon Sep 17 00:00:00 2001 From: YuanHeDx <96035115+YuanHeDx@users.noreply.github.com> Date: Thu, 20 Mar 2025 15:59:55 +0800 Subject: [PATCH] fix: fix FieldArrayModel.swap state issue with doc example covered (#70) * feat: add get values to FormModel * fix: fix FieldArrayModel.swap state issue with doc example added * doc: fix array demo code --- .../src/components/field-wrapper.tsx | 2 +- apps/demo-node-form/src/constant.ts | 2 +- .../docs/components/node-form/array/index.css | 7 + .../node-form/array/node-registry.tsx | 116 ++++++++++++++ .../components/node-form/array/preview.tsx | 146 ++++++++++++++++++ .../docs/src/zh/examples/node-form/_meta.json | 3 +- apps/docs/src/zh/examples/node-form/array.mdx | 10 ++ .../form/__tests__/field-array-model.test.ts | 142 +++++++++++++++-- .../form/src/core/field-array-model.ts | 90 ++++++++++- packages/node-engine/form/src/core/utils.ts | 6 +- packages/node-engine/form/src/types/form.ts | 2 +- .../node-engine/node/src/form-model-v2.ts | 4 + 12 files changed, 511 insertions(+), 19 deletions(-) create mode 100644 apps/docs/components/node-form/array/index.css create mode 100644 apps/docs/components/node-form/array/node-registry.tsx create mode 100644 apps/docs/components/node-form/array/preview.tsx create mode 100644 apps/docs/src/zh/examples/node-form/array.mdx diff --git a/apps/demo-node-form/src/components/field-wrapper.tsx b/apps/demo-node-form/src/components/field-wrapper.tsx index 69f33c58..c315da5b 100644 --- a/apps/demo-node-form/src/components/field-wrapper.tsx +++ b/apps/demo-node-form/src/components/field-wrapper.tsx @@ -4,7 +4,7 @@ import './field-wrapper.css'; interface FieldWrapperProps { required?: boolean; - title: string; + title?: string; children?: React.ReactNode; error?: string; note?: string; diff --git a/apps/demo-node-form/src/constant.ts b/apps/demo-node-form/src/constant.ts index 12ed43f2..10e47e68 100644 --- a/apps/demo-node-form/src/constant.ts +++ b/apps/demo-node-form/src/constant.ts @@ -4,7 +4,7 @@ import './field-wrapper.css'; interface FieldWrapperProps { required?: boolean; - title: string; + title?: string; children?: React.ReactNode; error?: string; note?: string; diff --git a/apps/docs/components/node-form/array/index.css b/apps/docs/components/node-form/array/index.css new file mode 100644 index 00000000..fe733c6b --- /dev/null +++ b/apps/docs/components/node-form/array/index.css @@ -0,0 +1,7 @@ +.array-item-wrapper { + display: flex; + align-items: center; +} +.icon-button-popover { + padding: 6px 8px; +} diff --git a/apps/docs/components/node-form/array/node-registry.tsx b/apps/docs/components/node-form/array/node-registry.tsx new file mode 100644 index 00000000..4d3a0492 --- /dev/null +++ b/apps/docs/components/node-form/array/node-registry.tsx @@ -0,0 +1,116 @@ +import { + DataEvent, + EffectFuncProps, + Field, + FieldRenderProps, + FormMeta, + ValidateTrigger, + WorkflowNodeRegistry, + FieldArray, + FieldArrayRenderProps, +} from '@flowgram.ai/free-layout-editor'; +import { FieldWrapper } from '@flowgram.ai/demo-node-form'; +import { Input, Button, Popover } from '@douyinfe/semi-ui'; +import { IconPlus, IconCrossCircleStroked, IconArrowDown } from '@douyinfe/semi-icons'; +import './index.css'; +import '../index.css'; + +export const render = () => ( +
+
Array Examples
+ + {({ field, fieldState }: FieldArrayRenderProps) => ( + + {field.map((child, index) => ( + + {({ field: childField, fieldState: childState }: FieldRenderProps) => ( + +
+ + {index < field.value!.length - 1 ? ( + +
+
+ )} +
+ ))} +
+ +
+
+ )} +
+
+); + +interface FormData { + array: string[]; +} + +const formMeta: FormMeta = { + render, + validateTrigger: ValidateTrigger.onChange, + defaultValues: { + array: ['default'], + }, + validate: { + 'array.*': ({ value }) => + value.length > 8 ? 'max length exceeded: current length is ' + value.length : undefined, + }, + effect: { + 'array.*': [ + { + event: DataEvent.onValueInit, + effect: ({ value, name }: EffectFuncProps) => { + console.log(name + ' value init to ', value); + }, + }, + { + event: DataEvent.onValueChange, + effect: ({ value, name }: EffectFuncProps) => { + console.log(name + ' value changed to ', value); + }, + }, + ], + }, +}; + +export const nodeRegistry: WorkflowNodeRegistry = { + type: 'custom', + meta: {}, + defaultPorts: [{ type: 'output' }, { type: 'input' }], + formMeta, +}; diff --git a/apps/docs/components/node-form/array/preview.tsx b/apps/docs/components/node-form/array/preview.tsx new file mode 100644 index 00000000..87cfbbe6 --- /dev/null +++ b/apps/docs/components/node-form/array/preview.tsx @@ -0,0 +1,146 @@ +import { + DEFAULT_INITIAL_DATA, + defaultInitialDataTs, + fieldWrapperCss, + fieldWrapperTs, +} from '@flowgram.ai/demo-node-form'; + +import { Editor } from '../editor.tsx'; +import { PreviewEditor } from '../../preview-editor.tsx'; +import { nodeRegistry } from './node-registry.tsx'; + +const nodeRegistryFile = { + code: `import { + DataEvent, + EffectFuncProps, + Field, + FieldRenderProps, + FormMeta, + ValidateTrigger, + WorkflowNodeRegistry, + FieldArray, + FieldArrayRenderProps, +} from '@flowgram.ai/free-layout-editor'; +import { FieldWrapper } from '@flowgram.ai/demo-node-form'; +import { Input, Button, Popover } from '@douyinfe/semi-ui'; +import { IconPlus, IconCrossCircleStroked, IconArrowDown } from '@douyinfe/semi-icons'; +import './index.css'; +import '../index.css'; + +export const render = () => ( +
+
Array Examples
+ + {({ field, fieldState }: FieldArrayRenderProps) => ( + + {field.map((child, index) => ( + + {({ field: childField, fieldState: childState }: FieldRenderProps) => ( + +
+ + {index < field.value!.length - 1 ? ( + +
+
+ )} +
+ ))} +
+ +
+
+ )} +
+
+); + +interface FormData { + array: string[]; +} + +const formMeta: FormMeta = { + render, + validateTrigger: ValidateTrigger.onChange, + defaultValues: { + array: ['default'], + }, + validate: { + 'array.*': ({ value }) => + value.length > 8 ? 'max length exceeded: current length is ' + value.length : undefined, + }, + effect: { + 'array.*': [ + { + event: DataEvent.onValueInit, + effect: ({ value, name }: EffectFuncProps) => { + console.log(name + ' value init to ', value); + }, + }, + { + event: DataEvent.onValueChange, + effect: ({ value, name }: EffectFuncProps) => { + console.log(name + ' value changed to ', value); + }, + }, + ], + }, +}; + +export const nodeRegistry: WorkflowNodeRegistry = { + type: 'custom', + meta: {}, + defaultPorts: [{ type: 'output' }, { type: 'input' }], + formMeta, +}; + +`, + active: true, +}; + +export const NodeFormArrayPreview = () => { + const files = { + 'node-registry.tsx': nodeRegistryFile, + 'initial-data.ts': { code: defaultInitialDataTs, active: true }, + 'field-wrapper.tsx': { code: fieldWrapperTs, active: true }, + 'field-wrapper.css': { code: fieldWrapperCss, active: true }, + }; + return ( + + + + ); +}; diff --git a/apps/docs/src/zh/examples/node-form/_meta.json b/apps/docs/src/zh/examples/node-form/_meta.json index aa824cf5..235caf4a 100644 --- a/apps/docs/src/zh/examples/node-form/_meta.json +++ b/apps/docs/src/zh/examples/node-form/_meta.json @@ -1,4 +1,5 @@ [ "basic", - "effect" + "effect", + "array" ] diff --git a/apps/docs/src/zh/examples/node-form/array.mdx b/apps/docs/src/zh/examples/node-form/array.mdx new file mode 100644 index 00000000..61d66042 --- /dev/null +++ b/apps/docs/src/zh/examples/node-form/array.mdx @@ -0,0 +1,10 @@ +--- +outline: false +--- + + +# 数组 + +import { NodeFormArrayPreview } from '../../../../components/node-form/array/preview'; + + diff --git a/packages/node-engine/form/__tests__/field-array-model.test.ts b/packages/node-engine/form/__tests__/field-array-model.test.ts index c18f1412..f2cb359e 100644 --- a/packages/node-engine/form/__tests__/field-array-model.test.ts +++ b/packages/node-engine/form/__tests__/field-array-model.test.ts @@ -4,6 +4,8 @@ import { Errors, ValidateTrigger, Warnings } from '@/types'; import { FormModel } from '@/core/form-model'; import { type FieldArrayModel } from '@/core/field-array-model'; +import { FeedbackLevel } from '../src/types'; + describe('FormArrayModel', () => { let formModel = new FormModel(); describe('children', () => { @@ -580,53 +582,171 @@ describe('FormArrayModel', () => { it('can swap from 0 to middle index', () => { const arrayField = formModel.createFieldArray('arr'); - arrayField!.append('a'); - arrayField!.append('b'); - arrayField!.append('c'); + const a = arrayField!.append('a'); + const b = arrayField!.append('b'); + const c = arrayField!.append('c'); formModel.init({}); + a.state.errors = { + 'arr.0': [{ name: 'arr.0', message: 'err0', level: FeedbackLevel.Error }], + }; + b.state.errors = { + 'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }], + }; + expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] }); arrayField.swap(0, 1); expect(formModel.values).toEqual({ arr: ['b', 'a', 'c'] }); + expect(formModel.getField('arr.0').state.errors).toEqual({ + 'arr.0': [{ name: 'arr.0', message: 'err1', level: FeedbackLevel.Error }], + }); + expect(formModel.getField('arr.1').state.errors).toEqual({ + 'arr.1': [{ name: 'arr.1', message: 'err0', level: FeedbackLevel.Error }], + }); }); it('can swap from 0 to last index', () => { const arrayField = formModel.createFieldArray('arr'); - arrayField!.append('a'); - arrayField!.append('b'); - arrayField!.append('c'); + const a = arrayField!.append('a'); + const b = arrayField!.append('b'); + const c = arrayField!.append('c'); formModel.init({}); + a.state.errors = { + 'arr.0': [{ name: 'arr.0', message: 'err0', level: FeedbackLevel.Error }], + }; + c.state.errors = { + 'arr.2': [{ name: 'arr.2', message: 'err2', level: FeedbackLevel.Error }], + }; + expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] }); arrayField.swap(0, 2); expect(formModel.values).toEqual({ arr: ['c', 'b', 'a'] }); + expect(formModel.getField('arr.0').state.errors).toEqual({ + 'arr.0': [{ name: 'arr.0', message: 'err2', level: FeedbackLevel.Error }], + }); + expect(formModel.getField('arr.2').state.errors).toEqual({ + 'arr.2': [{ name: 'arr.2', message: 'err0', level: FeedbackLevel.Error }], + }); }); it('can swap from middle index to last index', () => { const arrayField = formModel.createFieldArray('arr'); - arrayField!.append('a'); - arrayField!.append('b'); - arrayField!.append('c'); + const a = arrayField!.append('a'); + const b = arrayField!.append('b'); + const c = arrayField!.append('c'); formModel.init({}); + b.state.errors = { + 'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }], + }; + c.state.errors = { + 'arr.2': [{ name: 'arr.2', message: 'err2', level: FeedbackLevel.Error }], + }; + expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] }); arrayField.swap(1, 2); expect(formModel.values).toEqual({ arr: ['a', 'c', 'b'] }); + expect(formModel.getField('arr.1').state.errors).toEqual({ + 'arr.1': [{ name: 'arr.1', message: 'err2', level: FeedbackLevel.Error }], + }); + expect(formModel.getField('arr.2').state.errors).toEqual({ + 'arr.2': [{ name: 'arr.2', message: 'err1', level: FeedbackLevel.Error }], + }); }); it('can swap from middle index to another middle index', () => { const arrayField = formModel.createFieldArray('arr'); arrayField!.append('a'); - arrayField!.append('b'); - arrayField!.append('c'); + const b = arrayField!.append('b'); + const c = arrayField!.append('c'); arrayField!.append('d'); formModel.init({}); + b.state.errors = { + 'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }], + }; + c.state.errors = { + 'arr.2': [{ name: 'arr.2', message: 'err2', level: FeedbackLevel.Error }], + }; + expect(formModel.values).toEqual({ arr: ['a', 'b', 'c', 'd'] }); arrayField.swap(1, 2); expect(formModel.values).toEqual({ arr: ['a', 'c', 'b', 'd'] }); + expect(formModel.getField('arr.1').state.errors).toEqual({ + 'arr.1': [{ name: 'arr.1', message: 'err2', level: FeedbackLevel.Error }], + }); + expect(formModel.getField('arr.2').state.errors).toEqual({ + 'arr.2': [{ name: 'arr.2', message: 'err1', level: FeedbackLevel.Error }], + }); + }); + + it('can swap for nested array', () => { + const arrayField = formModel.createFieldArray('arr'); + const a = arrayField!.append({ x: 'x0', y: 'y0' }); + const b = arrayField!.append({ x: 'x1', y: 'y1' }); + const ax = formModel.createField('arr.0.x'); + const ay = formModel.createField('arr.0.y'); + const bx = formModel.createField('arr.1.x'); + const by = formModel.createField('arr.1.y'); + + formModel.init({}); + + ax.state.errors = { + 'arr.0.x': [{ name: 'arr.0.x', message: 'err0x', level: FeedbackLevel.Error }], + }; + bx.state.errors = { + 'arr.1.x': [{ name: 'arr.1.x', message: 'err1x', level: FeedbackLevel.Error }], + }; + + expect(formModel.values).toEqual({ + arr: [ + { x: 'x0', y: 'y0' }, + { x: 'x1', y: 'y1' }, + ], + }); + arrayField.swap(0, 1); + expect(formModel.values).toEqual({ + arr: [ + { x: 'x1', y: 'y1' }, + { x: 'x0', y: 'y0' }, + ], + }); + expect(formModel.getField('arr.0.x').state.errors).toEqual({ + 'arr.0.x': [{ name: 'arr.0.x', message: 'err1x', level: FeedbackLevel.Error }], + }); + expect(formModel.getField('arr.1.x').state.errors).toEqual({ + 'arr.1.x': [{ name: 'arr.1.x', message: 'err0x', level: FeedbackLevel.Error }], + }); + + // assert form.state.errors + expect(formModel.state.errors['arr.0.x']).toEqual([ + { name: 'arr.0.x', message: 'err1x', level: FeedbackLevel.Error }, + ]); + expect(formModel.state.errors['arr.1.x']).toEqual([ + { name: 'arr.1.x', message: 'err0x', level: FeedbackLevel.Error }, + ]); + }); + + it('should have correct form.state.errors after swapping invalid field with valid field', () => { + const arrayField = formModel.createFieldArray('arr'); + const a = arrayField!.append('a'); + const b = arrayField!.append('b'); + arrayField!.append('c'); + + formModel.init({}); + + b.state.errors = { + 'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }], + }; + + arrayField.swap(0, 1); + expect(formModel.getField('arr.0').state.errors).toEqual({ + 'arr.0': [{ name: 'arr.0', message: 'err1', level: FeedbackLevel.Error }], + }); + expect(formModel.getField('arr.1').state.errors).toEqual(undefined); }); it('should trigger array effect and child effect', () => { diff --git a/packages/node-engine/form/src/core/field-array-model.ts b/packages/node-engine/form/src/core/field-array-model.ts index 3e7ddf1c..0faf98a9 100644 --- a/packages/node-engine/form/src/core/field-array-model.ts +++ b/packages/node-engine/form/src/core/field-array-model.ts @@ -204,6 +204,7 @@ export class FieldArrayModel extends FieldModel extends FieldModel(this.form.fieldMap); + + const fromFields = this.findAllFieldsAt(from); + const toFields = this.findAllFieldsAt(to); + const fromRootPath = this.getPathAt(from); + const toRootPath = this.getPathAt(to); + const leafFieldsModified: FieldModel[] = []; + fromFields.forEach((f) => { + const newName = f.path.replaceParent(fromRootPath, toRootPath).toString(); + f.name = newName; + if (!f.children.length) { + f.updateNameForLeafState(newName); + leafFieldsModified.push(f); + } + newFieldMap.set(newName, f); + }); + toFields.forEach((f) => { + const newName = f.path.replaceParent(toRootPath, fromRootPath).toString(); + f.name = newName; + if (!f.children.length) { + f.updateNameForLeafState(newName); + } + newFieldMap.set(newName, f); + leafFieldsModified.push(f); + }); + this.form.fieldMap = newFieldMap; + leafFieldsModified.forEach((f) => f.bubbleState()); + this.form.alignStateWithFieldMap(); } move(from: number, to: number) { @@ -236,5 +276,53 @@ export class FieldArrayModel extends FieldModel this.value.length) { + throw new Error(`[Form]: FieldArrayModel.insertAt Error: index exceeds array boundary`); + } + + const tempValue = [...this.value]; + tempValue.splice(index, 0, value); + this.form.setValueIn(this.name, tempValue); + + // todo: should move field in order to make sure field state is also moved + } + + /** + * get element path at given index + * @param index + * @protected + */ + protected getPathAt(index: number) { + return this.path.concat(index); + } + + /** + * find all fields including child and grandchild fields at given index. + * @param index + * @protected + */ + protected findAllFieldsAt(index: number) { + const rootPath = this.getPathAt(index); + const rootPathString = rootPath.toString(); + + const res: FieldModel[] = this.form.fieldMap.get(rootPathString) + ? [this.form.fieldMap.get(rootPathString)!] + : []; + + this.form.fieldMap.forEach((field, fieldName) => { + if (rootPath.isChildOrGrandChild(fieldName)) { + res.push(field); + } + }); + return res; } } diff --git a/packages/node-engine/form/src/core/utils.ts b/packages/node-engine/form/src/core/utils.ts index 383a01eb..f8cb1cce 100644 --- a/packages/node-engine/form/src/core/utils.ts +++ b/packages/node-engine/form/src/core/utils.ts @@ -5,7 +5,7 @@ import { Errors, Feedback, OnFormValuesChangePayload, ValidateTrigger, Warnings import { Path } from './path'; export function updateFeedbacksName(feedbacks: Feedback[], name: string) { - return feedbacks.map((f) => ({ + return (feedbacks || []).map((f) => ({ ...f, name, })); @@ -91,7 +91,7 @@ export namespace FieldEventUtils { ) { const { name: changedName, options } = payload; - if (options?.action === 'array-splice') { + if (options?.action === 'array-splice' || options?.action === 'array-swap') { // const splicedIndexes = options?.indexes || []; // // const splicedPaths = splicedIndexes.map(index => new Path(changedName).concat(index)); @@ -109,7 +109,7 @@ export namespace FieldEventUtils { // return false; // } - // splice 情况下仅触发数组field的校验 + // splice 和 swap 都属于数组跟级别的变更,仅需触发数组field的校验, 无需校验子项 return fieldName === changedName; } diff --git a/packages/node-engine/form/src/types/form.ts b/packages/node-engine/form/src/types/form.ts index 56aab5b2..acd7429e 100644 --- a/packages/node-engine/form/src/types/form.ts +++ b/packages/node-engine/form/src/types/form.ts @@ -108,7 +108,7 @@ export interface CreateFormReturn { } export interface OnFormValuesChangeOptions { - action?: 'array-append' | 'array-splice'; + action?: 'array-append' | 'array-splice' | 'array-swap'; indexes?: number[]; } diff --git a/packages/node-engine/node/src/form-model-v2.ts b/packages/node-engine/node/src/form-model-v2.ts index 5f66b32e..19305a00 100644 --- a/packages/node-engine/node/src/form-model-v2.ts +++ b/packages/node-engine/node/src/form-model-v2.ts @@ -135,6 +135,10 @@ export class FormModelV2 extends FormModel implements Disposable { return this.node.getNodeRegistry().formMeta; } + get values() { + return this.nativeFormModel?.values; + } + protected _feedbacks: FormFeedback[] = []; get feedbacks(): FormFeedback[] {