feat(demo-fixed-layout): add case-default/break-loop/if nodes,condition -> switch (#336)

* feat(demo-fixed-layout): add case-default/break-loop/if nodes

* feat(demo-fixed-layout): condition -> switch

* chore: e2e fixed
This commit is contained in:
xiamidaxia 2025-06-06 19:07:15 +08:00 committed by GitHub
parent bb623e9fe6
commit de863df6fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 398 additions and 95 deletions

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" focusable="false" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M9.56066 2.43934C10.1464 3.02513 10.1464 3.97487 9.56066 4.56066L7.12132 7H14.75C18.8353 7 22 10.5796 22 14.5C22 18.4204 18.8353 22 14.75 22H11.5C10.6716 22 10 21.3284 10 20.5C10 19.6716 10.6716 19 11.5 19H14.75C17.016 19 19 16.9308 19 14.5C19 12.0692 17.016 10 14.75 10H7.12132L9.56066 12.4393C10.1464 13.0251 10.1464 13.9749 9.56066 14.5607C8.97487 15.1464 8.02513 15.1464 7.43934 14.5607L2.43934 9.56066C1.85355 8.97487 1.85355 8.02513 2.43934 7.43934L7.43934 2.43934C8.02513 1.85355 8.97487 1.85355 9.56066 2.43934Z" fill="#54A9FF"></path></svg>

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -53,7 +53,8 @@ export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
* isBlockIcon: 整个 condition
* isBlockOrderIcon: 分支的第一个节点
*/
...(nodeRender.isBlockOrderIcon || nodeRender.isBlockIcon ? { width: 260 } : {}),
...(nodeRender.isBlockOrderIcon || nodeRender.isBlockIcon ? {} : {}),
...nodeRender.node.getNodeRegistry().meta.style,
outline: form?.state.invalid ? '1px solid red' : 'none',
}}
>

View File

