This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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,
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'react/no-deprecated': 'off',
|
||||
'@flowgram.ai/e2e-data-testid': 'off',
|
||||
'react/prop-types': 'off'
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@flowgram.ai/test-run-plugin",
|
||||
"version": "0.1.8",
|
||||
"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",
|
||||
"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": "exit 0",
|
||||
"test:cov": "exit 0",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"inversify": "^6.0.1",
|
||||
"nanoid": "^5.0.9",
|
||||
"zustand": "^4.5.5",
|
||||
"@flowgram.ai/form-core": "workspace:*",
|
||||
"@flowgram.ai/core": "workspace:*",
|
||||
"@flowgram.ai/utils": "workspace:*",
|
||||
"@flowgram.ai/document": "workspace:*",
|
||||
"@flowgram.ai/form": "workspace:*",
|
||||
"@flowgram.ai/reactive": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react": "^18",
|
||||
"@types/react": "^18",
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { definePluginCreator } from '@flowgram.ai/core';
|
||||
|
||||
import { TestRunFormEntity, TestRunFormFactory, TestRunFormManager } from './services/form';
|
||||
import {
|
||||
TestRunService,
|
||||
TestRunPipelineEntity,
|
||||
TestRunPipelineFactory,
|
||||
TestRunConfig,
|
||||
defineConfig,
|
||||
} from './services';
|
||||
|
||||
export const createTestRunPlugin = definePluginCreator<Partial<TestRunConfig>>({
|
||||
onBind: ({ bind }, opt) => {
|
||||
/** service */
|
||||
bind(TestRunService).toSelf().inSingletonScope();
|
||||
/** config */
|
||||
bind(TestRunConfig).toConstantValue(defineConfig(opt));
|
||||
/** form manager */
|
||||
bind(TestRunFormManager).toSelf().inSingletonScope();
|
||||
/** form entity */
|
||||
bind<TestRunFormFactory>(TestRunFormFactory).toFactory<TestRunFormEntity>((context) => () => {
|
||||
const e = context.container.resolve(TestRunFormEntity);
|
||||
return e;
|
||||
});
|
||||
/** pipeline entity */
|
||||
bind<TestRunPipelineFactory>(TestRunPipelineFactory).toFactory<TestRunPipelineEntity>(
|
||||
(context) => () => {
|
||||
const e = context.container.resolve(TestRunPipelineEntity);
|
||||
e.container = context.container.createChild();
|
||||
return e;
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { FormComponents } from './types';
|
||||
import type { FormSchemaModel } from './model';
|
||||
|
||||
/** Model context for each form item */
|
||||
export const FieldModelContext = createContext<FormSchemaModel>({} as any);
|
||||
/** The form's model context */
|
||||
export const FormModelContext = createContext<FormSchemaModel>({} as any);
|
||||
/** Context of material component map */
|
||||
export const ComponentsContext = createContext<FormComponents>({});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { SchemaField, type SchemaFieldProps } from './schema-field';
|
||||
import { FormComponents } from '../types';
|
||||
|
||||
type InnerSchemaFieldProps = Omit<SchemaFieldProps, 'components'> &
|
||||
Pick<Partial<SchemaFieldProps>, 'components'>;
|
||||
|
||||
export interface CreateSchemaFieldOptions {
|
||||
components?: FormComponents;
|
||||
}
|
||||
export const createSchemaField = (options: CreateSchemaFieldOptions) => {
|
||||
const InnerSchemaField: React.FC<PropsWithChildren<InnerSchemaFieldProps>> = ({
|
||||
components,
|
||||
...props
|
||||
}) => (
|
||||
<SchemaField
|
||||
components={{
|
||||
...options.components,
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return InnerSchemaField;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { Field } from '@flowgram.ai/form';
|
||||
|
||||
import { ReactiveField } from './reactive-field';
|
||||
import type { FormSchemaModel } from '../model';
|
||||
import { FieldModelContext } from '../contexts';
|
||||
|
||||
export interface GeneralFieldProps {
|
||||
model: FormSchemaModel;
|
||||
}
|
||||
|
||||
export const GeneralField: React.FC<GeneralFieldProps> = ({ model }) => (
|
||||
<FieldModelContext.Provider value={model}>
|
||||
<Field
|
||||
name={model.uniqueName}
|
||||
defaultValue={model.defaultValue}
|
||||
render={({ field, fieldState }) => (
|
||||
<ReactiveField
|
||||
componentProps={{
|
||||
value: field.value,
|
||||
onChange: field.onChange,
|
||||
onFocus: field.onFocus,
|
||||
onBlur: field.onBlur,
|
||||
...fieldState,
|
||||
}}
|
||||
decoratorProps={fieldState}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldModelContext.Provider>
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
/** field components */
|
||||
export { SchemaField, type SchemaFieldProps } from './schema-field';
|
||||
/** field functions */
|
||||
export { createSchemaField, type CreateSchemaFieldOptions } from './create-field';
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { FormSchemaModel } from '../model';
|
||||
import { FieldModelContext } from '../contexts';
|
||||
import { ReactiveField } from './reactive-field';
|
||||
|
||||
export interface ObjectFieldProps {
|
||||
model: FormSchemaModel;
|
||||
}
|
||||
|
||||
export const ObjectField: React.FC<React.PropsWithChildren<ObjectFieldProps>> = ({
|
||||
model,
|
||||
children,
|
||||
}) => (
|
||||
<FieldModelContext.Provider value={model}>
|
||||
<ReactiveField>{children}</ReactiveField>
|
||||
</FieldModelContext.Provider>
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useContext } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { useFormState } from '../hooks/use-form';
|
||||
import { useFieldModel, useFieldState } from '../hooks/use-field';
|
||||
import { ComponentsContext } from '../contexts';
|
||||
|
||||
interface ReactiveFieldProps {
|
||||
componentProps?: Record<string, unknown>;
|
||||
decoratorProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const ReactiveField: React.FC<React.PropsWithChildren<ReactiveFieldProps>> = (props) => {
|
||||
const formState = useFormState();
|
||||
const model = useFieldModel();
|
||||
const modelState = useFieldState();
|
||||
const components = useContext(ComponentsContext);
|
||||
|
||||
const disabled = modelState.disabled || formState.disabled;
|
||||
const componentRender = () => {
|
||||
if (!model.componentType || !components[model.componentType]) {
|
||||
return props.children;
|
||||
}
|
||||
return React.createElement(
|
||||
components[model.componentType],
|
||||
{
|
||||
disabled,
|
||||
...model.componentProps,
|
||||
...props.componentProps,
|
||||
},
|
||||
props.children
|
||||
);
|
||||
};
|
||||
|
||||
const decoratorRender = (children: React.ReactNode) => {
|
||||
if (!model.decoratorType || !components[model.decoratorType]) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return React.createElement(
|
||||
components[model.decoratorType],
|
||||
{
|
||||
type: model.type,
|
||||
required: model.required,
|
||||
...model.decoratorProps,
|
||||
...props.decoratorProps,
|
||||
},
|
||||
children
|
||||
);
|
||||
};
|
||||
|
||||
return decoratorRender(componentRender());
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { FormSchemaModel } from '../model';
|
||||
import { ObjectField } from './object-field';
|
||||
import { GeneralField } from './general-field';
|
||||
|
||||
interface RecursionFieldProps {
|
||||
model: FormSchemaModel;
|
||||
}
|
||||
|
||||
export const RecursionField: React.FC<RecursionFieldProps> = ({ model }) => {
|
||||
const properties = useMemo(() => model.getPropertyList(), [model]);
|
||||
|
||||
/** general field has no children */
|
||||
if (model.type !== 'object') {
|
||||
return <GeneralField model={model} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ObjectField model={model}>
|
||||
{properties.map((item) => (
|
||||
<RecursionField key={item.uniqueName} model={item} />
|
||||
))}
|
||||
</ObjectField>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { FormComponents } from '../types';
|
||||
import { FormSchemaModel } from '../model';
|
||||
import { ComponentsContext, FormModelContext } from '../contexts';
|
||||
import { RecursionField } from './recursion-field';
|
||||
|
||||
export interface SchemaFieldProps {
|
||||
model: FormSchemaModel;
|
||||
components: FormComponents;
|
||||
}
|
||||
export const SchemaField: React.FC<React.PropsWithChildren<SchemaFieldProps>> = ({
|
||||
components,
|
||||
model,
|
||||
children,
|
||||
}) => {
|
||||
/** Only initialized once, dynamic is not supported */
|
||||
const [innerComponents] = useState(() => components);
|
||||
return (
|
||||
<ComponentsContext.Provider value={innerComponents}>
|
||||
<FormModelContext.Provider value={model}>
|
||||
<RecursionField model={model} />
|
||||
{children}
|
||||
</FormModelContext.Provider>
|
||||
</ComponentsContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { Form } from '@flowgram.ai/form';
|
||||
|
||||
import { FormSchema, FormComponents } from '../types';
|
||||
import { useCreateForm, type UseCreateFormOptions } from '../hooks';
|
||||
import { createSchemaField } from '../fields';
|
||||
|
||||
const SchemaField = createSchemaField({});
|
||||
|
||||
export type FormEngineProps = React.PropsWithChildren<
|
||||
{
|
||||
/** Form schema */
|
||||
schema: FormSchema;
|
||||
/** form material map */
|
||||
components?: FormComponents;
|
||||
} & UseCreateFormOptions
|
||||
>;
|
||||
|
||||
export const FormEngine: React.FC<FormEngineProps> = ({
|
||||
schema,
|
||||
components,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const { model, control } = useCreateForm(schema, props);
|
||||
|
||||
return (
|
||||
<Form control={control}>
|
||||
<SchemaField model={model} components={components}>
|
||||
{children}
|
||||
</SchemaField>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { FormEngine, type FormEngineProps } from './form';
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { useCreateForm, type UseCreateFormOptions } from './use-create-form';
|
||||
export { useFieldModel, useFieldState } from './use-field';
|
||||
export { useFormModel, useFormState } from './use-form';
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import type { OnFormValuesChangePayload } from '@flowgram.ai/form-core';
|
||||
import { createForm, ValidateTrigger, type IForm } from '@flowgram.ai/form';
|
||||
|
||||
import { createValidate } from '../utils';
|
||||
import { FormSchema, FormSchemaValidate } from '../types';
|
||||
import { FormSchemaModel } from '../model';
|
||||
|
||||
export interface FormInstance {
|
||||
model: FormSchemaModel;
|
||||
form: IForm;
|
||||
}
|
||||
export interface UseCreateFormOptions {
|
||||
defaultValues?: any;
|
||||
validate?: Record<string, FormSchemaValidate>;
|
||||
validateTrigger?: ValidateTrigger;
|
||||
onMounted?: (form: FormInstance) => void;
|
||||
onFormValuesChange?: (payload: OnFormValuesChangePayload) => void;
|
||||
onUnmounted?: () => void;
|
||||
}
|
||||
|
||||
export const useCreateForm = (schema: FormSchema, options: UseCreateFormOptions = {}) => {
|
||||
const { form, control } = useMemo(
|
||||
() =>
|
||||
createForm({
|
||||
validate: {
|
||||
...createValidate(schema),
|
||||
...options.validate,
|
||||
},
|
||||
validateTrigger: options.validateTrigger ?? ValidateTrigger.onBlur,
|
||||
}),
|
||||
[schema]
|
||||
);
|
||||
|
||||
const model = useMemo(
|
||||
() => new FormSchemaModel({ type: 'object', ...schema, defaultValue: options.defaultValues }),
|
||||
[schema]
|
||||
);
|
||||
|
||||
/** Lifecycle and event binding */
|
||||
useEffect(() => {
|
||||
if (options.onMounted) {
|
||||
options.onMounted({ model, form });
|
||||
}
|
||||
const disposable = control._formModel.onFormValuesChange((payload) => {
|
||||
if (options.onFormValuesChange) {
|
||||
options.onFormValuesChange(payload);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
if (options.onUnmounted) {
|
||||
options.onUnmounted();
|
||||
}
|
||||
};
|
||||
}, [control]);
|
||||
|
||||
return {
|
||||
form,
|
||||
control,
|
||||
model,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { useObserve } from '@flowgram.ai/reactive';
|
||||
|
||||
import { FieldModelContext } from '../contexts';
|
||||
|
||||
export const useFieldModel = () => useContext(FieldModelContext);
|
||||
export const useFieldState = () => useObserve(useFieldModel().state.value);
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { useObserve } from '@flowgram.ai/reactive';
|
||||
|
||||
import { FormModelContext } from '../contexts';
|
||||
|
||||
export const useFormModel = () => useContext(FormModelContext);
|
||||
export const useFormState = () => useObserve(useFormModel().state.value);
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { FormEngine, type FormEngineProps } from './form';
|
||||
export { FormSchemaModel } from './model';
|
||||
/** utils */
|
||||
export { connect, isFormEmpty } from './utils';
|
||||
|
||||
/** types */
|
||||
export type {
|
||||
FormSchema,
|
||||
FormSchemaValidate,
|
||||
FormComponents,
|
||||
FormComponent,
|
||||
FormComponentProps,
|
||||
} from './types';
|
||||
export type { FormInstance } from './hooks/use-create-form';
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { ReactiveState } from '@flowgram.ai/reactive';
|
||||
|
||||
import { getUniqueFieldName, mergeFieldPath } from '../utils';
|
||||
import { FormSchema, FormSchemaType, FormSchemaModelState } from '../types';
|
||||
|
||||
export class FormSchemaModel implements FormSchema {
|
||||
name?: string;
|
||||
|
||||
type?: FormSchemaType;
|
||||
|
||||
defaultValue?: any;
|
||||
|
||||
properties?: Record<string, FormSchema>;
|
||||
|
||||
['x-index']?: number;
|
||||
|
||||
['x-component']?: string;
|
||||
|
||||
['x-component-props']?: Record<string, unknown>;
|
||||
|
||||
['x-decorator']?: string;
|
||||
|
||||
['x-decorator-props']?: Record<string, unknown>;
|
||||
|
||||
[key: string]: any;
|
||||
|
||||
path: string[] = [];
|
||||
|
||||
state = new ReactiveState<FormSchemaModelState>({ disabled: false });
|
||||
|
||||
get componentType() {
|
||||
return this['x-component'];
|
||||
}
|
||||
|
||||
get componentProps() {
|
||||
return this['x-component-props'];
|
||||
}
|
||||
|
||||
get decoratorType() {
|
||||
return this['x-decorator'];
|
||||
}
|
||||
|
||||
get decoratorProps() {
|
||||
return this['x-decorator-props'];
|
||||
}
|
||||
|
||||
get uniqueName() {
|
||||
return getUniqueFieldName(...this.path);
|
||||
}
|
||||
|
||||
constructor(json: FormSchema, path: string[] = []) {
|
||||
this.fromJSON(json);
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
private fromJSON(json: FormSchema) {
|
||||
Object.entries(json).forEach(([key, value]) => {
|
||||
this[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
getPropertyList() {
|
||||
const orderProperties: FormSchemaModel[] = [];
|
||||
const unOrderProperties: FormSchemaModel[] = [];
|
||||
Object.entries(this.properties || {}).forEach(([key, item]) => {
|
||||
const index = item['x-index'];
|
||||
const defaultValues = this.defaultValue;
|
||||
/**
|
||||
* The upper layer's default value has a higher priority than its own default value,
|
||||
* because the upper layer's default value ultimately comes from the outside world.
|
||||
*/
|
||||
if (typeof defaultValues === 'object' && defaultValues !== null && key in defaultValues) {
|
||||
item.defaultValue = defaultValues[key];
|
||||
}
|
||||
const current = new FormSchemaModel(item, mergeFieldPath(this.path, key));
|
||||
if (index !== undefined && !isNaN(index)) {
|
||||
orderProperties[index] = current;
|
||||
} else {
|
||||
unOrderProperties.push(current);
|
||||
}
|
||||
});
|
||||
return orderProperties.concat(unOrderProperties).filter((item) => !!item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { Validate, FieldState } from '@flowgram.ai/form';
|
||||
|
||||
/** field type */
|
||||
export type FormSchemaType = 'string' | 'number' | 'boolean' | 'object' | string;
|
||||
|
||||
export type FormSchemaValidate = Validate;
|
||||
|
||||
export interface FormSchema {
|
||||
/** core */
|
||||
name?: string;
|
||||
type?: FormSchemaType;
|
||||
defaultValue?: any;
|
||||
|
||||
/** children */
|
||||
properties?: Record<string, FormSchema>;
|
||||
|
||||
/** ui */
|
||||
title?: string | React.ReactNode;
|
||||
description?: string | React.ReactNode;
|
||||
['x-index']?: number;
|
||||
['x-visible']?: boolean;
|
||||
['x-hidden']?: boolean;
|
||||
['x-component']?: string;
|
||||
['x-component-props']?: Record<string, unknown>;
|
||||
['x-decorator']?: string;
|
||||
['x-decorator-props']?: Record<string, unknown>;
|
||||
|
||||
/** rule */
|
||||
required?: boolean;
|
||||
['x-validator']?: FormSchemaValidate;
|
||||
|
||||
/** custom */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type FormComponentProps = {
|
||||
type?: FormSchemaType;
|
||||
disabled?: boolean;
|
||||
[key: string]: any;
|
||||
} & FormSchema['x-component-props'] &
|
||||
FormSchema['x-decorator-props'] &
|
||||
Partial<FieldState>;
|
||||
export type FormComponent = React.FunctionComponent<any>;
|
||||
export type FormComponents = Record<string, FormComponent>;
|
||||
|
||||
/** ui state */
|
||||
export interface FormSchemaModelState {
|
||||
disabled: boolean;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { createElement } from 'react';
|
||||
|
||||
import type { FormSchema, FormSchemaValidate, FormComponentProps } from './types';
|
||||
|
||||
/** Splice form item unique name */
|
||||
export const getUniqueFieldName = (...args: (string | undefined)[]) =>
|
||||
args.filter((path) => path).join('.');
|
||||
|
||||
export const mergeFieldPath = (path?: string[], name?: string) =>
|
||||
[...(path || []), name].filter((i): i is string => Boolean(i));
|
||||
|
||||
/** Create validation rules */
|
||||
export const createValidate = (schema: FormSchema) => {
|
||||
const rules: Record<string, FormSchemaValidate> = {};
|
||||
|
||||
visit(schema);
|
||||
|
||||
return rules;
|
||||
|
||||
function visit(current: FormSchema, name?: string) {
|
||||
if (name && current['x-validator']) {
|
||||
rules[name] = current['x-validator'];
|
||||
}
|
||||
if (current.type === 'object' && current.properties) {
|
||||
Object.entries(current.properties).forEach(([key, value]) => {
|
||||
visit(value, getUniqueFieldName(name, key));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const connect = <T = any>(
|
||||
Component: React.FunctionComponent<any>,
|
||||
mapProps: (p: FormComponentProps) => T
|
||||
) => {
|
||||
const Connected = (props: FormComponentProps) => {
|
||||
const mappedProps = mapProps(props);
|
||||
return createElement(Component, mappedProps, (mappedProps as any).children);
|
||||
};
|
||||
|
||||
return Connected;
|
||||
};
|
||||
|
||||
export const isFormEmpty = (schema: FormSchema) => {
|
||||
/** is not general field and not has children */
|
||||
const isEmpty = (s: FormSchema): boolean => {
|
||||
if (!s.type || s.type === 'object' || !s.name) {
|
||||
return Object.entries(schema.properties || {})
|
||||
.map(([key, value]) => ({
|
||||
name: key,
|
||||
...value,
|
||||
}))
|
||||
.every(isFormEmpty);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return isEmpty(schema);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { createTestRunPlugin } from './create-test-run-plugin';
|
||||
export { useCreateForm, useTestRunService } from './reactive';
|
||||
|
||||
export {
|
||||
FormEngine,
|
||||
connect,
|
||||
type FormInstance,
|
||||
type FormEngineProps,
|
||||
type FormSchema,
|
||||
type FormComponentProps,
|
||||
} from './form-engine';
|
||||
|
||||
export {
|
||||
type TestRunPipelinePlugin,
|
||||
TestRunPipelineEntity,
|
||||
type TestRunPipelineEntityCtx,
|
||||
type TestRunConfig,
|
||||
} from './services';
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { useCreateForm } from './use-create-form';
|
||||
export { useTestRunService } from './use-test-run-service';
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { DisposableCollection } from '@flowgram.ai/utils';
|
||||
import type { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
|
||||
import { TestRunFormEntity } from '../../services/form/form';
|
||||
import { FormEngineProps, isFormEmpty } from '../../form-engine';
|
||||
import { useTestRunService } from './use-test-run-service';
|
||||
|
||||
interface UseFormOptions {
|
||||
node?: FlowNodeEntity;
|
||||
/** form loading */
|
||||
loadingRenderer?: React.ReactNode;
|
||||
/** form empty */
|
||||
emptyRenderer?: React.ReactNode;
|
||||
defaultValues?: FormEngineProps['defaultValues'];
|
||||
onMounted?: FormEngineProps['onMounted'];
|
||||
onUnmounted?: FormEngineProps['onUnmounted'];
|
||||
onFormValuesChange?: FormEngineProps['onFormValuesChange'];
|
||||
}
|
||||
|
||||
export const useCreateForm = ({
|
||||
node,
|
||||
loadingRenderer,
|
||||
emptyRenderer,
|
||||
defaultValues,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
onFormValuesChange,
|
||||
}: UseFormOptions) => {
|
||||
const testRun = useTestRunService();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form, setForm] = useState<TestRunFormEntity | null>(null);
|
||||
const renderer = useMemo(() => {
|
||||
if (loading || !form) {
|
||||
return loadingRenderer;
|
||||
}
|
||||
|
||||
const isEmpty = isFormEmpty(form.schema);
|
||||
|
||||
return form.render({
|
||||
defaultValues,
|
||||
onFormValuesChange,
|
||||
children: isEmpty ? emptyRenderer : null,
|
||||
});
|
||||
}, [form, loading]);
|
||||
|
||||
const compute = async () => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const formEntity = await testRun.createForm(node);
|
||||
setForm(formEntity);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
compute();
|
||||
}, [node]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const disposable = new DisposableCollection(
|
||||
form.onFormMounted((data) => {
|
||||
onMounted?.(data);
|
||||
}),
|
||||
form.onFormUnmounted(() => {
|
||||
onUnmounted?.();
|
||||
})
|
||||
);
|
||||
return () => disposable.dispose();
|
||||
}, [form]);
|
||||
|
||||
return {
|
||||
renderer,
|
||||
loading,
|
||||
form,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useService } from '@flowgram.ai/core';
|
||||
|
||||
import { TestRunService } from '../../services/test-run';
|
||||
|
||||
export const useTestRunService = () => useService<TestRunService>(TestRunService);
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { useCreateForm, useTestRunService } from './hooks';
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { FlowNodeType, FlowNodeEntity } from '@flowgram.ai/document';
|
||||
|
||||
import type { MaybePromise } from '../types';
|
||||
import type { FormSchema, FormComponents } from '../form-engine';
|
||||
import type { TestRunPipelinePlugin } from './pipeline';
|
||||
|
||||
type PropertiesFunctionParams = {
|
||||
node: FlowNodeEntity;
|
||||
};
|
||||
export type NodeMap = Record<FlowNodeType, NodeTestConfig>;
|
||||
export interface NodeTestConfig {
|
||||
/** Enable node TestRun */
|
||||
enabled?: boolean;
|
||||
/** Input schema properties */
|
||||
properties?:
|
||||
| Record<string, FormSchema>
|
||||
| ((params: PropertiesFunctionParams) => MaybePromise<Record<string, FormSchema>>);
|
||||
}
|
||||
|
||||
export interface TestRunConfig {
|
||||
components: FormComponents;
|
||||
nodes: NodeMap;
|
||||
plugins: (new () => TestRunPipelinePlugin)[];
|
||||
}
|
||||
|
||||
export const TestRunConfig = Symbol('TestRunConfig');
|
||||
export const defineConfig = (config: Partial<TestRunConfig>) => {
|
||||
const defaultConfig: TestRunConfig = {
|
||||
components: {},
|
||||
nodes: {},
|
||||
plugins: [],
|
||||
};
|
||||
return {
|
||||
...defaultConfig,
|
||||
...config,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { TestRunFormEntity } from './form';
|
||||
|
||||
export const TestRunFormFactory = Symbol('TestRunFormFactory');
|
||||
export type TestRunFormFactory = () => TestRunFormEntity;
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { injectable, inject } from 'inversify';
|
||||
import { Emitter } from '@flowgram.ai/utils';
|
||||
|
||||
import { TestRunConfig } from '../config';
|
||||
import { FormSchema, FormEngine, type FormInstance, type FormEngineProps } from '../../form-engine';
|
||||
|
||||
export type FormRenderProps = Omit<
|
||||
FormEngineProps,
|
||||
'schema' | 'components' | 'onMounted' | 'onUnmounted'
|
||||
>;
|
||||
|
||||
@injectable()
|
||||
export class TestRunFormEntity {
|
||||
@inject(TestRunConfig) private readonly config: TestRunConfig;
|
||||
|
||||
private _schema: FormSchema;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
id = nanoid();
|
||||
|
||||
form: FormInstance | null = null;
|
||||
|
||||
onFormMountedEmitter = new Emitter<FormInstance>();
|
||||
|
||||
onFormMounted = this.onFormMountedEmitter.event;
|
||||
|
||||
onFormUnmountedEmitter = new Emitter<void>();
|
||||
|
||||
onFormUnmounted = this.onFormUnmountedEmitter.event;
|
||||
|
||||
get schema() {
|
||||
return this._schema;
|
||||
}
|
||||
|
||||
init(options: { schema: FormSchema }) {
|
||||
if (this.initialized) return;
|
||||
|
||||
this._schema = options.schema;
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
render(props?: FormRenderProps): ReactNode {
|
||||
if (!this.initialized) {
|
||||
return null;
|
||||
}
|
||||
const { children, ...restProps } = props || {};
|
||||
return createElement(
|
||||
FormEngine,
|
||||
{
|
||||
schema: this.schema,
|
||||
components: this.config.components,
|
||||
onMounted: (instance) => {
|
||||
this.form = instance;
|
||||
this.onFormMountedEmitter.fire(instance);
|
||||
},
|
||||
onUnmounted: this.onFormUnmountedEmitter.fire.bind(this.onFormUnmountedEmitter),
|
||||
...restProps,
|
||||
},
|
||||
children
|
||||
);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._schema = {};
|
||||
this.form = null;
|
||||
this.onFormMountedEmitter.dispose();
|
||||
this.onFormUnmountedEmitter.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { TestRunFormEntity } from './form';
|
||||
export { TestRunFormFactory } from './factory';
|
||||
export { TestRunFormManager } from './manager';
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { inject, injectable } from 'inversify';
|
||||
|
||||
import type { TestRunFormEntity } from './form';
|
||||
import { TestRunFormFactory } from './factory';
|
||||
|
||||
@injectable()
|
||||
export class TestRunFormManager {
|
||||
@inject(TestRunFormFactory) private readonly factory: TestRunFormFactory;
|
||||
|
||||
private entities = new Map<string, TestRunFormEntity>();
|
||||
|
||||
createForm() {
|
||||
return this.factory();
|
||||
}
|
||||
|
||||
getForm(id: string) {
|
||||
return this.entities.get(id);
|
||||
}
|
||||
|
||||
getAllForm() {
|
||||
return Array.from(this.entities);
|
||||
}
|
||||
|
||||
disposeForm(id: string) {
|
||||
const form = this.entities.get(id);
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
form.dispose();
|
||||
this.entities.delete(id);
|
||||
}
|
||||
|
||||
disposeAllForm() {
|
||||
for (const id of this.entities.keys()) {
|
||||
this.disposeForm(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { TestRunService } from './test-run';
|
||||
export { TestRunFormEntity, TestRunFormFactory } from './form';
|
||||
export {
|
||||
TestRunPipelineEntity,
|
||||
TestRunPipelineFactory,
|
||||
type TestRunPipelinePlugin,
|
||||
type TestRunPipelineEntityCtx,
|
||||
} from './pipeline';
|
||||
export { TestRunConfig, defineConfig } from './config';
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { TestRunPipelineEntity } from './pipeline';
|
||||
|
||||
export const TestRunPipelineFactory = Symbol('TestRunPipelineFactory');
|
||||
export type TestRunPipelineFactory = () => TestRunPipelineEntity;
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { TestRunPipelineFactory } from './factory';
|
||||
export {
|
||||
TestRunPipelineEntity,
|
||||
type TestRunPipelineEntityOptions,
|
||||
type TestRunPipelineEntityCtx,
|
||||
} from './pipeline';
|
||||
export { TestRunPipelinePlugin } from './plugin';
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { StoreApi } from 'zustand';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { injectable, interfaces } from 'inversify';
|
||||
import { Emitter } from '@flowgram.ai/utils';
|
||||
|
||||
import { Tap } from './tap';
|
||||
import type { TestRunPipelinePlugin } from './plugin';
|
||||
import { StoreService } from '../store';
|
||||
export interface TestRunPipelineEntityOptions {
|
||||
plugins: (new () => TestRunPipelinePlugin)[];
|
||||
}
|
||||
|
||||
interface TestRunPipelineEntityState<T = any> {
|
||||
status: 'idle' | 'preparing' | 'executing' | 'canceled' | 'finished' | 'disposed';
|
||||
data?: T;
|
||||
result?: any;
|
||||
getData: () => T;
|
||||
setData: (next: any) => void;
|
||||
}
|
||||
|
||||
export interface TestRunPipelineEntityCtx<T = any> {
|
||||
id: string;
|
||||
store: StoreApi<TestRunPipelineEntityState<T>>;
|
||||
operate: {
|
||||
update: (data: any) => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: Omit<TestRunPipelineEntityState, 'getData' | 'setData'> = {
|
||||
status: 'idle',
|
||||
data: {},
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class TestRunPipelineEntity extends StoreService<TestRunPipelineEntityState> {
|
||||
container: interfaces.Container | undefined;
|
||||
|
||||
id = nanoid();
|
||||
|
||||
plugins: TestRunPipelinePlugin[] = [];
|
||||
|
||||
prepare = new Tap<TestRunPipelineEntityCtx>();
|
||||
|
||||
private execute?: (ctx: TestRunPipelineEntityCtx) => Promise<void> | void;
|
||||
|
||||
private progress?: (ctx: TestRunPipelineEntityCtx) => Promise<void> | void;
|
||||
|
||||
get status() {
|
||||
return this.getState().status;
|
||||
}
|
||||
|
||||
set status(next: TestRunPipelineEntityState['status']) {
|
||||
this.setState({ status: next });
|
||||
}
|
||||
|
||||
onProgressEmitter = new Emitter<any>();
|
||||
|
||||
onProgress = this.onProgressEmitter.event;
|
||||
|
||||
onFinishedEmitter = new Emitter();
|
||||
|
||||
onFinished = this.onFinishedEmitter.event;
|
||||
|
||||
constructor() {
|
||||
super((set, get) => ({
|
||||
...initialState,
|
||||
getData: () => get().data || {},
|
||||
setData: (next: any) => set((state) => ({ ...state, data: { ...state.data, ...next } })),
|
||||
}));
|
||||
}
|
||||
|
||||
public init(options: TestRunPipelineEntityOptions) {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
const { plugins } = options;
|
||||
for (const PluginClass of plugins) {
|
||||
const plugin = this.container.resolve<TestRunPipelinePlugin>(PluginClass);
|
||||
plugin.apply(this);
|
||||
this.plugins.push(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
public registerExecute(fn: (ctx: TestRunPipelineEntityCtx) => Promise<void> | void) {
|
||||
this.execute = fn;
|
||||
}
|
||||
|
||||
public registerProgress(fn: (ctx: TestRunPipelineEntityCtx) => Promise<void> | void) {
|
||||
this.progress = fn;
|
||||
}
|
||||
|
||||
async start<T>(options?: { data: T }) {
|
||||
const { data } = options || {};
|
||||
if (this.status !== 'idle') {
|
||||
return;
|
||||
}
|
||||
/** initialization data */
|
||||
this.setState({ data });
|
||||
const ctx: TestRunPipelineEntityCtx = {
|
||||
id: this.id,
|
||||
store: this.store,
|
||||
operate: {
|
||||
update: this.update.bind(this),
|
||||
cancel: this.cancel.bind(this),
|
||||
},
|
||||
};
|
||||
|
||||
this.status = 'preparing';
|
||||
await this.prepare.call(ctx);
|
||||
if (this.status !== 'preparing') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status = 'executing';
|
||||
if (this.execute) {
|
||||
await this.execute(ctx);
|
||||
}
|
||||
if (this.progress) {
|
||||
await this.progress(ctx);
|
||||
}
|
||||
if (this.status === 'executing') {
|
||||
this.status = 'finished';
|
||||
this.onFinishedEmitter.fire(this.getState().result);
|
||||
}
|
||||
}
|
||||
|
||||
update(result: any) {
|
||||
this.setState({ result });
|
||||
this.onProgressEmitter.fire(result);
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if ((this.status = 'preparing')) {
|
||||
this.prepare.freeze();
|
||||
}
|
||||
this.status = 'canceled';
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.status = 'disposed';
|
||||
this.plugins.forEach((p) => {
|
||||
if (p.dispose) {
|
||||
p.dispose();
|
||||
}
|
||||
});
|
||||
this.onProgressEmitter.dispose();
|
||||
this.onFinishedEmitter.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { TestRunPipelineEntity } from './pipeline';
|
||||
|
||||
export interface TestRunPipelinePlugin {
|
||||
name: string;
|
||||
apply(pipeline: TestRunPipelineEntity): void;
|
||||
|
||||
dispose?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { MaybePromise } from '../../types';
|
||||
|
||||
interface TapValue<T> {
|
||||
name: string;
|
||||
fn: (arg: T) => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export class Tap<T> {
|
||||
private taps: TapValue<T>[] = [];
|
||||
|
||||
private frozen = false;
|
||||
|
||||
tap(name: string, fn: TapValue<T>['fn']) {
|
||||
this.taps.push({ name, fn });
|
||||
}
|
||||
|
||||
async call(ctx: T) {
|
||||
for (const tap of this.taps) {
|
||||
if (this.frozen) {
|
||||
return;
|
||||
}
|
||||
await tap.fn(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
freeze() {
|
||||
this.frozen = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { createStore } from 'zustand/vanilla';
|
||||
import type { StoreApi, StateCreator } from 'zustand';
|
||||
import { injectable, unmanaged } from 'inversify';
|
||||
/**
|
||||
* 包含 Store 的 Service
|
||||
*/
|
||||
@injectable()
|
||||
export class StoreService<State> {
|
||||
store: StoreApi<State>;
|
||||
|
||||
get getState() {
|
||||
return this.store.getState.bind(this.store);
|
||||
}
|
||||
|
||||
get setState() {
|
||||
return this.store.setState.bind(this.store);
|
||||
}
|
||||
|
||||
constructor(@unmanaged() stateCreator: StateCreator<State>) {
|
||||
this.store = createStore(stateCreator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { Disposable, DisposableCollection, Emitter } from '@flowgram.ai/utils';
|
||||
import type { FlowNodeEntity, FlowNodeType } from '@flowgram.ai/document';
|
||||
|
||||
import { TestRunPipelineFactory } from './pipeline/factory';
|
||||
import { TestRunFormManager } from './form';
|
||||
import { FormSchema } from '../form-engine';
|
||||
import { TestRunPipelineEntity, type TestRunPipelineEntityOptions } from './pipeline';
|
||||
import { TestRunConfig } from './config';
|
||||
|
||||
@injectable()
|
||||
export class TestRunService {
|
||||
@inject(TestRunConfig) private readonly config: TestRunConfig;
|
||||
|
||||
@inject(TestRunPipelineFactory) private readonly pipelineFactory: TestRunPipelineFactory;
|
||||
|
||||
@inject(TestRunFormManager) readonly formManager: TestRunFormManager;
|
||||
|
||||
pipelineEntities = new Map<string, TestRunPipelineEntity>();
|
||||
|
||||
pipelineBindings = new Map<string, Disposable>();
|
||||
|
||||
onPipelineProgressEmitter = new Emitter();
|
||||
|
||||
onPipelineProgress = this.onPipelineProgressEmitter.event;
|
||||
|
||||
onPipelineFinishedEmitter = new Emitter();
|
||||
|
||||
onPipelineFinished = this.onPipelineFinishedEmitter.event;
|
||||
|
||||
public isEnabled(nodeType: FlowNodeType) {
|
||||
const config = this.config.nodes[nodeType];
|
||||
return config && config?.enabled !== false;
|
||||
}
|
||||
|
||||
async toSchema(node: FlowNodeEntity) {
|
||||
const nodeType = node.flowNodeType;
|
||||
const config = this.config.nodes[nodeType];
|
||||
if (!this.isEnabled(nodeType)) {
|
||||
return {};
|
||||
}
|
||||
const properties =
|
||||
typeof config.properties === 'function'
|
||||
? await config.properties({ node })
|
||||
: config.properties;
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
};
|
||||
}
|
||||
|
||||
createFormWithSchema(schema: FormSchema) {
|
||||
const form = this.formManager.createForm();
|
||||
form.init({ schema });
|
||||
return form;
|
||||
}
|
||||
|
||||
async createForm(node: FlowNodeEntity) {
|
||||
const schema = await this.toSchema(node);
|
||||
return this.createFormWithSchema(schema);
|
||||
}
|
||||
|
||||
createPipeline(options: TestRunPipelineEntityOptions) {
|
||||
const pipeline = this.pipelineFactory();
|
||||
this.pipelineEntities.set(pipeline.id, pipeline);
|
||||
pipeline.init(options);
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
disposePipeline(id: string) {
|
||||
const pipeline = this.pipelineEntities.get(id);
|
||||
if (pipeline) {
|
||||
this.pipelineEntities.delete(id);
|
||||
pipeline.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
connectPipeline(pipeline: TestRunPipelineEntity) {
|
||||
if (this.pipelineBindings.get(pipeline.id)) {
|
||||
return;
|
||||
}
|
||||
const disposable = new DisposableCollection(
|
||||
pipeline.onProgress(this.onPipelineProgressEmitter.fire.bind(this.onPipelineProgressEmitter)),
|
||||
pipeline.onFinished(this.onPipelineFinishedEmitter.fire.bind(this.onPipelineFinishedEmitter))
|
||||
);
|
||||
this.pipelineBindings.set(pipeline.id, disposable);
|
||||
}
|
||||
|
||||
disconnectPipeline(id: string) {
|
||||
if (this.pipelineBindings.has(id)) {
|
||||
const disposable = this.pipelineBindings.get(id);
|
||||
disposable?.dispose();
|
||||
this.pipelineBindings.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectAllPipeline() {
|
||||
for (const id of this.pipelineBindings.keys()) {
|
||||
this.disconnectPipeline(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { FlowNodeType, FlowNodeEntity } from '@flowgram.ai/document';
|
||||
|
||||
import type { FormSchema, FormComponents } from './form-engine';
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
type PropertiesFunctionParams = {
|
||||
node: FlowNodeEntity;
|
||||
};
|
||||
|
||||
export interface NodeTestConfig {
|
||||
/** Enable node TestRun */
|
||||
enabled?: boolean;
|
||||
/** Input schema properties */
|
||||
properties?:
|
||||
| Record<string, FormSchema>
|
||||
| ((params: PropertiesFunctionParams) => MaybePromise<Record<string, FormSchema>>);
|
||||
}
|
||||
export type NodeMap = Record<FlowNodeType, NodeTestConfig>;
|
||||
|
||||
export interface TestRunPluginConfig {
|
||||
components?: FormComponents;
|
||||
nodes?: NodeMap;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@flowgram.ai/ts-config/tsconfig.flow.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user