chore: import upstream snapshot with attribution
CI / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:51 +08:00
commit 0232b4e2bb
3528 changed files with 291404 additions and 0 deletions
@@ -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);
});
});
@@ -0,0 +1,84 @@
/**
* 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 { FormMeta } from '../src/types';
import { FormModelV2 } from '../src/form-model-v2';
describe('FormModelV2', () => {
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);
});
describe('v1 apis', () => {
it('getFormItemValueByPath', () => {
const formMeta = {
render: vi.fn(),
};
formModelV2.init(formMeta, {
a: 1,
b: 2,
});
expect(formModelV2.getFormItemValueByPath('/a')).toBe(1);
expect(formModelV2.getFormItemValueByPath('/b')).toBe(2);
expect(formModelV2.getFormItemValueByPath('/')).toEqual({ a: 1, b: 2 });
});
it('getFormItemByPath when path is /', () => {
const formMeta = {
render: vi.fn(),
};
formModelV2.init(formMeta, {
a: 1,
b: 2,
});
const formItem = formModelV2.getFormItemByPath('/');
expect(formItem?.value).toEqual({
a: 1,
b: 2,
});
formItem!.value = { a: 3, b: 4 };
expect(formItem?.value).toEqual({
a: 3,
b: 4,
});
});
});
describe('onFormValueChangeIn', () => {
beforeEach(() => {
formModelV2.dispose();
formModelV2 = new FormModelV2(node);
});
it('should trigger callback when value change', () => {
const mockCallback = vi.fn();
const formMeta = {
render: vi.fn(),
} as unknown as FormMeta;
formModelV2.init(formMeta, { a: 1 });
formModelV2.onFormValueChangeIn('a', mockCallback);
formModelV2.setValueIn('a', 2);
expect(mockCallback).toHaveBeenCalledOnce();
});
it('should throw error when formModel is not initialized', () => {
expect(() => formModelV2.onFormValueChangeIn('a', vi.fn())).toThrowError();
});
});
});
@@ -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();
});
});
@@ -0,0 +1,92 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
// test src/glob.ts
import { describe, expect, it } from 'vitest';
import { Glob } from '@flowgram.ai/form';
describe('glob', () => {
it('return original path array if no *', () => {
const obj = { a: { b: { c: 1 } } };
expect(Glob.findMatchPaths(obj, 'a.b.c')).toEqual(['a.b.c']);
});
it('object: when * is in middle of the path', () => {
const obj = {
a: { b: { c: 1 } },
x: { y: { z: 2 } },
};
expect(Glob.findMatchPaths(obj, 'a.*.c')).toEqual(['a.b.c']);
});
it('object:when * is at the end of the path', () => {
const obj = {
a: { b: { c: 1 } },
x: { y: { z: 2 } },
};
expect(Glob.findMatchPaths(obj, 'a.*')).toEqual(['a.b']);
});
it('object:when * is at the start of the path', () => {
const obj = {
a: { b: { c: 1 } },
x: { y: { z: 2 } },
};
expect(Glob.findMatchPaths(obj, '*.y')).toEqual(['x.y']);
});
it('array: when * is at the end of the path', () => {
const obj = {
other: 100,
arr: [
{
x: 1,
y: { a: 1, b: 2 },
},
{
x: 10,
y: {
a: 10,
b: 20,
},
},
],
};
expect(Glob.findMatchPaths(obj, 'arr.*')).toEqual(['arr.0', 'arr.1']);
});
it('array: when * is at the start of the path', () => {
const arr = [
{
x: 1,
y: { a: 1, b: 2 },
},
{
x: 10,
y: {
a: 10,
b: 20,
},
},
];
expect(Glob.findMatchPaths(arr, '*')).toEqual(['0', '1']);
});
it('array: when * is in the middle of the path', () => {
const obj = {
other: 100,
arr: [
{
x: 1,
y: { a: 1, b: 2 },
},
{
x: 10,
y: {
a: 10,
b: 20,
},
},
],
};
expect(Glob.findMatchPaths(obj, 'arr.*.y')).toEqual(['arr.0.y', 'arr.1.y']);
});
});
@@ -0,0 +1,11 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
});
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@flowgram.ai/node",
"version": "0.1.8",
"description": "automation form core",
"keywords": [
"flow",
"engine"
],
"homepage": "https://flowgram.ai/",
"repository": "https://github.com/bytedance/flowgram.ai",
"license": "MIT",
"exports": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/index.js"
},
"main": "./dist/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "npm run build:fast -- --dts-resolve",
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
"build:watch": "npm run build:fast -- --dts-resolve",
"clean": "rimraf dist",
"test": "vitest run",
"test:cov": "vitest run --coverage",
"ts-check": "tsc --noEmit",
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
},
"dependencies": {
"@flowgram.ai/core": "workspace:*",
"@flowgram.ai/document": "workspace:*",
"@flowgram.ai/form": "workspace:*",
"@flowgram.ai/form-core": "workspace:*",
"@flowgram.ai/utils": "workspace:*",
"inversify": "^6.0.1",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.9",
"reflect-metadata": "~0.2.2"
},
"devDependencies": {
"@flowgram.ai/eslint-config": "workspace:*",
"@flowgram.ai/form": "workspace:*",
"@flowgram.ai/ts-config": "workspace:*",
"@types/lodash-es": "^4.17.12",
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.0.0",
"tsup": "^8.0.1",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
@@ -0,0 +1,575 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { get, groupBy, isEmpty, isNil, mapKeys, uniq } from 'lodash-es';
import { Disposable, DisposableCollection, Emitter } from '@flowgram.ai/utils';
import {
FlowNodeFormData,
FormFeedback,
FormItem,
FormManager,
FormModel,
FormModelValid,
IFormItem,
NodeFormContext,
OnFormValuesChangePayload,
} from '@flowgram.ai/form-core';
import {
createForm,
FieldArrayModel,
FieldName,
FieldValue,
type FormControl,
FormModel as NativeFormModel,
FormValidateReturn,
Glob,
IField,
IFieldArray,
toForm,
} from '@flowgram.ai/form';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { PlaygroundContext, PluginContext } from '@flowgram.ai/core';
import {
convertGlobPath,
findMatchedInMap,
formFeedbacksToNodeCoreFormFeedbacks,
mergeEffectReturn,
runAndDeleteEffectReturn,
} from './utils';
import {
DataEvent,
Effect,
EffectOptions,
EffectReturn,
FormMeta,
onFormValueChangeInPayload,
} from './types';
import { renderForm } from './form-render';
import { FormPlugin } from './form-plugin';
const DEFAULT = {
// Different formModel should have different reference
EFFECT_MAP: () => ({}),
EFFECT_RETURN_MAP: () =>
new Map([
[DataEvent.onValueInitOrChange, {}],
[DataEvent.onValueChange, {}],
[DataEvent.onValueInit, {}],
[DataEvent.onArrayAppend, {}],
[DataEvent.onArrayDelete, {}],
]),
FORM_FEEDBACKS: () => [],
VALID: null,
};
export class FormModelV2 extends FormModel implements Disposable {
protected effectMap: Record<string, EffectOptions[]> = DEFAULT.EFFECT_MAP();
protected effectReturnMap: Map<DataEvent, Record<string, EffectReturn>> =
DEFAULT.EFFECT_RETURN_MAP();
protected plugins: FormPlugin[] = [];
protected node: FlowNodeEntity;
protected formFeedbacks: FormValidateReturn | undefined = DEFAULT.FORM_FEEDBACKS();
protected onInitializedEmitter = new Emitter<FormModel>();
protected onValidateEmitter = new Emitter<FormModel>();
readonly onValidate = this.onValidateEmitter.event;
readonly onInitialized = this.onInitializedEmitter.event;
protected onDisposeEmitter = new Emitter<void>();
readonly onDispose = this.onDisposeEmitter.event;
protected toDispose = new DisposableCollection();
protected onFormValuesChangeEmitter = new Emitter<OnFormValuesChangePayload>();
readonly onFormValuesChange = this.onFormValuesChangeEmitter.event;
protected onValidChangeEmitter = new Emitter<FormModelValid>();
readonly onValidChange = this.onValidChangeEmitter.event;
protected onFeedbacksChangeEmitter = new Emitter<FormFeedback[]>();
readonly onFeedbacksChange = this.onFeedbacksChangeEmitter.event;
constructor(node: FlowNodeEntity) {
super();
this.node = node;
this.toDispose.pushAll([
this.onInitializedEmitter,
this.onValidateEmitter,
this.onValidChangeEmitter,
this.onFeedbacksChangeEmitter,
this.onFormValuesChangeEmitter,
]);
}
protected _valid: FormModelValid = DEFAULT.VALID;
get valid(): FormModelValid {
return this._valid;
}
private set valid(valid: FormModelValid) {
this._valid = valid;
this.onValidChangeEmitter.fire(valid);
}
get flowNodeEntity() {
return this.node;
}
get formManager() {
return this.node.getService(FormManager);
}
protected _formControl?: FormControl<any>;
get formControl() {
return this._formControl;
}
protected _formMeta: FormMeta;
get formMeta(): FormMeta {
return this._formMeta || (this.node.getNodeRegistry().formMeta as FormMeta);
}
get values() {
return this.nativeFormModel?.values;
}
protected _feedbacks: FormFeedback[] = [];
get feedbacks(): FormFeedback[] {
return this._feedbacks;
}
updateFormValues(value: any) {
if (this.nativeFormModel) {
const finalValue = this.formMeta.formatOnInit
? this.formMeta.formatOnInit(value, this.nodeContext)
: value;
this.nativeFormModel.values = finalValue;
}
}
private set feedbacks(feedbacks: FormFeedback[]) {
this._feedbacks = feedbacks;
this.onFeedbacksChangeEmitter.fire(feedbacks);
}
get formItemPathMap(): Map<string, IFormItem> {
return new Map<string, IFormItem>();
}
protected _initialized: boolean = false;
get initialized(): boolean {
return this._initialized;
}
get nodeContext(): NodeFormContext {
return {
node: this.node,
playgroundContext: this.node.getService(PlaygroundContext),
clientContext: this.node.getService(PluginContext),
};
}
get nativeFormModel(): NativeFormModel | undefined {
return this._formControl?._formModel;
}
render() {
return renderForm(this);
}
initPlugins(plugins: FormPlugin[]) {
if (!plugins.length) {
return;
}
this.plugins = plugins;
plugins.forEach((plugin) => {
plugin.init(this);
});
}
init(formMeta: FormMeta, rawInitialValues?: any) {
/* 透传 onFormValuesChange 事件给 FlowNodeFormData */
const formData = this.node.getData<FlowNodeFormData>(FlowNodeFormData);
this.onFormValuesChange(() => {
this._valid = null;
formData.fireChange();
});
(formMeta.plugins || [])?.forEach((_plugin) => {
if (_plugin.setupFormMeta) {
formMeta = _plugin.setupFormMeta(formMeta, this.nodeContext);
}
});
this._formMeta = formMeta;
const { validateTrigger, validate, effect } = formMeta;
if (effect) {
this.effectMap = effect;
}
// 计算初始值: defaultValues 是默认表单值,不需要被format, 而rawInitialValues 是用户创建form 时传入的初始值,可能不同于表单数据格式,需要被format
const defaultValues =
typeof formMeta.defaultValues === 'function'
? formMeta.defaultValues(this.nodeContext)
: formMeta.defaultValues;
const initialValues = formMeta.formatOnInit
? formMeta.formatOnInit(rawInitialValues, this.nodeContext)
: rawInitialValues;
// 初始化底层表单
const { control } = createForm({
initialValues: initialValues || defaultValues,
validateTrigger,
context: this.nodeContext,
validate: validate,
disableAutoInit: true,
});
this._formControl = control;
const nativeFormModel = control._formModel;
this.toDispose.push(nativeFormModel);
// forward onFormValuesChange event
nativeFormModel.onFormValuesChange((props) => {
this.onFormValuesChangeEmitter.fire(props);
});
if (formMeta.plugins) {
this.initPlugins(formMeta.plugins);
}
// Form 数据变更时触发对应的effect
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);
}
}
const effectOptionsArr = this.effectMap[pattern];
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);
}
}
});
});
});
});
// Form 数据初始化时触发对应的 effect
nativeFormModel.onFormValuesInit(({ values, name, prevValues }) => {
Object.keys(this.effectMap).forEach((pattern) => {
// 找到匹配 pattern 的数据路径
const paths = Glob.findMatchPaths(values, pattern);
// 获取配置在该 pattern上的所有effect配置
const effectOptionsArr = this.effectMap[pattern];
paths.forEach((path) => {
if (Glob.isMatchOrParent(name, path) || name === path) {
// 对触发 init 事件的 name 或他的字 path 触发 effect
runAndDeleteEffectReturn(this.effectReturnMap, path, [
DataEvent.onValueInit,
DataEvent.onValueInitOrChange,
]);
effectOptionsArr.forEach(({ event, effect }: EffectOptions) => {
if (event === DataEvent.onValueInit || event === DataEvent.onValueInitOrChange) {
const effectReturn = (effect as Effect)({
name: path,
value: get(values, path),
formValues: values,
prevValue: get(prevValues, path),
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);
}
}
});
}
});
});
});
// 为 Field 添加 effect, 主要针对array
nativeFormModel.onFieldModelCreate((field) => {
// register effect
const effectOptionsArr = findMatchedInMap<EffectOptions[]>(field, this.effectMap);
if (effectOptionsArr?.length) {
// 按事件聚合
const eventMap = groupBy(effectOptionsArr, 'event');
mapKeys(eventMap, (optionsArr, event) => {
const combinedEffect = (props: any) => {
// 该事件下执行所有effect
optionsArr.forEach(({ effect }) =>
effect({
...props,
formValues: nativeFormModel.values,
form: toForm(this.nativeFormModel!),
context: this.nodeContext,
})
);
};
switch (event) {
case DataEvent.onArrayAppend:
if (field instanceof FieldArrayModel) {
(field as FieldArrayModel).onAppend(combinedEffect);
}
break;
case DataEvent.onArrayDelete:
if (field instanceof FieldArrayModel) {
(field as FieldArrayModel).onDelete(combinedEffect);
}
break;
}
});
}
});
// 手动初始化form
this._formControl.init();
this._initialized = true;
this.onInitializedEmitter.fire(this);
this.onDispose(() => {
this._initialized = false;
this.effectMap = {};
nativeFormModel.dispose();
});
}
toJSON() {
if (this.formMeta.formatOnSubmit) {
return this.formMeta.formatOnSubmit(this.nativeFormModel?.values, this.nodeContext);
}
return this.nativeFormModel?.values;
}
clearValid() {}
async validate() {
this.formFeedbacks = await this.nativeFormModel?.validate();
this.valid = isEmpty(this.formFeedbacks?.filter((f) => f.level === 'error'));
this.onValidateEmitter.fire(this);
return this.valid;
}
getValues<T = any>(): T | undefined {
return this._formControl?._formModel.values;
}
getField<
TValue = FieldValue,
TField extends IFieldArray<TValue> | IField<TValue> = IField<TValue>
>(name: FieldName): TField | undefined {
let finalName = name.includes('/') ? convertGlobPath(name) : name;
return this.formControl?.getField<TValue, TField>(finalName) as TField;
}
getValueIn<TValue>(name: FieldName): TValue | undefined {
let finalName = name.includes('/') ? convertGlobPath(name) : name;
return this.nativeFormModel?.getValueIn(finalName);
}
setValueIn(name: FieldName, value: any) {
let finalName = name.includes('/') ? convertGlobPath(name) : name;
this.nativeFormModel?.setValueIn(finalName, value);
}
/**
* 监听表单某个路径下的值变化
* @param name 路径
* @param callback 回调函数
*/
onFormValueChangeIn<TValue = FieldValue, TFormValue = FieldValue>(
name: FieldName,
callback: (payload: onFormValueChangeInPayload<TValue, TFormValue>) => void
): Disposable {
if (!this._initialized) {
throw new Error(
`[NodeEngine] FormModel Error: onFormValueChangeIn can not be called before initialized`
);
}
return this.formControl!._formModel.onFormValuesChange(
({ name: changedName, values, prevValues }) => {
if (changedName === name) {
callback({
value: get(values, name),
prevValue: get(prevValues, name),
formValues: values,
prevFormValues: prevValues,
});
}
}
);
}
/**
* @deprecated 该方法用于兼容 V1 版本 FormModel接口,如果确定是FormModelV2 请使用 FormModel.getValueIn
* @param path glob path
*/
getFormItemValueByPath(globPath: string) {
if (!globPath) {
return;
}
if (globPath === '/') {
return this._formControl?._formModel.values;
}
const name = convertGlobPath(globPath);
return this.getValueIn(name!);
}
async validateWithFeedbacks(): Promise<FormFeedback[]> {
await this.validate();
return formFeedbacksToNodeCoreFormFeedbacks(this.formFeedbacks!);
}
/**
* @deprecated 该方法用于兼容 V1 版本 FormModel接口,如果确定是FormModelV2, 请使用FormModel.getValueIn 和 FormModel.setValueIn
* @param path glob path
*/
getFormItemByPath(path: string): FormItem | undefined {
if (!this.nativeFormModel) {
return;
}
const that = this;
if (path === '/') {
return {
get value() {
return that.nativeFormModel!.values;
},
set value(v) {
that.nativeFormModel!.values = v;
},
} as FormItem;
}
const name = convertGlobPath(path);
const formItemValue = that.getValueIn(name!);
return {
get value() {
return formItemValue;
},
set value(v) {
that.setValueIn(name, v);
},
} as FormItem;
}
dispose(): void {
this.onDisposeEmitter.fire();
// 执行所有effect return
this.effectReturnMap.forEach((eventMap) => {
Object.values(eventMap).forEach((effectReturn) => {
effectReturn();
});
});
this.effectMap = DEFAULT.EFFECT_MAP();
this.effectReturnMap = DEFAULT.EFFECT_RETURN_MAP();
this.plugins.forEach((p) => {
p.dispose();
});
this.plugins = [];
this.formFeedbacks = DEFAULT.FORM_FEEDBACKS();
this._valid = DEFAULT.VALID;
this._formControl = undefined;
this._initialized = false;
this.toDispose.dispose();
}
}
@@ -0,0 +1,129 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
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';
export interface FormPluginConfig<Opts = any> {
/**
* form plugin name, for debug use
*/
name?: string;
/**
* setup formMeta
* @param ctx
* @returns
*/
onSetupFormMeta?: (ctx: FormPluginSetupMetaCtx, opts: Opts) => void;
/**
* FormModel 初始化时执行
* @param ctx
*/
onInit?: (ctx: FormPluginCtx, opts: Opts) => void;
/**
* FormModel 销毁时执行
*/
onDispose?: (ctx: FormPluginCtx, opts: Opts) => void;
}
export class FormPlugin<Opts = any> implements Disposable {
readonly name: string;
readonly pluginId: string;
readonly config: FormPluginConfig;
readonly opts?: Opts;
protected _formModel: FormModelV2;
constructor(config: FormPluginConfig, opts?: Opts) {
this.name = config?.name || '';
this.pluginId = `${this.name}__${nanoid()}`;
this.config = config;
this.opts = opts;
}
get formModel(): FormModelV2 {
return this._formModel;
}
get ctx(): { formModel: FormModelV2 } & NodeFormContext {
return {
formModel: this.formModel,
...this.formModel.nodeContext,
};
}
setupFormMeta(formMeta: FormMeta, nodeContext: NodeFormContext): FormMeta {
const nextFormMeta: FormMeta = {
...formMeta,
};
this.config.onSetupFormMeta?.(
{
mergeEffect: (effect) => {
nextFormMeta.effect = mergeEffectMap(nextFormMeta.effect || {}, effect);
},
mergeValidate: (validate) => {
nextFormMeta.validate = {
...(nextFormMeta.validate || {}),
...validate,
};
},
addFormatOnInit: (formatOnInit) => {
if (!nextFormMeta.formatOnInit) {
nextFormMeta.formatOnInit = formatOnInit;
return;
}
const legacyFormatOnInit = nextFormMeta.formatOnInit;
nextFormMeta.formatOnInit = (v, c) => formatOnInit?.(legacyFormatOnInit(v, c), c);
},
addFormatOnSubmit: (formatOnSubmit) => {
if (!nextFormMeta.formatOnSubmit) {
nextFormMeta.formatOnSubmit = formatOnSubmit;
return;
}
const legacyFormatOnSubmit = nextFormMeta.formatOnSubmit;
nextFormMeta.formatOnSubmit = (v, c) => formatOnSubmit?.(legacyFormatOnSubmit(v, c), c);
},
...nodeContext,
},
this.opts
);
return nextFormMeta;
}
init(formModel: FormModelV2) {
this._formModel = formModel;
this.config?.onInit?.(this.ctx, this.opts);
}
dispose() {
if (this.config?.onDispose) {
this.config?.onDispose(this.ctx, this.opts);
}
}
}
export type FormPluginCreator<Opts> = (opts: Opts) => FormPlugin<Opts>;
export function defineFormPluginCreator<Opts>(
config: FormPluginConfig<Opts>
): FormPluginCreator<Opts> {
return function (opts: Opts) {
return new FormPlugin(config, opts);
};
}
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { Form } from '@flowgram.ai/form';
import { FormModelV2 } from './form-model-v2';
interface FormRenderProps {
formModel: FormModelV2;
}
const FormRender = ({ formModel }: FormRenderProps) =>
formModel?.formControl ? (
<>
<Form control={formModel?.formControl} keepModelOnUnMount>
{formModel.formMeta.render}
</Form>
</>
) : null;
export function renderForm(formModel: FormModelV2) {
return <FormRender formModel={formModel} />;
}
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { Disposable, Event } from '@flowgram.ai/utils';
import { FlowNodeFormData, NodeRender, OnFormValuesChangePayload } from '@flowgram.ai/form-core';
import { FieldName, FieldValue, FormState } from '@flowgram.ai/form';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { onFormValueChangeInPayload } from './types';
import { FormModelV2 } from './form-model-v2';
export interface NodeFormProps<TValues> {
/**
* The initialValues of the form.
*/
initialValues: TValues;
/**
* Form values. Returns a deep copy of the data in the store.
*/
values: TValues;
/**
* Form state
*/
state: FormState;
/**
* Get value in certain path
* @param name path
*/
getValueIn<TValue = FieldValue>(name: FieldName): TValue;
/**
* Set value in certain path.
* It will trigger the re-rendering of the Field Component if a Field is related to this path
* @param name path
*/
setValueIn<TValue>(name: FieldName, value: TValue): void;
/**
* set form values
*/
updateFormValues(values: any): void;
/**
* Render form
*/
render: () => React.ReactNode;
/**
* Form value change event
*/
onFormValuesChange: Event<OnFormValuesChangePayload>;
/**
* Trigger form validate
*/
validate: () => Promise<boolean>;
/**
* Form validate event
*/
onValidate: Event<FormState>;
/**
* Form field value change event
*/
onFormValueChangeIn<TValue = FieldValue, TFormValue = FieldValue>(
name: FieldName,
callback: (payload: onFormValueChangeInPayload<TValue, TFormValue>) => void
): Disposable;
}
/**
* Use `node.form` instead
* @deprecated
* @param node
*/
export function getNodeForm<TValues = FieldValue>(
node: FlowNodeEntity
): NodeFormProps<TValues> | undefined {
const formModel = node.getData<FlowNodeFormData>(FlowNodeFormData)?.getFormModel<FormModelV2>();
const nativeFormModel = formModel?.nativeFormModel;
if (!formModel || !nativeFormModel) return undefined;
const result: NodeFormProps<TValues> = {
initialValues: nativeFormModel.initialValues,
get values() {
return nativeFormModel.values;
},
state: nativeFormModel.state,
getValueIn: (name: FieldName) => nativeFormModel.getValueIn(name),
setValueIn: (name: FieldName, value: any) => nativeFormModel.setValueIn(name, value),
updateFormValues: (values: any) => {
formModel.updateFormValues(values);
},
render: () => <NodeRender node={node} />,
onFormValuesChange: formModel.onFormValuesChange.bind(formModel),
onFormValueChangeIn: formModel.onFormValueChangeIn.bind(formModel),
onValidate: formModel.nativeFormModel.onValidate,
validate: formModel.validate.bind(formModel),
};
Object.defineProperty(result, '_formModel', {
enumerable: false,
get() {
return formModel;
},
});
return result;
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeFormData } from '@flowgram.ai/form-core';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { DataEvent } from './types';
import { FormModelV2 } from './form-model-v2';
export function getFormModel(node: FlowNodeEntity) {
// @ts-ignore
return node.getData<FlowNodeFormData>(FlowNodeFormData)?.formModel as FormModelV2;
}
export function isFormV2(node: FlowNodeEntity) {
return !!node.getNodeRegistry().formMeta?.render;
}
export function createEffectOptions<T>(
event: DataEvent,
effect: T
): { effect: T; event: DataEvent } {
return {
event,
effect,
};
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { useRefresh } from '@flowgram.ai/utils';
import { FlowNodeFormData } from '@flowgram.ai/form-core';
import { Errors, Warnings } from '@flowgram.ai/form';
import { FormState, useFormErrors, useFormState, useFormWarnings } from '@flowgram.ai/form';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { FormModelV2 } from './form-model-v2';
/**
* Listen to Form's values and refresh the React component.
* By providing related node, you can use this hook outside the Form Component.
* @param node
*/
export function useWatchFormValues<T = any>(node: FlowNodeEntity): T | undefined {
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const refresh = useRefresh();
useEffect(() => {
const disposable = formModel.nativeFormModel?.onFormValuesChange(() => {
refresh();
});
return () => disposable?.dispose();
}, [formModel.nativeFormModel]);
return formModel.getValues<T>();
}
/**
* Listen to Form's value in a certain path and refresh the React component.
* By providing related node, you can use this hook outside the Form Component.
* @param node
*/
export function useWatchFormValueIn<T = any>(node: FlowNodeEntity, name: string): T | undefined {
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const refresh = useRefresh();
useEffect(() => {
const disposable = formModel.nativeFormModel?.onFormValuesChange(({ name: changedName }) => {
if (name === changedName) {
refresh();
}
});
return () => disposable?.dispose();
}, []);
return formModel.getValueIn<T>(name);
}
/**
* Listen to FormModel's initialization and refresh React component.
* By providing related node, you can use this hook outside the Form Component.
* @param node
*/
export function useInitializedFormModel(node: FlowNodeEntity) {
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const refresh = useRefresh();
useEffect(() => {
const disposable = formModel.onInitialized(() => {
refresh();
});
return () => disposable.dispose();
}, [formModel]);
return formModel;
}
/**
* Get Form's state, Form State is a proxy, it will refresh the React component when the value you accessed changed
* By providing related node, you can use this hook outside the Form Component.
* @param node
*/
export function useWatchFormState(node: FlowNodeEntity): FormState | undefined {
const formModel = useInitializedFormModel(node);
return useFormState(formModel.formControl);
}
/**
* Get Form's errors, Form errors is a proxy, it will refresh the React component when the value you accessed changed
* By providing related node, you can use this hook outside the Form Component.
* @param node
*/
export function useWatchFormErrors(node: FlowNodeEntity): Errors | undefined {
const formModel = useInitializedFormModel(node);
return useFormErrors(formModel.formControl);
}
/**
* Get Form's warnings, Form warnings is a proxy, it will refresh the React component when the value you accessed changed
* By providing related node, you can use this hook outside the Form Component.
* @param node
*/
export function useWatchFormWarnings(node: FlowNodeEntity): Warnings | undefined {
const formModel = useInitializedFormModel(node);
return useFormWarnings(formModel.formControl);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './types';
export * from './form-model-v2';
export { isFormV2, createEffectOptions } from './helpers';
export * from './hooks';
export * from './form-plugin';
export { type NodeFormProps, getNodeForm } from './get-node-form';
+153
View File
@@ -0,0 +1,153 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import * as React from 'react';
import { FormModel, IFormMeta, NodeContext } from '@flowgram.ai/form-core';
import { FieldName, FieldValue } from '@flowgram.ai/form';
import {
FormRenderProps,
IForm,
Validate as FormValidate,
ValidateTrigger,
} from '@flowgram.ai/form';
import { FormPlugin } from './form-plugin';
import { FormModelV2 } from './form-model-v2';
export interface Node {}
export interface Flow {}
export type Validate<TFieldValue = any, TFormValues = any> = (props: {
value: TFieldValue;
formValues: TFormValues;
context: NodeContext;
name: FieldName;
}) => ReturnType<FormValidate<TFieldValue, TFormValues>>;
export enum DataEvent {
/* When value change */
onValueChange = 'onValueChange',
/**
* When value Initit triggers when
* - defaultValue is configured in formMeta, it will trigger when form is initializing.
* - defaultValue is configured in Field, it will trigger when this Field is initializing if no initial value is set to this field.
*/
onValueInit = 'onValueInit',
/**
* When Value Init or change
*/
onValueInitOrChange = 'onValueInitOrChange',
/* It will trigger when ArrayField.append is called. It relies on ArrayField's rendering. If ArrayField is possibly not rendered in your case, please avoid using this event */
onArrayAppend = 'onArrayAppend',
/* It will trigger when ArrayField.delete is called. It relies on ArrayField's rendering. If ArrayField is possibly not rendered in your case, please avoid using this event */
onArrayDelete = 'onArrayDelete',
}
export type EffectReturn = () => void;
export interface EffectFuncProps<TFieldValue = any, TFormValues = any> {
name: FieldName;
value: TFieldValue;
prevValue?: TFieldValue;
formValues: TFormValues;
form: IForm;
context: NodeContext;
}
export type Effect<TFieldValue = any, TFormValues = any> = (
props: EffectFuncProps<TFieldValue, TFormValues>
) => void | EffectReturn;
export type ArrayAppendEffect<TFieldValue = any, TFormValues = any> = (props: {
index: number;
value: TFieldValue;
arrayValues: Array<TFieldValue>;
formValues: TFormValues;
form: IForm;
context: NodeContext;
}) => void | EffectReturn;
export type ArrayDeleteEffect<TFieldValue = any, TFormValues = any> = (props: {
index: number;
arrayValue: Array<TFieldValue>;
formValues: TFormValues;
form: IForm;
context: NodeContext;
}) => void | EffectReturn;
export type EffectOptions =
| { effect: Effect; event: DataEvent }
| { effect: ArrayAppendEffect; event: DataEvent }
| { effect: ArrayDeleteEffect; event: DataEvent };
export interface FormMeta<TValues = any> {
/**
* The render method of the node form content. <Form /> is already integrated, so you don't need to wrap your components with <Form />
* @param props
*/
render: (props: FormRenderProps<any>) => React.ReactElement;
/**
* When to trigger the validation.
*/
validateTrigger?: ValidateTrigger;
/**
* Form data's validation rules. It's a key value map, where the key is a pattern of data's path (or field name), the value is a validate function.
*/
validate?:
| Record<FieldName, Validate>
| ((values: TValues, ctx: NodeContext) => Record<FieldName, Validate>);
/**
* Form data's effects. It's a key value map, where the key is a pattern of data's path (or field name), the value is an array of effect configuration.
*/
effect?: Record<FieldName, EffectOptions[]>;
/**
* Form data's complete default value. it will not be sent to formatOnInit, but used directly as form's value when needed.
*/
defaultValues?: TValues | ((context: NodeContext) => TValues);
/**
* This function is to format the value when initiate the form, the returned value will be used as the initial value of the form.
* @param value value input to node as initialValue.
* @param context
*/
formatOnInit?: (value: any, context: NodeContext) => any;
/**
* This function is to format the value when FormModel.toJSON is called, the returned value will be used as the final value to be saved .
* @param value value sent by form before format.
* @param context
*/
formatOnSubmit?: (value: any, context: NodeContext) => any;
/**
* Form's plugins
*/
plugins?: FormPlugin[];
}
export function isFormModelV2(fm: FormModel | FormModelV2): fm is FormModelV2 {
return 'onFormValuesChange' in fm;
}
export function isFormMetaV2(formMeta: IFormMeta | FormMeta) {
return 'render' in formMeta;
}
export type FormPluginCtx = {
formModel: FormModelV2;
} & NodeContext;
export type FormPluginSetupMetaCtx = {
mergeEffect: (effect: Record<string, EffectOptions[]>) => void;
mergeValidate: (validate: Record<FieldName, Validate>) => void;
addFormatOnInit: (formatOnInit: FormMeta['formatOnInit']) => void;
addFormatOnSubmit: (formatOnSubmit: FormMeta['formatOnSubmit']) => void;
} & NodeContext;
export interface onFormValueChangeInPayload<TValue = FieldValue, TFormValues = FieldValue> {
value: TValue;
prevValue: TValue;
formValues: TFormValues;
prevFormValues: TFormValues;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { find, mergeWith } from 'lodash-es';
import { FormFeedback, FormPathService } from '@flowgram.ai/form-core';
import type { FieldError, FieldWarning, FormValidateReturn } from '@flowgram.ai/form';
import { type FieldModel, FieldName } from '@flowgram.ai/form';
import { DataEvent, EffectOptions, EffectReturn } from './types';
export function findMatchedInMap<T = any>(
field: FieldModel<any>,
validateMap: Record<FieldName, T> | undefined
): T | undefined {
if (!validateMap) {
return;
}
if (validateMap[field.name]) {
return validateMap[field.name];
}
const found = find(Object.keys(validateMap), (key) => {
if (key.startsWith('regex:')) {
const regex = RegExp(key.split(':')[1]);
return regex.test(field.name);
}
return false;
});
if (found) {
return validateMap[found];
}
}
export function formFeedbacksToNodeCoreFormFeedbacks(
formFeedbacks: FormValidateReturn
): FormFeedback[] {
return formFeedbacks.map(
(f: FieldError | FieldWarning) =>
({
feedbackStatus: f.level,
feedbackText: f.message,
path: f.name,
} as FormFeedback)
);
}
export function convertGlobPath(path: string) {
if (path.startsWith('/')) {
const parts = FormPathService.normalize(path).slice(1).split('/');
return parts.join('.');
}
return path;
}
export function mergeEffectMap(
origin: Record<string, EffectOptions[]>,
source: Record<string, EffectOptions[]>
) {
return mergeWith(origin, source, function (objValue: EffectOptions[], srcValue: EffectOptions[]) {
return (objValue || []).concat(srcValue);
});
}
export function mergeEffectReturn(origin?: EffectReturn, source?: EffectReturn): EffectReturn {
return () => {
origin?.();
source?.();
};
}
export function runAndDeleteEffectReturn(
effectReturnMap: Map<DataEvent, Record<string, EffectReturn>>,
name: string,
events: DataEvent[]
) {
events.forEach((event) => {
const eventMap = effectReturnMap.get(event);
if (eventMap?.[name]) {
eventMap[name]();
delete eventMap[name];
}
});
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
"compilerOptions": {
"rootDir": "./",
"baseUrl": "./",
},
"include": [
"./src"
],
"exclude": [
"node_modules",
"./__mocks__",
"./__tests__"
]
}
@@ -0,0 +1,35 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const path = require('path');
import {defineConfig} from 'vitest/config';
export default defineConfig({
build: {
commonjsOptions: {
transformMixedEsModules: true,
},
},
test: {
globals: true,
mockReset: false,
environment: 'jsdom',
setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/lib/**', // lib 编译结果忽略掉
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
},
},
});
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import 'reflect-metadata';