diff --git a/apps/demo-free-layout/src/form-components/form-inputs/index.tsx b/apps/demo-free-layout/src/form-components/form-inputs/index.tsx
index 765a6f08..691fe36b 100644
--- a/apps/demo-free-layout/src/form-components/form-inputs/index.tsx
+++ b/apps/demo-free-layout/src/form-components/form-inputs/index.tsx
@@ -31,7 +31,9 @@ export function FormInputs() {
onChange={field.onChange}
readonly={readonly}
hasError={Object.keys(fieldState?.errors || {}).length > 0}
- schema={property}
+ constantProps={{
+ schema: property,
+ }}
/>
diff --git a/apps/demo-free-layout/src/form-components/form-item/index.tsx b/apps/demo-free-layout/src/form-components/form-item/index.tsx
index 698324e5..9d70ff03 100644
--- a/apps/demo-free-layout/src/form-components/form-item/index.tsx
+++ b/apps/demo-free-layout/src/form-components/form-item/index.tsx
@@ -14,6 +14,7 @@ interface FormItemProps {
required?: boolean;
description?: string;
labelWidth?: number;
+ vertical?: boolean;
}
export function FormItem({
children,
@@ -22,6 +23,7 @@ export function FormItem({
description,
type,
labelWidth,
+ vertical,
}: FormItemProps): JSX.Element {
const renderTitle = useCallback(
(showTooltip?: boolean) => (
@@ -42,9 +44,13 @@ export function FormItem({
width: '100%',
position: 'relative',
display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
gap: 8,
+ ...(vertical
+ ? { flexDirection: 'column' }
+ : {
+ justifyContent: 'center',
+ alignItems: 'center',
+ }),
}}
>
= (props) => {
updateProperty('default', val)}
- schema={value}
+ constantProps={{
+ schema: value,
+ }}
style={{ flexGrow: 1 }}
/>
}
diff --git a/apps/demo-free-layout/src/initial-data.ts b/apps/demo-free-layout/src/initial-data.ts
index 1f17e3cb..fd38b4d7 100644
--- a/apps/demo-free-layout/src/initial-data.ts
+++ b/apps/demo-free-layout/src/initial-data.ts
@@ -130,6 +130,20 @@ export const initialData: FlowDocumentJSON = {
},
data: {
title: 'Loop_1',
+ batchFor: {
+ type: 'ref',
+ content: ['start_0', 'array_obj'],
+ },
+ batchOutputs: {
+ results: {
+ type: 'ref',
+ content: ['llm_6aSyo', 'result'],
+ },
+ indexList: {
+ type: 'ref',
+ content: ['loop_sGybT_locals', 'index'],
+ },
+ },
},
blocks: [
{
diff --git a/apps/demo-free-layout/src/nodes/loop/index.ts b/apps/demo-free-layout/src/nodes/loop/index.ts
index fad3936b..e9519989 100644
--- a/apps/demo-free-layout/src/nodes/loop/index.ts
+++ b/apps/demo-free-layout/src/nodes/loop/index.ts
@@ -4,7 +4,7 @@ import {
PositionSchema,
FlowNodeTransformData,
} from '@flowgram.ai/free-layout-editor';
-import { provideBatchInputEffect } from '@flowgram.ai/form-materials';
+import { createBatchOutputsFormPlugin, provideBatchInputEffect } from '@flowgram.ai/form-materials';
import { defaultFormMeta } from '../default-form-meta';
import { FlowNodeRegistry } from '../../typings';
@@ -73,5 +73,6 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
effect: {
batchFor: provideBatchInputEffect,
},
+ plugins: [createBatchOutputsFormPlugin({ outputKey: 'batchOutputs' })],
},
};
diff --git a/apps/demo-free-layout/src/nodes/loop/loop-form-render.tsx b/apps/demo-free-layout/src/nodes/loop/loop-form-render.tsx
index 97456965..420a287e 100644
--- a/apps/demo-free-layout/src/nodes/loop/loop-form-render.tsx
+++ b/apps/demo-free-layout/src/nodes/loop/loop-form-render.tsx
@@ -1,6 +1,6 @@
import { FormRenderProps, FlowNodeJSON, Field } from '@flowgram.ai/free-layout-editor';
import { SubCanvasRender } from '@flowgram.ai/free-container-plugin';
-import { BatchVariableSelector, IFlowRefValue } from '@flowgram.ai/form-materials';
+import { BatchOutputs, BatchVariableSelector, IFlowRefValue } from '@flowgram.ai/form-materials';
import { useIsSidebar, useNodeRenderContext } from '../../hooks';
import { FormHeader, FormContent, FormOutputs, FormItem, Feedback } from '../../form-components';
@@ -33,12 +33,30 @@ export const LoopFormRender = ({ form }: FormRenderProps) => {
);
+ const batchOutputs = (
+ | undefined> name={`batchOutputs`}>
+ {({ field, fieldState }) => (
+
+ field.onChange(val)}
+ readonly={readonly}
+ hasError={Object.keys(fieldState?.errors || {}).length > 0}
+ />
+
+
+ )}
+
+ );
+
if (isSidebar) {
return (
<>
{batchFor}
+ {batchOutputs}
>
diff --git a/packages/materials/form-materials/bin/materials.ts b/packages/materials/form-materials/bin/materials.ts
index 6ace84e8..783a8c9d 100644
--- a/packages/materials/form-materials/bin/materials.ts
+++ b/packages/materials/form-materials/bin/materials.ts
@@ -17,7 +17,7 @@ export interface Material {
[key: string]: any; // For other properties from config.json
}
-const _types: string[] = ['components', 'effects', 'utils', 'typings'];
+const _types: string[] = ['components', 'effects', 'utils', 'typings', 'form-plugins'];
export function listAllMaterials(): Material[] {
const _materials: Material[] = [];
diff --git a/packages/materials/form-materials/src/components/batch-outputs/config.json b/packages/materials/form-materials/src/components/batch-outputs/config.json
new file mode 100644
index 00000000..42d25c63
--- /dev/null
+++ b/packages/materials/form-materials/src/components/batch-outputs/config.json
@@ -0,0 +1,12 @@
+{
+ "name": "batch-outputs",
+ "depMaterials": [
+ "flow-value",
+ "variable-selector"
+ ],
+ "depPackages": [
+ "@douyinfe/semi-ui",
+ "@douyinfe/semi-icons",
+ "styled-components"
+ ]
+}
diff --git a/packages/materials/form-materials/src/components/batch-outputs/index.tsx b/packages/materials/form-materials/src/components/batch-outputs/index.tsx
new file mode 100644
index 00000000..f2791a32
--- /dev/null
+++ b/packages/materials/form-materials/src/components/batch-outputs/index.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+
+import { Button, Input } from '@douyinfe/semi-ui';
+import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
+
+import { useList } from './use-list';
+import { PropsType } from './types';
+import { VariableSelector } from '../variable-selector';
+import { UIRow, UIRows } from './styles';
+
+export function BatchOutputs(props: PropsType) {
+ const { readonly, style } = props;
+
+ const { list, add, update, remove } = useList(props);
+
+ return (
+
+
+ {list.map((item) => (
+
+ update({ ...item, key: v })}
+ />
+
+ update({
+ ...item,
+ value: {
+ type: 'ref',
+ content: v,
+ },
+ })
+ }
+ />
+ }
+ size="small"
+ onClick={() => remove(item.id)}
+ />
+
+ ))}
+
+ } size="small" onClick={add}>
+ Add
+
+
+ );
+}
diff --git a/packages/materials/form-materials/src/components/batch-outputs/styles.tsx b/packages/materials/form-materials/src/components/batch-outputs/styles.tsx
new file mode 100644
index 00000000..07b5595e
--- /dev/null
+++ b/packages/materials/form-materials/src/components/batch-outputs/styles.tsx
@@ -0,0 +1,14 @@
+import styled from 'styled-components';
+
+export const UIRows = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-bottom: 10px;
+`;
+
+export const UIRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 5px;
+`;
diff --git a/packages/materials/form-materials/src/components/batch-outputs/types.ts b/packages/materials/form-materials/src/components/batch-outputs/types.ts
new file mode 100644
index 00000000..d39c904a
--- /dev/null
+++ b/packages/materials/form-materials/src/components/batch-outputs/types.ts
@@ -0,0 +1,17 @@
+import { IFlowRefValue } from '../../typings';
+
+export type ValueType = Record;
+
+export interface OutputItem {
+ id: number;
+ key?: string;
+ value?: IFlowRefValue;
+}
+
+export interface PropsType {
+ value?: ValueType;
+ onChange: (value?: ValueType) => void;
+ readonly?: boolean;
+ hasError?: boolean;
+ style?: React.CSSProperties;
+}
diff --git a/packages/materials/form-materials/src/components/batch-outputs/use-list.ts b/packages/materials/form-materials/src/components/batch-outputs/use-list.ts
new file mode 100644
index 00000000..541a3a1a
--- /dev/null
+++ b/packages/materials/form-materials/src/components/batch-outputs/use-list.ts
@@ -0,0 +1,81 @@
+import { useEffect, useState } from 'react';
+
+import { difference } from 'lodash';
+
+import { OutputItem, PropsType } from './types';
+
+let _id = 0;
+function genId() {
+ return _id++;
+}
+
+export function useList({ value, onChange }: PropsType) {
+ const [list, setList] = useState([]);
+
+ useEffect(() => {
+ setList((_prevList) => {
+ const newKeys = Object.keys(value || {});
+ const oldKeys = _prevList.map((item) => item.key).filter(Boolean) as string[];
+ const addKeys = difference(newKeys, oldKeys);
+
+ return _prevList
+ .filter((item) => !item.key || newKeys.includes(item.key))
+ .map((item) => ({
+ id: item.id,
+ key: item.key,
+ value: item.key ? value?.[item.key!] : undefined,
+ }))
+ .concat(
+ addKeys.map((_key) => ({
+ id: genId(),
+ key: _key,
+ value: value?.[_key],
+ }))
+ );
+ });
+ }, [value]);
+
+ const add = () => {
+ setList((prevList) => [
+ ...prevList,
+ {
+ id: genId(),
+ },
+ ]);
+ };
+
+ const update = (item: OutputItem) => {
+ setList((prevList) => {
+ const nextList = prevList.map((_item) => {
+ if (_item.id === item.id) {
+ return item;
+ }
+ return _item;
+ });
+
+ onChange(
+ Object.fromEntries(
+ nextList.filter((item) => item.key).map((item) => [item.key!, item.value])
+ )
+ );
+
+ return nextList;
+ });
+ };
+
+ const remove = (itemId: number) => {
+ setList((prevList) => {
+ const nextList = prevList.filter((_item) => _item.id !== itemId);
+
+ onChange(
+ Object.fromEntries(
+ nextList.filter((item) => item.key).map((item) => [item.key!, item.value])
+ )
+ );
+
+ return nextList;
+ });
+ };
+
+ return { list, add, update, remove };
+}
diff --git a/packages/materials/form-materials/src/components/dynamic-value-input/index.tsx b/packages/materials/form-materials/src/components/dynamic-value-input/index.tsx
index c84847d4..3d7f62c6 100644
--- a/packages/materials/form-materials/src/components/dynamic-value-input/index.tsx
+++ b/packages/materials/form-materials/src/components/dynamic-value-input/index.tsx
@@ -19,6 +19,7 @@ interface PropsType {
schema?: IJsonSchema;
constantProps?: {
strategies?: Strategy[];
+ schema?: IJsonSchema; // set schema of constant input only
[key: string]: any;
};
}
diff --git a/packages/materials/form-materials/src/components/index.ts b/packages/materials/form-materials/src/components/index.ts
index 019f2934..036087d7 100644
--- a/packages/materials/form-materials/src/components/index.ts
+++ b/packages/materials/form-materials/src/components/index.ts
@@ -5,3 +5,4 @@ export * from './batch-variable-selector';
export * from './constant-input';
export * from './dynamic-value-input';
export * from './condition-row';
+export * from './batch-outputs';
diff --git a/packages/materials/form-materials/src/effects/provide-batch-outputs/index.ts b/packages/materials/form-materials/src/effects/provide-batch-outputs/index.ts
index c392cd4d..24f7d1b9 100644
--- a/packages/materials/form-materials/src/effects/provide-batch-outputs/index.ts
+++ b/packages/materials/form-materials/src/effects/provide-batch-outputs/index.ts
@@ -9,7 +9,6 @@ import {
import { IFlowRefValue } from '../../typings';
export const provideBatchOutputsEffect: EffectOptions[] = createEffectFromVariableProvider({
- private: true,
parse: (value: Record, ctx) => [
ASTFactory.createVariableDeclaration({
key: `${ctx.node.id}`,
diff --git a/packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/config.json b/packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/config.json
new file mode 100644
index 00000000..e3c554e2
--- /dev/null
+++ b/packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/config.json
@@ -0,0 +1,7 @@
+{
+ "name": "batch-outputs-plugin",
+ "depMaterials": [
+ "flow-value"
+ ],
+ "depPackages": []
+}
diff --git a/packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/index.ts b/packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/index.ts
new file mode 100644
index 00000000..75a00e19
--- /dev/null
+++ b/packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/index.ts
@@ -0,0 +1,99 @@
+import {
+ ASTFactory,
+ createEffectFromVariableProvider,
+ defineFormPluginCreator,
+ FlowNodeRegistry,
+ getNodeForm,
+ getNodePrivateScope,
+ getNodeScope,
+ ScopeChainTransformService,
+ type EffectOptions,
+ type FormPluginCreator,
+ FlowNodeScopeType,
+} from '@flowgram.ai/editor';
+
+import { IFlowRefValue } from '../../typings';
+
+export const provideBatchOutputsEffect: EffectOptions[] = createEffectFromVariableProvider({
+ parse: (value: Record, ctx) => [
+ ASTFactory.createVariableDeclaration({
+ key: `${ctx.node.id}`,
+ meta: {
+ title: getNodeForm(ctx.node)?.getValueIn('title'),
+ icon: ctx.node.getNodeRegistry().info?.icon,
+ },
+ type: ASTFactory.createObject({
+ properties: Object.entries(value).map(([_key, value]) =>
+ ASTFactory.createProperty({
+ key: _key,
+ initializer: ASTFactory.createWrapArrayExpression({
+ wrapFor: ASTFactory.createKeyPathExpression({
+ keyPath: value?.content || [],
+ }),
+ }),
+ })
+ ),
+ }),
+ }),
+ ],
+});
+
+/**
+ * Free Layout only right now
+ */
+export const createBatchOutputsFormPlugin: FormPluginCreator<{ outputKey: string }> =
+ defineFormPluginCreator({
+ name: 'batch-outputs-plugin',
+ onSetupFormMeta({ mergeEffect }, { outputKey }) {
+ mergeEffect({
+ [outputKey]: provideBatchOutputsEffect,
+ });
+ },
+ onInit(ctx, { outputKey }) {
+ const chainTransformService = ctx.node.getService(ScopeChainTransformService);
+
+ const batchNodeType = ctx.node.flowNodeType;
+
+ const transformerId = `${batchNodeType}-outputs`;
+
+ if (chainTransformService.hasTransformer(transformerId)) {
+ return;
+ }
+
+ chainTransformService.registerTransformer(transformerId, {
+ transformCovers: (covers, ctx) => {
+ const node = ctx.scope.meta?.node;
+
+ // Child Node's variable can cover parent
+ if (node?.parent?.flowNodeType === batchNodeType) {
+ return [...covers, getNodeScope(node.parent)];
+ }
+
+ return covers;
+ },
+ transformDeps(scopes, ctx) {
+ const scopeMeta = ctx.scope.meta;
+
+ if (scopeMeta?.type === FlowNodeScopeType.private) {
+ return scopes;
+ }
+
+ const node = scopeMeta?.node;
+
+ // Public of Loop Node depends on child Node
+ if (node?.flowNodeType === batchNodeType) {
+ // Get all child blocks
+ const childBlocks = node.blocks;
+
+ // public scope of all child blocks
+ return [
+ getNodePrivateScope(node),
+ ...childBlocks.map((_childBlock) => getNodeScope(_childBlock)),
+ ];
+ }
+
+ return scopes;
+ },
+ });
+ },
+ });
diff --git a/packages/materials/form-materials/src/form-plugins/index.ts b/packages/materials/form-materials/src/form-plugins/index.ts
new file mode 100644
index 00000000..661d5188
--- /dev/null
+++ b/packages/materials/form-materials/src/form-plugins/index.ts
@@ -0,0 +1 @@
+export { createBatchOutputsFormPlugin } from './batch-outputs-plugin';
diff --git a/packages/materials/form-materials/src/index.ts b/packages/materials/form-materials/src/index.ts
index ef5b7a92..d1ea22af 100644
--- a/packages/materials/form-materials/src/index.ts
+++ b/packages/materials/form-materials/src/index.ts
@@ -2,3 +2,4 @@ export * from './components';
export * from './effects';
export * from './utils';
export * from './typings';
+export * from './form-plugins';
diff --git a/packages/node-engine/form-core/src/form/models/form-model.ts b/packages/node-engine/form-core/src/form/models/form-model.ts
index 1161cd43..ed086823 100644
--- a/packages/node-engine/form-core/src/form/models/form-model.ts
+++ b/packages/node-engine/form-core/src/form/models/form-model.ts
@@ -2,7 +2,7 @@ import { injectable } from 'inversify';
import { DisposableCollection, Event, MaybePromise } from '@flowgram.ai/utils';
import { type FlowNodeEntity } from '@flowgram.ai/document';
-import { FormFeedback, FormModelValid, IFormItem, IFormMeta } from '../types';
+import { FormFeedback, FormModelValid, IFormItem } from '../types';
import { FormManager } from '../services/form-manager';
import { type FormItem } from '.';
@@ -33,7 +33,7 @@ export abstract class FormModel {
*/
abstract get formManager(): FormManager;
- abstract get formMeta(): IFormMeta;
+ abstract get formMeta(): any;
abstract get initialized(): boolean;
diff --git a/packages/node-engine/form-core/src/form/types/form-meta.types.ts b/packages/node-engine/form-core/src/form/types/form-meta.types.ts
index 2047fb1d..a0541b00 100644
--- a/packages/node-engine/form-core/src/form/types/form-meta.types.ts
+++ b/packages/node-engine/form-core/src/form/types/form-meta.types.ts
@@ -1,6 +1,6 @@
+import { MaybePromise } from '@flowgram.ai/utils';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { PlaygroundContext } from '@flowgram.ai/core';
-import { MaybePromise } from '@flowgram.ai/utils';
import { type FormItemAbilityMeta } from './form-ability.types';
@@ -82,7 +82,7 @@ export interface IFormMeta {
/**
* 表单树结构root
*/
- root: IFormItemMeta;
+ root?: IFormItemMeta;
/**
* 表单全局配置
*/
@@ -109,7 +109,7 @@ export interface FormMetaGeneratorParams {
}
export type FormMetaGenerator = (
- params: FormMetaGeneratorParams,
+ params: FormMetaGeneratorParams
) => MaybePromise;
export type FormMetaOrFormMetaGenerator = FormMetaGenerator | IFormMeta;
diff --git a/packages/node-engine/node/__tests__/form-model-v2.test.ts b/packages/node-engine/node/__tests__/form-model-v2.test.ts
index 0f3c0165..ab61bd70 100644
--- a/packages/node-engine/node/__tests__/form-model-v2.test.ts
+++ b/packages/node-engine/node/__tests__/form-model-v2.test.ts
@@ -42,14 +42,15 @@ describe('FormModelV2', () => {
});
const formItem = formModelV2.getFormItemByPath('/');
- expect(formItem.value).toEqual({
+ expect(formItem?.value).toEqual({
a: 1,
b: 2,
});
- formItem.value = { a: 3, b: 4 };
+ // @ts-expect-error
+ formItem?.value = { a: 3, b: 4 };
- expect(formItem.value).toEqual({
+ expect(formItem?.value).toEqual({
a: 3,
b: 4,
});
@@ -336,7 +337,8 @@ describe('FormModelV2', () => {
});
it('should call onInit when formModel init', () => {
const mockInit = vi.fn();
- const plugin = defineFormPluginCreator('test', {
+ const plugin = defineFormPluginCreator({
+ name: 'test',
onInit: mockInit,
})({ opt1: 1 });
const formMeta = {
@@ -353,7 +355,8 @@ describe('FormModelV2', () => {
});
it('should call onDispose when formModel dispose', () => {
const mockDispose = vi.fn();
- const plugin = defineFormPluginCreator('test', {
+ const plugin = defineFormPluginCreator({
+ name: 'test',
onDispose: mockDispose,
})({ opt1: 1 });
const formMeta = {
@@ -373,14 +376,17 @@ describe('FormModelV2', () => {
const mockEffectPlugin = vi.fn();
const mockEffectOrigin = vi.fn();
- const plugin = defineFormPluginCreator('test', {
- effect: {
- a: [
- {
- event: DataEvent.onValueInitOrChange,
- effect: mockEffectPlugin,
- },
- ],
+ const plugin = defineFormPluginCreator({
+ name: 'test',
+ onSetupFormMeta(ctx, opts) {
+ ctx.mergeEffect({
+ a: [
+ {
+ event: DataEvent.onValueInitOrChange,
+ effect: mockEffectPlugin,
+ },
+ ],
+ });
},
})({ opt1: 1 });
@@ -407,20 +413,23 @@ describe('FormModelV2', () => {
const mockEffectOriginArrStar = vi.fn();
const mockEffectPluginOther = vi.fn();
- const plugin = defineFormPluginCreator('test', {
- effect: {
- 'arr.*': [
- {
- event: DataEvent.onValueChange,
- effect: mockEffectPluginArrStar,
- },
- ],
- other: [
- {
- event: DataEvent.onValueChange,
- effect: mockEffectPluginOther,
- },
- ],
+ const plugin = defineFormPluginCreator({
+ name: 'test',
+ onSetupFormMeta(ctx, opts) {
+ ctx.mergeEffect({
+ 'arr.*': [
+ {
+ event: DataEvent.onValueChange,
+ effect: mockEffectPluginArrStar,
+ },
+ ],
+ other: [
+ {
+ event: DataEvent.onValueChange,
+ effect: mockEffectPluginOther,
+ },
+ ],
+ });
},
})({ opt1: 1 });
diff --git a/packages/node-engine/node/src/form-model-v2.ts b/packages/node-engine/node/src/form-model-v2.ts
index 43beeff6..42bbd9b0 100644
--- a/packages/node-engine/node/src/form-model-v2.ts
+++ b/packages/node-engine/node/src/form-model-v2.ts
@@ -26,12 +26,7 @@ import {
import { FlowNodeEntity } from '@flowgram.ai/document';
import { PlaygroundContext, PluginContext } from '@flowgram.ai/core';
-import {
- convertGlobPath,
- findMatchedInMap,
- formFeedbacksToNodeCoreFormFeedbacks,
- mergeEffectMap,
-} from './utils';
+import { convertGlobPath, findMatchedInMap, formFeedbacksToNodeCoreFormFeedbacks } from './utils';
import {
DataEvent,
Effect,
@@ -133,8 +128,10 @@ export class FormModelV2 extends FormModel implements Disposable {
return this._formControl;
}
- get formMeta() {
- return this.node.getNodeRegistry().formMeta;
+ protected _formMeta: FormMeta;
+
+ get formMeta(): FormMeta {
+ return this._formMeta || (this.node.getNodeRegistry().formMeta as FormMeta);
}
get values() {
@@ -195,9 +192,6 @@ export class FormModelV2 extends FormModel implements Disposable {
this.plugins = plugins;
plugins.forEach((plugin) => {
plugin.init(this);
- if (plugin.config?.effect) {
- mergeEffectMap(this.effectMap, plugin.config.effect);
- }
});
}
@@ -209,6 +203,14 @@ export class FormModelV2 extends FormModel implements Disposable {
formData.fireChange();
});
+ (formMeta.plugins || [])?.forEach((_plugin) => {
+ if (_plugin.setupFormMeta) {
+ formMeta = _plugin.setupFormMeta(formMeta, this.nodeContext);
+ }
+ });
+
+ this._formMeta = formMeta;
+
const { validateTrigger, validate, effect } = formMeta;
if (effect) {
this.effectMap = effect;
diff --git a/packages/node-engine/node/src/form-plugin.ts b/packages/node-engine/node/src/form-plugin.ts
index 688ef76a..97155639 100644
--- a/packages/node-engine/node/src/form-plugin.ts
+++ b/packages/node-engine/node/src/form-plugin.ts
@@ -1,23 +1,31 @@
import { nanoid } from 'nanoid';
import { Disposable } from '@flowgram.ai/utils';
+import { type NodeFormContext } from '@flowgram.ai/form-core';
-import { EffectOptions, FormPluginCtx } from './types';
+import { type FormMeta, type FormPluginCtx, type FormPluginSetupMetaCtx } from './types';
import { FormModelV2 } from './form-model-v2';
export interface FormPluginConfig {
+ /**
+ * form plugin name, for debug use
+ */
+ name?: string;
+
+ /**
+ * setup formMeta
+ * @param ctx
+ * @returns
+ */
+ onSetupFormMeta?: (ctx: FormPluginSetupMetaCtx, opts: Opts) => void;
+
/**
* FormModel 初始化时执行
* @param ctx
*/
onInit?: (ctx: FormPluginCtx, opts: Opts) => void;
- /**
- * 同 FormMeta 中的effects 会与 FormMeta 中的effects 合并
- */
- effect?: Record;
/**
* FormModel 销毁时执行
- * @param ctx
*/
onDispose?: (ctx: FormPluginCtx, opts: Opts) => void;
}
@@ -33,10 +41,11 @@ export class FormPlugin implements Disposable {
protected _formModel: FormModelV2;
- constructor(name: string, config: FormPluginConfig, opts?: Opts) {
- this.name = name;
- this.pluginId = `${name}__${nanoid()}`;
+ constructor(config: FormPluginConfig, opts?: Opts) {
+ this.name = config?.name || '';
+ this.pluginId = `${this.name}__${nanoid()}`;
this.config = config;
+
this.opts = opts;
}
@@ -52,6 +61,49 @@ export class FormPlugin implements Disposable {
};
}
+ setupFormMeta(formMeta: FormMeta, nodeContext: NodeFormContext): FormMeta {
+ const nextFormMeta: FormMeta = {
+ ...formMeta,
+ };
+
+ this.config.onSetupFormMeta?.(
+ {
+ mergeEffect: (effect) => {
+ nextFormMeta.effect = {
+ ...(nextFormMeta.effect || {}),
+ ...effect,
+ };
+ },
+ mergeValidate: (validate) => {
+ nextFormMeta.validate = {
+ ...(nextFormMeta.validate || {}),
+ ...validate,
+ };
+ },
+ addFormatOnInit: (formatOnInit) => {
+ if (!nextFormMeta.formatOnInit) {
+ nextFormMeta.formatOnInit = formatOnInit;
+ return;
+ }
+ const legacyFormatOnInit = nextFormMeta.formatOnInit;
+ nextFormMeta.formatOnInit = (v, c) => formatOnInit?.(legacyFormatOnInit(v, c), c);
+ },
+ addFormatOnSubmit: (formatOnSubmit) => {
+ if (!nextFormMeta.formatOnSubmit) {
+ nextFormMeta.formatOnSubmit = formatOnSubmit;
+ return;
+ }
+ const legacyFormatOnSubmit = nextFormMeta.formatOnSubmit;
+ nextFormMeta.formatOnSubmit = (v, c) => formatOnSubmit?.(legacyFormatOnSubmit(v, c), c);
+ },
+ ...nodeContext,
+ },
+ this.opts
+ );
+
+ return nextFormMeta;
+ }
+
init(formModel: FormModelV2) {
this._formModel = formModel;
this.config?.onInit?.(this.ctx, this.opts);
@@ -64,8 +116,10 @@ export class FormPlugin implements Disposable {
}
}
-export function defineFormPluginCreator(name: string, config: FormPluginConfig) {
+export type FormPluginCreator = (opts: Opts) => FormPlugin;
+
+export function defineFormPluginCreator(config: FormPluginConfig): FormPluginCreator {
return function (opts: Opts) {
- return new FormPlugin(name, config, opts);
+ return new FormPlugin(config, opts);
};
}
diff --git a/packages/node-engine/node/src/types.ts b/packages/node-engine/node/src/types.ts
index e7fcf474..0b0ec1fb 100644
--- a/packages/node-engine/node/src/types.ts
+++ b/packages/node-engine/node/src/types.ts
@@ -138,6 +138,13 @@ export type FormPluginCtx = {
formModel: FormModelV2;
} & NodeContext;
+export type FormPluginSetupMetaCtx = {
+ mergeEffect: (effect: Record) => void;
+ mergeValidate: (validate: Record) => void;
+ addFormatOnInit: (formatOnInit: FormMeta['formatOnInit']) => void;
+ addFormatOnSubmit: (formatOnSubmit: FormMeta['formatOnSubmit']) => void;
+} & NodeContext;
+
export interface onFormValueChangeInPayload {
value: TValue;
prevValue: TValue;
diff --git a/packages/plugins/node-variable-plugin/src/form-v2/create-provider-effect.ts b/packages/plugins/node-variable-plugin/src/form-v2/create-provider-effect.ts
index e8452907..9a9c9cf7 100644
--- a/packages/plugins/node-variable-plugin/src/form-v2/create-provider-effect.ts
+++ b/packages/plugins/node-variable-plugin/src/form-v2/create-provider-effect.ts
@@ -10,7 +10,7 @@ import { type VariableProviderAbilityOptions } from '../types';
* @returns
*/
export function createEffectFromVariableProvider(
- options: VariableProviderAbilityOptions,
+ options: VariableProviderAbilityOptions
): EffectOptions[] {
const getScope = (node: FlowNodeEntity): Scope => {
const variableData: FlowNodeVariableData = node.getData(FlowNodeVariableData);
@@ -42,7 +42,7 @@ export function createEffectFromVariableProvider(
return [
{
event: DataEvent.onValueInit,
- effect: (params => {
+ effect: ((params) => {
const { context } = params;
const scope = getScope(context.node);
@@ -51,8 +51,6 @@ export function createEffectFromVariableProvider(
scope,
options,
formItem: undefined,
- // Hack: 新表单引擎暂时不支持 triggerSync
- triggerSync: undefined as any,
});
if (disposable) {
@@ -65,7 +63,7 @@ export function createEffectFromVariableProvider(
},
{
event: DataEvent.onValueChange,
- effect: (params => {
+ effect: ((params) => {
transformValueToAST(params);
}) as Effect,
},
diff --git a/packages/plugins/node-variable-plugin/src/form-v2/create-variable-provider-plugin.ts b/packages/plugins/node-variable-plugin/src/form-v2/create-variable-provider-plugin.ts
index 0917b1e7..bb8c9e99 100644
--- a/packages/plugins/node-variable-plugin/src/form-v2/create-variable-provider-plugin.ts
+++ b/packages/plugins/node-variable-plugin/src/form-v2/create-variable-provider-plugin.ts
@@ -1,20 +1,23 @@
import { DataEvent, defineFormPluginCreator } from '@flowgram.ai/node';
-export const createVariableProviderPlugin = defineFormPluginCreator('VariableProviderPlugin', {
+export const createVariableProviderPlugin = defineFormPluginCreator({
+ name: 'VariableProviderPlugin',
onInit: (ctx, opts) => {
// todo
// console.log('>>> VariableProviderPlugin init', ctx, opts);
},
- effect: {
- arr: [
- {
- event: DataEvent.onValueInitOrChange,
- effect: () => {
- // todo
- // console.log('>>> VariableProviderPlugin effect triggered');
+ onSetupFormMeta({ mergeEffect }) {
+ mergeEffect({
+ arr: [
+ {
+ event: DataEvent.onValueInitOrChange,
+ effect: () => {
+ // todo
+ // console.log('>>> VariableProviderPlugin effect triggered');
+ },
},
- },
- ],
+ ],
+ });
},
onDispose: (ctx, opts) => {
// todo
diff --git a/packages/plugins/node-variable-plugin/src/types.ts b/packages/plugins/node-variable-plugin/src/types.ts
index 90332c94..a76aeb4f 100644
--- a/packages/plugins/node-variable-plugin/src/types.ts
+++ b/packages/plugins/node-variable-plugin/src/types.ts
@@ -3,9 +3,9 @@ import {
type ASTNodeJSON,
type VariableDeclarationJSON,
} from '@flowgram.ai/variable-plugin';
+import { Disposable } from '@flowgram.ai/utils';
import { FormItem } from '@flowgram.ai/form-core';
import { FlowNodeEntity } from '@flowgram.ai/document';
-import { Disposable } from '@flowgram.ai/utils';
export interface VariableAbilityCommonContext {
node: FlowNodeEntity; // 节点
@@ -14,9 +14,7 @@ export interface VariableAbilityCommonContext {
options: VariableAbilityOptions;
}
-export interface VariableAbilityInitCtx extends VariableAbilityCommonContext {
- triggerSync: () => void; // 触发变量同步
-}
+export interface VariableAbilityInitCtx extends VariableAbilityCommonContext {}
export interface VariableAbilityOptions {
// 变量提供能力可复用
diff --git a/packages/plugins/variable-plugin/src/index.ts b/packages/plugins/variable-plugin/src/index.ts
index 04d39d92..96877c80 100644
--- a/packages/plugins/variable-plugin/src/index.ts
+++ b/packages/plugins/variable-plugin/src/index.ts
@@ -1,3 +1,12 @@
export * from './create-variable-plugin';
export * from '@flowgram.ai/variable-core';
-export { FlowNodeVariableData, GlobalScope } from '@flowgram.ai/variable-layout';
+export {
+ FlowNodeVariableData,
+ GlobalScope,
+ ScopeChainTransformService,
+ getNodeScope,
+ getNodePrivateScope,
+ FlowNodeScopeType,
+ type FlowNodeScopeMeta,
+ type FlowNodeScope,
+} from '@flowgram.ai/variable-layout';
diff --git a/packages/variable-engine/variable-core/src/ast/ast-node.ts b/packages/variable-engine/variable-core/src/ast/ast-node.ts
index f896182a..c738fc5a 100644
--- a/packages/variable-engine/variable-core/src/ast/ast-node.ts
+++ b/packages/variable-engine/variable-core/src/ast/ast-node.ts
@@ -12,8 +12,8 @@ import { shallowEqual } from 'fast-equals';
import { Disposable, DisposableCollection } from '@flowgram.ai/utils';
import { subsToDisposable } from '../utils/toDisposable';
-import { type Scope } from '../scope';
import { updateChildNodeHelper } from './utils/helpers';
+import { type Scope } from '../scope';
import {
type ASTNodeJSON,
type ObserverOrNext,
@@ -73,7 +73,7 @@ export abstract class ASTNode
/**
* 节点的版本号,每 fireChange 一次 version + 1
*/
- private _version: number = 0;
+ protected _version: number = 0;
/**
* 更新锁
@@ -109,8 +109,8 @@ export abstract class ASTNode
Disposable.create(() => {
// 子元素删除时,父元素触发更新
this.parent?.fireChange();
- this.children.forEach(child => child.dispose());
- }),
+ this.children.forEach((child) => child.dispose());
+ })
);
/**
@@ -191,7 +191,7 @@ export abstract class ASTNode
child.toDispose.push(
Disposable.create(() => {
this._children.delete(child);
- }),
+ })
);
return child;
@@ -204,7 +204,7 @@ export abstract class ASTNode
protected updateChildNodeByKey(keyInThis: keyof this, nextJSON?: ASTNodeJSON) {
this.withBatchUpdate(updateChildNodeHelper).call(this, {
getChildNode: () => this[keyInThis] as ASTNode,
- updateChildNode: _node => ((this as any)[keyInThis] = _node),
+ updateChildNode: (_node) => ((this as any)[keyInThis] = _node),
removeChildNode: () => ((this as any)[keyInThis] = undefined),
nextJSON,
});
@@ -216,7 +216,7 @@ export abstract class ASTNode
* @returns
*/
protected withBatchUpdate(
- updater: (...args: ParamTypes) => ReturnType,
+ updater: (...args: ParamTypes) => ReturnType
) {
return (...args: ParamTypes) => {
// batchUpdate 里面套 batchUpdate 只能生效一次
@@ -281,7 +281,7 @@ export abstract class ASTNode
*/
subscribe(
observer: ObserverOrNext,
- { selector, debounceAnimation, triggerOnInit }: SubscribeConfig = {},
+ { selector, debounceAnimation, triggerOnInit }: SubscribeConfig = {}
): Disposable {
return subsToDisposable(
this.value$
@@ -289,25 +289,25 @@ export abstract class ASTNode
map(() => (selector ? selector(this) : (this as any))),
distinctUntilChanged(
(a, b) => shallowEqual(a, b),
- value => {
+ (value) => {
if (value instanceof ASTNode) {
// 如果 value 是 ASTNode,则进行 hash 的比较
return value.hash;
}
return value;
- },
+ }
),
// 默认跳过 BehaviorSubject 第一次触发
triggerOnInit ? tap(() => null) : skip(1),
// 每个 animationFrame 内所有更新合并成一个
- debounceAnimation ? debounceTime(0, animationFrameScheduler) : tap(() => null),
+ debounceAnimation ? debounceTime(0, animationFrameScheduler) : tap(() => null)
)
- .subscribe(observer),
+ .subscribe(observer)
);
}
dispatchGlobalEvent(
- event: Omit,
+ event: Omit
) {
this.scope.event.dispatch({
...event,
diff --git a/packages/variable-engine/variable-core/src/ast/declaration/base-variable-field.ts b/packages/variable-engine/variable-core/src/ast/declaration/base-variable-field.ts
index 8e6dc0c9..28961f01 100644
--- a/packages/variable-engine/variable-core/src/ast/declaration/base-variable-field.ts
+++ b/packages/variable-engine/variable-core/src/ast/declaration/base-variable-field.ts
@@ -35,6 +35,10 @@ export abstract class BaseVariableField extends ASTNode<
return getParentFields(this);
}
+ get keyPath(): string[] {
+ return this.parentFields.reverse().map((_field) => _field.key);
+ }
+
get meta(): VariableMeta {
return this._meta;
}
@@ -47,6 +51,10 @@ export abstract class BaseVariableField extends ASTNode<
return this._initializer;
}
+ get hash(): string {
+ return `[${this._version}]${this.keyPath.join('.')}`;
+ }
+
/**
* 解析 VariableDeclarationJSON 从而生成变量声明节点
*/
@@ -96,7 +104,7 @@ export abstract class BaseVariableField extends ASTNode<
* @returns
*/
onTypeChange(observer: (type: ASTNode | undefined) => void) {
- return this.subscribe(observer, { selector: curr => curr.type });
+ return this.subscribe(observer, { selector: (curr) => curr.type });
}
/**
diff --git a/packages/variable-engine/variable-core/src/ast/expression/wrap-array-expression.ts b/packages/variable-engine/variable-core/src/ast/expression/wrap-array-expression.ts
index 65184051..5fb3d863 100644
--- a/packages/variable-engine/variable-core/src/ast/expression/wrap-array-expression.ts
+++ b/packages/variable-engine/variable-core/src/ast/expression/wrap-array-expression.ts
@@ -28,6 +28,7 @@ export class WrapArrayExpression extends BaseExpression
refreshReturnType() {
// 被遍历表达式的返回值
const childReturnTypeJSON = this.wrapFor?.returnType?.toJSON();
+
this.updateChildNodeByKey('_returnType', {
kind: ASTKind.Array,
items: childReturnTypeJSON,
@@ -51,9 +52,12 @@ export class WrapArrayExpression extends BaseExpression
@postConstructAST()
protected init() {
+ this.refreshReturnType = this.refreshReturnType.bind(this);
+
this.toDispose.push(
this.subscribe(this.refreshReturnType, {
selector: (curr) => curr.wrapFor?.returnType,
+ triggerOnInit: true,
})
);
}
diff --git a/packages/variable-engine/variable-core/src/scope/datas/scope-available-data.ts b/packages/variable-engine/variable-core/src/scope/datas/scope-available-data.ts
index 7a1bfef3..09d227b4 100644
--- a/packages/variable-engine/variable-core/src/scope/datas/scope-available-data.ts
+++ b/packages/variable-engine/variable-core/src/scope/datas/scope-available-data.ts
@@ -6,7 +6,9 @@ import {
merge,
share,
skip,
+ startWith,
switchMap,
+ tap,
} from 'rxjs';
import { flatten } from 'lodash';
import { shallowEqual } from 'fast-equals';
@@ -17,7 +19,7 @@ import { IVariableTable } from '../types';
import { type Scope } from '../scope';
import { subsToDisposable } from '../../utils/toDisposable';
import { createMemo } from '../../utils/memo';
-import { Property, VariableDeclaration } from '../../ast';
+import { BaseVariableField, VariableDeclaration } from '../../ast';
/**
* 作用域可用变量
*/
@@ -135,11 +137,35 @@ export class ScopeAvailableData {
* @param keyPath
* @returns
*/
- getByKeyPath(keyPath: string[] = []): VariableDeclaration | Property | undefined {
+ getByKeyPath(keyPath: string[] = []): BaseVariableField | undefined {
// 检查变量是否在可访问范围内
if (!this.variableKeys.includes(keyPath[0])) {
return;
}
return this.globalVariableTable.getByKeyPath(keyPath);
}
+
+ /**
+ * Track Variable Change (Includes type update and children update) By KeyPath
+ * @returns
+ */
+ trackByKeyPath(
+ keyPath: string[] = [],
+ cb: (variable?: BaseVariableField | undefined) => void,
+ opts?: {
+ triggerOnInit?: boolean;
+ }
+ ): Disposable {
+ const { triggerOnInit = true } = opts || {};
+
+ return subsToDisposable(
+ merge(this.anyVariableChange$, this.variables$)
+ .pipe(
+ triggerOnInit ? startWith() : tap(() => null),
+ map(() => this.getByKeyPath(keyPath)),
+ distinctUntilChanged((_prevNode, _node) => _prevNode?.hash !== _node?.hash)
+ )
+ .subscribe(cb)
+ );
+ }
}
diff --git a/packages/variable-engine/variable-core/src/scope/scope.ts b/packages/variable-engine/variable-core/src/scope/scope.ts
index d1432823..8c95a7ff 100644
--- a/packages/variable-engine/variable-core/src/scope/scope.ts
+++ b/packages/variable-engine/variable-core/src/scope/scope.ts
@@ -2,7 +2,7 @@ import { DisposableCollection } from '@flowgram.ai/utils';
import { type VariableEngine } from '../variable-engine';
import { createMemo } from '../utils/memo';
-import { ASTKind, MapNode } from '../ast';
+import { ASTKind, type ASTNode, type ASTNodeJSON, MapNode } from '../ast';
import { ScopeAvailableData, ScopeEventData, ScopeOutputData } from './datas';
export interface IScopeConstructor {
@@ -117,4 +117,44 @@ export class Scope = Record>
get disposed(): boolean {
return this.toDispose.disposed;
}
+
+ /**
+ * Sets a variable in the Scope with the default key 'outputs'.
+ *
+ * @param json - The JSON value to store.
+ * @returns The updated AST node.
+ */
+ public setVar(json: ASTNodeJSON): ASTNode;
+
+ public setVar(arg1: string | ASTNodeJSON, arg2?: ASTNodeJSON): ASTNode {
+ if (typeof arg1 === 'string' && arg2 !== undefined) {
+ return this.ast.set(arg1, arg2);
+ }
+
+ if (typeof arg1 === 'object' && arg2 === undefined) {
+ return this.ast.set('outputs', arg1);
+ }
+
+ throw new Error('Invalid arguments');
+ }
+
+ /**
+ * Retrieves a variable from the Scope by key.
+ *
+ * @param key - The key of the variable to retrieve. Defaults to 'outputs'.
+ * @returns The value of the variable, or undefined if not found.
+ */
+ public getVar(key: string = 'outputs') {
+ return this.ast.get(key);
+ }
+
+ /**
+ * Clears a variable from the Scope by key.
+ *
+ * @param key - The key of the variable to clear. Defaults to 'outputs'.
+ * @returns The updated AST node.
+ */
+ public clearVar(key: string = 'outputs') {
+ return this.ast.remove(key);
+ }
}
diff --git a/packages/variable-engine/variable-layout/__mocks__/container.ts b/packages/variable-engine/variable-layout/__mocks__/container.ts
index 95878b81..a1bb2052 100644
--- a/packages/variable-engine/variable-layout/__mocks__/container.ts
+++ b/packages/variable-engine/variable-layout/__mocks__/container.ts
@@ -24,10 +24,12 @@ import { WorkflowDocumentContainerModule, WorkflowLinesManager, WorkflowSimpleLi
export interface TestConfig extends VariableLayoutConfig {
enableGlobalScope?: boolean;
+ onInit?: (container: Container) => void;
+ runExtraTest?: (container: Container) => void
}
export function getContainer(layout: 'free' | 'fixed', config?: TestConfig): Container {
- const { enableGlobalScope, ...layoutConfig } = config || {};
+ const { enableGlobalScope, onInit, runExtraTest, ...layoutConfig } = config || {};
const container = createPlaygroundContainer() as Container;
container.load(VariableContainerModule);
@@ -74,5 +76,7 @@ export function getContainer(layout: 'free' | 'fixed', config?: TestConfig): Con
);
document.registerNodeDatas(FlowNodeVariableData);
+ onInit?.(container);
+
return container;
}
diff --git a/packages/variable-engine/variable-layout/__mocks__/run-fixed-layout-test.ts b/packages/variable-engine/variable-layout/__mocks__/run-fixed-layout-test.ts
index 0a4bee85..88dbddb6 100644
--- a/packages/variable-engine/variable-layout/__mocks__/run-fixed-layout-test.ts
+++ b/packages/variable-engine/variable-layout/__mocks__/run-fixed-layout-test.ts
@@ -95,6 +95,8 @@ export const runFixedLayoutTest = (testName:string, spec: FlowDocumentJSON, conf
test('test sort', () => {
expect(variableEngine.getAllScopes({ sort: true }).map(_scope => _scope.id)).toMatchSnapshot();
});
+
+ config?.runExtraTest?.(container);
});
diff --git a/packages/variable-engine/variable-layout/__mocks__/run-free-layout-test.ts b/packages/variable-engine/variable-layout/__mocks__/run-free-layout-test.ts
index b1bb6ee9..2863f159 100644
--- a/packages/variable-engine/variable-layout/__mocks__/run-free-layout-test.ts
+++ b/packages/variable-engine/variable-layout/__mocks__/run-free-layout-test.ts
@@ -95,6 +95,8 @@ export const runFreeLayoutTest = (testName: string, spec: WorkflowJSON, config?:
test('test sort', () => {
expect(variableEngine.getAllScopes({ sort: true }).map(_scope => _scope.id)).toMatchSnapshot();
});
+
+ config?.runExtraTest?.(container);
});
diff --git a/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-fix-layout-transform-empty.test.ts.snap b/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-fix-layout-transform-empty.test.ts.snap
new file mode 100644
index 00000000..2a9717ac
--- /dev/null
+++ b/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-fix-layout-transform-empty.test.ts.snap
@@ -0,0 +1,109 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Variable Fixed Layout transform empty > test get Covers 1`] = `
+Map {
+ "start_0" => [],
+ "end_0" => [],
+ "base_1" => [],
+ "base_2" => [],
+ "loop_1" => [],
+ "base_in_loop_1" => [],
+ "base_in_loop_2" => [],
+ "base_in_loop_3" => [],
+ "base_3" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test get Covers After Init Private 1`] = `
+Map {
+ "start_0" => [],
+ "end_0" => [],
+ "base_1" => [],
+ "base_2" => [],
+ "loop_1" => [],
+ "base_in_loop_1" => [],
+ "base_in_loop_2" => [],
+ "base_in_loop_3" => [],
+ "base_3" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test get Deps 1`] = `
+Map {
+ "start_0" => [],
+ "end_0" => [],
+ "base_1" => [],
+ "base_2" => [],
+ "loop_1" => [],
+ "base_in_loop_1" => [],
+ "base_in_loop_2" => [],
+ "base_in_loop_3" => [],
+ "base_3" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test get Deps After Init Private 1`] = `
+Map {
+ "start_0" => [],
+ "end_0" => [],
+ "base_1" => [],
+ "base_2" => [],
+ "loop_1" => [],
+ "base_in_loop_1" => [],
+ "base_in_loop_2" => [],
+ "base_in_loop_3" => [],
+ "base_3" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test get private scope Covers 1`] = `
+Map {
+ "start_0_private" => [],
+ "end_0_private" => [],
+ "base_1_private" => [],
+ "base_2_private" => [],
+ "loop_1_private" => [],
+ "base_in_loop_1_private" => [],
+ "base_in_loop_2_private" => [],
+ "base_in_loop_3_private" => [],
+ "base_3_private" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test get private scope Deps 1`] = `
+Map {
+ "start_0_private" => [],
+ "end_0_private" => [],
+ "base_1_private" => [],
+ "base_2_private" => [],
+ "loop_1_private" => [],
+ "base_in_loop_1_private" => [],
+ "base_in_loop_2_private" => [],
+ "base_in_loop_3_private" => [],
+ "base_3_private" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test sort 1`] = `
+[
+ "start_0",
+ "testScope",
+ "end_0",
+ "base_1",
+ "base_2",
+ "loop_1",
+ "base_in_loop_1",
+ "base_in_loop_2",
+ "base_in_loop_3",
+ "base_3",
+ "start_0_private",
+ "end_0_private",
+ "base_1_private",
+ "base_2_private",
+ "loop_1_private",
+ "base_in_loop_1_private",
+ "base_in_loop_2_private",
+ "base_in_loop_3_private",
+ "base_3_private",
+]
+`;
diff --git a/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-free-layout-transform-empty.test.ts.snap b/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-free-layout-transform-empty.test.ts.snap
index 591d1479..8b028bef 100644
--- a/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-free-layout-transform-empty.test.ts.snap
+++ b/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-free-layout-transform-empty.test.ts.snap
@@ -107,3 +107,111 @@ exports[`Variable Free Layout > test sort 1`] = `
"base_3_private",
]
`;
+
+exports[`Variable Free Layout transform empty > test get Covers 1`] = `
+Map {
+ "start_0" => [],
+ "end_0" => [],
+ "base_1" => [],
+ "base_2" => [],
+ "loop_1" => [],
+ "base_in_loop_1" => [],
+ "base_in_loop_2" => [],
+ "base_in_loop_3" => [],
+ "base_3" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test get Covers After Init Private 1`] = `
+Map {
+ "start_0" => [],
+ "end_0" => [],
+ "base_1" => [],
+ "base_2" => [],
+ "loop_1" => [],
+ "base_in_loop_1" => [],
+ "base_in_loop_2" => [],
+ "base_in_loop_3" => [],
+ "base_3" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test get Deps 1`] = `
+Map {
+ "start_0" => [],
+ "end_0" => [],
+ "base_1" => [],
+ "base_2" => [],
+ "loop_1" => [],
+ "base_in_loop_1" => [],
+ "base_in_loop_2" => [],
+ "base_in_loop_3" => [],
+ "base_3" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test get Deps After Init Private 1`] = `
+Map {
+ "start_0" => [],
+ "end_0" => [],
+ "base_1" => [],
+ "base_2" => [],
+ "loop_1" => [],
+ "base_in_loop_1" => [],
+ "base_in_loop_2" => [],
+ "base_in_loop_3" => [],
+ "base_3" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test get private scope Covers 1`] = `
+Map {
+ "start_0_private" => [],
+ "end_0_private" => [],
+ "base_1_private" => [],
+ "base_2_private" => [],
+ "loop_1_private" => [],
+ "base_in_loop_1_private" => [],
+ "base_in_loop_2_private" => [],
+ "base_in_loop_3_private" => [],
+ "base_3_private" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test get private scope Deps 1`] = `
+Map {
+ "start_0_private" => [],
+ "end_0_private" => [],
+ "base_1_private" => [],
+ "base_2_private" => [],
+ "loop_1_private" => [],
+ "base_in_loop_1_private" => [],
+ "base_in_loop_2_private" => [],
+ "base_in_loop_3_private" => [],
+ "base_3_private" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test sort 1`] = `
+[
+ "testScope",
+ "start_0",
+ "end_0",
+ "base_1",
+ "base_2",
+ "loop_1",
+ "base_in_loop_1",
+ "base_in_loop_2",
+ "base_in_loop_3",
+ "base_3",
+ "start_0_private",
+ "end_0_private",
+ "base_1_private",
+ "base_2_private",
+ "loop_1_private",
+ "base_in_loop_1_private",
+ "base_in_loop_2_private",
+ "base_in_loop_3_private",
+ "base_3_private",
+]
+`;
diff --git a/packages/variable-engine/variable-layout/__tests__/variable-fix-layout-transform-empty.test.ts b/packages/variable-engine/variable-layout/__tests__/variable-fix-layout-transform-empty.test.ts
new file mode 100644
index 00000000..0b8b5bd1
--- /dev/null
+++ b/packages/variable-engine/variable-layout/__tests__/variable-fix-layout-transform-empty.test.ts
@@ -0,0 +1,34 @@
+import { test, expect } from 'vitest';
+
+import { ScopeChainTransformService } from '../src';
+import { runFixedLayoutTest } from '../__mocks__/run-fixed-layout-test';
+import { freeLayout1 } from '../__mocks__/free-layout-specs';
+
+runFixedLayoutTest('Variable Fixed Layout transform empty', freeLayout1, {
+ onInit(container) {
+ const transformService = container.get(ScopeChainTransformService);
+
+ transformService.registerTransformer('MOCK', {
+ transformCovers: (scopes) => scopes,
+ transformDeps: (scopes) => scopes,
+ });
+
+ // again transformer, prevent duplicated transformerId
+ transformService.registerTransformer('MOCK', {
+ transformCovers: () => [],
+ transformDeps: () => [],
+ });
+ transformService.registerTransformer('MOCK', {
+ transformCovers: () => [],
+ transformDeps: () => [],
+ });
+ },
+ runExtraTest: (container) => {
+ test('check has transformer', () => {
+ const transformService = container.get(ScopeChainTransformService);
+ expect(transformService.hasTransformer('MOCK')).to.be.true;
+ expect(transformService.hasTransformer('VARIABLE_LAYOUT_CONFIG')).to.be.false;
+ expect(transformService.hasTransformer('NOT_EXIST')).to.be.false;
+ });
+ },
+});
diff --git a/packages/variable-engine/variable-layout/__tests__/variable-free-layout-transform-empty.test.ts b/packages/variable-engine/variable-layout/__tests__/variable-free-layout-transform-empty.test.ts
index 1bb566b6..0d1f5952 100644
--- a/packages/variable-engine/variable-layout/__tests__/variable-free-layout-transform-empty.test.ts
+++ b/packages/variable-engine/variable-layout/__tests__/variable-free-layout-transform-empty.test.ts
@@ -1,8 +1,17 @@
+import { test, expect } from 'vitest';
+
+import { ScopeChainTransformService } from '../src';
import { runFreeLayoutTest } from '../__mocks__/run-free-layout-test';
import { freeLayout1 } from '../__mocks__/free-layout-specs';
-runFreeLayoutTest('Variable Free Layout', freeLayout1, {
+runFreeLayoutTest('Variable Free Layout transform empty', freeLayout1, {
// 模拟清空作用域
transformCovers: () => [],
transformDeps: () => [],
+ runExtraTest: (container) => {
+ test('check has transformer', () => {
+ const transformService = container.get(ScopeChainTransformService);
+ expect(transformService.hasTransformer('VARIABLE_LAYOUT_CONFIG')).to.be.true;
+ });
+ },
});
diff --git a/packages/variable-engine/variable-layout/src/chains/fixed-layout-scope-chain.ts b/packages/variable-engine/variable-layout/src/chains/fixed-layout-scope-chain.ts
index 4817f6fa..7881a119 100644
--- a/packages/variable-engine/variable-layout/src/chains/fixed-layout-scope-chain.ts
+++ b/packages/variable-engine/variable-layout/src/chains/fixed-layout-scope-chain.ts
@@ -136,9 +136,11 @@ export class FixedLayoutScopeChain extends ScopeChain {
// If scope is GlobalScope, return all scopes except GlobalScope
if (GlobalScope.is(scope)) {
- return this.variableEngine
+ const scopes = this.variableEngine
.getAllScopes({ sort: true })
.filter((_scope) => !GlobalScope.is(_scope));
+
+ return this.transformService.transformCovers(scopes, { scope });
}
const node = scope.meta.node;
diff --git a/packages/variable-engine/variable-layout/src/chains/free-layout-scope-chain.ts b/packages/variable-engine/variable-layout/src/chains/free-layout-scope-chain.ts
index f92e1eba..d49391c0 100644
--- a/packages/variable-engine/variable-layout/src/chains/free-layout-scope-chain.ts
+++ b/packages/variable-engine/variable-layout/src/chains/free-layout-scope-chain.ts
@@ -53,19 +53,19 @@ export class FreeLayoutScopeChain extends ScopeChain {
// 获取同一层级所有输入节点
protected getAllInputLayerNodes(curr: FlowNodeEntity): FlowNodeEntity[] {
- const currParent = this.getParent(curr);
+ const currParent = this.getNodeParent(curr);
return (curr.getData(WorkflowNodeLinesData)?.allInputNodes || []).filter(
- (_node) => this.getParent(_node) === currParent
+ (_node) => this.getNodeParent(_node) === currParent
);
}
// 获取同一层级所有输出节点
protected getAllOutputLayerNodes(curr: FlowNodeEntity): FlowNodeEntity[] {
- const currParent = this.getParent(curr);
+ const currParent = this.getNodeParent(curr);
return (curr.getData(WorkflowNodeLinesData)?.allOutputNodes || []).filter(
- (_node) => this.getParent(_node) === currParent
+ (_node) => this.getNodeParent(_node) === currParent
);
}
@@ -94,7 +94,7 @@ export class FreeLayoutScopeChain extends ScopeChain {
deps.push(currVarData.private);
}
- curr = this.getParent(curr);
+ curr = this.getNodeParent(curr);
}
// If scope is GlobalScope, add globalScope to deps
@@ -110,9 +110,11 @@ export class FreeLayoutScopeChain extends ScopeChain {
getCovers(scope: FlowNodeScope): FlowNodeScope[] {
// If scope is GlobalScope, return all scopes except GlobalScope
if (GlobalScope.is(scope)) {
- return this.variableEngine
+ const scopes = this.variableEngine
.getAllScopes({ sort: true })
.filter((_scope) => !GlobalScope.is(_scope));
+
+ return this.transformService.transformCovers(scopes, { scope });
}
const { node } = scope.meta || {};
@@ -127,7 +129,7 @@ export class FreeLayoutScopeChain extends ScopeChain {
if (isPrivate) {
// private 只能覆盖其子节点
- queue.push(...this.getChildren(node));
+ queue.push(...this.getNodeChildren(node));
} else {
// 否则覆盖其所有输出线的节点
queue.push(...(this.getAllOutputLayerNodes(node) || []));
@@ -140,7 +142,7 @@ export class FreeLayoutScopeChain extends ScopeChain {
const _node = queue.shift()!;
const variableData: FlowNodeVariableData = _node.getData(FlowNodeVariableData);
scopes.push(...variableData.allScopes);
- const children = _node && this.getChildren(_node);
+ const children = _node && this.getNodeChildren(_node);
if (children?.length) {
queue.push(...children);
@@ -158,9 +160,9 @@ export class FreeLayoutScopeChain extends ScopeChain {
return this.transformService.transformCovers(uniqScopes, { scope });
}
- getChildren(node: FlowNodeEntity): FlowNodeEntity[] {
- if (this.configs?.getFreeChildren) {
- return this.configs.getFreeChildren?.(node);
+ getNodeChildren(node: FlowNodeEntity): FlowNodeEntity[] {
+ if (this.configs?.getNodeChildren) {
+ return this.configs.getNodeChildren?.(node);
}
const nodeMeta = node.getNodeMeta();
const subCanvas = nodeMeta.subCanvas?.(node);
@@ -178,10 +180,10 @@ export class FreeLayoutScopeChain extends ScopeChain {
return this.tree.getChildren(node);
}
- getParent(node: FlowNodeEntity): FlowNodeEntity | undefined {
+ getNodeParent(node: FlowNodeEntity): FlowNodeEntity | undefined {
// 部分场景通过连线来表达父子关系,因此需要上层配置
- if (this.configs?.getFreeParent) {
- return this.configs.getFreeParent(node);
+ if (this.configs?.getNodeParent) {
+ return this.configs.getNodeParent(node);
}
let parent = node.document.originTree.getParent(node);
diff --git a/packages/variable-engine/variable-layout/src/flow-node-variable-data.ts b/packages/variable-engine/variable-layout/src/flow-node-variable-data.ts
index 493a4675..f5f71f84 100644
--- a/packages/variable-engine/variable-layout/src/flow-node-variable-data.ts
+++ b/packages/variable-engine/variable-layout/src/flow-node-variable-data.ts
@@ -1,4 +1,4 @@
-import { VariableEngine } from '@flowgram.ai/variable-core';
+import { BaseVariableField, VariableEngine } from '@flowgram.ai/variable-core';
import { type ASTNode, ASTNodeJSON } from '@flowgram.ai/variable-core';
import { FlowNodeEntity } from '@flowgram.ai/document';
import { EntityData } from '@flowgram.ai/core';
@@ -179,4 +179,22 @@ export class FlowNodeVariableData extends EntityData {
}
return this._private;
}
+
+ /**
+ * Find a variable field by key path in the public scope by scope chain.
+ * @param keyPath - The key path of the variable field.
+ * @returns The variable field, or undefined if not found.
+ */
+ getByKeyPath(keyPath: string[]): BaseVariableField | undefined {
+ return this.public.available.getByKeyPath(keyPath);
+ }
+
+ /**
+ * Find a variable field by key path in the private scope by scope chain.
+ * @param keyPath - The key path of the variable field.
+ * @returns The variable field, or undefined if not found.
+ */
+ getByKeyPathInPrivate(keyPath: string[]): BaseVariableField | undefined {
+ return this.private?.available.getByKeyPath(keyPath);
+ }
}
diff --git a/packages/variable-engine/variable-layout/src/index.ts b/packages/variable-engine/variable-layout/src/index.ts
index ba431ecf..998c1c8b 100644
--- a/packages/variable-engine/variable-layout/src/index.ts
+++ b/packages/variable-engine/variable-layout/src/index.ts
@@ -9,3 +9,4 @@ export {
} from './types';
export { GlobalScope, bindGlobalScope } from './scopes/global-scope';
export { ScopeChainTransformService } from './services/scope-chain-transform-service';
+export { getNodeScope, getNodePrivateScope } from './utils';
diff --git a/packages/variable-engine/variable-layout/src/scopes/global-scope.ts b/packages/variable-engine/variable-layout/src/scopes/global-scope.ts
index 8c2dce74..e47654f0 100644
--- a/packages/variable-engine/variable-layout/src/scopes/global-scope.ts
+++ b/packages/variable-engine/variable-layout/src/scopes/global-scope.ts
@@ -1,62 +1,13 @@
import { injectable, interfaces } from 'inversify';
-import { ASTNode, ASTNodeJSON, Scope, VariableEngine } from '@flowgram.ai/variable-core';
+import { Scope, VariableEngine } from '@flowgram.ai/variable-core';
@injectable()
export class GlobalScope extends Scope {
static readonly ID = Symbol('GlobalScope');
- static is(scope: Scope): scope is GlobalScope {
+ static is(scope: Scope) {
return scope.id === GlobalScope.ID;
}
-
- /**
- * Sets a variable in the Global Scope with the given key and JSON value.
- *
- * @param key - The key under which the variable will be stored.
- * @param json - The JSON value to store.
- * @returns The updated AST node.
- */
- public setVar(key: string, json: ASTNodeJSON): ASTNode;
-
- /**
- * Sets a variable in the Global Scope with the default key 'outputs'.
- *
- * @param json - The JSON value to store.
- * @returns The updated AST node.
- */
- public setVar(json: ASTNodeJSON): ASTNode;
-
- public setVar(arg1: string | ASTNodeJSON, arg2?: ASTNodeJSON): ASTNode {
- if (typeof arg1 === 'string' && arg2 !== undefined) {
- return this.ast.set(arg1, arg2);
- }
-
- if (typeof arg1 === 'object' && arg2 === undefined) {
- return this.ast.set('outputs', arg1);
- }
-
- throw new Error('Invalid arguments');
- }
-
- /**
- * Retrieves a variable from the Global Scope by key.
- *
- * @param key - The key of the variable to retrieve. Defaults to 'outputs'.
- * @returns The value of the variable, or undefined if not found.
- */
- public getVar(key: string = 'outputs') {
- return this.ast.get(key);
- }
-
- /**
- * Clears a variable from the Global Scope by key.
- *
- * @param key - The key of the variable to clear. Defaults to 'outputs'.
- * @returns The updated AST node.
- */
- public clearVar(key: string = 'outputs') {
- return this.ast.remove(key);
- }
}
export const bindGlobalScope = (bind: interfaces.Bind) => {
diff --git a/packages/variable-engine/variable-layout/src/services/scope-chain-transform-service.ts b/packages/variable-engine/variable-layout/src/services/scope-chain-transform-service.ts
index 37fb5818..0a71eee3 100644
--- a/packages/variable-engine/variable-layout/src/services/scope-chain-transform-service.ts
+++ b/packages/variable-engine/variable-layout/src/services/scope-chain-transform-service.ts
@@ -14,11 +14,14 @@ export interface TransformerContext {
export type IScopeTransformer = (scopes: Scope[], ctx: TransformerContext) => Scope[];
+const passthrough: IScopeTransformer = (scopes, ctx) => scopes;
+
@injectable()
export class ScopeChainTransformService {
- protected transformDepsFns: IScopeTransformer[] = [];
-
- protected transformCoversFns: IScopeTransformer[] = [];
+ protected transformerMap: Map<
+ string,
+ { transformDeps: IScopeTransformer; transformCovers: IScopeTransformer }
+ > = new Map();
@lazyInject(FlowDocument) document: FlowDocument;
@@ -29,43 +32,65 @@ export class ScopeChainTransformService {
@inject(VariableLayoutConfig)
protected configs?: VariableLayoutConfig
) {
- if (this.configs?.transformDeps) {
- this.transformDepsFns.push(this.configs.transformDeps);
- }
- if (this.configs?.transformCovers) {
- this.transformCoversFns.push(this.configs.transformCovers);
+ if (this.configs?.transformDeps || this.configs?.transformCovers) {
+ this.transformerMap.set('VARIABLE_LAYOUT_CONFIG', {
+ transformDeps: this.configs.transformDeps || passthrough,
+ transformCovers: this.configs.transformCovers || passthrough,
+ });
}
}
- registerTransformDeps(transformer: IScopeTransformer) {
- this.transformDepsFns.push(transformer);
+ /**
+ * check if transformer registered
+ * @param transformerId used to identify transformer, prevent duplicated
+ * @returns
+ */
+ hasTransformer(transformerId: string) {
+ return this.transformerMap.has(transformerId);
}
- registerTransformCovers(transformer: IScopeTransformer) {
- this.transformCoversFns.push(transformer);
+ /**
+ * register new transform function
+ * @param transformerId used to identify transformer, prevent duplicated transformer
+ * @param transformer
+ */
+ registerTransformer(
+ transformerId: string,
+ transformer: {
+ transformDeps: IScopeTransformer;
+ transformCovers: IScopeTransformer;
+ }
+ ) {
+ this.transformerMap.set(transformerId, transformer);
}
transformDeps(scopes: Scope[], { scope }: { scope: Scope }): Scope[] {
- return this.transformDepsFns.reduce(
- (scopes, transformer) =>
- transformer(scopes, {
- scope,
- document: this.document,
- variableEngine: this.variableEngine,
- }),
- scopes
- );
+ return Array.from(this.transformerMap.values()).reduce((scopes, transformer) => {
+ if (!transformer.transformDeps) {
+ return scopes;
+ }
+
+ scopes = transformer.transformDeps(scopes, {
+ scope,
+ document: this.document,
+ variableEngine: this.variableEngine,
+ });
+ return scopes;
+ }, scopes);
}
transformCovers(scopes: Scope[], { scope }: { scope: Scope }): Scope[] {
- return this.transformCoversFns.reduce(
- (scopes, transformer) =>
- transformer(scopes, {
- scope,
- document: this.document,
- variableEngine: this.variableEngine,
- }),
- scopes
- );
+ return Array.from(this.transformerMap.values()).reduce((scopes, transformer) => {
+ if (!transformer.transformCovers) {
+ return scopes;
+ }
+
+ scopes = transformer.transformCovers(scopes, {
+ scope,
+ document: this.document,
+ variableEngine: this.variableEngine,
+ });
+ return scopes;
+ }, scopes);
}
}
diff --git a/packages/variable-engine/variable-layout/src/types.ts b/packages/variable-engine/variable-layout/src/types.ts
index 80c4b679..caa08c73 100644
--- a/packages/variable-engine/variable-layout/src/types.ts
+++ b/packages/variable-engine/variable-layout/src/types.ts
@@ -19,4 +19,4 @@ export interface ScopeVirtualNode {
export type ScopeChainNode = FlowNodeEntity | ScopeVirtualNode;
// 节点内部的作用域
-export type FlowNodeScope = Scope;
+export interface FlowNodeScope extends Scope {}
diff --git a/packages/variable-engine/variable-layout/src/utils.ts b/packages/variable-engine/variable-layout/src/utils.ts
new file mode 100644
index 00000000..422806d3
--- /dev/null
+++ b/packages/variable-engine/variable-layout/src/utils.ts
@@ -0,0 +1,11 @@
+import { FlowNodeEntity } from '@flowgram.ai/document';
+
+import { FlowNodeVariableData } from './flow-node-variable-data';
+
+export function getNodeScope(node: FlowNodeEntity) {
+ return node.getData(FlowNodeVariableData).public;
+}
+
+export function getNodePrivateScope(node: FlowNodeEntity) {
+ return node.getData(FlowNodeVariableData).initPrivate();
+}
diff --git a/packages/variable-engine/variable-layout/src/variable-layout-config.ts b/packages/variable-engine/variable-layout/src/variable-layout-config.ts
index 94b43aa0..d80046ba 100644
--- a/packages/variable-engine/variable-layout/src/variable-layout-config.ts
+++ b/packages/variable-engine/variable-layout/src/variable-layout-config.ts
@@ -12,19 +12,18 @@ export interface VariableLayoutConfig {
isNodeChildrenPrivate?: (node: ScopeChainNode) => boolean;
/**
- * 用于自由画布场景,部分场景通过连线或者其他交互形式来表达节点之间的父子关系,需要可配置化
+ * 用于固定布局场景时:父子中间存在大量无用节点(如 inlineBlocks 等,需要配置化略过)
+ * 用于自由画布场景时:部分场景通过连线或者其他交互形式来表达节点之间的父子关系,需可配置化
*/
- getFreeChildren?: (node: FlowNodeEntity) => FlowNodeEntity[];
- getFreeParent?: (node: FlowNodeEntity) => FlowNodeEntity | undefined;
+ getNodeChildren?: (node: FlowNodeEntity) => FlowNodeEntity[];
+ getNodeParent?: (node: FlowNodeEntity) => FlowNodeEntity | undefined;
/**
- * @deprecated
* 对依赖作用域进行微调
*/
transformDeps?: IScopeTransformer;
/**
- * @deprecated
* 对依赖作用域进行微调
*/
transformCovers?: IScopeTransformer;