Browse Source

refactor(demo-free-layout-simple): ports auto update via form values (#916)

xiamidaxia 3 tháng trước cách đây
mục cha
commit
bdb185ba29

+ 1 - 15
apps/demo-free-layout-simple/src/hooks/use-editor-props.tsx

@@ -91,24 +91,10 @@ export const useEditorProps = () =>
          * Render Node
          */
         renderDefaultNode: (props: WorkflowNodeProps) => {
-          const { form, node } = useNodeRender();
+          const { form } = useNodeRender();
           return (
             <WorkflowNodeRenderer className="demo-free-node" node={props.node}>
               {form?.render()}
-              {node.flowNodeType === 'condition' && (
-                <div
-                  data-port-id="if"
-                  data-port-type="output"
-                  style={{ position: 'absolute', right: 0, top: '33%' }}
-                />
-              )}
-              {node.flowNodeType === 'condition' && (
-                <div
-                  data-port-id="else"
-                  data-port-type="output"
-                  style={{ position: 'absolute', right: 0, top: '66%' }}
-                />
-              )}
             </WorkflowNodeRenderer>
           );
         },

+ 1 - 0
apps/demo-free-layout-simple/src/initial-data.ts

@@ -33,6 +33,7 @@ export const initialData: WorkflowJSON = {
       data: {
         title: 'Condition',
         content: 'Condition node content',
+        ports: ['if', 'else'],
       },
     },
     {

+ 0 - 81
apps/demo-free-layout-simple/src/node-registries.ts

@@ -1,81 +0,0 @@
-/**
- * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
- * SPDX-License-Identifier: MIT
- */
-
-import { WorkflowNodeRegistry } from '@flowgram.ai/free-layout-editor';
-
-/**
- * You can customize your own node registry
- * 你可以自定义节点的注册器
- */
-export const nodeRegistries: WorkflowNodeRegistry[] = [
-  {
-    type: 'start',
-    meta: {
-      isStart: true, // Mark as start
-      deleteDisable: true, // The start node cannot be deleted
-      copyDisable: true, // The start node cannot be copied
-      defaultPorts: [{ type: 'output' }], // Used to define the input and output ports, the start node only has the output port
-    },
-  },
-  {
-    type: 'condition',
-    meta: {
-      defaultPorts: [{ type: 'input' }],
-      useDynamicPort: true,
-    },
-  },
-  {
-    type: 'chain',
-    meta: {
-      defaultPorts: [
-        { type: 'input' },
-        { type: 'output' },
-        {
-          portID: 'p4',
-          location: 'bottom',
-          locationConfig: { left: '33%', bottom: 0 },
-          type: 'output',
-        },
-        {
-          portID: 'p5',
-          location: 'bottom',
-          locationConfig: { left: '66%', bottom: 0 },
-          type: 'output',
-        },
-      ],
-    },
-  },
-  {
-    type: 'tool',
-    meta: {
-      defaultPorts: [{ location: 'top', type: 'input' }],
-    },
-  },
-  {
-    // 支持双向连接, Support two-way connection
-    type: 'twoway',
-    meta: {
-      defaultPorts: [
-        { type: 'input', portID: 'input-left', location: 'left' },
-        { type: 'output', portID: 'output-left', location: 'left' },
-        { type: 'input', portID: 'input-right', location: 'right' },
-        { type: 'output', portID: 'output-right', location: 'right' },
-      ],
-    },
-  },
-  {
-    type: 'end',
-    meta: {
-      deleteDisable: true,
-      copyDisable: true,
-      defaultPorts: [{ type: 'input' }],
-    },
-  },
-  {
-    type: 'custom',
-    meta: {},
-    defaultPorts: [{ type: 'output' }, { type: 'input' }], // A normal node has two ports
-  },
-];

+ 153 - 0
apps/demo-free-layout-simple/src/node-registries.tsx

@@ -0,0 +1,153 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import {
+  WorkflowNodeRegistry,
+  Field,
+  DataEvent,
+  EffectFuncProps,
+  WorkflowPorts,
+} from '@flowgram.ai/free-layout-editor';
+
+const CONDITION_ITEM_HEIGHT = 30;
+/**
+ * You can customize your own node registry
+ * 你可以自定义节点的注册器
+ */
+export const nodeRegistries: WorkflowNodeRegistry[] = [
+  {
+    type: 'start',
+    meta: {
+      isStart: true, // Mark as start
+      deleteDisable: true, // The start node cannot be deleted
+      copyDisable: true, // The start node cannot be copied
+      defaultPorts: [{ type: 'output' }], // Used to define the input and output ports, the start node only has the output port
+    },
+  },
+  {
+    type: 'condition',
+    meta: {
+      defaultPorts: [{ type: 'input' }],
+    },
+    formMeta: {
+      /**
+       * Initialize the form values
+       * @param value
+       */
+      formatOnInit: (value) => ({
+        portKeys: ['if', 'else'],
+        ...value,
+      }),
+      effect: {
+        /**
+         * Listen for "portsKeys" changes and update ports
+         */
+        portKeys: [
+          {
+            event: DataEvent.onValueInitOrChange,
+            effect: ({ value, context }: EffectFuncProps<Array<string>, FormData>) => {
+              const { node } = context;
+              const defaultPorts: WorkflowPorts = [{ type: 'input' }];
+              const newPorts: WorkflowPorts = value.map((portID: string, i: number) => ({
+                type: 'output',
+                portID,
+                location: 'right',
+                locationConfig: {
+                  right: 0,
+                  top: (i + 1) * CONDITION_ITEM_HEIGHT,
+                },
+              }));
+              node.ports.updateAllPorts([...defaultPorts, ...newPorts]);
+            },
+          },
+        ],
+      },
+      render: () => (
+        <>
+          <Field<string> name="title">
+            {({ field }) => <div className="demo-free-node-title">{field.value}</div>}
+          </Field>
+          <Field<Array<string>> name="portKeys">
+            {({ field: { value, onChange } }) => (
+              <div
+                className="demo-free-node-content"
+                style={{
+                  width: 160,
+                  height: value.length * CONDITION_ITEM_HEIGHT,
+                  minHeight: 2 * CONDITION_ITEM_HEIGHT,
+                }}
+              >
+                <div>
+                  <button onClick={() => onChange(value.concat(`if_${value.length}`))}>
+                    Add Port
+                  </button>
+                </div>
+                <div style={{ marginTop: 8 }}>
+                  <button
+                    onClick={() => onChange(value.filter((v, i, arr) => i !== arr.length - 1))}
+                  >
+                    Delete Port
+                  </button>
+                </div>
+              </div>
+            )}
+          </Field>
+        </>
+      ),
+    },
+  },
+  {
+    type: 'chain',
+    meta: {
+      defaultPorts: [
+        { type: 'input' },
+        { type: 'output' },
+        {
+          portID: 'p4',
+          location: 'bottom',
+          locationConfig: { left: '33%', bottom: 0 },
+          type: 'output',
+        },
+        {
+          portID: 'p5',
+          location: 'bottom',
+          locationConfig: { left: '66%', bottom: 0 },
+          type: 'output',
+        },
+      ],
+    },
+  },
+  {
+    type: 'tool',
+    meta: {
+      defaultPorts: [{ location: 'top', type: 'input' }],
+    },
+  },
+  {
+    // 支持双向连接, Support two-way connection
+    type: 'twoway',
+    meta: {
+      defaultPorts: [
+        { type: 'input', portID: 'input-left', location: 'left' },
+        { type: 'output', portID: 'output-left', location: 'left' },
+        { type: 'input', portID: 'input-right', location: 'right' },
+        { type: 'output', portID: 'output-right', location: 'right' },
+      ],
+    },
+  },
+  {
+    type: 'end',
+    meta: {
+      deleteDisable: true,
+      copyDisable: true,
+      defaultPorts: [{ type: 'input' }],
+    },
+  },
+  {
+    type: 'custom',
+    meta: {},
+    defaultPorts: [{ type: 'output' }, { type: 'input' }], // A normal node has two ports
+  },
+];

+ 1 - 1
apps/docs/components/free-layout-simple/preview.tsx

@@ -5,7 +5,7 @@
 
 /* eslint-disable import/no-unresolved */
 
-import nodeRegistriesCode from '@flowgram.ai/demo-free-layout-simple/src/node-registries.ts?raw';
+import nodeRegistriesCode from '@flowgram.ai/demo-free-layout-simple/src/node-registries.tsx?raw';
 import dataCode from '@flowgram.ai/demo-free-layout-simple/src/initial-data.ts?raw';
 import useEditorPropsCode from '@flowgram.ai/demo-free-layout-simple/src/hooks/use-editor-props.tsx?raw';
 import editorCode from '@flowgram.ai/demo-free-layout-simple/src/editor.tsx?raw';

+ 2 - 2
apps/docs/components/preview-editor.tsx

@@ -34,7 +34,7 @@ export const PreviewEditor = ({
   const content = codeInRight ? (
     <>
       <SandpackLayout style={{ width: '100%', display: 'flex' }}>
-        <div className="light-mode" style={previewStyle}>
+        <div className="light-mode preview-ediitor" style={previewStyle}>
           {children}
         </div>
         <SandpackCodeEditor style={editorStyle} readOnly />
@@ -43,7 +43,7 @@ export const PreviewEditor = ({
   ) : (
     <>
       <SandpackLayout style={previewStyle}>
-        <div className="light-mode">{children}</div>
+        <div className="light-mode preview-ediitor">{children}</div>
         {/* <SandpackPreview /> */}
       </SandpackLayout>
       <SandpackLayout>

+ 23 - 0
apps/docs/global.less

@@ -83,6 +83,29 @@
     background: transparent;
   }
 }
+.preview-ediitor {
+  button {
+    color: #000000e6;
+    background: #e1e3e4;
+    border: 1px solid #e5e6eb;
+    border-radius: 8px;
+    justify-content: center;
+    align-items: center;
+    gap: 8px;
+    height: 30px;
+    padding: 0px 8px;
+    font-size: 14px;
+    font-style: normal;
+    font-weight: 400;
+    transition: background .3s, border .3s, opacity .3s;
+    display: inline-flex
+  }
+  input {
+    border: 1px solid #e5e6eb;
+    background-color: var(--rp-c-bg);
+    padding: 4px 8px;
+  }
+}
 
 .light-mode * {
   color: var(--text-color) !important;

+ 9 - 18
apps/docs/src/en/guide/_meta.json

@@ -9,55 +9,46 @@
   {
     "type": "dir",
     "name": "free-layout",
-    "label": "Free Layout",
-    "collapsed": true
+    "label": "Free Layout"
   },
   {
     "type": "dir",
     "name": "fixed-layout",
-    "label": "Fixed Layout",
-    "collapsed": true
+    "label": "Fixed Layout"
   },
   {
     "type": "dir",
     "name": "form",
-    "label": "Form",
-    "collapsed": true
+    "label": "Form"
   },
   {
     "type": "dir",
     "name": "variable",
-    "label": "Variable",
-    "collapsed": true
+    "label": "Variable"
   },
   {
     "type": "dir",
     "name": "materials",
-    "label": "Official Materials (WIP)",
-    "collapsed": true
+    "label": "Official Materials (WIP)"
   },
   {
     "type": "dir",
     "name": "plugin",
-    "label": "Plugin",
-    "collapsed": true
+    "label": "Plugin"
   },
   {
     "type": "dir",
     "name": "advanced",
-    "label": "Advanced",
-    "collapsed": true
+    "label": "Advanced"
   },
   {
     "type": "dir",
     "name": "concepts",
-    "label": "Concepts",
-    "collapsed": true
+    "label": "Concepts"
   },
   {
     "type": "dir",
     "name": "runtime",
-    "label": "Runtime (WIP)",
-    "collapsed": true
+    "label": "Runtime (WIP)"
   }
 ]

+ 72 - 0
apps/docs/src/en/guide/free-layout/port.mdx

@@ -149,6 +149,78 @@ node.ports.updateAllPorts([
 node.ports.updateDynamicPorts()
 ```
 
+## Update Ports Data Via Form Values Changed
+
+Below, the `condition` node listens to `portKeys` data and updates ports data via [Form Effect](/guide/form/form.html), details see [Demo](/examples/free-layout/free-layout-simple.html)
+
+<img loading="lazy" className="invert-img" height="200" src="/free-layout/auto-update-ports.gif"/>
+
+```tsx pure title="node-registries.ts"
+import {
+  Field,
+  DataEvent,
+  EffectFuncProps,
+  WorkflowPorts
+} from '@flowgram.ai/free-layout-editor';
+
+const CONDITION_ITEM_HEIGHT = 30
+const conditionNodeRegistry =  {
+    type: 'condition',
+    meta: {
+      defaultPorts: [{ type: 'input' }],
+    },
+    formMeta: {
+      effect: {
+        /**
+         * Listen for "portsKeys" changes and update ports
+         */
+        portKeys: [{
+          event: DataEvent.onValueInitOrChange,
+          effect: ({ value, context }: EffectFuncProps<Array<string>, FormData>) => {
+            const { node } = context
+            const defaultPorts: WorkflowPorts = [{ type: 'input'}]
+            const newPorts: WorkflowPorts = value.map((portID: string, i: number) => ({
+              type: 'output',
+              portID,
+              location: 'right',
+              locationConfig: {
+                right: 0,
+                top: (i + 1) * CONDITION_ITEM_HEIGHT
+              }
+            }))
+            node.ports.updateAllPorts([...defaultPorts, ...newPorts])
+          },
+        }],
+      },
+      render: () => (
+        <>
+          <Field<string> name="title">
+            {({ field }) => <div className="demo-free-node-title">{field.value}</div>}
+          </Field>
+          <Field<Array<string>> name="portKeys">
+            {({ field: { value, onChange }, }) => {
+              return (
+                <div className="demo-free-node-content" style={{
+                  width: 160,
+                  height: value.length * CONDITION_ITEM_HEIGHT,
+                  minHeight: 2 * CONDITION_ITEM_HEIGHT
+                }}>
+                  <div>
+                    <button onClick={() => onChange(value.concat(`if_${value.length}`))}>Add Port</button>
+                  </div>
+                  <div style={{ marginTop: 8 }}>
+                    <button onClick={() => onChange(value.filter((v, i, arr) => i !== arr.length - 1))}>Delete Port
+                    </button>
+                  </div>
+                </div>
+              )
+            }}
+          </Field>
+        </>
+      ),
+    },
+  }
+```
 ## Port Rendering
 
 Ports are ultimately rendered through the `WorkflowPortRender` component, supporting custom styles, or you can reimplement this component based on the source code. Refer to [Free Layout Best Practices - Node Rendering](https://github.com/bytedance/flowgram.ai/blob/main/apps/demo-free-layout/src/components/base-node/node-wrapper.tsx)

BIN
apps/docs/src/public/free-layout/auto-update-ports.gif


+ 9 - 18
apps/docs/src/zh/guide/_meta.json

@@ -9,55 +9,46 @@
   {
     "type": "dir",
     "name": "free-layout",
-    "label": "自由布局",
-    "collapsed": true
+    "label": "自由布局"
   },
   {
     "type": "dir",
     "name": "fixed-layout",
-    "label": "固定布局",
-    "collapsed": true
+    "label": "固定布局"
   },
   {
     "type": "dir",
     "name": "form",
-    "label": "表单",
-    "collapsed": true
+    "label": "表单"
   },
   {
     "type": "dir",
     "name": "variable",
-    "label": "变量",
-    "collapsed": true
+    "label": "变量"
   },
   {
     "type": "dir",
     "name": "materials",
-    "label": "官方物料库 (WIP)",
-    "collapsed": true
+    "label": "官方物料库 (WIP)"
   },
   {
     "type": "dir",
     "name": "plugin",
-    "label": "插件",
-    "collapsed": true
+    "label": "插件"
   },
   {
     "type": "dir",
     "name": "advanced",
-    "label": "进阶",
-    "collapsed": true
+    "label": "进阶"
   },
   {
     "type": "dir",
     "name": "concepts",
-    "label": "概念",
-    "collapsed": true
+    "label": "概念"
   },
   {
     "type": "dir",
     "name": "runtime",
-    "label": "运行时接入 (WIP)",
-    "collapsed": true
+    "label": "运行时接入 (WIP)"
   }
 ]

+ 72 - 0
apps/docs/src/zh/guide/free-layout/port.mdx

@@ -149,6 +149,78 @@ node.ports.updateAllPorts([
 node.ports.updateDynamicPorts()
 ```
 
+## 监听表单变化并更新端口数据
+
+下边 condition 节点通过 [表单effect](/guide/form/form.html) 监听 `portKeys` 数据并更新端口数据, 详细见 [Demo](/examples/free-layout/free-layout-simple.html)
+
+<img loading="lazy" className="invert-img" height="200" src="/free-layout/auto-update-ports.gif"/>
+
+```tsx pure title="node-registries.ts"
+import {
+  Field,
+  DataEvent,
+  EffectFuncProps,
+  WorkflowPorts
+} from '@flowgram.ai/free-layout-editor';
+
+const CONDITION_ITEM_HEIGHT = 30
+const conditionNodeRegistry =  {
+    type: 'condition',
+    meta: {
+      defaultPorts: [{ type: 'input' }],
+    },
+    formMeta: {
+      effect: {
+        /**
+         * Listen for "portsKeys" changes and update ports
+         */
+        portKeys: [{
+          event: DataEvent.onValueInitOrChange,
+          effect: ({ value, context }: EffectFuncProps<Array<string>, FormData>) => {
+            const { node } = context
+            const defaultPorts: WorkflowPorts = [{ type: 'input'}]
+            const newPorts: WorkflowPorts = value.map((portID: string, i: number) => ({
+              type: 'output',
+              portID,
+              location: 'right',
+              locationConfig: {
+                right: 0,
+                top: (i + 1) * CONDITION_ITEM_HEIGHT
+              }
+            }))
+            node.ports.updateAllPorts([...defaultPorts, ...newPorts])
+          },
+        }],
+      },
+      render: () => (
+        <>
+          <Field<string> name="title">
+            {({ field }) => <div className="demo-free-node-title">{field.value}</div>}
+          </Field>
+          <Field<Array<string>> name="portKeys">
+            {({ field: { value, onChange }, }) => {
+              return (
+                <div className="demo-free-node-content" style={{
+                  width: 160,
+                  height: value.length * CONDITION_ITEM_HEIGHT,
+                  minHeight: 2 * CONDITION_ITEM_HEIGHT
+                }}>
+                  <div>
+                    <button onClick={() => onChange(value.concat(`if_${value.length}`))}>Add Port</button>
+                  </div>
+                  <div style={{ marginTop: 8 }}>
+                    <button onClick={() => onChange(value.filter((v, i, arr) => i !== arr.length - 1))}>Delete Port
+                    </button>
+                  </div>
+                </div>
+              )
+            }}
+          </Field>
+        </>
+      ),
+    },
+  }
+```
 ## 端口渲染
 
 端口最终通过 `WorkflowPortRender` 组件渲染,支持自定义 style, 或者业务基于源码重新实现该组件, 参考 [自由布局最佳实践 - 节点渲染](https://github.com/bytedance/flowgram.ai/blob/main/apps/demo-free-layout/src/components/base-node/node-wrapper.tsx)