Преглед изворни кода

fix(form): trigger effect for inputsValues.* (#627)

* fix(form): trigger effect for inputsValues.*

* feat: add example in form meta

* fix: json schema required missed

* fix: export json schema basic type

* fix: ts error

* feat: split files

* fix: form tests

* chore: ref log

* fix: array append init effect
Yiwei Mao пре 5 месеци
родитељ
комит
0f557bac7e

+ 4 - 0
apps/demo-free-layout/src/nodes/default-form-meta.tsx

@@ -11,6 +11,7 @@ import {
   DisplayOutputs,
   validateFlowValue,
   validateWhenVariableSync,
+  listenRefSchemaChange,
 } from '@flowgram.ai/form-materials';
 import { Divider } from '@douyinfe/semi-ui';
 
@@ -69,5 +70,8 @@ export const defaultFormMeta: FormMeta<FlowNodeJSON> = {
     title: syncVariableTitle,
     outputs: provideJsonSchemaOutputs,
     inputsValues: [...autoRenameRefEffect, ...validateWhenVariableSync({ scope: 'public' })],
+    'inputsValues.*': listenRefSchemaChange((params) => {
+      console.log(`[${params.context.node.id}][${params.name}] Schema Of Ref Updated`);
+    }),
   },
 };

+ 2 - 0
packages/materials/form-materials/src/plugins/json-schema-preset/index.tsx

@@ -10,6 +10,7 @@ import {
   useTypeManager as useOriginTypeManager,
   TypePresetProvider as OriginTypePresetProvider,
   JsonSchemaTypeManager,
+  type JsonSchemaBasicType,
 } from '@flowgram.ai/json-schema';
 
 import { jsonSchemaTypePreset } from './type-definition';
@@ -36,4 +37,5 @@ export {
   JsonSchemaUtils,
   JsonSchemaTypeRegistry,
   ConstantRendererProps,
+  JsonSchemaBasicType,
 };

+ 360 - 0
packages/node-engine/node/__tests__/form-effects.test.ts

