Explorar o código

feat(demo): test run form (#530)

* refactor(demo): test run style refactored to module.less

* feat(demo): test run form
Louis Young hai 6 meses
pai
achega
bbfb3dad4c
Modificáronse 26 ficheiros con 820 adicións e 382 borrados
  1. 2 1
      apps/demo-free-layout/package.json
  2. 24 0
      apps/demo-free-layout/src/assets/icon-cancel.tsx
  3. 0 0
      apps/demo-free-layout/src/assets/icon-success.tsx
  4. 0 0
      apps/demo-free-layout/src/assets/icon-warning.tsx
  5. 0 13
      apps/demo-free-layout/src/components/testrun/node-status-bar/group/index.css
  6. 30 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/group/index.module.less
  7. 7 15
      apps/demo-free-layout/src/components/testrun/node-status-bar/group/index.tsx
  8. 57 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/header/index.module.less
  9. 19 9
      apps/demo-free-layout/src/components/testrun/node-status-bar/header/index.tsx
  10. 0 62
      apps/demo-free-layout/src/components/testrun/node-status-bar/header/style.ts
  11. 0 19
      apps/demo-free-layout/src/components/testrun/node-status-bar/render/index.css
  12. 90 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/render/index.module.less
  13. 27 88
      apps/demo-free-layout/src/components/testrun/node-status-bar/render/index.tsx
  14. 0 142
      apps/demo-free-layout/src/components/testrun/node-status-bar/viewer/index.css
  15. 142 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/viewer/index.module.less
  16. 35 20
      apps/demo-free-layout/src/components/testrun/node-status-bar/viewer/index.tsx
  17. 113 0
      apps/demo-free-layout/src/components/testrun/testrun-form/index.module.less
  18. 122 0
      apps/demo-free-layout/src/components/testrun/testrun-form/index.tsx
  19. 21 0
      apps/demo-free-layout/src/components/testrun/testrun-form/type.ts
  20. 50 0
      apps/demo-free-layout/src/components/testrun/testrun-form/use-fields.ts
  21. 62 0
      apps/demo-free-layout/src/components/testrun/testrun-form/use-form-meta.ts
  22. 1 1
      apps/demo-free-layout/src/components/testrun/testrun-panel/index.module.less
  23. 13 10
      apps/demo-free-layout/src/components/testrun/testrun-panel/index.tsx
  24. 1 2
      apps/demo-free-layout/src/plugins/runtime-plugin/runtime-service/index.ts
  25. 3 0
      common/config/rush/pnpm-lock.yaml
  26. 1 0
      packages/materials/form-materials/src/typings/json-schema/index.ts

+ 2 - 1
apps/demo-free-layout/package.json

@@ -44,7 +44,8 @@
     "nanoid": "^4.0.2",
     "react": "^18",
     "react-dom": "^18",
-    "styled-components": "^5"
+    "styled-components": "^5",
+    "classnames": "^2.5.1"
   },
   "devDependencies": {
     "@flowgram.ai/ts-config": "workspace:*",

+ 24 - 0
apps/demo-free-layout/src/assets/icon-cancel.tsx

@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+interface Props {
+  className?: string;
+  style?: React.CSSProperties;
+}
+
+export const IconCancel = ({ className, style }: Props) => (
+  <svg
+    className={className}
+    style={style}
+    width="1em"
+    height="1em"
+    viewBox="0 0 24 24"
+    fill="currentColor"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <path d="M9.5 8C8.67157 8 8 8.67157 8 9.5V14.5C8 15.3284 8.67157 16 9.5 16H14.5C15.3284 16 16 15.3284 16 14.5V9.5C16 8.67157 15.3284 8 14.5 8H9.5Z"></path>
+    <path d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21Z"></path>
+  </svg>
+);

+ 0 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/icon/success.tsx → apps/demo-free-layout/src/assets/icon-success.tsx


+ 0 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/icon/warning.tsx → apps/demo-free-layout/src/assets/icon-warning.tsx


+ 0 - 13
apps/demo-free-layout/src/components/testrun/node-status-bar/group/index.css

@@ -1,13 +0,0 @@
-/**
- * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
- * SPDX-License-Identifier: MIT
- */
-
-.node-status-group {
-    padding: 6px;
-    font-weight: 500;
-    color: #333;
-    font-size: 15px;
-    display: flex;
-    align-items: center;
-}

+ 30 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/group/index.module.less

@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+.node-status-group {
+  padding: 6px;
+  font-weight: 500;
+  color: #333;
+  font-size: 15px;
+  display: flex;
+  align-items: center;
+
+  &-icon {
+    transform: rotate(-90deg);
+    transition: transform 0.2s;
+    cursor: pointer;
+    margin-right: 4px;
+    opacity: 0;
+
+    &-expanded {
+      transform: rotate(0deg);
+      opacity: 1;
+    }
+  }
+
+  &-tag {
+    margin-left: 4px;
+  }
+}

+ 7 - 15
apps/demo-free-layout/src/components/testrun/node-status-bar/group/index.tsx

@@ -5,12 +5,13 @@
 
 import { FC, useState } from 'react';
 
+import classNames from 'classnames';
 import { Tag } from '@douyinfe/semi-ui';
 import { IconSmallTriangleDown } from '@douyinfe/semi-icons';
 
 import { DataStructureViewer } from '../viewer';
 
-import './index.css';
+import styles from './index.module.less';
 
 interface NodeStatusGroupProps {
   title: string;
@@ -37,28 +38,19 @@ export const NodeStatusGroup: FC<NodeStatusGroupProps> = ({
   return (
     <>
       <div
-        className="node-status-group"
+        className={styles['node-status-group']}
         onClick={() => hasContent && !disableCollapse && setIsExpanded(!isExpanded)}
       >
         {!disableCollapse && (
           <IconSmallTriangleDown
-            style={{
-              transform: isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)',
-              transition: 'transform 0.2s',
-              cursor: 'pointer',
-              marginRight: '4px',
-              opacity: hasContent ? 1 : 0,
-            }}
+            className={classNames(styles['node-status-group-icon'], {
+              [styles['node-status-group-icon-expanded']]: isExpanded && hasContent,
+            })}
           />
         )}
         <span>{title}:</span>
         {!hasContent && (
-          <Tag
-            size="small"
-            style={{
-              marginLeft: 4,
-            }}
-          >
+          <Tag size="small" className={styles['node-status-group-tag']}>
             null
           </Tag>
         )}

+ 57 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/header/index.module.less

@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+.node-status-header {
+  border: 1px solid rgba(68, 83, 130, 0.25);
+  border-radius: 8px;
+  background-color: #fff;
+  position: absolute;
+  top: calc(100% + 8px);
+  left: 0;
+  width: 100%;
+  min-width: 360px;
+
+  &-content {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 6px;
+
+    &-opened {
+      padding-bottom: 0;
+    }
+
+    .status-title {
+      height: 24px;
+      display: flex;
+      align-items: center;
+      column-gap: 8px;
+      min-width: 0;
+
+      :global(.coz-tag) {
+        height: 20px;
+      }
+      :global(.semi-tag-content) {
+        font-weight: 500;
+        line-height: 16px;
+        font-size: 12px;
+      }
+      :global(.semi-tag-suffix-icon > div) {
+        font-size: 14px;
+      }
+    }
+
+    .status-btns {
+      height: 24px;
+      display: flex;
+      align-items: center;
+      column-gap: 4px;
+
+      .is-show-detail {
+        transform: rotate(180deg);
+      }
+    }
+  }
+}

+ 19 - 9
apps/demo-free-layout/src/components/testrun/node-status-bar/header/index.tsx

@@ -5,10 +5,12 @@
 
 import React, { useState } from 'react';
 
+import classNames from 'classnames';
 import { IconChevronDown } from '@douyinfe/semi-icons';
 
 import { useNodeRenderContext } from '../../../../hooks';
-import { NodeStatusHeaderContentStyle, NodeStatusHeaderStyle } from './style';
+
+import styles from './index.module.less';
 
 interface NodeStatusBarProps {
   header?: React.ReactNode;
@@ -32,26 +34,34 @@ export const NodeStatusHeader: React.FC<React.PropsWithChildren<NodeStatusBarPro
   };
 
   return (
-    <NodeStatusHeaderStyle
+    <div
+      className={styles['node-status-header']}
       // 必须要禁止 down 冒泡,防止判定圈选和 node hover(不支持多边形)
       onMouseDown={(e) => e.stopPropagation()}
     >
-      <NodeStatusHeaderContentStyle
-        className={showDetail ? 'status-header-opened' : ''}
+      <div
+        className={classNames(
+          styles['node-status-header-content'],
+          showDetail && styles['node-status-header-content-opened']
+        )}
         // 必须要禁止 down 冒泡,防止判定圈选和 node hover(不支持多边形)
         onMouseDown={(e) => e.stopPropagation()}
         // 其他事件统一走点击事件,且也需要阻止冒泡
         onClick={handleToggleShowDetail}
       >
-        <div className="status-title">
+        <div className={styles['status-title']}>
           {header}
           {extraBtns.length > 0 ? extraBtns : null}
         </div>
-        <div className="status-btns">
-          <IconChevronDown className={showDetail ? 'is-show-detail' : ''} />
+        <div className={styles['status-btns']}>
+          <IconChevronDown
+            className={classNames({
+              [styles['is-show-detail']]: showDetail,
+            })}
+          />
         </div>
-      </NodeStatusHeaderContentStyle>
+      </div>
       {showDetail ? children : null}
-    </NodeStatusHeaderStyle>
+    </div>
   );
 };

+ 0 - 62
apps/demo-free-layout/src/components/testrun/node-status-bar/header/style.ts

@@ -1,62 +0,0 @@
-/**
- * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
- * SPDX-License-Identifier: MIT
- */
-
-import styled from 'styled-components';
-
-export const NodeStatusHeaderStyle = styled.div`
-  border: 1px solid rgba(68, 83, 130, 0.25);
-  border-radius: 8px;
-  background-color: #fff;
-
-  position: absolute;
-  top: calc(100% + 8px);
-  left: 0;
-
-  width: 100%;
-  min-width: 360px;
-`;
-
-export const NodeStatusHeaderContentStyle = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 6px;
-
-  &-opened {
-    padding-bottom: 0;
-  }
-
-  .status-title {
-    height: 24px;
-    display: flex;
-    align-items: center;
-    column-gap: 8px;
-    min-width: 0;
-
-    :global {
-      .coz-tag {
-        height: 20px;
-      }
-      .semi-tag-content {
-        font-weight: 500;
-        line-height: 16px;
-        font-size: 12px;
-      }
-      .semi-tag-suffix-icon > div {
-        font-size: 14px;
-      }
-    }
-  }
-  .status-btns {
-    height: 24px;
-    display: flex;
-    align-items: center;
-    column-gap: 4px;
-  }
-
-  .is-show-detail {
-    transform: rotate(180deg);
-  }
-`;

+ 0 - 19
apps/demo-free-layout/src/components/testrun/node-status-bar/render/index.css

@@ -1,19 +0,0 @@
-/**
- * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
- * SPDX-License-Identifier: MIT
- */
-
-.node-status-succeed {
-    background-color: rgba(105, 209, 140, 0.3);
-    color: rgba(0, 178, 60, 1);
-}
-
-.node-status-processing {
-    background-color: rgba(153, 187, 255, 0.3);
-    color: rgba(61, 121, 242, 1);
-}
-
-.node-status-failed {
-    background-color: rgba(255, 163, 171, 0.3);
-    color: rgba(229, 50, 65, 1);
-}

+ 90 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/render/index.module.less

@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+.nodeStatus {
+  &Succeed {
+    background-color: rgba(105, 209, 140, 0.3);
+    color: #00b42a;
+  }
+
+  &Processing {
+    background-color: rgba(153, 187, 255, 0.3);
+    color: #4d53e8;
+  }
+
+  &Failed {
+    background-color: rgba(255, 163, 171, 0.3);
+    color: #f53f3f;
+  }
+}
+
+.icon {
+  &.processing {
+    color: rgba(77, 83, 232, 1);
+  }
+}
+
+.desc {
+  margin: 0;
+}
+
+.count {
+  font-weight: 500;
+  color: #333;
+  font-size: 15px;
+  margin-left: 12px;
+}
+
+.snapshotNavigation {
+  margin: 12px;
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.snapshotButton {
+  min-width: 32px;
+  height: 32px;
+  padding: 0;
+  border-radius: 4px;
+  font-size: 12px;
+  border: 1px solid;
+  font-weight: 500;
+
+  &.active {
+    border-color: #4d53e8;
+    font-weight: 800;
+  }
+
+  &.inactive {
+    border-color: rgba(29, 28, 35, 0.08);
+  }
+}
+
+.snapshotSelect {
+  width: 90px;
+  height: 32px;
+  border: 1px solid;
+
+  &.active {
+    border-color: #4d53e8;
+  }
+
+  &.inactive {
+    border-color: rgba(29, 28, 35, 0.08);
+  }
+}
+
+.container {
+  width: 100%;
+  height: 100%;
+  padding: 4px 2px 4px 2px;
+}
+
+.error {
+  padding: 12px;
+  color: red;
+}

+ 27 - 88
apps/demo-free-layout/src/components/testrun/node-status-bar/render/index.tsx

@@ -5,15 +5,17 @@
 
 import { FC, useMemo, useState } from 'react';
 
+import classnames from 'classnames';
 import { NodeReport, WorkflowStatus } from '@flowgram.ai/runtime-interface';
 import { Tag, Button, Select } from '@douyinfe/semi-ui';
 import { IconSpin } from '@douyinfe/semi-icons';
 
-import { IconWarningFill } from '../icon/warning';
-import { IconSuccessFill } from '../icon/success';
 import { NodeStatusHeader } from '../header';
-import './index.css';
 import { NodeStatusGroup } from '../group';
+import { IconWarningFill } from '../../../../assets/icon-warning';
+import { IconSuccessFill } from '../../../../assets/icon-success';
+
+import styles from './index.module.less';
 
 interface NodeStatusRenderProps {
   report: NodeReport;
@@ -38,26 +40,19 @@ export const NodeStatusRender: FC<NodeStatusRenderProps> = ({ report }) => {
 
   const tagColor = useMemo(() => {
     if (isNodeSucceed) {
-      return 'node-status-succeed';
+      return styles.nodeStatusSucceed;
     }
     if (isNodeFailed) {
-      return 'node-status-failed';
+      return styles.nodeStatusFailed;
     }
     if (isNodeProcessing) {
-      return 'node-status-processing';
+      return styles.nodeStatusProcessing;
     }
   }, [isNodeSucceed, isNodeFailed, isNodeProcessing]);
 
   const renderIcon = () => {
     if (isNodeProcessing) {
-      return (
-        <IconSpin
-          spin
-          style={{
-            color: 'rgba(77,83,232,1',
-          }}
-        />
-      );
+      return <IconSpin spin className={classnames(styles.icon, styles.processing)} />;
     }
     if (isNodeSucceed) {
       return <IconSuccessFill />;
@@ -81,7 +76,7 @@ export const NodeStatusRender: FC<NodeStatusRenderProps> = ({ report }) => {
 
     const desc = getDesc();
 
-    return desc ? <p style={{ margin: 0 }}>{desc}</p> : null;
+    return desc ? <p className={styles.desc}>{desc}</p> : null;
   };
   const renderCost = () => (
     <Tag size="small" className={tagColor}>
@@ -94,49 +89,23 @@ export const NodeStatusRender: FC<NodeStatusRenderProps> = ({ report }) => {
       return null;
     }
 
-    const count = (
-      <p
-        style={{
-          fontWeight: 500,
-          color: '#333',
-          fontSize: '15px',
-          marginLeft: 12,
-        }}
-      >
-        Total: {snapshots.length}
-      </p>
-    );
+    const count = <p className={styles.count}>Total: {snapshots.length}</p>;
 
     if (snapshots.length <= displayCount) {
       return (
         <>
           {count}
-          <div
-            style={{
-              margin: '12px',
-              display: 'flex',
-              gap: '8px',
-              alignItems: 'center',
-              flexWrap: 'wrap',
-            }}
-          >
+          <div className={styles.snapshotNavigation}>
             {snapshots.map((_, index) => (
               <Button
                 key={index}
                 size="small"
                 type={currentSnapshotIndex === index ? 'primary' : 'tertiary'}
                 onClick={() => setCurrentSnapshotIndex(index)}
-                style={{
-                  minWidth: '32px',
-                  height: '32px',
-                  padding: '0',
-                  borderRadius: '4px',
-                  fontSize: '12px',
-                  border: '1px solid',
-                  borderColor:
-                    currentSnapshotIndex === index ? '#4d53e8' : 'rgba(29, 28, 35, 0.08)',
-                  fontWeight: currentSnapshotIndex === index ? '800' : '500',
-                }}
+                className={classnames(styles.snapshotButton, {
+                  [styles.active]: currentSnapshotIndex === index,
+                  [styles.inactive]: currentSnapshotIndex !== index,
+                })}
               >
                 {index + 1}
               </Button>
@@ -150,31 +119,17 @@ export const NodeStatusRender: FC<NodeStatusRenderProps> = ({ report }) => {
     return (
       <>
         {count}
-        <div
-          style={{
-            margin: '12px',
-            display: 'flex',
-            gap: '8px',
-            alignItems: 'center',
-            flexWrap: 'wrap',
-          }}
-        >
+        <div className={styles.snapshotNavigation}>
           {snapshots.slice(0, displayCount).map((_, index) => (
             <Button
               key={index}
               size="small"
               type="tertiary"
               onClick={() => setCurrentSnapshotIndex(index)}
-              style={{
-                minWidth: '32px',
-                height: '32px',
-                padding: '0',
-                borderRadius: '4px',
-                fontSize: '12px',
-                border: '1px solid',
-                borderColor: currentSnapshotIndex === index ? '#4d53e8' : 'rgba(29, 28, 35, 0.08)',
-                fontWeight: currentSnapshotIndex === index ? '800' : '500',
-              }}
+              className={classnames(styles.snapshotButton, {
+                [styles.active]: currentSnapshotIndex === index,
+                [styles.inactive]: currentSnapshotIndex !== index,
+              })}
             >
               {index + 1}
             </Button>
@@ -182,13 +137,10 @@ export const NodeStatusRender: FC<NodeStatusRenderProps> = ({ report }) => {
           <Select
             value={currentSnapshotIndex >= displayCount ? currentSnapshotIndex : undefined}
             onChange={(value) => setCurrentSnapshotIndex(value as number)}
-            style={{
-              width: '90px',
-              height: '32px',
-              border: '1px solid',
-              borderColor:
-                currentSnapshotIndex >= displayCount ? '#4d53e8' : 'rgba(29, 28, 35, 0.08)',
-            }}
+            className={classnames(styles.snapshotSelect, {
+              [styles.active]: currentSnapshotIndex >= displayCount,
+              [styles.inactive]: currentSnapshotIndex < displayCount,
+            })}
             size="small"
             placeholder="Select"
           >
@@ -220,22 +172,9 @@ export const NodeStatusRender: FC<NodeStatusRenderProps> = ({ report }) => {
         </>
       }
     >
-      <div
-        style={{
-          width: '100%',
-          height: '100%',
-          padding: '0px 2px 10px 2px',
-        }}
-      >
+      <div className={styles.container}>
         {isNodeFailed && currentSnapshot?.error && (
-          <div
-            style={{
-              padding: 12,
-              color: 'red',
-            }}
-          >
-            {currentSnapshot.error}
-          </div>
+          <div className={styles.error}>{currentSnapshot.error}</div>
         )}
         {renderSnapshotNavigation()}
         <NodeStatusGroup title="Inputs" data={currentSnapshot?.inputs} />

+ 0 - 142
apps/demo-free-layout/src/components/testrun/node-status-bar/viewer/index.css

@@ -1,142 +0,0 @@
-/**
- * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
- * SPDX-License-Identifier: MIT
- */
-
-.node-status-data-structure-viewer {
-    font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
-    font-size: 14px;
-    line-height: 1.5;
-    color: #333;
-    background: #fafafa;
-    border-radius: 6px;
-    padding: 12px 12px 12px 0;
-    margin: 12px;
-    border: 1px solid #e1e4e8;
-    overflow: hidden;
-}
-
-.tree-node {
-    margin: 2px 0;
-}
-
-.tree-node-header {
-    display: flex;
-    align-items: flex-start;
-    gap: 4px;
-    min-height: 20px;
-    padding: 2px 0;
-    border-radius: 3px;
-    transition: background-color 0.15s ease;
-}
-
-.tree-node-header:hover {
-    background-color: rgba(0, 0, 0, 0.04);
-}
-
-.expand-button {
-    background: none;
-    border: none;
-    cursor: pointer;
-    font-size: 10px;
-    color: #666;
-    width: 16px;
-    height: 16px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    border-radius: 2px;
-    transition: all 0.15s ease;
-    padding: 0;
-    margin: 0;
-}
-
-.expand-button:hover {
-    background-color: rgba(0, 0, 0, 0.1);
-    color: #333;
-}
-
-.expand-button.expanded {
-    transform: rotate(90deg);
-}
-
-.expand-button.collapsed {
-    transform: rotate(0deg);
-}
-
-.expand-placeholder {
-    width: 16px;
-    height: 16px;
-    display: inline-block;
-    flex-shrink: 0;
-}
-
-.node-label {
-    color: #0969da;
-    font-weight: 500;
-    cursor: pointer;
-    user-select: auto;
-    margin-right: 4px;
-}
-
-.node-label:hover {
-    text-decoration: underline;
-}
-
-.node-value {
-    margin-left: 4px;
-}
-
-.primitive-value-quote {
-    color: #8f8f8f;
-}
-
-.primitive-value {
-    cursor: pointer;
-    user-select: all;
-    padding: 1px 3px;
-    border-radius: 3px;
-    transition: background-color 0.15s ease;
-}
-
-.primitive-value:hover {
-    background-color: rgba(0, 0, 0, 0.05);
-}
-
-.primitive-value.string {
-    color: #032f62;
-    background-color: rgba(3, 47, 98, 0.05);
-}
-
-.primitive-value.number {
-    color: #005cc5;
-    background-color: rgba(0, 92, 197, 0.05);
-}
-
-.primitive-value.boolean {
-    color: #e36209;
-    background-color: rgba(227, 98, 9, 0.05);
-}
-
-.primitive-value.null,
-.primitive-value.undefined {
-    color: #6a737d;
-    font-style: italic;
-    background-color: rgba(106, 115, 125, 0.05);
-}
-
-.tree-node-children {
-    margin-left: 8px;
-    padding-left: 8px;
-    position: relative;
-}
-
-.tree-node-children::before {
-    content: '';
-    position: absolute;
-    left: 0;
-    top: 0;
-    bottom: 0;
-    width: 1px;
-    background: #e1e4e8;
-}

+ 142 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/viewer/index.module.less

@@ -0,0 +1,142 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+.dataStructureViewer {
+  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+  font-size: 14px;
+  line-height: 1.5;
+  color: #333;
+  background: #fafafa;
+  border-radius: 6px;
+  padding: 12px 12px 12px 0;
+  margin: 12px;
+  border: 1px solid #e1e4e8;
+  overflow: hidden;
+
+  .treeNode {
+    margin: 2px 0;
+
+    &Header {
+      display: flex;
+      align-items: flex-start;
+      gap: 4px;
+      min-height: 20px;
+      padding: 2px 0;
+      border-radius: 3px;
+      transition: background-color 0.15s ease;
+
+      &:hover {
+        background-color: rgba(0, 0, 0, 0.04);
+      }
+    }
+
+    &Children {
+      margin-left: 8px;
+      padding-left: 8px;
+      position: relative;
+
+      &::before {
+        content: '';
+        position: absolute;
+        left: 0;
+        top: 0;
+        bottom: 0;
+        width: 1px;
+        background: #e1e4e8;
+      }
+    }
+  }
+
+  .expandButton {
+    background: none;
+    border: none;
+    cursor: pointer;
+    font-size: 10px;
+    color: #666;
+    width: 16px;
+    height: 16px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 2px;
+    transition: all 0.15s ease;
+    padding: 0;
+    margin: 0;
+
+    &:hover {
+      background-color: rgba(0, 0, 0, 0.1);
+      color: #333;
+    }
+
+    &.expanded {
+      transform: rotate(90deg);
+    }
+
+    &.collapsed {
+      transform: rotate(0deg);
+    }
+  }
+
+  .expandPlaceholder {
+    width: 16px;
+    height: 16px;
+    display: inline-block;
+    flex-shrink: 0;
+  }
+
+  .nodeLabel {
+    color: #0969da;
+    font-weight: 500;
+    cursor: pointer;
+    user-select: auto;
+    margin-right: 4px;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+
+  .nodeValue {
+    margin-left: 4px;
+  }
+
+  .primitiveValue {
+    cursor: pointer;
+    user-select: all;
+    padding: 1px 3px;
+    border-radius: 3px;
+    transition: background-color 0.15s ease;
+
+    &:hover {
+      background-color: rgba(0, 0, 0, 0.05);
+    }
+
+    &Quote {
+      color: #8f8f8f;
+    }
+
+    &.string {
+      color: #032f62;
+      background-color: rgba(3, 47, 98, 0.05);
+    }
+
+    &.number {
+      color: #005cc5;
+      background-color: rgba(0, 92, 197, 0.05);
+    }
+
+    &.boolean {
+      color: #e36209;
+      background-color: rgba(227, 98, 9, 0.05);
+    }
+
+    &.null,
+    &.undefined {
+      color: #6a737d;
+      font-style: italic;
+      background-color: rgba(106, 115, 125, 0.05);
+    }
+  }
+}

+ 35 - 20
apps/demo-free-layout/src/components/testrun/node-status-bar/viewer/index.tsx

@@ -5,9 +5,11 @@
 
 import React, { useState } from 'react';
 
-import './index.css';
+import classnames from 'classnames';
 import { Toast } from '@douyinfe/semi-ui';
 
+import styles from './index.module.less';
+
 interface DataStructureViewerProps {
   data: any;
   level?: number;
@@ -35,30 +37,38 @@ const TreeNode: React.FC<TreeNodeProps> = ({ label, value, level, isLast = false
       (!Array.isArray(val) && Object.keys(val).length > 0));
 
   const renderPrimitiveValue = (val: any) => {
-    if (val === null) return <span className="primitive-value null">null</span>;
-    if (val === undefined) return <span className="primitive-value undefined">undefined</span>;
+    if (val === null)
+      return <span className={classnames(styles.primitiveValue, styles.null)}>null</span>;
+    if (val === undefined)
+      return <span className={classnames(styles.primitiveValue, styles.undefined)}>undefined</span>;
 
     switch (typeof val) {
       case 'string':
         return (
-          <span className="string">
-            <span className="primitive-value-quote">{'"'}</span>
-            <span className="primitive-value" onDoubleClick={() => handleCopy(val)}>
+          <span>
+            <span className={styles.primitiveValueQuote}>{'"'}</span>
+            <span
+              className={classnames(styles.primitiveValue, styles.string)}
+              onDoubleClick={() => handleCopy(val)}
+            >
               {val}
             </span>
-            <span className="primitive-value-quote">{'"'}</span>
+            <span className={styles.primitiveValueQuote}>{'"'}</span>
           </span>
         );
       case 'number':
         return (
-          <span className="primitive-value number" onDoubleClick={() => handleCopy(String(val))}>
+          <span
+            className={classnames(styles.primitiveValue, styles.number)}
+            onDoubleClick={() => handleCopy(String(val))}
+          >
             {val}
           </span>
         );
       case 'boolean':
         return (
           <span
-            className="primitive-value boolean"
+            className={classnames(styles.primitiveValue, styles.boolean)}
             onDoubleClick={() => handleCopy(val.toString())}
           >
             {val.toString()}
@@ -66,7 +76,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({ label, value, level, isLast = false
         );
       default:
         return (
-          <span className="primitive-value" onDoubleClick={() => handleCopy(String(val))}>
+          <span className={styles.primitiveValue} onDoubleClick={() => handleCopy(String(val))}>
             {String(val)}
           </span>
         );
@@ -99,20 +109,23 @@ const TreeNode: React.FC<TreeNodeProps> = ({ label, value, level, isLast = false
   };
 
   return (
-    <div className="tree-node">
-      <div className="tree-node-header">
+    <div className={styles.treeNode}>
+      <div className={styles.treeNodeHeader}>
         {isExpandable(value) ? (
           <button
-            className={`expand-button ${isExpanded ? 'expanded' : 'collapsed'}`}
+            className={classnames(
+              styles.expandButton,
+              isExpanded ? styles.expanded : styles.collapsed
+            )}
             onClick={() => setIsExpanded(!isExpanded)}
           >
           </button>
         ) : (
-          <span className="expand-placeholder"></span>
+          <span className={styles.expandPlaceholder}></span>
         )}
         <span
-          className="node-label"
+          className={styles.nodeLabel}
           onClick={() =>
             handleCopy(
               JSON.stringify({
@@ -123,10 +136,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({ label, value, level, isLast = false
         >
           {label}
         </span>
-        {!isExpandable(value) && <span className="node-value">{renderPrimitiveValue(value)}</span>}
+        {!isExpandable(value) && (
+          <span className={styles.nodeValue}>{renderPrimitiveValue(value)}</span>
+        )}
       </div>
       {isExpandable(value) && isExpanded && (
-        <div className="tree-node-children">{renderChildren()}</div>
+        <div className={styles.treeNodeChildren}>{renderChildren()}</div>
       )}
     </div>
   );
@@ -135,7 +150,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({ label, value, level, isLast = false
 export const DataStructureViewer: React.FC<DataStructureViewerProps> = ({ data, level = 0 }) => {
   if (data === null || data === undefined || typeof data !== 'object') {
     return (
-      <div className="node-status-data-structure-viewer">
+      <div className={styles.dataStructureViewer}>
         <TreeNode label="value" value={data} level={0} />
       </div>
     );
@@ -144,11 +159,11 @@ export const DataStructureViewer: React.FC<DataStructureViewerProps> = ({ data,
   const entries = Object.entries(data);
 
   return (
-    <div className="node-status-data-structure-viewer">
+    <div className={styles.dataStructureViewer}>
       {entries.map(([key, value], index) => (
         <TreeNode
           key={key}
-          label={key}
+          label={`${key}:`}
           value={value}
           level={0}
           isLast={index === entries.length - 1}

+ 113 - 0
apps/demo-free-layout/src/components/testrun/testrun-form/index.module.less

@@ -0,0 +1,113 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+.formContainer {
+  margin: 8px 0;
+}
+
+.formTitle {
+  font-size: 20px;
+  font-weight: 600;
+  color: #1a1a1a;
+  margin-bottom: 24px;
+  text-align: center;
+}
+
+.fieldGroup {
+  margin-bottom: 8px;
+  width: 326px;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.fieldLabel {
+  display: block;
+  font-size: 14px;
+  font-weight: 500;
+  color: #333333;
+  margin-bottom: 8px;
+  line-height: 1.4;
+}
+
+.fieldInput {
+  width: 100%;
+
+  :global(.semi-input) {
+
+    &:hover {
+      border-color: #4096ff;
+    }
+
+    &:focus {
+      border-color: #4096ff;
+      box-shadow: 0 0 0 2px rgba(64, 150, 255, 0.1);
+    }
+  }
+
+  :global(.semi-input-number) {
+    width: 100%;
+
+    &:hover {
+      border-color: #4096ff;
+    }
+
+    &:focus-within {
+      border-color: #4096ff;
+      box-shadow: 0 0 0 2px rgba(64, 150, 255, 0.1);
+    }
+  }
+}
+
+.codeEditorWrapper {
+  min-height: 100px;
+  max-height: 200px;
+  background: #fff;
+  padding: 8px 8px 8px 4px;
+  border-radius: 4px;
+  border: 1px solid #52649a0f;
+  width: 312px;
+
+  :global(.cm-editor) {
+    height: 100% !important;
+    overflow: auto !important;
+  }
+
+  :global(.cm-scroller) {
+    min-height: 100px !important;
+    max-height: 200px !important;
+  }
+
+  :global(.cm-content) {
+    min-height: 100px !important;
+    max-height: 200px !important;
+  }
+}
+
+.fieldTypeIndicator {
+  display: inline-block;
+  padding: 2px 8px;
+  font-size: 12px;
+  font-weight: 500;
+  border-radius: 4px;
+}
+
+.emptyState {
+  text-align: center;
+  padding: 20px 20px;
+  color: #999999;
+  font-size: 14px;
+
+  .emptyText {
+    font-weight: 500;
+  }
+}
+
+.requiredIndicator {
+  color: #ff4d4f;
+  margin-left: 4px;
+  font-weight: 500;
+}

+ 122 - 0
apps/demo-free-layout/src/components/testrun/testrun-form/index.tsx

@@ -0,0 +1,122 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FC } from 'react';
+
+import classNames from 'classnames';
+import { CodeEditor } from '@flowgram.ai/form-materials';
+import { Input, Switch, InputNumber } from '@douyinfe/semi-ui';
+
+import { TypeTag } from '../../../form-components';
+import { useFormMeta } from './use-form-meta';
+import { useFields } from './use-fields';
+
+import styles from './index.module.less';
+
+interface TestRunFormProps {
+  values: Record<string, unknown>;
+  setValues: (values: Record<string, unknown>) => void;
+}
+
+export const TestRunForm: FC<TestRunFormProps> = ({ values, setValues }) => {
+  const formMeta = useFormMeta();
+
+  const fields = useFields({
+    formMeta,
+    values,
+    setValues,
+  });
+
+  const renderField = (field: any) => {
+    switch (field.type) {
+      case 'boolean':
+        return (
+          <div className={styles.fieldInput}>
+            <Switch checked={field.value} onChange={(checked) => field.onChange(checked)} />
+          </div>
+        );
+      case 'integer':
+        return (
+          <div className={styles.fieldInput}>
+            <InputNumber
+              precision={0}
+              value={field.value}
+              onChange={(value) => field.onChange(value)}
+              placeholder="Please input integer"
+            />
+          </div>
+        );
+      case 'number':
+        return (
+          <div className={styles.fieldInput}>
+            <InputNumber
+              value={field.value}
+              onChange={(value) => field.onChange(value)}
+              placeholder="Please input number"
+            />
+          </div>
+        );
+      case 'object':
+        return (
+          <div className={classNames(styles.fieldInput, styles.codeEditorWrapper)}>
+            <CodeEditor
+              languageId="json"
+              value={field.value}
+              onChange={(value) => field.onChange(value)}
+            />
+          </div>
+        );
+      case 'array':
+        return (
+          <div className={classNames(styles.fieldInput, styles.codeEditorWrapper)}>
+            <CodeEditor
+              languageId="json"
+              value={field.value}
+              onChange={(value) => field.onChange(value)}
+            />
+          </div>
+        );
+      default:
+        return (
+          <div className={styles.fieldInput}>
+            <Input
+              value={field.value}
+              onChange={(value) => field.onChange(value)}
+              placeholder="Please input text"
+            />
+          </div>
+        );
+    }
+  };
+
+  // Show empty state if no fields
+  if (fields.length === 0) {
+    return (
+      <div className={styles.formContainer}>
+        <div className={styles.emptyState}>
+          <div className={styles.emptyText}>Empty</div>
+          <div className={styles.emptyText}>No inputs found in start node</div>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className={styles.formContainer}>
+      {fields.map((field) => (
+        <div key={field.name} className={styles.fieldGroup}>
+          <label htmlFor={field.name} className={styles.fieldLabel}>
+            {field.name}
+            {field.required && <span className={styles.requiredIndicator}>*</span>}
+            <span className={styles.fieldTypeIndicator}>
+              <TypeTag type={field.itemsType ?? field.type} isArray={field.type === 'array'} />
+            </span>
+          </label>
+          {renderField(field)}
+        </div>
+      ))}
+    </div>
+  );
+};

+ 21 - 0
apps/demo-free-layout/src/components/testrun/testrun-form/type.ts

@@ -0,0 +1,21 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import type { JsonSchemaBasicType } from '@flowgram.ai/form-materials';
+
+export interface TestRunFormMetaItem {
+  type: JsonSchemaBasicType;
+  name: string;
+  defaultValue: unknown;
+  required: boolean;
+  itemsType?: JsonSchemaBasicType;
+}
+
+export type TestRunFormMeta = TestRunFormMetaItem[];
+
+export interface TestRunFormField extends TestRunFormMetaItem {
+  value: unknown;
+  onChange: (value: unknown) => void;
+}

+ 50 - 0
apps/demo-free-layout/src/components/testrun/testrun-form/use-fields.ts

@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { TestRunFormField, TestRunFormMeta } from './type';
+
+export const useFields = (params: {
+  formMeta: TestRunFormMeta;
+  values: Record<string, unknown>;
+  setValues: (values: Record<string, unknown>) => void;
+}): TestRunFormField[] => {
+  const { formMeta, values, setValues } = params;
+
+  // Convert each meta item to a form field with value and onChange handler
+  const fields: TestRunFormField[] = formMeta.map((meta) => {
+    // Handle object type specially - serialize object to JSON string for display
+    const getCurrentValue = (): unknown => {
+      const rawValue = values[meta.name] ?? meta.defaultValue;
+      if ((meta.type === 'object' || meta.type === 'array') && rawValue !== null) {
+        return JSON.stringify(rawValue, null, 2);
+      }
+      return rawValue;
+    };
+
+    const currentValue = getCurrentValue();
+
+    const handleChange = (newValue: unknown): void => {
+      if (meta.type === 'object' || meta.type === 'array') {
+        setValues({
+          ...values,
+          [meta.name]: JSON.parse((newValue ?? '{}') as string),
+        });
+      } else {
+        setValues({
+          ...values,
+          [meta.name]: newValue,
+        });
+      }
+    };
+
+    return {
+      ...meta,
+      value: currentValue,
+      onChange: handleChange,
+    };
+  });
+
+  return fields;
+};

+ 62 - 0
apps/demo-free-layout/src/components/testrun/testrun-form/use-form-meta.ts

@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useMemo } from 'react';
+
+import {
+  FlowNodeFormData,
+  FormModelV2,
+  useService,
+  WorkflowDocument,
+} from '@flowgram.ai/free-layout-editor';
+import { IJsonSchema, JsonSchemaBasicType } from '@flowgram.ai/form-materials';
+
+import { WorkflowNodeType } from '../../../nodes';
+import { TestRunFormMetaItem } from './type';
+
+const getWorkflowInputsDeclare = (document: WorkflowDocument): IJsonSchema => {
+  const defaultDeclare = {
+    type: 'object',
+    properties: {},
+  };
+
+  const startNode = document.root.blocks.find(
+    (node) => node.flowNodeType === WorkflowNodeType.Start
+  );
+  if (!startNode) {
+    return defaultDeclare;
+  }
+
+  const startFormModel = startNode.getData(FlowNodeFormData).getFormModel<FormModelV2>();
+  const declare = startFormModel.getValueIn<IJsonSchema>('outputs');
+
+  if (!declare) {
+    return defaultDeclare;
+  }
+
+  return declare;
+};
+
+export const useFormMeta = (): TestRunFormMetaItem[] => {
+  const document = useService(WorkflowDocument);
+
+  // Add state for form values
+  const formMeta = useMemo(() => {
+    const formFields: TestRunFormMetaItem[] = [];
+    const workflowInputs = getWorkflowInputsDeclare(document);
+    Object.entries(workflowInputs.properties!).forEach(([name, property]) => {
+      formFields.push({
+        type: property.type as JsonSchemaBasicType,
+        name,
+        defaultValue: property.default,
+        required: property.isPropertyRequired ?? false,
+        itemsType: property.items?.type as JsonSchemaBasicType,
+      });
+    });
+    return formFields;
+  }, [document]);
+
+  return formMeta;
+};

+ 1 - 1
apps/demo-free-layout/src/components/testrun/testrun-panel/index.module.less

@@ -73,7 +73,7 @@
 
 
 .testrun-panel-container {
-  background: rgb(251, 251, 251);
+  background: rgb(255, 255, 255);
   margin: 8px 8px 8px 0;
   height: calc(100vh - 40px);
   border-radius: 8px;

+ 13 - 10
apps/demo-free-layout/src/components/testrun/testrun-panel/index.tsx

@@ -5,15 +5,17 @@
 
 import { FC, useContext, useEffect, useState } from 'react';
 
+import classnames from 'classnames';
 import { WorkflowInputs, WorkflowOutputs } from '@flowgram.ai/runtime-interface';
 import { useService } from '@flowgram.ai/free-layout-editor';
-import { CodeEditor } from '@flowgram.ai/form-materials';
 import { Button, SideSheet } from '@douyinfe/semi-ui';
-import { IconClose, IconPlay, IconSpin, IconStop } from '@douyinfe/semi-icons';
+import { IconClose, IconPlay, IconSpin } from '@douyinfe/semi-icons';
 
+import { TestRunForm } from '../testrun-form';
 import { NodeStatusGroup } from '../node-status-bar/group';
 import { WorkflowRuntimeService } from '../../../plugins/runtime-plugin/runtime-service';
 import { SidebarContext } from '../../../context';
+import { IconCancel } from '../../../assets/icon-cancel';
 
 import styles from './index.module.less';
 
@@ -27,7 +29,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
   const { nodeId: sidebarNodeId, setNodeId } = useContext(SidebarContext);
 
   const [isRunning, setRunning] = useState(false);
-  const [value, setValue] = useState<string>(`{}`);
+  const [values, setValues] = useState<Record<string, unknown>>({});
   const [errors, setErrors] = useState<string[]>();
   const [result, setResult] = useState<
     | {
@@ -46,7 +48,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
     setErrors(undefined);
     setRunning(true);
     try {
-      await runtimeService.taskRun(value);
+      await runtimeService.taskRun(values);
     } catch (e: any) {
       setErrors([e.message]);
     }
@@ -54,7 +56,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
 
   const onClose = async () => {
     await runtimeService.taskCancel();
-    setValue(`{}`);
+    setValues({});
     setRunning(false);
     onCancel();
   };
@@ -91,9 +93,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
   const renderForm = (
     <div className={styles['testrun-panel-form']}>
       <div className={styles.title}>Input Form</div>
-      <div className={styles['code-editor-container']}>
-        <CodeEditor languageId="json" value={value} onChange={setValue} />
-      </div>
+      <TestRunForm values={values} setValues={setValues} />
       {errors?.map((e) => (
         <div className={styles.error} key={e}>
           {e}
@@ -107,8 +107,11 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
   const renderButton = (
     <Button
       onClick={onTestRun}
-      icon={isRunning ? <IconStop size="small" /> : <IconPlay size="small" />}
-      className={`${styles.button} ${isRunning ? styles.running : styles.default}`}
+      icon={isRunning ? <IconCancel /> : <IconPlay size="small" />}
+      className={classnames(styles.button, {
+        [styles.running]: isRunning,
+        [styles.default]: !isRunning,
+      })}
     >
       {isRunning ? 'Cancel' : 'Test Run'}
     </Button>

+ 1 - 2
apps/demo-free-layout/src/plugins/runtime-plugin/runtime-service/index.ts

@@ -73,14 +73,13 @@ export class WorkflowRuntimeService {
     );
   }
 
-  public async taskRun(inputsString: string): Promise<void> {
+  public async taskRun(inputs: WorkflowInputs): Promise<void> {
     if (this.taskID) {
       await this.taskCancel();
     }
     if (!this.validateForm()) {
       return;
     }
-    const inputs = JSON.parse(inputsString) as WorkflowInputs;
     const schema = this.document.toJSON();
     const validateResult = await this.runtimeClient.TaskValidate({
       schema: JSON.stringify(schema),

+ 3 - 0
common/config/rush/pnpm-lock.yaml

@@ -232,6 +232,9 @@ importers:
       '@flowgram.ai/runtime-js':
         specifier: workspace:*
         version: link:../../packages/runtime/js-core
+      classnames:
+        specifier: ^2.5.1
+        version: 2.5.1
       lodash-es:
         specifier: ^4.17.21
         version: 4.17.21

+ 1 - 0
packages/materials/form-materials/src/typings/json-schema/index.ts

@@ -23,6 +23,7 @@ export interface IJsonSchema<T = string> {
   items?: IJsonSchema<T>;
   required?: string[];
   $ref?: string;
+  isPropertyRequired?: boolean;
   extra?: {
     index?: number;
     // Used in BaseType.isEqualWithJSONSchema, the type comparison will be weak