@ -20,9 +20,12 @@ export default function BranchAdder(props: PropsType) {
function addBranch() {
const block = operation.addBlock(
node,
node.flowNodeType === 'condition'
node.flowNodeType === 'switch'
? CaseNodeRegistry.onAdd!(ctx, node)
: CatchBlockNodeRegistry.onAdd!(ctx, node)
: CatchBlockNodeRegistry.onAdd!(ctx, node),
{
index: 0,
}
);
setTimeout(() => {

View File

@ -55,7 +55,7 @@ export function NodeList(props: { onSelect: (meta: any) => void; from: FlowNodeE
};
return (
<NodesWrap style={{ width: 80 * 2 + 20 }}>
{FlowNodeRegistries.map((registry) => (
{FlowNodeRegistries.filter((registry) => !registry.meta?.addDisable).map((registry) => (
<Node
key={registry.type}
disabled={!(registry.canAdd?.(context, props.from) ?? true)}

View File

@ -63,8 +63,8 @@ export const SidebarRenderer = () => {
if (!node) {
return false;
}
const { disableSideBar = false } = node.getNodeMeta<FlowNodeMeta>();
return !disableSideBar;
const { sidebarDisable = false } = node.getNodeMeta<FlowNodeMeta>();
return !sidebarDisable;
}, [node]);
if (playground.config.readonly) {
@ -73,11 +73,12 @@ export const SidebarRenderer = () => {
/**
* Add "key" to rerender the sidebar when the node changes
*/
const content = node ? (
<PlaygroundEntityContext.Provider key={node.id} value={node}>
<SidebarNodeRenderer node={node} />
</PlaygroundEntityContext.Provider>
) : null;
const content =
node && visible ? (
<PlaygroundEntityContext.Provider key={node.id} value={node}>
<SidebarNodeRenderer node={node} />
</PlaygroundEntityContext.Provider>
) : null;
return (
<SideSheet mask={false} visible={visible} onCancel={handleClose}>

View File

@ -88,63 +88,115 @@ export const initialData: FlowDocumentJSON = {
},
},
{
id: 'loop_0',
type: 'loop',
id: 'if_0',
type: 'if',
data: {
title: 'Loop',
batchFor: {
type: 'ref',
content: ['start_0', 'array_obj'],
title: 'If',
inputsValues: {
condition: { type: 'constant', content: true },
},
inputs: {
type: 'object',
required: ['condition'],
properties: {
condition: {
type: 'boolean',
},
},
},
},
blocks: [
{
id: 'condition_0',
type: 'condition',
id: 'if_true',
type: 'ifBlock',
data: {
title: 'Condition',
title: 'true',
},
blocks: [],
},
{
id: 'if_false',
type: 'ifBlock',
data: {
title: 'false',
},
blocks: [
{
id: 'case_0',
type: 'case',
id: 'loop_0',
type: 'loop',
data: {
title: 'If_0',
inputsValues: {
condition: { type: 'constant', content: true },
},
inputs: {
type: 'object',
required: ['condition'],
properties: {
condition: {
type: 'boolean',
},
},
title: 'Loop',
batchFor: {
type: 'ref',
content: ['start_0', 'array_obj'],
},
},
blocks: [],
},
{
id: 'case_1',
type: 'case',
data: {
title: 'If_1',
inputsValues: {
condition: { type: 'constant', content: true },
},
inputs: {
type: 'object',
required: ['condition'],
properties: {
condition: {
type: 'boolean',
},
blocks: [
{
id: 'switch_0',
type: 'switch',
data: {
title: 'Switch',
},
blocks: [
{
id: 'case_0',
type: 'case',
data: {
title: 'Case_0',
inputsValues: {
condition: { type: 'constant', content: true },
},
inputs: {
type: 'object',
required: ['condition'],
properties: {
condition: {
type: 'boolean',
},
},
},
},
blocks: [],
},
{
id: 'case_1',
type: 'case',
data: {
title: 'Case_1',
inputsValues: {
condition: { type: 'constant', content: true },
},
inputs: {
type: 'object',
required: ['condition'],
properties: {
condition: {
type: 'boolean',
},
},
},
},
},
{
id: 'case_default_1',
type: 'caseDefault',
data: {
title: 'Default',
},
blocks: [
{
id: 'break_0',
type: 'breakLoop',
data: {
title: 'BreakLoop',
},
},
],
},
],
},
},
meta: {},
blocks: [],
],
},
],
},

View File

@ -0,0 +1,13 @@
import { FormMeta } from '@flowgram.ai/fixed-layout-editor';
import { FormHeader } from '../../form-components';
export const renderForm = () => (
<>
<FormHeader />
</>
);
export const formMeta: FormMeta = {
render: renderForm,
};

View File

@ -0,0 +1,42 @@
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '../../typings';
import iconBreak from '../../assets/icon-break.svg';
import { formMeta } from './form-meta';
/**
* Break loop
*/
export const BreakLoopNodeRegistry: FlowNodeRegistry = {
type: 'breakLoop',
extend: 'end',
info: {
icon: iconBreak,
description: 'Break in current Loop.',
},
meta: {
style: {
width: 240,
},
},
/**
* Render node via formMeta
*/
formMeta,
canAdd(ctx, from) {
while (from.parent) {
if (from.parent.flowNodeType === 'loop') return true;
from = from.parent;
}
return false;
},
onAdd(ctx, from) {
return {
id: `break_${nanoid()}`,
type: 'breakLoop',
data: {
title: 'BreakLoop',
},
};
},
};

View File

@ -0,0 +1,32 @@
import { FormRenderProps, FormMeta, ValidateTrigger } from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeJSON } from '../../typings';
import { FormHeader, FormContent, FormInputs, FormOutputs } from '../../form-components';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON['data']>) => (
<>
<FormHeader />
<FormContent>
<FormInputs />
<FormOutputs />
</FormContent>
</>
);
export const formMeta: FormMeta<FlowNodeJSON['data']> = {
render: renderForm,
validateTrigger: ValidateTrigger.onChange,
validate: {
'inputsValues.*': ({ value, context, formValues, name }) => {
const valuePropetyKey = name.replace(/^inputsValues\./, '');
const required = formValues.inputs?.required || [];
if (
required.includes(valuePropetyKey) &&
(value === '' || value === undefined || value?.content === '')
) {
return `${valuePropetyKey} is required`;
}
return undefined;
},
},
};

View File

@ -0,0 +1,31 @@
import { FlowNodeRegistry } from '../../typings';
import iconCase from '../../assets/icon-case.png';
import { formMeta } from './form-meta';
export const CaseDefaultNodeRegistry: FlowNodeRegistry = {
type: 'caseDefault',
/**
* block
* Branch nodes need to inherit from 'block'
*/
extend: 'case',
meta: {
copyDisable: true,
addDisable: true,
/**
* caseDefault
* "caseDefault" is always in the last branch, so dragging and sorting is not allowed.
*/
draggable: false,
deleteDisable: true,
style: {
width: 240,
},
},
info: {
icon: iconCase,
description: 'Switch default branch',
},
canDelete: (ctx, node) => false,
formMeta,
};

View File

@ -1,7 +1,7 @@
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '../../typings';
import iconIf from '../../assets/icon-if.png';
import iconCase from '../../assets/icon-case.png';
import { formMeta } from './form-meta';
let id = 2;
@ -14,19 +14,19 @@ export const CaseNodeRegistry: FlowNodeRegistry = {
extend: 'block',
meta: {
copyDisable: true,
addDisable: true,
},
info: {
icon: iconIf,
icon: iconCase,
description: 'Execute the branch when the condition is met.',
},
canAdd: () => false,
canDelete: (ctx, node) => node.parent!.blocks.length >= 3,
onAdd(ctx, from) {
return {
id: `if_${nanoid(5)}`,
id: `Case_${nanoid(5)}`,
type: 'case',
data: {
title: `If_${id++}`,
title: `Case_${id++}`,
inputs: {
type: 'object',
required: ['condition'],

View File

@ -1,7 +1,7 @@
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '../../typings';
import iconIf from '../../assets/icon-if.png';
import iconCase from '../../assets/icon-case.png';
import { formMeta } from './form-meta';
let id = 3;
@ -9,9 +9,10 @@ export const CatchBlockNodeRegistry: FlowNodeRegistry = {
type: 'catchBlock',
meta: {
copyDisable: true,
addDisable: true,
},
info: {
icon: iconIf,
icon: iconCase,
description: 'Execute the catch branch when the condition is met.',
},
canAdd: () => false,

View File

@ -0,0 +1,28 @@
import { FormRenderProps, FormMeta, Field } from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeJSON } from '../../typings';
import { useNodeRenderContext } from '../../hooks';
export const renderForm = (props: FormRenderProps<FlowNodeJSON['data']>) => {
const { node } = useNodeRenderContext();
return (
<div
style={{
width: '100%',
height: '100%',
backgroundColor: node.index === 0 ? 'green' : 'red',
color: 'white',
display: 'flex',
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Field name="title">{({ field }) => <>{field.value}</>}</Field>
</div>
);
};
export const formMeta: FormMeta<FlowNodeJSON['data']> = {
render: renderForm,
};

View File

@ -0,0 +1,30 @@
import { FlowNodeRegistry } from '../../typings';
import iconIf from '../../assets/icon-if.png';
import { formMeta } from './form-meta';
export const IFBlockNodeRegistry: FlowNodeRegistry = {
type: 'ifBlock',
/**
* block
* Branch nodes need to inherit from 'block'
*/
extend: 'block',
meta: {
copyDisable: true,
addDisable: true,
sidebarDisable: true,
defaultExpanded: false,
style: {
width: 66,
height: 20,
borderRadius: 4,
},
},
info: {
icon: iconIf,
description: '',
},
canAdd: () => false,
canDelete: (ctx, node) => false,
formMeta,
};

View File

@ -0,0 +1,57 @@
import { nanoid } from 'nanoid';
import { FlowNodeSplitType } from '@flowgram.ai/fixed-layout-editor';
import { defaultFormMeta } from '../default-form-meta';
import { FlowNodeRegistry } from '../../typings';
import iconIf from '../../assets/icon-if.png';
export const IFNodeRegistry: FlowNodeRegistry = {
extend: FlowNodeSplitType.STATIC_SPLIT,
type: 'if',
info: {
icon: iconIf,
description: 'Only the corresponding branch will be executed if the set conditions are met.',
},
meta: {
expandable: false, // disable expanded
},
formMeta: defaultFormMeta,
onAdd() {
return {
id: `if_${nanoid(5)}`,
type: 'if',
data: {
title: 'If',
inputsValues: {
condition: { type: 'constant', content: true },
},
inputs: {
type: 'object',
required: ['condition'],
properties: {
condition: {
type: 'boolean',
},
},
},
},
blocks: [
{
id: nanoid(5),
type: 'ifBlock',
data: {
title: 'true',
},
blocks: [],
},
{
id: nanoid(5),
type: 'ifBlock',
data: {
title: 'false',
},
},
],
};
},
};

View File

@ -1,20 +1,28 @@
import { type FlowNodeRegistry } from '../typings';
import { TryCatchNodeRegistry } from './trycatch';
import { SwitchNodeRegistry } from './switch';
import { StartNodeRegistry } from './start';
import { LoopNodeRegistry } from './loop';
import { LLMNodeRegistry } from './llm';
import { IFBlockNodeRegistry } from './if-block';
import { IFNodeRegistry } from './if';
import { EndNodeRegistry } from './end';
import { ConditionNodeRegistry } from './condition';
import { CatchBlockNodeRegistry } from './catch-block';
import { CaseDefaultNodeRegistry } from './case-default';
import { CaseNodeRegistry } from './case';
import { BreakLoopNodeRegistry } from './break-loop';
export const FlowNodeRegistries: FlowNodeRegistry[] = [
StartNodeRegistry,
EndNodeRegistry,
ConditionNodeRegistry,
SwitchNodeRegistry,
LLMNodeRegistry,
LoopNodeRegistry,
CaseNodeRegistry,
TryCatchNodeRegistry,
CatchBlockNodeRegistry,
IFNodeRegistry,
IFBlockNodeRegistry,
BreakLoopNodeRegistry,
CaseDefaultNodeRegistry,
];

View File

@ -10,6 +10,7 @@ export const StartNodeRegistry: FlowNodeRegistry = {
selectable: false, // Start node cannot select
copyDisable: true, // Start node cannot copy
expandable: false, // disable expanded
addDisable: true, // Start Node cannot be added
},
info: {
icon: iconStart,
@ -20,10 +21,4 @@ export const StartNodeRegistry: FlowNodeRegistry = {
* Render node via formMeta
*/
formMeta,
/**
* Start Node cannot be added
*/
canAdd() {
return false;
},
};

View File

@ -5,9 +5,9 @@ import { defaultFormMeta } from '../default-form-meta';
import { FlowNodeRegistry } from '../../typings';
import iconCondition from '../../assets/icon-condition.svg';
export const ConditionNodeRegistry: FlowNodeRegistry = {
export const SwitchNodeRegistry: FlowNodeRegistry = {
extend: FlowNodeSplitType.DYNAMIC_SPLIT,
type: 'condition',
type: 'switch',
info: {
icon: iconCondition,
description:
@ -19,17 +19,17 @@ export const ConditionNodeRegistry: FlowNodeRegistry = {
formMeta: defaultFormMeta,
onAdd() {
return {
id: `condition_${nanoid(5)}`,
type: 'condition',
id: `switch_${nanoid(5)}`,
type: 'switch',
data: {
title: 'Condition',
title: 'Switch',
},
blocks: [
{
id: nanoid(5),
type: 'case',
data: {
title: 'If_0',
title: 'Case_0',
inputsValues: {
condition: { type: 'constant', content: '' },
},
@ -49,7 +49,7 @@ export const ConditionNodeRegistry: FlowNodeRegistry = {
id: nanoid(5),
type: 'case',
data: {
title: 'If_1',
title: 'Case_1',
inputsValues: {
condition: { type: 'constant', content: '' },
},
@ -64,6 +64,14 @@ export const ConditionNodeRegistry: FlowNodeRegistry = {
},
},
},
{
id: nanoid(5),
type: 'caseDefault',
data: {
title: 'Default',
},
blocks: [],
},
],
};
},

View File

@ -43,14 +43,15 @@ export interface FlowNodeJSON extends FlowNodeJSONDefault {
* meta
*/
export interface FlowNodeMeta extends FlowNodeMetaDefault {
disableSideBar?: boolean;
sidebarDisable?: boolean;
style?: React.CSSProperties;
}
/**
* You can customize your own node registry
*
*/
export interface FlowNodeRegistry extends FlowNodeRegistryDefault {
meta: FlowNodeMeta;
meta?: FlowNodeMeta;
info: {
icon: string;
description: string;

View File

@ -63,8 +63,8 @@ export const SidebarRenderer = () => {
if (!node) {
return false;
}
const { disableSideBar = false } = node.getNodeMeta<FlowNodeMeta>();
return !disableSideBar;
const { sidebarDisable = false } = node.getNodeMeta<FlowNodeMeta>();
return !sidebarDisable;
}, [node]);
if (playground.config.readonly) {
@ -73,11 +73,12 @@ export const SidebarRenderer = () => {
/**
* Add "key" to rerender the sidebar when the node changes
*/
const content = node ? (
<PlaygroundEntityContext.Provider key={node.id} value={node}>
<SidebarNodeRenderer node={node} />
</PlaygroundEntityContext.Provider>
) : null;
const content =
node && visible ? (
<PlaygroundEntityContext.Provider key={node.id} value={node}>
<SidebarNodeRenderer node={node} />
</PlaygroundEntityContext.Provider>
) : null;
return (
<SideSheet mask={false} visible={visible} onCancel={handleClose}>

View File

@ -6,7 +6,7 @@ import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
import { createFreeSnapPlugin } from '@flowgram.ai/free-snap-plugin';
import { createFreeNodePanelPlugin } from '@flowgram.ai/free-node-panel-plugin';
import { createFreeLinesPlugin } from '@flowgram.ai/free-lines-plugin';
import { FreeLayoutProps, WorkflowNodeLinesData } from '@flowgram.ai/free-layout-editor';
import { FreeLayoutProps, WorkflowNodeLinesData, Form } from '@flowgram.ai/free-layout-editor';
import { createFreeGroupPlugin } from '@flowgram.ai/free-group-plugin';
import { createContainerNodePlugin } from '@flowgram.ai/free-container-plugin';

View File

@ -4,7 +4,7 @@ import { FlowNodeRegistry } from '../../typings';
export const CommentNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Comment,
meta: {
disableSideBar: true,
sidebarDisable: true,
defaultPorts: [],
renderKey: WorkflowNodeType.Comment,
size: {

View File

@ -1,5 +1,4 @@
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 { useIsSidebar, useNodeRenderContext } from '../../hooks';
@ -48,7 +47,6 @@ export const LoopFormRender = ({ form }: FormRenderProps<LoopNodeJSON>) => {
<FormHeader />
<FormContent>
{batchFor}
<SubCanvasRender />
<FormOutputs />
</FormContent>
</>

View File

@ -44,7 +44,7 @@ export interface FlowNodeJSON extends FlowNodeJSONDefault {
* meta
*/
export interface FlowNodeMeta extends WorkflowNodeMeta {
disableSideBar?: boolean;
sidebarDisable?: boolean;
}
/**

View File

@ -25,7 +25,7 @@ class FixedLayoutModel {
}
public async isConditionNodeExist() {
return await this.page.locator('[data-node-id="$blockIcon$condition_0"]').count();
return await this.page.locator('[data-node-id="$blockIcon$switch_0"]').count();
}
public async insert(searchText: string, { from, to }: InsertEdgeOptions) {

View File

@ -21,11 +21,11 @@ test.describe('node operations', () => {
test('add node', async () => {
const prevCount = await editorPage.getNodeCount();
await editorPage.insert('condition', {
await editorPage.insert('switch', {
from: 'llm_0',
to: 'loop_0',
to: 'if_0',
});
const defaultNodeCount = await editorPage.getNodeCount();
expect(defaultNodeCount).toEqual(prevCount + 3);
expect(defaultNodeCount).toEqual(prevCount + 4);
});
});

View File

@ -174,7 +174,7 @@ export class FlowNodeEntity extends Entity<FlowNodeEntityConfig> {
return this.document.renderTree.getParent(this);
}
getNodeRegistry<M extends FlowNodeRegistry = FlowNodeRegistry>(): M {
getNodeRegistry<M extends FlowNodeRegistry = FlowNodeRegistry & { meta: FlowNodeMeta }>(): M {
if (this._registerCache) return this._registerCache as M;
this._registerCache = this.document.getNodeRegistry(this.flowNodeType, this.originParent);
return this._registerCache as M;