Kaynağa Gözat

feat(demo-free-layout): add multi-condition node (#1026)

lq9958 3 hafta önce
ebeveyn
işleme
048e6ac294

+ 6 - 0
apps/demo-free-layout/src/nodes/constants.ts

@@ -3,6 +3,11 @@
  * SPDX-License-Identifier: MIT
  */
 
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
 export enum WorkflowNodeType {
   Start = 'start',
   End = 'end',
@@ -11,6 +16,7 @@ export enum WorkflowNodeType {
   Code = 'code',
   Variable = 'variable',
   Condition = 'condition',
+  MultiCondition = 'multi-condition',
   Loop = 'loop',
   BlockStart = 'block-start',
   BlockEnd = 'block-end',

+ 7 - 0
apps/demo-free-layout/src/nodes/index.ts

@@ -3,6 +3,11 @@
  * SPDX-License-Identifier: MIT
  */
 
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
 import { FlowNodeRegistry } from '../typings';
 import { VariableNodeRegistry } from './variable';
 import { StartNodeRegistry } from './start';
@@ -18,6 +23,7 @@ import { CodeNodeRegistry } from './code';
 import { BreakNodeRegistry } from './break';
 import { BlockStartNodeRegistry } from './block-start';
 import { BlockEndNodeRegistry } from './block-end';
+import { MultiConditionNodeRegistry } from "./multi-condition";
 export { WorkflowNodeType } from './constants';
 
 export const nodeRegistries: FlowNodeRegistry[] = [
@@ -35,4 +41,5 @@ export const nodeRegistries: FlowNodeRegistry[] = [
   BreakNodeRegistry,
   VariableNodeRegistry,
   GroupNodeRegistry,
+  MultiConditionNodeRegistry,
 ];

+ 185 - 0
apps/demo-free-layout/src/nodes/multi-condition/condition-inputs/index.tsx

@@ -0,0 +1,185 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useLayoutEffect } from 'react';
+
+import { nanoid } from 'nanoid';
+import { Field, FieldArray, I18n, WorkflowNodePortsData } from '@flowgram.ai/free-layout-editor';
+import { ConditionRow, ConditionRowValueType } from '@flowgram.ai/form-materials';
+import { Button, Select, Space } from '@douyinfe/semi-ui';
+import { IconCrossCircleStroked, IconDelete, IconPlus } from '@douyinfe/semi-icons';
+
+import { useNodeRenderContext, useIsSidebar } from '../../../hooks';
+import { Feedback, FormItem } from '../../../form-components';
+import { ConditionBranch, ConditionBranchLogic, ConditionPort } from './styles';
+
+interface ConditionValue {
+  key: string;
+  value?: ConditionRowValueType;
+}
+
+interface BranchItem {
+  logic: string; // 'and' | 'or'
+  conditions: ConditionValue[];
+}
+
+export function ConditionInputs() {
+  const { node, readonly } = useNodeRenderContext();
+  const isSidebar = useIsSidebar();
+
+  useLayoutEffect(() => {
+    window.requestAnimationFrame(() => {
+      node.getData<WorkflowNodePortsData>(WorkflowNodePortsData).updateDynamicPorts();
+    });
+  }, [node]);
+
+  return (
+    <FieldArray name="branch">
+      {({ field: conditions }) => (
+        <>
+          {conditions.map((branch, index) => (
+            <Field<BranchItem> name={branch.name} key={branch.name}>
+              {({ field, fieldState }) => (
+                <FormItem
+                  type="boolean"
+                  labelWidth={100}
+                  name={index === 0 ? I18n.t('IF') : I18n.t('ELSE-IF')}
+                  vertical
+                  required={index === 0}
+                >
+                  <ConditionBranch>
+                    {field.value.conditions.length > 1 && (
+                      <ConditionBranchLogic>
+                        <Select
+                          size="small"
+                          value={field.value.logic}
+                          style={{ backgroundColor: 'var(--semi-color-bg-0)' }}
+                          onChange={(v) =>
+                            field.onChange({
+                              ...field.value,
+                              logic: (v as string) ?? 'and',
+                            })
+                          }
+                        >
+                          <Select.Option value="and">{I18n.t('AND')}</Select.Option>
+                          <Select.Option value="or">{I18n.t('OR')}</Select.Option>
+                        </Select>
+                      </ConditionBranchLogic>
+                    )}
+                    <div style={{ flex: 1 }}>
+                      {field.value.conditions.map((condition, childIndex) => (
+                        <Field<ConditionValue>
+                          name={`${field.name}.conditions.${childIndex}`}
+                          key={condition.key}
+                        >
+                          {({ field: conditionField }) => (
+                            <Space align="center" style={{ padding: '6px 0', width: '100%' }}>
+                              <div style={{ flex: 1 }}>
+                                <ConditionRow
+                                  readonly={readonly}
+                                  value={conditionField.value.value}
+                                  onChange={(v) => {
+                                    conditionField.onChange({
+                                      value: v,
+                                      key: conditionField.value.key,
+                                    });
+                                  }}
+                                />
+                              </div>
+                              {/*remove current branch condition*/}
+                              {isSidebar && !readonly && (
+                                <Button
+                                  theme="borderless"
+                                  disabled={field.value?.conditions.length === 1}
+                                  icon={<IconCrossCircleStroked />}
+                                  onClick={() =>
+                                    field.onChange({
+                                      ...field.value,
+                                      conditions: field.value.conditions.filter(
+                                        (i: ConditionValue) => i.key !== condition.key
+                                      ),
+                                    })
+                                  }
+                                />
+                              )}
+                            </Space>
+                          )}
+                        </Field>
+                      ))}
+                    </div>
+
+                    <ConditionPort data-port-id={`${branch.name}`} data-port-type="output" />
+                  </ConditionBranch>
+
+                  {/* remove current branch and add new condition*/}
+                  {isSidebar && !readonly && (
+                    <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
+                      <Button
+                        size="small"
+                        theme="borderless"
+                        icon={<IconPlus />}
+                        onClick={() => {
+                          field.onChange({
+                            ...field.value,
+                            conditions: [
+                              ...field.value.conditions,
+                              {
+                                key: `condition_${nanoid(6)}`,
+                                value: {},
+                              },
+                            ],
+                          });
+                        }}
+                      >
+                        {I18n.t('Add condition')}
+                      </Button>
+                      <Button
+                        disabled={conditions.value?.length === 1}
+                        size="small"
+                        theme="borderless"
+                        icon={<IconDelete />}
+                        onClick={() => conditions.remove(index)}
+                      >
+                        {I18n.t('Remove branch')}
+                      </Button>
+                    </div>
+                  )}
+                  <Feedback errors={fieldState?.errors} invalid={fieldState?.invalid} />
+                </FormItem>
+              )}
+            </Field>
+          ))}
+
+          {/*  else */}
+          <FormItem name={I18n.t('ELSE')} type="boolean" required={true} labelWidth={100}>
+            <ConditionPort data-port-id="else" data-port-type="output" />
+          </FormItem>
+
+          {!readonly && (
+            <div>
+              <Button
+                theme="borderless"
+                icon={<IconPlus />}
+                onClick={() =>
+                  conditions.append({
+                    logic: 'and',
+                    conditions: [
+                      {
+                        key: `condition_${nanoid(6)}`,
+                        value: {},
+                      },
+                    ],
+                  })
+                }
+              >
+                {I18n.t('Add branch')}
+              </Button>
+            </div>
+          )}
+        </>
+      )}
+    </FieldArray>
+  );
+}

+ 43 - 0
apps/demo-free-layout/src/nodes/multi-condition/condition-inputs/styles.tsx

@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import styled from "styled-components";
+
+export const ConditionPort = styled.div`
+  position: absolute;
+  right: -12px;
+  top: 50%;
+`;
+
+export const ConditionBranch = styled.div`
+  display: flex;
+  width: 100%;
+  align-items: stretch;
+  position: relative;
+`;
+
+export const ConditionBranchLogic = styled.div`
+  position: relative;
+  width: 80px;
+  display: flex;
+  align-items: center;
+
+  &::before {
+    content: '';
+    position: absolute;
+    width: 50%;
+    border: 1px solid var(--semi-color-tertiary-light-active);
+    border-radius: 6px 0 0 6px;
+    border-right: none;
+    left: 50%;
+    top: 32px;
+    bottom: 32px;
+  }
+`;

+ 45 - 0
apps/demo-free-layout/src/nodes/multi-condition/form-meta.tsx

@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FormRenderProps, FormMeta, ValidateTrigger } from '@flowgram.ai/free-layout-editor';
+import { autoRenameRefEffect } from '@flowgram.ai/form-materials';
+
+import { FlowNodeJSON } from '../../typings';
+import { FormHeader, FormContent } from '../../form-components';
+
+import { ConditionInputs } from './condition-inputs';
+
+export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
+  <>
+    <FormHeader />
+    <FormContent>
+      <ConditionInputs />
+    </FormContent>
+  </>
+);
+
+export const formMeta: FormMeta<FlowNodeJSON> = {
+  render: renderForm,
+  validateTrigger: ValidateTrigger.onChange,
+  validate: {
+    title: ({ value }: { value: string }) => (value ? undefined : 'Title is required'),
+    'branch.*': ({ value }) => {
+      const haveEmptyCondition =
+        value.conditions.filter((item: any) => {
+          return Object.keys(item.value).length === 0;
+        }).length > 0;
+      if (haveEmptyCondition) return 'Condition is required';
+      return undefined;
+    },
+  },
+  effect: {
+    conditions: autoRenameRefEffect,
+  },
+};

+ 57 - 0
apps/demo-free-layout/src/nodes/multi-condition/index.ts

@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+import { nanoid } from 'nanoid';
+
+import { FlowNodeRegistry } from '../../typings';
+import { WorkflowNodeType } from '../constants';
+import iconCondition from '../../assets/icon-condition.svg';
+
+import { formMeta } from './form-meta';
+
+let index = 0;
+export const MultiConditionNodeRegistry: FlowNodeRegistry = {
+  type: WorkflowNodeType.MultiCondition,
+  info: {
+    icon: iconCondition,
+    description:
+      'Connect multiple downstream branches. Only the corresponding branch will be executed if the set conditions are met.',
+  },
+  meta: {
+    defaultPorts: [{ type: 'input' }],
+    // Condition Outputs use dynamic port
+    useDynamicPort: true,
+    expandable: false, // disable expanded
+    size: {
+      width: 360,
+      height: 210,
+    },
+  },
+  formMeta,
+  onAdd() {
+    return {
+      id: `multi_condition_${nanoid(5)}`,
+      type: 'condition',
+      data: {
+        title: `multi_condition_${++index}`,
+        branch: [
+          {
+            logic: 'and',
+            conditions: [
+              {
+                key: `condition_${nanoid(5)}`,
+                value: {},
+              },
+            ],
+          },
+        ],
+      },
+    };
+  },
+};