This commit is contained in:
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 Init,it 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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user