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,35 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, it } from 'vitest';
import { FormPathService } from '../src/form/services/form-path-service';
describe('FormPathService', () => {
it('should parse array item path correctly', () => {
const path = 'a/b/2/description';
const result = FormPathService.parseArrayItemPath(path);
expect(result).toEqual({
itemIndex: 2,
arrayPath: 'a/b/[]',
itemMetaPath: 'a/b/[]/description',
});
});
it('should handle non-numeric item index correctly', () => {
const path = 'a/b';
const result = FormPathService.parseArrayItemPath(path);
expect(result).toBeNull();
});
it('should handle empty path correctly', () => {
const path = '';
const result = FormPathService.parseArrayItemPath(path);
expect(result).toBeNull();
});
});
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { beforeEach, describe, expect, it } from 'vitest';
describe('form test', () => {
it('form test', () => {
// eslint-disable-next-line no-console
console.log('form test');
});
});
@@ -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,61 @@
{
"name": "@flowgram.ai/form-core",
"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/utils": "workspace:*",
"inversify": "^6.0.1",
"lodash-es": "^4.17.21",
"reflect-metadata": "~0.2.2"
},
"devDependencies": {
"@flowgram.ai/eslint-config": "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,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { NodeContainerModule } from '../node/node-container-module';
import { FormCoreContainerModule } from '../form';
import { ErrorContainerModule } from '../error';
export function createNodeContainerModules() {
return [NodeContainerModule, FormCoreContainerModule, ErrorContainerModule];
}
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { EntityDataRegistry } from '@flowgram.ai/core';
import { FlowNodeFormData } from '../form';
import { FlowNodeErrorData } from '../error';
export function createNodeEntityDatas(): EntityDataRegistry[] {
return [FlowNodeFormData, FlowNodeErrorData];
}
@@ -0,0 +1,11 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './create-node-container-modules';
export * from './node-render';
export * from './node-material-client';
export * from './create-node-entity-datas';
export * from '../form/client';
export * from '../error/client';
@@ -0,0 +1,18 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { MATERIAL_KEY } from '../node/core-materials';
import { NodeManager, NodePlaceholderRender, Render } from '../node';
export function registerNodeErrorRender(nodeManager: NodeManager, render: Render): void {
nodeManager.registerMaterialRender(MATERIAL_KEY.NODE_ERROR_RENDER, render);
}
export function registerNodePlaceholderRender(
nodeManager: NodeManager,
render: NodePlaceholderRender,
): void {
nodeManager.registerMaterialRender(MATERIAL_KEY.NODE_PLACEHOLDER_RENDER, render);
}
@@ -0,0 +1,75 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { memo, useCallback, useEffect } from 'react';
import { PlaygroundContext, useRefresh, useService, PluginContext } from '@flowgram.ai/core';
import { NodeEngineReactContext } from '../node-react/context/node-engine-react-context';
import { useNodeEngineContext } from '../node-react';
import { NodeManager } from '../node/node-manager';
import { PLUGIN_KEY } from '../node/core-plugins';
import { MATERIAL_KEY, type NodeRenderProps } from '../node';
import { getFormModel, isNodeFormReady } from '../form';
import { FlowNodeErrorData } from '../error/flow-node-error-data';
import { getNodeError } from '../error';
const PureNodeRender = ({ node }: NodeRenderProps) => {
const refresh = useRefresh();
const nodeErrorData = node.getData<FlowNodeErrorData>(FlowNodeErrorData);
const formModel = getFormModel(node);
const isNodeError = !!getNodeError(node);
const isFormReady = isNodeFormReady(node);
const playgroundContext = useService<PlaygroundContext>(PlaygroundContext);
const clientContext = useService<PluginContext>(PluginContext);
const nodeManager = useService<NodeManager>(NodeManager);
const nodeFormRender = nodeManager.getPluginRender(PLUGIN_KEY.FORM);
const nodeErrorRender = nodeManager.getPluginRender(PLUGIN_KEY.ERROR);
const nodePlaceholderRender = nodeManager.getMaterialRender(MATERIAL_KEY.NODE_PLACEHOLDER_RENDER);
const nodeEngineContext = useNodeEngineContext();
useEffect(() => {
const errorDisposable = nodeErrorData.onDataChange(() => {
refresh();
});
const formDisposable = formModel.onInitialized(() => {
refresh();
});
return () => {
errorDisposable.dispose();
formDisposable.dispose();
};
}, []);
const renderContent = useCallback(() => {
if (isNodeError) {
return nodeErrorRender!({ node, playgroundContext, clientContext });
}
if (!formModel.formMeta) {
return null;
}
if (isFormReady) {
return nodeFormRender!({ node, playgroundContext, clientContext });
}
return nodePlaceholderRender?.({ node, playgroundContext }) || null;
}, [
isNodeError,
isFormReady,
nodeErrorRender,
nodeFormRender,
nodePlaceholderRender,
node,
playgroundContext,
]);
return (
<NodeEngineReactContext.Provider value={nodeEngineContext.json}>
{nodeManager.nodeRenderHoc(renderContent)()}
</NodeEngineReactContext.Provider>
);
};
export const NodeRender = memo(PureNodeRender);
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeEntity } from '@flowgram.ai/document';
import { FlowNodeErrorData } from './flow-node-error-data';
export function getNodeError(node: FlowNodeEntity) {
return node.getData<FlowNodeErrorData>(FlowNodeErrorData).getError();
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ContainerModule } from 'inversify';
import { bindContributions } from '@flowgram.ai/utils';
import { NodeContribution } from '../node';
import { ErrorNodeContribution } from './error-node-contribution';
export const ErrorContainerModule = new ContainerModule(bind => {
bindContributions(bind, ErrorNodeContribution, [NodeContribution]);
});
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { injectable } from 'inversify';
import { NodeContribution } from '../node';
import { NodeManager, PLUGIN_KEY } from '../node';
import { errorPluginRender } from './renders';
@injectable()
export class ErrorNodeContribution implements NodeContribution {
onRegister(nodeManager: NodeManager) {
nodeManager.registerPluginRender(PLUGIN_KEY.ERROR, errorPluginRender);
}
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { EntityData } from '@flowgram.ai/core';
export interface ErrorData {
error: Error | null;
}
export class FlowNodeErrorData extends EntityData {
static type = 'FlowNodeErrorData';
getDefaultData(): ErrorData {
return { error: null };
}
setError(e: ErrorData['error']) {
this.update({ error: e });
}
getError(): Error {
return this.data.error;
}
}
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './flow-node-error-data';
export * from './types';
export * from './error-container-module';
export * from './client';
@@ -0,0 +1,16 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { NodeErrorRenderProps } from '../types';
const ERROR_STYLE = {
color: '#f54a45',
};
export const defaultErrorRender = ({ error }: NodeErrorRenderProps) => (
<div style={ERROR_STYLE}>{error.message}</div>
);
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { useCallback, useEffect } from 'react';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { PlaygroundContext, useRefresh, useService, PluginContext } from '@flowgram.ai/core';
import { FlowNodeErrorData } from '../flow-node-error-data';
import { MATERIAL_KEY, NodeManager, NodePluginRender } from '../../node';
import { defaultErrorRender } from './default-error-render';
interface NodeRenderProps {
node: FlowNodeEntity;
playgroundContext: PlaygroundContext;
clientContext: PluginContext;
}
export const ErrorRender = ({ node, playgroundContext, clientContext }: NodeRenderProps) => {
const refresh = useRefresh();
const nodeErrorData = node.getData<FlowNodeErrorData>(FlowNodeErrorData);
const nodeError = nodeErrorData.getError();
const nodeManager = useService<NodeManager>(NodeManager);
const nodeErrorRender = nodeManager.getMaterialRender(MATERIAL_KEY.NODE_ERROR_RENDER);
const renderError = useCallback(() => {
if (!nodeErrorRender) {
return defaultErrorRender({
error: nodeError,
context: { node, playgroundContext, clientContext },
});
}
return nodeErrorRender({
error: nodeError,
context: { node, playgroundContext, clientContext },
});
}, [nodeError, node, playgroundContext, clientContext]);
useEffect(() => {
const disposable = nodeErrorData.onDataChange(() => {
refresh();
});
return () => {
disposable.dispose();
};
}, []);
return nodeError ? renderError() : null;
};
export const errorPluginRender: NodePluginRender = (props) => <ErrorRender {...props} />;
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './error-render';
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { NodeContext, Render } from '../node';
export interface NodeErrorRenderProps {
error: Error;
context: NodeContext;
}
export type NodeErrorRender = Render<NodeErrorRenderProps>;
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormItemAbility } from '../../models/form-item-ability';
export class DecoratorAbility implements FormItemAbility {
static readonly type = 'decorator';
get type(): string {
return DecoratorAbility.type;
}
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './decorator-ability';
export * from './types';
@@ -0,0 +1,28 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormItemContext, FormItemFeedback } from '../../types';
import { SetterOrDecoratorContext } from '../../abilities/setter-ability';
export interface DecoratorAbilityOptions {
/**
* 已注册的decorator的唯一标识
*/
key: string;
}
export interface DecoratorComponentProps<CustomOptions = any>
extends FormItemFeedback,
FormItemContext {
readonly: boolean;
children?: any;
options: DecoratorAbilityOptions & CustomOptions;
context: SetterOrDecoratorContext;
}
export interface DecoratorExtension {
key: string;
component: (props: DecoratorComponentProps) => any;
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormItemAbility } from '../../models/form-item-ability';
export class DefaultAbility implements FormItemAbility {
static readonly type = 'default';
get type(): string {
return DefaultAbility.type;
}
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './default-ability';
export * from './types';
@@ -0,0 +1,15 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormItemContext, FormItemMaterialContext } from '../..';
export interface GetDefaultValueProps extends FormItemContext {
options: DefaultAbilityOptions;
context: FormItemMaterialContext;
}
export interface DefaultAbilityOptions<T = any> {
getDefaultValue: (params: GetDefaultValueProps) => T;
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormItemAbility } from '../../models/form-item-ability';
export class EffectAbility implements FormItemAbility {
static readonly type = 'effect';
get type(): string {
return EffectAbility.type;
}
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './effect-ability';
export * from './types';
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormItemContext, FormItemEventName } from '../../types';
import { type FormItemMaterialContext } from '../../models/form-item-material-context';
export interface EffectAbilityOptions {
/**
* 已注册的effect 唯一标识
*/
key?: string;
/**
* 触发 effect 的事件
*/
event?: FormItemEventName;
/**
* 如果不使用已经注册的effect, 也支持直接写effect函数
*/
effect?: EffectFunction;
}
export interface EffectEvent {
target: any & { value: any };
currentTarget: any;
type: FormItemEventName;
}
export interface EffectProps<CustomOptions = any, Event = EffectEvent> extends FormItemContext {
event: Event;
options: EffectAbilityOptions & CustomOptions;
context: FormItemMaterialContext;
}
export type EffectFunction = (props: EffectProps) => void;
export interface EffectExtension {
key: string;
effect: EffectFunction;
}
@@ -0,0 +1,11 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './setter-ability';
export * from './decorator-ability';
export * from './visibility-ability';
export * from './effect-ability';
export * from './default-ability';
export * from './validation-ability';
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './setter-ability';
export * from './types';
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormItemAbility } from '../../models/form-item-ability';
export class SetterAbility implements FormItemAbility {
static readonly type = 'setter';
get type(): string {
return SetterAbility.type;
}
}
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { FormItemContext, FormItemFeedback } from '../../types';
import { type FormItemMaterialContext } from '../../models/form-item-material-context';
import { ValidatorFunction } from '../../abilities/validation-ability';
export interface SetterAbilityOptions {
/**
* 已注册的setter的唯一标识
*/
key: string;
}
/**
* Setter context 是 FormItemMaterialContext 的外观
* 基于外观设计模式设计,屏蔽了FormItemMaterialContext中一些setter不可见的接口
* readonly: 对于setter 已经放在props 根级别,所以在这里屏蔽,防止干扰
* getFormItemValueByPath: setter需通过表单联动方式获取其他表单项的值,不推荐是用这个方法,所以屏蔽
*/
export type SetterOrDecoratorContext = Omit<
FormItemMaterialContext,
'getFormItemValueByPath' | 'readonly'
>;
export interface SetterComponentProps<T = any, CustomOptions = any>
extends FormItemFeedback,
FormItemContext {
value: T;
onChange: (v: T) => void;
/**
* 节点引擎全局readonly
*/
readonly: boolean;
children?: any;
options: SetterAbilityOptions & CustomOptions;
context: SetterOrDecoratorContext;
}
export interface SetterExtension {
key: string;
component: (props: SetterComponentProps) => any;
validator?: ValidatorFunction;
}
export type SetterHoc = (
Component: React.JSXElementConstructor<SetterComponentProps>
) => React.JSXElementConstructor<SetterComponentProps>;
@@ -0,0 +1,74 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
// export type ValidatorFormats =
// | 'url'
// | 'email'
// | 'ipv6'
// | 'ipv4'
// | 'number'
// | 'integer'
// | 'idcard'
// | 'qq'
// | 'phone'
// | 'money'
// | 'zh'
// | 'date'
// | 'zip'
// | (string & {});
//
// export interface IValidatorRules<Context = any> {
// format?: ValidatorFormats;
// validator?: ValidatorFunction<Context>;
// required?: boolean;
// pattern?: RegExp | string;
// max?: number;
// maximum?: number;
// maxItems?: number;
// minItems?: number;
// maxLength?: number;
// minLength?: number;
// exclusiveMaximum?: number;
// exclusiveMinimum?: number;
// minimum?: number;
// min?: number;
// len?: number;
// whitespace?: boolean;
// enum?: any[];
// const?: any;
// multipleOf?: number;
// uniqueItems?: boolean;
// maxProperties?: number;
// minProperties?: number;
// message?: string;
//
// [key: string]: any;
// }
//
// export interface IValidateResult {
// type: 'error' | 'warning';
// message: string;
// }
//
// export const isValidateResult = (obj: any): obj is IValidateResult =>
// Boolean(obj.type) && Boolean(obj.message);
//
// export type ValidatorFunctionResponse =
// | null
// | void
// | undefined
// | string
// | boolean
// | IValidateResult;
//
// export type ValidatorFunction<Context = any> = (
// value: any,
// ctx: Context,
// ) => ValidatorFunctionResponse | Promise<ValidatorFunctionResponse>;
//
// export type Validator<Context = any> =
// | ValidatorFormats
// | ValidatorFunction<Context>
// | IValidatorRules;
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './validation-ability';
export * from './types';
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormItemMaterialContext } from '../../models';
// 这里参照了rehaje 的validator function 返回格式
export interface IValidateResult {
type: 'error' | 'warning';
message: string;
}
export type ValidatorFunctionResponse =
| null
| void
| undefined
| string
| boolean
| IValidateResult;
export interface ValidationAbilityOptions {
/**
* 已注册的validator唯一标识
*/
key?: string;
/**
* 不使用已注册的validator 也支持在options中直接写validator
*/
validator?: ValidatorFunction;
}
export interface ValidatorProps<T = any, CustomOptions = any> {
value: T;
options: ValidationAbilityOptions & CustomOptions;
context: FormItemMaterialContext;
}
export type ValidatorFunction = (props: ValidatorProps) => ValidatorFunctionResponse;
export interface ValidationExtension {
key: string;
validator: ValidatorFunction;
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormItemAbility } from '../../models/form-item-ability';
export class ValidationAbility implements FormItemAbility {
static readonly type = 'validation';
get type(): string {
return ValidationAbility.type;
}
}
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './visibility-ability';
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormItemAbility } from '../../models/form-item-ability';
export interface VisibilityAbilityOptions {
/**
* 是否隐藏
*/
hidden: string | boolean;
/**
* 隐藏是否要清空表单值, 默认为false
*/
clearWhenHidden?: boolean;
}
export class VisibilityAbility implements FormItemAbility {
static readonly type = 'visibility';
get type(): string {
return VisibilityAbility.type;
}
}
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeEntity } from '@flowgram.ai/document';
import { FlowNodeFormData } from '../flow-node-form-data';
import { FormModel } from '..';
export function isNodeFormReady(node: FlowNodeEntity) {
return node.getData<FlowNodeFormData>(FlowNodeFormData).getFormModel<FormModel>().initialized;
}
export function getFormModel(node: FlowNodeEntity) {
return node.getData<FlowNodeFormData>(FlowNodeFormData).formModel;
}
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Disposable, Emitter } from '@flowgram.ai/utils';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { EntityData } from '@flowgram.ai/core';
import { FlowNodeErrorData } from '../error';
import { FormMetaOrFormMetaGenerator } from './types';
import { FormModel, type FormModelFactory } from './models';
interface Options {
formModelFactory: FormModelFactory;
}
export interface DetailChangeEvent {
path: string;
oldValue: any;
value: any;
initialized: boolean;
}
export interface OnFormValuesChangePayload {
values: any;
prevValues: any;
name: string;
}
export class FlowNodeFormData extends EntityData {
static type = 'FlowNodeEntityFormData';
readonly formModel: FormModel;
protected flowNodeEntity: FlowNodeEntity;
/**
* @deprecated rehaje 版表单form Values change 事件
* @protected
*/
protected onDetailChangeEmitter = new Emitter<DetailChangeEvent>();
/**
* @deprecated 该方法为旧版引擎(rehaje)表单数据变更事件, 新版节点引擎请使用
* this.getFormModel<FormModelV2>().onFormValuesChange.
* @protected
*/
readonly onDetailChange = this.onDetailChangeEmitter.event;
constructor(entity: FlowNodeEntity, opts: Options) {
super(entity);
this.flowNodeEntity = entity;
this.formModel = opts.formModelFactory(entity);
this.toDispose.push(this.onDetailChangeEmitter);
this.toDispose.push(
Disposable.create(() => {
this.formModel.dispose();
})
);
}
getFormModel<TFormModel>(): TFormModel {
// @ts-ignore
return this.formModel as TFormModel;
}
getDefaultData(): any {
return {};
}
createForm(formMetaOrFormMetaGenerator: any, initialValue?: any): void {
const errorData = this.flowNodeEntity.getData<FlowNodeErrorData>(FlowNodeErrorData);
errorData.setError(null);
try {
this.formModel.init(formMetaOrFormMetaGenerator, initialValue);
} catch (e) {
errorData.setError(e as Error);
}
}
updateFormValues(value: any) {
this.formModel.updateFormValues(value);
}
recreateForm(formMetaOrFormMetaGenerator: FormMetaOrFormMetaGenerator, initialValue?: any): void {
this.createForm(formMetaOrFormMetaGenerator, initialValue);
}
toJSON(): any {
return this.formModel.toJSON();
}
dispose(): void {
super.dispose();
}
/**
* @deprecated rehaje 版表单form Values change 事件触发函数
* @protected
*/
fireDetaiChange(detailChangeEvent: DetailChangeEvent) {
this.onDetailChangeEmitter.fire(detailChangeEvent);
}
}
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type FormManager } from './services/form-manager';
export const FormContribution = Symbol('FormContribution');
export interface FormContribution {
onRegister?(formManager: FormManager): void;
}
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ContainerModule } from 'inversify';
import { bindContributions } from '@flowgram.ai/utils';
import { FormContextMaker } from './services/form-context-maker';
import { NodeContribution } from '../node';
import { FormManager, FormPathService } from './services';
import { FormNodeContribution } from './form-node-contribution';
export const FormCoreContainerModule = new ContainerModule((bind) => {
bind(FormManager).toSelf().inSingletonScope();
bind(FormPathService).toSelf().inSingletonScope();
bind(FormContextMaker).toSelf().inSingletonScope();
bindContributions(bind, FormNodeContribution, [NodeContribution]);
});
@@ -0,0 +1,16 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { injectable } from 'inversify';
import { NodeContribution, NodeManager, PLUGIN_KEY } from '../node';
import { formPluginRender } from './form-render';
@injectable()
export class FormNodeContribution implements NodeContribution {
onRegister(nodeManager: NodeManager) {
nodeManager.registerPluginRender(PLUGIN_KEY.FORM, formPluginRender);
}
}
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { useEffect } from 'react';
import { useRefresh } from '@flowgram.ai/utils';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { PlaygroundContext } from '@flowgram.ai/core';
import { NodeContext } from '../node';
import { FormModel } from './models';
import { FlowNodeFormData } from './flow-node-form-data';
interface FormRenderProps {
node: FlowNodeEntity;
playgroundContext?: PlaygroundContext;
}
function getFormModelFromNode(node: FlowNodeEntity) {
return node.getData(FlowNodeFormData)?.getFormModel<FormModel>();
}
export function FormRender({ node }: FormRenderProps): any {
const refresh = useRefresh();
const formModel = getFormModelFromNode(node);
useEffect(() => {
const disposable = formModel?.onInitialized(() => {
refresh();
});
return () => {
disposable.dispose();
};
}, [formModel]);
return formModel?.initialized ? formModel.render() : null;
}
export const formPluginRender = (props: NodeContext) => <FormRender {...props} />;
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './form-core-container-module';
export * from './form-contribution';
export * from './services';
export * from './models';
export * from './types';
export * from './abilities';
export * from './flow-node-form-data';
export * from './client';
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { injectable } from 'inversify';
export interface Extension {
key: string;
}
@injectable()
export class FormAbilityExtensionRegistry {
protected registry = new Map<string, Extension>();
register(extension: Extension): void {
this.registry.set(extension.key, extension);
}
get<T extends Extension>(key: string): T | undefined {
return this.registry.get(key) as T | undefined;
}
get objectMap(): Record<string, Extension> {
return Object.fromEntries(this.registry);
}
get collection(): Extension[] {
return Array.from(this.registry.values());
}
}
@@ -0,0 +1,18 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export interface FormItemAbility {
type: string;
/**
* 注册到formManager时钩子时调用
*/
onAbilityRegister?: () => void;
}
export interface AbilityClass {
type: string;
new (): FormItemAbility;
}
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type FlowNodeEntity } from '@flowgram.ai/document';
import { PlaygroundContext } from '@flowgram.ai/core';
import { type FormModel, IFormItemMeta } from '..';
export interface FormItemMaterialContext {
/**
* 当前表单项的meta
*/
meta: IFormItemMeta;
/**
* 当前表单项的路径
*/
path: string;
/**
* 节点引擎全局readonly
*/
readonly: boolean;
/**
* 通过路径获取表单项的值
* @param path 表单项在当前表单中的绝对路径,路径协议遵循glob
*/
getFormItemValueByPath: <T>(path: string) => T;
/**
* 节点表单校验回调函数注册
*/
onFormValidate: FormModel['onValidate'];
/**
* 获取Node模型
*/
node: FlowNodeEntity;
/**
* 获取FormModel原始模型
*/
form: FormModel;
/**
* 业务注入的全局context
*/
playgroundContext: PlaygroundContext;
/**
* 数组场景下当前项的index
*/
index?: number | undefined;
}
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { DisposableCollection, Emitter } from '@flowgram.ai/utils';
import { FormItemDomRef, type IFormItemMeta } from '..';
import { type FormModel } from '.';
export abstract class FormItem {
readonly meta: IFormItemMeta;
readonly path: string;
readonly formModel: FormModel;
readonly onInitEventEmitter = new Emitter<FormItem>();
readonly onInit = this.onInitEventEmitter.event;
protected toDispose: DisposableCollection = new DisposableCollection();
readonly onDispose = this.toDispose.onDispose;
// todo(heyuan): 将dom 相关逻辑拆到form item插件里
private _domRef: FormItemDomRef;
protected constructor(meta: IFormItemMeta, path: string, formModel: FormModel) {
this.meta = meta;
this.path = path;
this.formModel = formModel;
this.toDispose.push(this.onInitEventEmitter);
}
abstract get value(): any;
abstract set value(value: any);
abstract validate(): void;
set domRef(domRef: FormItemDomRef) {
this._domRef = domRef;
}
get domRef() {
return this._domRef;
}
dispose(): void {
this.toDispose.dispose();
}
}
@@ -0,0 +1,62 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type IFormItemMeta, type IFormMeta, type IFormMetaOptions } from '../types';
import { FormPathService } from '../services';
export interface FormMetaTraverseParams {
formItemMeta: IFormItemMeta;
parentPath?: string;
handle: (params: { formItemMeta: IFormItemMeta; path: string }) => any;
}
export class FormMeta implements IFormMeta {
constructor(root: IFormItemMeta, options: IFormMetaOptions) {
this._root = root;
this._options = options;
}
protected _root: IFormItemMeta;
get root(): IFormItemMeta {
return this._root;
}
protected _options: IFormMetaOptions;
get options(): IFormMetaOptions {
return this._options;
}
static traverse({ formItemMeta, parentPath = '', handle }: FormMetaTraverseParams): void {
if (!formItemMeta) {
return;
}
const isRoot = !parentPath;
const path = isRoot
? FormPathService.ROOT
: formItemMeta.name
? FormPathService.join([parentPath, formItemMeta.name])
: parentPath;
handle({ formItemMeta, path });
if (formItemMeta.items) {
this.traverse({
formItemMeta: formItemMeta.items,
handle,
parentPath: FormPathService.toArrayPath(path),
});
}
if (formItemMeta.children && formItemMeta.children.length) {
formItemMeta.children.forEach((child) => {
this.traverse({ formItemMeta: child, handle, parentPath: path });
});
}
}
}
@@ -0,0 +1,84 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { injectable } from 'inversify';
import { DisposableCollection, Event, MaybePromise } from '@flowgram.ai/utils';
import { type FlowNodeEntity } from '@flowgram.ai/document';
import { FormFeedback, FormModelValid, IFormItem } from '../types';
import { FormManager } from '../services/form-manager';
import { type FormItem } from '.';
export type FormModelFactory = (entity: FlowNodeEntity) => FormModel;
export const FormModelFactory = Symbol('FormModelFactory');
export const FormModelEntity = Symbol('FormModelEntity');
@injectable()
export abstract class FormModel {
readonly onValidate: Event<FormModel>;
readonly onValidChange: Event<FormModelValid>;
readonly onFeedbacksChange: Event<FormFeedback[]>;
readonly onInitialized: Event<FormModel>;
protected toDispose: DisposableCollection = new DisposableCollection();
/**
* @deprecated
* use `formModel.node` instead in FormModelV2
*/
abstract get flowNodeEntity(): FlowNodeEntity;
/**
* @deprecated
*/
abstract get formManager(): FormManager;
abstract get formMeta(): any;
abstract get initialized(): boolean;
abstract get valid(): FormModelValid;
abstract updateFormValues(value: any): void;
/**
* @deprecated
* use `formModel.getFieldIn` instead in FormModelV2 to get the model of a form field
* do not use this in FormModelV2 since it only return an empty Map.
*/
abstract get formItemPathMap(): Map<string, IFormItem>;
/**
* @deprecated
*/
abstract clearValid(): void;
abstract validate(): Promise<boolean>;
abstract validateWithFeedbacks(): Promise<FormFeedback[]>;
abstract init(formMetaOrFormMetaGenerator: any, initialValue?: any): MaybePromise<void>;
abstract toJSON(): any;
/**
* @deprecated
* use `formModel.getField` instead in FormModelV2
*/
abstract getFormItemByPath(path: string): FormItem | undefined;
/**
* @deprecated
* use `formModel.getFieldValue` instead in FormModelV2 to get the model of a form field by path
*/
abstract getFormItemValueByPath<T = any>(path: string): any | undefined;
abstract render(): any;
abstract dispose(): void;
}
@@ -0,0 +1,11 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './form-model';
export * from './form-item';
export * from './form-meta';
export * from './form-ability-extension-registry';
export * from './form-item-ability';
export * from './form-item-material-context';
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { inject, injectable } from 'inversify';
import { injectPlaygroundContext, PlaygroundContext } from '@flowgram.ai/core';
import { NodeEngineContext } from '../../node';
import { FormItem, FormItemMaterialContext } from '..';
@injectable()
export class FormContextMaker {
@inject(NodeEngineContext) readonly nodeEngineContext: NodeEngineContext;
@injectPlaygroundContext() readonly playgroundContext: PlaygroundContext;
makeFormItemMaterialContext(
formItem: FormItem,
options?: { getIndex: () => number | undefined }
): FormItemMaterialContext {
return {
meta: formItem.meta,
path: formItem.path,
readonly: this.nodeEngineContext.readonly,
getFormItemValueByPath: formItem.formModel.getFormItemValueByPath.bind(formItem.formModel),
onFormValidate: formItem.formModel.onValidate.bind(formItem.formModel),
form: formItem.formModel,
node: formItem.formModel.flowNodeEntity,
playgroundContext: this.playgroundContext,
index: options?.getIndex(),
};
}
}
@@ -0,0 +1,112 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { mapValues } from 'lodash-es';
import { inject, injectable, multiInject, optional, postConstruct } from 'inversify';
import { Emitter } from '@flowgram.ai/utils';
import { injectPlaygroundContext, PlaygroundContext } from '@flowgram.ai/core';
import { AbilityClass, FormItemAbility } from '../models/form-item-ability';
import { FormAbilityExtensionRegistry, FormModel } from '../models';
import { FormContribution } from '../form-contribution';
import {
DecoratorAbility,
DecoratorExtension,
SetterAbility,
SetterExtension,
SetterHoc,
} from '../abilities';
import { FormContextMaker, FormPathService } from './index';
@injectable()
export class FormManager {
readonly abilityRegistry: Map<string, FormItemAbility> = new Map();
readonly setterHocs: SetterHoc[] = [];
readonly extensionRegistryMap: Map<string, FormAbilityExtensionRegistry> = new Map();
@inject(FormPathService) readonly pathManager: FormPathService;
@inject(FormContextMaker) readonly formContextMaker: FormContextMaker;
@injectPlaygroundContext() readonly playgroundContext: PlaygroundContext;
@multiInject(FormContribution) @optional() protected formContributions: FormContribution[] = [];
private readonly onFormModelWillInitEmitter = new Emitter<{
model: FormModel;
data: any;
}>();
readonly onFormModelWillInit = this.onFormModelWillInitEmitter.event;
get components(): Record<string, any> {
return mapValues(
this.extensionRegistryMap.get(SetterAbility.type)?.objectMap || {},
(setter: SetterExtension) => setter.component
);
}
get decorators(): Record<string, any> {
return mapValues(
this.extensionRegistryMap.get(DecoratorAbility.type)?.objectMap || {},
(decorator: DecoratorExtension) => decorator.component
);
}
registerAbilityExtension(type: string, extension: any): void {
if (!this.extensionRegistryMap.get(type)) {
this.extensionRegistryMap.set(type, new FormAbilityExtensionRegistry());
}
const registry = this.extensionRegistryMap.get(type);
if (!registry) {
return;
}
registry.register(extension);
}
getAbilityExtension(abilityType: string, extensionKey: string): any {
return this.extensionRegistryMap.get(abilityType)?.get(extensionKey);
}
registerAbility(Ability: AbilityClass): void {
const ability = new Ability();
this.abilityRegistry.set(ability.type, ability);
}
registerAbilities(Abilities: AbilityClass[]): void {
Abilities.forEach(this.registerAbility.bind(this));
}
getAbility<ExtendAbility>(type: string): (FormItemAbility & ExtendAbility) | undefined {
return this.abilityRegistry.get(type) as FormItemAbility & ExtendAbility;
}
/**
* @deprecated
* Setter Hoc and setter are no longer supported in NodeEngineV2
* @param hoc
*/
registerSetterHoc(hoc: SetterHoc): void {
this.setterHocs.push(hoc);
}
fireFormModelWillInit(model: FormModel, data: any) {
this.onFormModelWillInitEmitter.fire({
model,
data,
});
}
dispose() {
this.onFormModelWillInitEmitter.dispose();
}
@postConstruct()
protected init(): void {
this.formContributions.forEach((contrib) => contrib.onRegister?.(this));
}
}
@@ -0,0 +1,88 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { injectable } from 'inversify';
@injectable()
export class FormPathService {
static readonly ROOT = '/';
static readonly DIVIDER = '/';
static readonly RELATIVE_PARENT = '..';
static readonly RELATIVE_CURRENT = '.';
static readonly ARRAY = '[]';
static normalize(path: string) {
if (path === FormPathService.ROOT) {
return path;
}
// 去掉末尾的斜杠
if (path.endsWith(FormPathService.DIVIDER)) {
path = path.slice(0, -1);
}
return path;
}
static join(paths: string[]): string {
if (paths[1].startsWith(FormPathService.ROOT)) {
throw new Error(
`FormPathService Error: join failed, invalid paths[1], paths[1]= ${paths[1]}`,
);
}
if (paths[0].endsWith(FormPathService.DIVIDER)) {
return `${paths[0]}${paths[1]}`;
}
return paths.join(FormPathService.DIVIDER);
}
static toArrayPath(path: string): string {
return FormPathService.join([path, FormPathService.ARRAY]);
}
static parseArrayItemPath(path: string) {
const names = path.split('/');
let i = 0;
while (i < names.length) {
const itemIndex = parseInt(names[i]);
if (!isNaN(itemIndex)) {
const arrayPath = FormPathService.toArrayPath(
names.slice(0, i).join(FormPathService.DIVIDER),
);
const restPath = names.slice(i + 1).join(FormPathService.DIVIDER);
const itemMetaPath = FormPathService.join([arrayPath, restPath]);
return { itemIndex, arrayPath, itemMetaPath };
}
i = i + 1;
}
return null;
}
simplify(path: string) {
const segments = path.split(FormPathService.DIVIDER);
const resSegments: string[] = [];
for (let i = 0; i < segments.length; i++) {
if (!segments[i]) {
throw new Error('FormPathService: join failed');
}
if (segments[i] === FormPathService.RELATIVE_CURRENT) {
// eslint-disable-next-line no-continue
continue;
}
if (segments[i] === FormPathService.RELATIVE_PARENT) {
resSegments.pop();
}
resSegments.push(segments[i]);
}
return resSegments.join(FormPathService.DIVIDER);
}
}
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './form-path-service';
export * from './form-manager';
export * from './form-context-maker';
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeEntity } from '@flowgram.ai/document';
import { PlaygroundContext } from '@flowgram.ai/core';
import { MaybePromise } from '@flowgram.ai/utils';
import { type IFormItem } from './form-model.types';
import { IFormItemMeta } from './form-meta.types';
export interface FormItemAbilityMeta<Options = any> {
type: string;
options: Options;
}
/**
* @deprecated
*/
export interface FormItemContext {
/**
* @deprecated Use context.node instead
*/
formItemMeta: IFormItemMeta;
/**
* @deprecated
*/
formItem: IFormItem;
/**
* @deprecated Use context.node instead
*/
flowNodeEntity: FlowNodeEntity;
/**
* @deprecated Use context.playgroundContext instead
*/
playgroundContext: PlaygroundContext;
}
export interface FormItemHookParams extends FormItemContext {
formItem: IFormItem;
}
export interface FormItemHooks<T> {
/**
* FormItem初始化钩子
*/
onInit?: (params: FormItemHookParams & T) => void;
/**
* FormItem提交时钩子
*/
onSubmit?: (params: FormItemHookParams & T) => void;
/**
* FormItem克隆时钩子
*/
onClone?: (params: FormItemHookParams & T) => MaybePromise<void>;
/**
* 克隆后执行的逻辑
*/
afterClone?: (params: FormItemHookParams & T) => void;
/**
* FormItem全局校验时钩子
*/
onValidate?: (params: FormItemHookParams & T) => void;
}
@@ -0,0 +1,121 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { MaybePromise } from '@flowgram.ai/utils';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { PlaygroundContext, PluginContext } from '@flowgram.ai/core';
import { type FormItemAbilityMeta } from './form-ability.types';
export type FormDataTypeName =
| 'string'
| 'number'
| 'integer'
| 'boolean'
| 'object'
| 'array'
| 'null';
export type FormDataType =
| string //
| number
| boolean
| FormDataObject
| DataArray
| null;
export interface FormDataObject {
[key: string]: FormDataType;
}
export type DataArray = Array<FormDataType>;
export const FORM_VOID = 'form-void' as const;
export interface TreeNode<T> {
name: string;
children?: TreeNode<T>[];
}
export interface IFormItemMeta extends TreeNode<IFormItemMeta> {
/**
* 表单项名称
*/
name: string;
/**
* 数据类型
*/
type: FormDataTypeName | typeof FORM_VOID;
/**
* 枚举值
*/
enum?: FormDataType[];
/**
* 数组类型item的数据类型描述
*/
items?: IFormItemMeta;
/**
* 表单项标题
*/
title?: string;
/**
* 表单项描述
*/
description?: string;
/**
* 表单项默认值
*/
default?: FormDataType;
/**
* 是否必填
*/
required?: boolean;
/**
* 扩展能力
*/
abilities?: FormItemAbilityMeta[];
/**
* 子表单项
*/
children?: IFormItemMeta[];
}
export interface IFormMeta {
/**
* 表单树结构root
*/
root?: IFormItemMeta;
/**
* 表单全局配置
*/
options?: IFormMetaOptions;
}
export interface NodeFormContext {
node: FlowNodeEntity;
playgroundContext: PlaygroundContext;
clientContext: PluginContext & Record<string, any>;
}
export interface IFormMetaOptions {
formatOnInit?: (value: any, context: NodeFormContext) => any;
formatOnSubmit?: (value: any, context: NodeFormContext) => any;
[key: string]: any;
}
export interface FormMetaGeneratorParams<PlaygroundContext, FormValue = any> {
node: FlowNodeEntity;
playgroundContext: PlaygroundContext;
initialValue?: FormValue;
}
export type FormMetaGenerator<PlaygroundContext = any, FormValue = any> = (
params: FormMetaGeneratorParams<FormValue, FormValue>
) => MaybePromise<IFormMeta>;
export type FormMetaOrFormMetaGenerator = FormMetaGenerator | IFormMeta;
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export interface IFormItem<T = any> {
value: T;
}
export enum FormItemEventName {
onFormValueChange = 'onFormValueChange',
onFormItemInit = 'onFormItemInit',
}
export type FormModelValid = boolean | null;
export type FeedbackStatus = 'error' | 'warning' | 'pending';
export type FeedbackText = string;
export interface FormItemFeedback {
feedbackStatus?: FeedbackStatus;
feedbackText?: FeedbackText;
}
export interface FormFeedback {
feedbackStatus?: FeedbackStatus;
feedbackText?: FeedbackText;
path: string;
}
export interface FormItemDomRef {
current: HTMLElement | null;
}
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './form-model.types';
export * from './form-meta.types';
export * from './form-ability.types';
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './node';
export * from './error';
export * from './form';
export * from './client';
export * from './node-react';
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './node-engine-react-context';
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { INodeEngineContext, NodeEngineContext } from '../../node';
export const NodeEngineReactContext = React.createContext<INodeEngineContext>(
NodeEngineContext.DEFAULT_JSON,
);
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './use-node-engine-context';
export * from './use-form-Item';
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { useEntityFromContext, useRefresh } from '@flowgram.ai/core';
import { FlowNodeFormData, FormModel, IFormItem } from '../../form';
export function useFormItem(path: string): IFormItem | undefined {
const refresh = useRefresh();
const node = useEntityFromContext<FlowNodeEntity>();
const formData = node.getData<FlowNodeFormData>(FlowNodeFormData);
const formItem = formData.getFormModel<FormModel>().getFormItemByPath(path);
useEffect(() => {
const disposable = formData.onDataChange(() => {
refresh();
});
return () => {
disposable.dispose();
};
}, []);
return formItem;
}
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { useService, useRefresh } from '@flowgram.ai/core';
import { NodeEngineContext } from '../../node';
export function useNodeEngineContext(): NodeEngineContext {
const refresh = useRefresh();
const nodeEngineContext = useService<NodeEngineContext>(NodeEngineContext);
useEffect(() => {
const disposable = nodeEngineContext.onChange(() => {
refresh();
});
return () => {
disposable.dispose();
};
}, []);
return nodeEngineContext;
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './hooks';
export * from './context';
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const MATERIAL_KEY = {
NODE_ERROR_RENDER: 'node_error_render',
NODE_PLACEHOLDER_RENDER: 'node_placeholder_render',
};
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const PLUGIN_KEY = {
FORM: 'Plugin_Form',
ERROR: 'Plugin_Error',
};
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { NodeContribution } from './node-contribution';
export * from './node-container-module';
export * from './node-manager';
export * from './types';
export * from './core-plugins';
export * from './core-materials';
export * from './node-engine';
export * from './node-engine-context';
@@ -0,0 +1,16 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ContainerModule } from 'inversify';
import { NodeManager } from './node-manager';
import { NodeEngineContext } from './node-engine-context';
import { NodeEngine } from './node-engine';
export const NodeContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
bind(NodeEngine).toSelf().inSingletonScope();
bind(NodeManager).toSelf().inSingletonScope();
bind(NodeEngineContext).toSelf().inSingletonScope();
});
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type NodeManager } from './node-manager';
export const NodeContribution = Symbol('NodeContribution');
export interface NodeContribution {
onRegister?(nodeManager: NodeManager): void;
}
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { injectable } from 'inversify';
import { Emitter } from '@flowgram.ai/utils';
export interface INodeEngineContext {
readonly: boolean;
}
/**
* NodeEngineContext 在 Node Engine 中为全局单例, 它的作用是让Node之间共享数据。
* context 分为内置context(如 readonly) 和 自定义context(业务可以按需注入)
*/
@injectable()
export class NodeEngineContext {
static DEFAULT_READONLY = false;
static DEFAULT_JSON = { readonly: NodeEngineContext.DEFAULT_READONLY };
readonly onChangeEmitter = new Emitter<NodeEngineContext>();
readonly onChange = this.onChangeEmitter.event;
private _readonly: boolean = NodeEngineContext.DEFAULT_READONLY;
private _json: INodeEngineContext = NodeEngineContext.DEFAULT_JSON;
get json(): INodeEngineContext {
return this._json;
}
get readonly(): boolean {
return this._readonly;
}
set readonly(value: boolean) {
this._readonly = value;
this.fireChange();
}
private fireChange(): void {
this.updateJSON();
this.onChangeEmitter.fire(this);
}
private updateJSON(): void {
this._json = {
readonly: this._readonly,
};
}
}
@@ -0,0 +1,16 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { injectable, inject } from 'inversify';
import { NodeManager } from './node-manager';
import { NodeEngineContext } from './node-engine-context';
@injectable()
export class NodeEngine {
@inject(NodeManager) nodeManager: NodeManager;
@inject(NodeEngineContext) context: NodeEngineContext;
}
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { flow } from 'lodash-es';
import { injectable, multiInject, optional, postConstruct } from 'inversify';
import { NodeErrorRenderProps } from '../error';
import { NodePluginRender, NodeRenderHoc, Render } from './types';
import { NodeContribution } from './node-contribution';
export enum MaterialRenderKey {
CustomNodeError = 'Material_CustomNodeError',
}
@injectable()
export class NodeManager {
readonly materialRenderRegistry: Map<string, Render> = new Map();
readonly pluginRenderRegistry: Map<string, Render> = new Map();
readonly nodeRenderHocs: NodeRenderHoc[] = [];
@multiInject(NodeContribution) @optional() protected nodeContributions: NodeContribution[] = [];
registerMaterialRender(key: string, render: Render) {
this.materialRenderRegistry.set(key, render);
}
getMaterialRender(key: string): Render | undefined {
return this.materialRenderRegistry.get(key);
}
registerPluginRender(key: string, render: NodePluginRender): void {
this.pluginRenderRegistry.set(key, render);
}
getPluginRender(key: string): NodePluginRender | undefined {
return this.pluginRenderRegistry.get(key);
}
registerNodeErrorRender(render: Render<NodeErrorRenderProps>) {
this.registerMaterialRender(MaterialRenderKey.CustomNodeError, render);
}
get nodeRenderHoc() {
return flow(this.nodeRenderHocs);
}
registerNodeRenderHoc(hoc: NodeRenderHoc) {
this.nodeRenderHocs.push(hoc);
}
get nodeErrorRender() {
return this.materialRenderRegistry.get(MaterialRenderKey.CustomNodeError);
}
@postConstruct()
protected init(): void {
this.nodeContributions.forEach((contrib) => contrib.onRegister?.(this));
}
}
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { NodeFormContext } from '../form';
/**
* @deprecated
* use `NodeFormContext` instead
*/
export type NodeContext = NodeFormContext;
export type Render<T = any> = (props: T) => any;
export type NodePluginRender = Render<NodeFormContext>;
export type NodePlaceholderRender = Render<NodeFormContext>;
export interface NodeRenderProps {
node: FlowNodeEntity;
}
export type NodeRenderHoc = (
Component: React.JSXElementConstructor<NodeRenderProps>
) => React.JSXElementConstructor<NodeRenderProps>;
@@ -0,0 +1,10 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
"compilerOptions": {
"rootDir": "./src",
"baseUrl": "./",
},
"include": ["./src"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,30 @@
/**
* 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.*',
],
},
});
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import 'reflect-metadata';