@@ -0,0 +1,360 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { FlowNodeEntity } from '@flowgram.ai/document';
+
+import { DataEvent } from '../src/types';
+import { FormModelV2 } from '../src/form-model-v2';
+
+describe('FormModelV2 effects', () => {
+  const node = {
+    getService: vi.fn().mockReturnValue({}),
+    getData: vi.fn().mockReturnValue({ fireChange: vi.fn() }),
+  } as unknown as FlowNodeEntity;
+
+  let formModelV2 = new FormModelV2(node);
+
+  beforeEach(() => {
+    formModelV2.dispose();
+    formModelV2 = new FormModelV2(node);
+  });
+
+  it('should trigger init effects when initialValues exists', () => {
+    const mockEffect = vi.fn();
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        a: [
+          {
+            event: DataEvent.onValueInit,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta, { a: 1 });
+    expect(mockEffect).toHaveBeenCalledOnce();
+  });
+  it('should trigger init effects when formatOnInit return value', () => {
+    const mockEffect = vi.fn();
+    const formMeta = {
+      render: vi.fn(),
+      formatOnInit: () => ({ a: { b: 1 } }),
+      effect: {
+        'a.b': [
+          {
+            event: DataEvent.onValueInit,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta);
+    expect(mockEffect).toHaveBeenCalledOnce();
+  });
+  it('should trigger value change effects', () => {
+    const mockEffect = vi.fn();
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        a: [
+          {
+            event: DataEvent.onValueChange,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta, { a: 1 });
+    formModelV2.setValueIn('a', 2);
+    expect(mockEffect).toHaveBeenCalledOnce();
+  });
+  it('should trigger onValueInitOrChange effects when form defaultValue init', () => {
+    const mockEffect = vi.fn();
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        a: [
+          {
+            event: DataEvent.onValueInitOrChange,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta, { a: 1 });
+    expect(mockEffect).toHaveBeenCalledOnce();
+  });
+  it('should trigger onValueInitOrChange effects when field defaultValue init', () => {
+    const mockEffect = vi.fn();
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        a: [
+          {
+            event: DataEvent.onValueInitOrChange,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta);
+    formModelV2.nativeFormModel?.setInitValueIn('a', 2);
+    expect(mockEffect).toHaveBeenCalledOnce();
+  });
+  it('should trigger child onValueInit effects when field defaultValue init', () => {
+    const mockEffect = vi.fn();
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        'a.b.c': [
+          {
+            event: DataEvent.onValueInit,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta);
+    formModelV2.nativeFormModel?.setInitValueIn('a', { b: { c: 1 } });
+    expect(mockEffect).toHaveBeenCalledOnce();
+  });
+  it('should not trigger child onValueInit effects when field defaultValue init but child path has no value', () => {
+    const mockEffect = vi.fn();
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        'a.b.c': [
+          {
+            event: DataEvent.onValueInit,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta);
+    formModelV2.nativeFormModel?.setInitValueIn('a', 2);
+    expect(mockEffect).not.toHaveBeenCalled();
+  });
+  it('should trigger onValueInitOrChange effects when value change', () => {
+    const mockEffect = vi.fn();
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        a: [
+          {
+            event: DataEvent.onValueInitOrChange,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta);
+    formModelV2.setValueIn('a', 2);
+    expect(mockEffect).toHaveBeenCalledOnce();
+
+    formModelV2.setValueIn('a', {});
+    expect(mockEffect).toHaveBeenCalledTimes(2);
+
+    formModelV2.setValueIn('a.b', 2);
+    expect(mockEffect).toHaveBeenCalledTimes(3);
+  });
+  it('should trigger single item init effect when array append', () => {
+    const mockArrItemEffect = vi.fn();
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        ['arr.*']: [
+          {
+            event: DataEvent.onValueInit,
+            effect: mockArrItemEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta);
+    const arrModel = formModelV2.nativeFormModel?.createFieldArray('arr');
+    arrModel?.append(1);
+    arrModel?.append(2);
+    expect(mockArrItemEffect).toHaveBeenCalledTimes(2);
+  });
+  it('should trigger value change effects return when value change', () => {
+    const mockEffectReturn = vi.fn();
+    const mockEffect = vi.fn(() => mockEffectReturn);
+
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        a: [
+          {
+            event: DataEvent.onValueChange,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta, { a: 1 });
+    formModelV2.setValueIn('a', 2);
+    formModelV2.setValueIn('a', 3);
+    expect(mockEffect).toHaveBeenCalledTimes(2);
+    expect(mockEffectReturn).toHaveBeenCalledOnce();
+  });
+  it('should trigger onValueInitOrChange effects return when value init', () => {
+    const mockEffectReturn = vi.fn();
+    const mockEffect = vi.fn(() => mockEffectReturn);
+
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        a: [
+          {
+            event: DataEvent.onValueInitOrChange,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta, { a: 1 });
+    formModelV2.setValueIn('a', 2);
+    expect(mockEffectReturn).toHaveBeenCalledOnce();
+  });
+  it('should trigger onValueInitOrChange effects return when value init and change', () => {
+    const mockEffectReturn = vi.fn();
+    const mockEffect = vi.fn(() => mockEffectReturn);
+
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        a: [
+          {
+            event: DataEvent.onValueInitOrChange,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta, { a: 1 });
+    formModelV2.setValueIn('a', 2);
+    formModelV2.setValueIn('a', 3);
+    // 第一次setValue,触发 init 时记录的return, 第二次setValue 触发 第一次setValue时记录的return, 共2次
+    expect(mockEffectReturn).toHaveBeenCalledTimes(2);
+  });
+  it('should update effect return function each time init or change the value', () => {
+    const mockEffectReturn = vi.fn().mockReturnValueOnce(1).mockReturnValueOnce(2);
+    const mockEffect = vi.fn(() => mockEffectReturn);
+
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        ['arr.*.var']: [
+          {
+            event: DataEvent.onValueInitOrChange,
+            effect: mockEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta, { arr: [] });
+    const form = formModelV2.nativeFormModel!;
+    const arrayField = form.createFieldArray('arr');
+    arrayField!.append({ var: 'x' });
+    form.setValueIn('arr.0.var', 'y');
+
+    formModelV2.dispose();
+
+    expect(mockEffectReturn).toHaveNthReturnedWith(1, 1);
+    expect(mockEffectReturn).toHaveNthReturnedWith(2, 2);
+  });
+  it('should trigger effects when setValueIn called in parent name', () => {
+    const mockInitEffectReturn = vi.fn();
+    const mockInitEffect = vi.fn(() => mockInitEffectReturn);
+
+    const mockInitOrChangeEffectReturn = vi.fn();
+    const mockInitOrChangeEffect = vi.fn(() => mockInitOrChangeEffectReturn);
+
+    const mockChangeEffectReturn = vi.fn();
+    const mockChangeEffect = vi.fn(() => mockChangeEffectReturn);
+
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        'inputsValues.*': [
+          {
+            event: DataEvent.onValueInit,
+            effect: mockInitEffect,
+          },
+          {
+            event: DataEvent.onValueInitOrChange,
+            effect: mockInitOrChangeEffect,
+          },
+          {
+            event: DataEvent.onValueChange,
+            effect: mockChangeEffect,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta, { inputsValues: { a: 1 } });
+    expect(mockInitEffect).toHaveBeenCalledTimes(1);
+    expect(mockInitOrChangeEffect).toHaveBeenCalledTimes(1);
+    expect(mockChangeEffect).toHaveBeenCalledTimes(0);
+
+    formModelV2.setValueIn('inputsValues', { a: 2 });
+    expect(mockInitEffect).toHaveBeenCalledTimes(1);
+    expect(mockInitOrChangeEffect).toHaveBeenCalledTimes(2);
+    expect(mockChangeEffect).toHaveBeenCalledTimes(1);
+
+    formModelV2.setValueIn('inputsValues', { b: 3 });
+    expect(mockInitEffect).toHaveBeenCalledTimes(2);
+    expect(mockInitOrChangeEffect).toHaveBeenCalledTimes(4);
+    expect(mockChangeEffect).toHaveBeenCalledTimes(2);
+
+    formModelV2.setValueIn('inputsValues', { b: 4 });
+    expect(mockInitEffect).toHaveBeenCalledTimes(2);
+    expect(mockInitOrChangeEffect).toHaveBeenCalledTimes(5);
+    expect(mockChangeEffect).toHaveBeenCalledTimes(3);
+
+    formModelV2.setValueIn('inputsValues', { a: 1, b: 4 });
+    expect(mockInitEffect).toHaveBeenCalledTimes(3);
+    expect(mockInitOrChangeEffect).toHaveBeenCalledTimes(6);
+    expect(mockChangeEffect).toHaveBeenCalledTimes(3);
+
+    formModelV2.setValueIn('inputsValues', {});
+    expect(mockInitEffect).toHaveBeenCalledTimes(3);
+    expect(mockInitOrChangeEffect).toHaveBeenCalledTimes(8);
+    expect(mockChangeEffect).toHaveBeenCalledTimes(5);
+  });
+  it('should trigger all effects return when formModel dispose', () => {
+    const mockEffectReturn1 = vi.fn();
+    const mockEffect1 = vi.fn(() => mockEffectReturn1);
+    const mockEffectReturn2 = vi.fn();
+    const mockEffect2 = vi.fn(() => mockEffectReturn2);
+
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        a: [
+          {
+            event: DataEvent.onValueInitOrChange,
+            effect: mockEffect1,
+          },
+        ],
+        b: [
+          {
+            event: DataEvent.onValueInit,
+            effect: mockEffect2,
+          },
+        ],
+      },
+    };
+    formModelV2.init(formMeta, { a: 1, b: 2 });
+
+    formModelV2.dispose();
+
+    expect(mockEffectReturn1).toHaveBeenCalledTimes(1);
+    expect(mockEffectReturn2).toHaveBeenCalledTimes(1);
+  });
+});

+ 2 - 402
packages/node-engine/node/__tests__/form-model-v2.test.ts

@@ -6,8 +6,7 @@
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 import { FlowNodeEntity } from '@flowgram.ai/document';
 
-import { DataEvent, FormMeta } from '../src/types';
-import { defineFormPluginCreator } from '../src/form-plugin';
+import { FormMeta } from '../src/types';
 import { FormModelV2 } from '../src/form-model-v2';
 
 describe('FormModelV2', () => {
@@ -52,8 +51,7 @@ describe('FormModelV2', () => {
         b: 2,
       });
 
-      // @ts-expect-error
-      formItem?.value = { a: 3, b: 4 };
+      formItem!.value = { a: 3, b: 4 };
 
       expect(formItem?.value).toEqual({
         a: 3,
@@ -62,404 +60,6 @@ describe('FormModelV2', () => {
     });
   });
 
-  describe('effects', () => {
-    it('should trigger init effects when initialValues exists', () => {
-      const mockEffect = vi.fn();
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          a: [
-            {
-              event: DataEvent.onValueInit,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta, { a: 1 });
-      expect(mockEffect).toHaveBeenCalledOnce();
-    });
-    it('should trigger init effects when formatOnInit return value', () => {
-      const mockEffect = vi.fn();
-      const formMeta = {
-        render: vi.fn(),
-        formatOnInit: () => ({ a: { b: 1 } }),
-        effect: {
-          'a.b': [
-            {
-              event: DataEvent.onValueInit,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta);
-      expect(mockEffect).toHaveBeenCalledOnce();
-    });
-    it('should trigger value change effects', () => {
-      const mockEffect = vi.fn();
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          a: [
-            {
-              event: DataEvent.onValueChange,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta, { a: 1 });
-      formModelV2.setValueIn('a', 2);
-      expect(mockEffect).toHaveBeenCalledOnce();
-    });
-    it('should trigger onValueInitOrChange effects when form defaultValue init', () => {
-      const mockEffect = vi.fn();
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          a: [
-            {
-              event: DataEvent.onValueInitOrChange,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta, { a: 1 });
-      expect(mockEffect).toHaveBeenCalledOnce();
-    });
-    it('should trigger onValueInitOrChange effects when field defaultValue init', () => {
-      const mockEffect = vi.fn();
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          a: [
-            {
-              event: DataEvent.onValueInitOrChange,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta);
-      formModelV2.nativeFormModel?.setInitValueIn('a', 2);
-      expect(mockEffect).toHaveBeenCalledOnce();
-    });
-    it('should trigger child onValueInit effects when field defaultValue init', () => {
-      const mockEffect = vi.fn();
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          'a.b.c': [
-            {
-              event: DataEvent.onValueInit,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta);
-      formModelV2.nativeFormModel?.setInitValueIn('a', { b: { c: 1 } });
-      expect(mockEffect).toHaveBeenCalledOnce();
-    });
-    it('should not trigger child onValueInit effects when field defaultValue init but child path has no value', () => {
-      const mockEffect = vi.fn();
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          'a.b.c': [
-            {
-              event: DataEvent.onValueInit,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta);
-      formModelV2.nativeFormModel?.setInitValueIn('a', 2);
-      expect(mockEffect).not.toHaveBeenCalled();
-    });
-    it('should trigger onValueInitOrChange effects when value change', () => {
-      const mockEffect = vi.fn();
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          a: [
-            {
-              event: DataEvent.onValueInitOrChange,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta);
-      formModelV2.setValueIn('a', 2);
-      expect(mockEffect).toHaveBeenCalledOnce();
-    });
-    it('should trigger single item init effect when array append', () => {
-      const mockEffect = vi.fn();
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          ['arr.*']: [
-            {
-              event: DataEvent.onValueInit,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta);
-      const arrModel = formModelV2.nativeFormModel?.createFieldArray('arr');
-      arrModel?.append(1);
-      arrModel?.append(2);
-      expect(mockEffect).toHaveBeenCalledTimes(2);
-    });
-    it('should trigger value change effects return when value change', () => {
-      const mockEffectReturn = vi.fn();
-      const mockEffect = vi.fn(() => mockEffectReturn);
-
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          a: [
-            {
-              event: DataEvent.onValueChange,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta, { a: 1 });
-      formModelV2.setValueIn('a', 2);
-      formModelV2.setValueIn('a', 3);
-      expect(mockEffect).toHaveBeenCalledTimes(2);
-      expect(mockEffectReturn).toHaveBeenCalledOnce();
-    });
-    it('should trigger onValueInitOrChange effects return when value init', () => {
-      const mockEffectReturn = vi.fn();
-      const mockEffect = vi.fn(() => mockEffectReturn);
-
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          a: [
-            {
-              event: DataEvent.onValueInitOrChange,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta, { a: 1 });
-      formModelV2.setValueIn('a', 2);
-      expect(mockEffectReturn).toHaveBeenCalledOnce();
-    });
-    it('should trigger onValueInitOrChange effects return when value init and change', () => {
-      const mockEffectReturn = vi.fn();
-      const mockEffect = vi.fn(() => mockEffectReturn);
-
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          a: [
-            {
-              event: DataEvent.onValueInitOrChange,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta, { a: 1 });
-      formModelV2.setValueIn('a', 2);
-      formModelV2.setValueIn('a', 3);
-      // 第一次setValue,触发 init 时记录的return, 第二次setValue 触发 第一次setValue时记录的return, 共2次
-      expect(mockEffectReturn).toHaveBeenCalledTimes(2);
-    });
-    it('should update effect return function each time init or change the value', () => {
-      const mockEffectReturn = vi.fn().mockReturnValueOnce(1).mockReturnValueOnce(2);
-      const mockEffect = vi.fn(() => mockEffectReturn);
-
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          ['arr.*.var']: [
-            {
-              event: DataEvent.onValueInitOrChange,
-              effect: mockEffect,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta, { arr: [] });
-      const form = formModelV2.nativeFormModel!;
-      const arrayField = form.createFieldArray('arr');
-      arrayField!.append({ var: 'x' });
-      form.setValueIn('arr.0.var', 'y');
-
-      formModelV2.dispose();
-
-      expect(mockEffectReturn).toHaveNthReturnedWith(1, 1);
-      expect(mockEffectReturn).toHaveNthReturnedWith(2, 2);
-    });
-    it('should trigger all effects return when formModel dispose', () => {
-      const mockEffectReturn1 = vi.fn();
-      const mockEffect1 = vi.fn(() => mockEffectReturn1);
-      const mockEffectReturn2 = vi.fn();
-      const mockEffect2 = vi.fn(() => mockEffectReturn2);
-
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          a: [
-            {
-              event: DataEvent.onValueInitOrChange,
-              effect: mockEffect1,
-            },
-          ],
-          b: [
-            {
-              event: DataEvent.onValueInit,
-              effect: mockEffect2,
-            },
-          ],
-        },
-      };
-      formModelV2.init(formMeta, { a: 1, b: 2 });
-
-      formModelV2.dispose();
-
-      expect(mockEffectReturn1).toHaveBeenCalledTimes(1);
-      expect(mockEffectReturn2).toHaveBeenCalledTimes(1);
-    });
-  });
-
-  describe('plugins', () => {
-    beforeEach(() => {
-      formModelV2.dispose();
-      formModelV2 = new FormModelV2(node);
-    });
-    it('should call onInit when formModel init', () => {
-      const mockInit = vi.fn();
-      const plugin = defineFormPluginCreator({
-        name: 'test',
-        onInit: mockInit,
-      })({ opt1: 1 });
-      const formMeta = {
-        render: vi.fn(),
-        plugins: [plugin],
-      } as unknown as FormMeta;
-      formModelV2.init(formMeta);
-
-      expect(mockInit).toHaveBeenCalledOnce();
-      expect(mockInit).toHaveBeenCalledWith(
-        { formModel: formModelV2, ...formModelV2.nodeContext },
-        { opt1: 1 }
-      );
-    });
-    it('should call onDispose when formModel dispose', () => {
-      const mockDispose = vi.fn();
-      const plugin = defineFormPluginCreator({
-        name: 'test',
-        onDispose: mockDispose,
-      })({ opt1: 1 });
-      const formMeta = {
-        render: vi.fn(),
-        plugins: [plugin],
-      } as unknown as FormMeta;
-      formModelV2.init(formMeta);
-      formModelV2.dispose();
-
-      expect(mockDispose).toHaveBeenCalledOnce();
-      expect(mockDispose).toHaveBeenCalledWith(
-        { formModel: formModelV2, ...formModelV2.nodeContext },
-        { opt1: 1 }
-      );
-    });
-    it('should call effects when corresponding events trigger', () => {
-      const mockEffectPlugin = vi.fn();
-      const mockEffectOrigin = vi.fn();
-
-      const plugin = defineFormPluginCreator({
-        name: 'test',
-        onSetupFormMeta(ctx, opts) {
-          ctx.mergeEffect({
-            a: [
-              {
-                event: DataEvent.onValueInitOrChange,
-                effect: mockEffectPlugin,
-              },
-            ],
-          });
-        },
-      })({ opt1: 1 });
-
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          a: [
-            {
-              event: DataEvent.onValueInitOrChange,
-              effect: mockEffectOrigin,
-            },
-          ],
-        },
-        plugins: [plugin],
-      } as unknown as FormMeta;
-
-      formModelV2.init(formMeta, { a: 0 });
-
-      expect(mockEffectPlugin).toHaveBeenCalledOnce();
-      expect(mockEffectOrigin).toHaveBeenCalledOnce();
-    });
-    it('should call effects when corresponding events trigger: array case', () => {
-      const mockEffectPluginArrStar = vi.fn();
-      const mockEffectOriginArrStar = vi.fn();
-      const mockEffectPluginOther = vi.fn();
-
-      const plugin = defineFormPluginCreator({
-        name: 'test',
-        onSetupFormMeta(ctx, opts) {
-          ctx.mergeEffect({
-            'arr.*': [
-              {
-                event: DataEvent.onValueChange,
-                effect: mockEffectPluginArrStar,
-              },
-            ],
-            other: [
-              {
-                event: DataEvent.onValueChange,
-                effect: mockEffectPluginOther,
-              },
-            ],
-          });
-        },
-      })({ opt1: 1 });
-
-      const formMeta = {
-        render: vi.fn(),
-        effect: {
-          'arr.*': [
-            {
-              event: DataEvent.onValueChange,
-              effect: mockEffectOriginArrStar,
-            },
-          ],
-        },
-        plugins: [plugin],
-      } as unknown as FormMeta;
-
-      formModelV2.init(formMeta, { arr: [0], other: 1 });
-      formModelV2.setValueIn('arr.0', 2);
-      formModelV2.setValueIn('other', 2);
-
-      expect(mockEffectOriginArrStar).toHaveBeenCalledOnce();
-      expect(mockEffectPluginArrStar).toHaveBeenCalledOnce();
-      expect(mockEffectPluginOther).toHaveBeenCalledOnce();
-    });
-  });
   describe('onFormValueChangeIn', () => {
     beforeEach(() => {
       formModelV2.dispose();

+ 152 - 0
packages/node-engine/node/__tests__/form-plugins.test.ts

@@ -0,0 +1,152 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { FlowNodeEntity } from '@flowgram.ai/document';
+
+import { DataEvent, FormMeta } from '../src/types';
+import { defineFormPluginCreator } from '../src/form-plugin';
+import { FormModelV2 } from '../src/form-model-v2';
+
+describe('FormModelV2 plugins', () => {
+  const node = {
+    getService: vi.fn().mockReturnValue({}),
+    getData: vi.fn().mockReturnValue({ fireChange: vi.fn() }),
+  } as unknown as FlowNodeEntity;
+
+  let formModelV2 = new FormModelV2(node);
+
+  beforeEach(() => {
+    formModelV2.dispose();
+    formModelV2 = new FormModelV2(node);
+  });
+
+  it('should call onInit when formModel init', () => {
+    const mockInit = vi.fn();
+    const plugin = defineFormPluginCreator({
+      name: 'test',
+      onInit: mockInit,
+    })({ opt1: 1 });
+    const formMeta = {
+      render: vi.fn(),
+      plugins: [plugin],
+    } as unknown as FormMeta;
+    formModelV2.init(formMeta);
+
+    expect(mockInit).toHaveBeenCalledOnce();
+    expect(mockInit).toHaveBeenCalledWith(
+      { formModel: formModelV2, ...formModelV2.nodeContext },
+      { opt1: 1 }
+    );
+  });
+
+  it('should call onDispose when formModel dispose', () => {
+    const mockDispose = vi.fn();
+    const plugin = defineFormPluginCreator({
+      name: 'test',
+      onDispose: mockDispose,
+    })({ opt1: 1 });
+    const formMeta = {
+      render: vi.fn(),
+      plugins: [plugin],
+    } as unknown as FormMeta;
+    formModelV2.init(formMeta);
+    formModelV2.dispose();
+
+    expect(mockDispose).toHaveBeenCalledOnce();
+    expect(mockDispose).toHaveBeenCalledWith(
+      { formModel: formModelV2, ...formModelV2.nodeContext },
+      { opt1: 1 }
+    );
+  });
+
+  it('should call effects when corresponding events trigger', () => {
+    const mockEffectPlugin = vi.fn();
+    const mockEffectOrigin = vi.fn();
+
+    const plugin = defineFormPluginCreator({
+      name: 'test',
+      onSetupFormMeta(ctx, opts) {
+        ctx.mergeEffect({
+          a: [
+            {
+              event: DataEvent.onValueInitOrChange,
+              effect: mockEffectPlugin,
+            },
+          ],
+        });
+      },
+    })({ opt1: 1 });
+
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        a: [
+          {
+            event: DataEvent.onValueInitOrChange,
+            effect: mockEffectOrigin,
+          },
+        ],
+      },
+      plugins: [plugin],
+    } as unknown as FormMeta;
+
+    formModelV2.init(formMeta, { a: 0 });
+
+    expect(mockEffectPlugin).toHaveBeenCalledOnce();
+    expect(mockEffectOrigin).toHaveBeenCalledOnce();
+  });
+
+  it('should call effects when corresponding events trigger: array case', () => {
+    const mockEffectPluginArrStar = vi.fn();
+    const mockEffectOriginArrStar = vi.fn();
+    const mockEffectPluginOther = vi.fn();
+
+    const plugin = defineFormPluginCreator({
+      name: 'test',
+      onSetupFormMeta(ctx, opts) {
+        ctx.mergeEffect({
+          'arr.*': [
+            {
+              event: DataEvent.onValueChange,
+              effect: mockEffectPluginArrStar,
+            },
+          ],
+          other: [
+            {
+              event: DataEvent.onValueChange,
+              effect: mockEffectPluginOther,
+            },
+          ],
+        });
+      },
+    })({ opt1: 1 });
+
+    const formMeta = {
+      render: vi.fn(),
+      effect: {
+        'arr.*': [
+          {
+            event: DataEvent.onValueChange,
+            effect: mockEffectOriginArrStar,
+          },
+        ],
+      },
+      plugins: [plugin],
+    } as unknown as FormMeta;
+
+    formModelV2.init(formMeta, { arr: [0], other: 1 });
+    expect(mockEffectOriginArrStar).not.toHaveBeenCalled();
+    expect(mockEffectPluginArrStar).not.toHaveBeenCalled();
+    expect(mockEffectPluginOther).not.toHaveBeenCalled();
+
+    formModelV2.setValueIn('arr.0', 2);
+    formModelV2.setValueIn('other', 2);
+
+    expect(mockEffectOriginArrStar).toHaveBeenCalledOnce();
+    expect(mockEffectPluginArrStar).toHaveBeenCalledOnce();
+    expect(mockEffectPluginOther).toHaveBeenCalledOnce();
+  });
+});

+ 1 - 2
packages/node-engine/node/__tests__/glob.test.ts

@@ -5,8 +5,7 @@
 
 // test src/glob.ts
 import { describe, expect, it } from 'vitest';
-
-import { Glob } from '../src/glob';
+import { Glob } from '@flowgram.ai/form';
 
 describe('glob', () => {
   it('return original path array if no *', () => {

+ 2 - 2
packages/node-engine/node/package.json

@@ -25,8 +25,8 @@
     "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
     "build:watch": "npm run build:fast -- --dts-resolve",
     "clean": "rimraf dist",
-    "test": "exit",
-    "test:cov": "exit",
+    "test": "vitest run",
+    "test:cov": "vitest run --coverage",
     "ts-check": "tsc --noEmit",
     "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
   },

+ 59 - 37
packages/node-engine/node/src/form-model-v2.ts

@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { get, groupBy, isEmpty, mapKeys } from 'lodash';
+import { get, groupBy, isEmpty, isNil, mapKeys, uniq } from 'lodash';
 import { Disposable, DisposableCollection, Emitter } from '@flowgram.ai/utils';
 import {
   FlowNodeFormData,
@@ -261,46 +261,68 @@ export class FormModelV2 extends FormModel implements Disposable {
     }
 
     // Form 数据变更时触发对应的effect
-    nativeFormModel.onFormValuesChange(({ values, prevValues, name }) => {
-      // 找到所有路径匹配的副作用,包括父亲路径
-      const effectKeys = Object.keys(this.effectMap).filter((pattern) =>
-        Glob.isMatchOrParent(pattern, name)
-      );
+    nativeFormModel.onFormValuesChange(({ values, prevValues, name, options }) => {
+      Object.keys(this.effectMap).forEach((pattern) => {
+        // 找到匹配 pattern 的数据路径
+        const paths = uniq([
+          ...Glob.findMatchPaths(values, pattern),
+          ...Glob.findMatchPaths(prevValues, pattern),
+        ]).filter(
+          (path) =>
+            // trigger effect by compare if value changed
+            get(values, path) !== get(prevValues, path)
+        );
+
+        if (Glob.isMatchOrParent(pattern, name)) {
+          const currentName = Glob.getParentPathByPattern(pattern, name);
+          if (!paths.includes(currentName)) {
+            // trigger effect anyway
+            paths.push(currentName);
+          }
+        }
 
-      effectKeys.forEach((effectKey) => {
-        const effectOptionsArr = this.effectMap[effectKey];
-        // 对于冒泡的事件,需要获取 parent 的 name
-        const currentName = Glob.getParentPathByPattern(effectKey, name);
-
-        // run all effectReturns before effect
-        runAndDeleteEffectReturn(this.effectReturnMap, currentName, [
-          DataEvent.onValueChange,
-          DataEvent.onValueInitOrChange,
-        ]);
-
-        // 执行该事件配置下所有 onValueChange 事件的 effect
-        effectOptionsArr.forEach(({ effect, event }: EffectOptions) => {
-          if (event === DataEvent.onValueChange || event === DataEvent.onValueInitOrChange) {
-            // 执行 effect
-            const effectReturn = (effect as Effect)({
-              name: currentName,
-              value: get(values, currentName),
-              prevValue: get(prevValues, currentName),
-              formValues: values,
-              form: toForm(this.nativeFormModel!),
-              context: this.nodeContext,
-            });
+        const effectOptionsArr = this.effectMap[pattern];
 
-            // 更新 effect return
-            if (
-              effectReturn &&
-              typeof effectReturn === 'function' &&
-              this.effectReturnMap.has(event)
-            ) {
-              const eventMap = this.effectReturnMap.get(event) as Record<string, EffectReturn>;
-              eventMap[currentName] = mergeEffectReturn(eventMap[currentName], effectReturn);
+        paths.forEach((path) => {
+          let eventList = [DataEvent.onValueChange, DataEvent.onValueInitOrChange];
+          const isPrevNil = isNil(get(prevValues, path));
+
+          if (isPrevNil) {
+            // HACK: For array append, onFormValuesInit will auto triggered for array[index]
+            if (options?.action === 'array-append' && Glob.isMatch(`${name}.*`, path)) {
+              eventList = [];
+            } else {
+              eventList = [DataEvent.onValueInit, DataEvent.onValueInitOrChange];
             }
           }
+
+          // 对触发 init 事件的 name 或他的字 path 触发 effect
+          runAndDeleteEffectReturn(this.effectReturnMap, path, eventList);
+
+          // 执行该事件配置下所有 onValueChange 事件的 effect
+          effectOptionsArr.forEach(({ effect, event }: EffectOptions) => {
+            if (eventList.includes(event)) {
+              // 执行 effect
+              const effectReturn = (effect as Effect)({
+                name: path,
+                value: get(values, path),
+                prevValue: get(prevValues, path),
+                formValues: values,
+                form: toForm(this.nativeFormModel!),
+                context: this.nodeContext,
+              });
+
+              // 更新 effect return
+              if (
+                effectReturn &&
+                typeof effectReturn === 'function' &&
+                this.effectReturnMap.has(event)
+              ) {
+                const eventMap = this.effectReturnMap.get(event) as Record<string, EffectReturn>;
+                eventMap[path] = mergeEffectReturn(eventMap[path], effectReturn);
+              }
+            }
+          });
         });
       });
     });

+ 2 - 4
packages/node-engine/node/src/form-plugin.ts

@@ -7,6 +7,7 @@ import { nanoid } from 'nanoid';
 import { Disposable } from '@flowgram.ai/utils';
 import { type NodeFormContext } from '@flowgram.ai/form-core';
 
+import { mergeEffectMap } from './utils';
 import { type FormMeta, type FormPluginCtx, type FormPluginSetupMetaCtx } from './types';
 import { FormModelV2 } from './form-model-v2';
 
@@ -73,10 +74,7 @@ export class FormPlugin<Opts = any> implements Disposable {
     this.config.onSetupFormMeta?.(
       {
         mergeEffect: (effect) => {
-          nextFormMeta.effect = {
-            ...(nextFormMeta.effect || {}),
-            ...effect,
-          };
+          nextFormMeta.effect = mergeEffectMap(nextFormMeta.effect || {}, effect);
         },
         mergeValidate: (validate) => {
           nextFormMeta.validate = {

+ 5 - 5
packages/variable-engine/json-schema/src/json-schema/utils.ts

@@ -27,7 +27,7 @@ export namespace JsonSchemaUtils {
    * @returns An AST node representing the JSON schema, or undefined if the schema type is not recognized.
    */
   export function schemaToAST(jsonSchema: IJsonSchema): ASTNodeJSON | undefined {
-    const { type, extra } = jsonSchema || {};
+    const { type, extra, required } = jsonSchema || {};
     const { weak = false } = extra || {};
 
     if (!type) {
@@ -52,7 +52,7 @@ export namespace JsonSchemaUtils {
               meta: {
                 title: _property.title,
                 description: _property.description,
-                required: _property.required,
+                required: !!required?.includes(key),
                 default: _property.default,
               },
             })),
@@ -138,6 +138,9 @@ export namespace JsonSchemaUtils {
     if (ASTMatch.isObject(typeAST)) {
       return {
         type: 'object',
+        required: typeAST.properties
+          .filter((property) => property.meta?.required)
+          .map((property) => property.key),
         properties: drilldownObject
           ? Object.fromEntries(
               typeAST.properties.map((property) => {
@@ -152,9 +155,6 @@ export namespace JsonSchemaUtils {
                 if (property.meta?.default && schema) {
                   schema.default = property.meta.default;
                 }
-                if (property.meta?.required && schema) {
-                  schema.required = property.meta.required;
-                }
 
                 return [property.key, schema!];
               })