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 ? (
+
+ }
+ onClick={() => field.swap(index, index + 1)}
+ />
+
+ ) : null}
+
+ }
+ onClick={() => field.delete(index)}
+ />
+
+
+
+ )}
+
+ ))}
+
+ }
+ onClick={() => field.append('default')}
+ >
+ Add
+
+
+
+ )}
+
+
+);
+
+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 ? (
+
+ }
+ onClick={() => field.swap(index, index + 1)}
+ />
+
+ ) : null}
+
+ }
+ onClick={() => field.delete(index)}
+ />
+
+
+
+ )}
+
+ ))}
+
+ }
+ onClick={() => field.append('default')}
+ >
+ Add
+
+
+
+ )}
+
+
+);
+
+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[] {