Browse Source

feat(material): form materials and add scripts (#196)

Yiwei Mao 8 months ago
parent
commit
79e4bb0556
45 changed files with 1271 additions and 939 deletions
  1. 1 0
      apps/demo-fixed-layout/package.json
  2. 1 1
      apps/demo-fixed-layout/src/form-components/fx-expression/index.tsx
  3. 1 1
      apps/demo-fixed-layout/src/form-components/properties-edit/property-edit.tsx
  4. 1 2
      apps/demo-fixed-layout/src/form-components/type-selector.tsx
  5. 1 2
      apps/demo-fixed-layout/src/form-components/type-tag.tsx
  6. 8 7
      apps/demo-fixed-layout/src/nodes/start/form-meta.tsx
  7. 2 1
      apps/demo-fixed-layout/src/plugins/sync-variable-plugin/sync-variable-plugin.ts
  8. 0 68
      apps/demo-fixed-layout/src/plugins/sync-variable-plugin/variable-selector/index.module.less
  9. 0 94
      apps/demo-fixed-layout/src/plugins/sync-variable-plugin/variable-selector/index.tsx
  10. 0 134
      apps/demo-fixed-layout/src/plugins/sync-variable-plugin/variable-selector/use-variable-tree.ts
  11. 63 62
      apps/demo-free-layout/package.json
  12. 1 1
      apps/demo-free-layout/src/form-components/fx-expression/index.tsx
  13. 1 1
      apps/demo-free-layout/src/form-components/properties-edit/property-edit.tsx
  14. 1 2
      apps/demo-free-layout/src/form-components/type-selector.tsx
  15. 1 2
      apps/demo-free-layout/src/form-components/type-tag.tsx
  16. 8 7
      apps/demo-free-layout/src/nodes/start/form-meta.tsx
  17. 0 243
      apps/demo-free-layout/src/plugins/sync-variable-plugin/icons.tsx
  18. 2 1
      apps/demo-free-layout/src/plugins/sync-variable-plugin/sync-variable-plugin.ts
  19. 0 68
      apps/demo-free-layout/src/plugins/sync-variable-plugin/variable-selector/index.module.less
  20. 0 94
      apps/demo-free-layout/src/plugins/sync-variable-plugin/variable-selector/index.tsx
  21. 0 134
      apps/demo-free-layout/src/plugins/sync-variable-plugin/variable-selector/use-variable-tree.ts
  22. 81 14
      common/config/rush/pnpm-lock.yaml
  23. 11 0
      packages/materials/form-materials/.eslintrc.js
  24. 63 0
      packages/materials/form-materials/bin/index.js
  25. 72 0
      packages/materials/form-materials/bin/materials.js
  26. 72 0
      packages/materials/form-materials/bin/project.js
  27. 69 0
      packages/materials/form-materials/package.json
  28. 3 0
      packages/materials/form-materials/src/components/index.ts
  29. 5 0
      packages/materials/form-materials/src/components/json-schema-editor/config.json
  30. 114 0
      packages/materials/form-materials/src/components/json-schema-editor/hooks.tsx
  31. 196 0
      packages/materials/form-materials/src/components/json-schema-editor/index.tsx
  32. 145 0
      packages/materials/form-materials/src/components/json-schema-editor/styles.tsx
  33. 11 0
      packages/materials/form-materials/src/components/json-schema-editor/types.ts
  34. 5 0
      packages/materials/form-materials/src/components/type-selector/config.json
  35. 94 0
      packages/materials/form-materials/src/components/type-selector/constants.tsx
  36. 54 0
      packages/materials/form-materials/src/components/type-selector/index.tsx
  37. 19 0
      packages/materials/form-materials/src/components/type-selector/types.ts
  38. 5 0
      packages/materials/form-materials/src/components/variable-selector/config.json
  39. 45 0
      packages/materials/form-materials/src/components/variable-selector/index.tsx
  40. 73 0
      packages/materials/form-materials/src/components/variable-selector/use-variable-tree.tsx
  41. 1 0
      packages/materials/form-materials/src/index.ts
  42. 8 0
      packages/materials/form-materials/tsconfig.json
  43. 26 0
      packages/materials/form-materials/vitest.config.ts
  44. 1 0
      packages/materials/form-materials/vitest.setup.ts
  45. 6 0
      rush.json

+ 1 - 0
apps/demo-fixed-layout/package.json

@@ -32,6 +32,7 @@
     "@douyinfe/semi-ui": "^2.72.3",
     "@flowgram.ai/fixed-layout-editor": "workspace:*",
     "@flowgram.ai/fixed-semi-materials": "workspace:*",
+    "@flowgram.ai/form-materials": "workspace:*",
     "@flowgram.ai/group-plugin": "workspace:*",
     "@flowgram.ai/minimap-plugin": "workspace:*",
     "lodash-es": "^4.17.21",

+ 1 - 1
apps/demo-fixed-layout/src/form-components/fx-expression/index.tsx

@@ -1,10 +1,10 @@
 import React, { type SVGProps } from 'react';
 
+import { VariableSelector } from '@flowgram.ai/form-materials';
 import { Input, Button } from '@douyinfe/semi-ui';
 
 import { ValueDisplay } from '../value-display';
 import { FlowRefValueSchema, FlowLiteralValueSchema } from '../../typings';
-import { VariableSelector } from '../../plugins/sync-variable-plugin/variable-selector';
 
 export function FxIcon(props: SVGProps<SVGSVGElement>) {
   return (

+ 1 - 1
apps/demo-fixed-layout/src/form-components/properties-edit/property-edit.tsx

@@ -1,11 +1,11 @@
 import React, { useState, useLayoutEffect } from 'react';
 
+import { VariableSelector } from '@flowgram.ai/form-materials';
 import { Input, Button } from '@douyinfe/semi-ui';
 import { IconCrossCircleStroked } from '@douyinfe/semi-icons';
 
 import { TypeSelector } from '../type-selector';
 import { JsonSchema } from '../../typings';
-import { VariableSelector } from '../../plugins/sync-variable-plugin/variable-selector';
 import { LeftColumn, Row } from './styles';
 
 export interface PropertyEditProps {

+ 1 - 2
apps/demo-fixed-layout/src/form-components/type-selector.tsx

@@ -1,9 +1,8 @@
 import React from 'react';
 
+import { VariableTypeIcons } from '@flowgram.ai/form-materials';
 import { Tag, Dropdown } from '@douyinfe/semi-ui';
 
-import { VariableTypeIcons } from '../plugins/sync-variable-plugin/icons';
-
 export interface TypeSelectorProps {
   value?: string;
   disabled?: boolean;

+ 1 - 2
apps/demo-fixed-layout/src/form-components/type-tag.tsx

@@ -1,8 +1,7 @@
 import styled from 'styled-components';
+import { VariableTypeIcons, ArrayIcons } from '@flowgram.ai/form-materials';
 import { Tag, Tooltip } from '@douyinfe/semi-ui';
 
-import { VariableTypeIcons, ArrayIcons } from '../plugins/sync-variable-plugin/icons';
-
 interface PropsType {
   name?: string | JSX.Element;
   type: string;

+ 8 - 7
apps/demo-fixed-layout/src/nodes/start/form-meta.tsx

@@ -1,3 +1,4 @@
+import { JsonSchemaEditor } from '@flowgram.ai/form-materials';
 import {
   Field,
   FieldRenderProps,
@@ -8,7 +9,7 @@ import {
 
 import { FlowNodeJSON, JsonSchema } from '../../typings';
 import { useIsSidebar } from '../../hooks';
-import { FormHeader, FormContent, FormOutputs, PropertiesEdit } from '../../form-components';
+import { FormHeader, FormContent, FormOutputs } from '../../form-components';
 
 export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON['data']>) => {
   const isSidebar = useIsSidebar();
@@ -18,13 +19,13 @@ export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON['data']>) => {
         <FormHeader />
         <FormContent>
           <Field
-            name="outputs.properties"
-            render={({
-              field: { value, onChange },
-              fieldState,
-            }: FieldRenderProps<Record<string, JsonSchema>>) => (
+            name="outputs"
+            render={({ field: { value, onChange } }: FieldRenderProps<JsonSchema>) => (
               <>
-                <PropertiesEdit value={value} onChange={onChange} />
+                <JsonSchemaEditor
+                  value={value}
+                  onChange={(value) => onChange(value as JsonSchema)}
+                />
               </>
             )}
           />

+ 2 - 1
apps/demo-fixed-layout/src/plugins/sync-variable-plugin/sync-variable-plugin.ts

@@ -49,7 +49,8 @@ export const createSyncVariablePlugin: PluginCreator<SyncVariablePluginOptions>
             variableData.setVar(
               ASTFactory.createVariableDeclaration({
                 meta: {
-                  title: `${title}.outputs`,
+                  title: `${title}`,
+                  icon: node.getNodeRegistry()?.info?.icon,
                   // NOTICE: You can add more metadata here as needed
                 },
                 key: `${node.id}.outputs`,

+ 0 - 68
apps/demo-fixed-layout/src/plugins/sync-variable-plugin/variable-selector/index.module.less

@@ -1,68 +0,0 @@
-.option-select-item-container {
-  display: flex;
-  align-items: flex-start;
-
-  .icon {
-    flex-shrink: 0;
-    margin-top: 2px;
-    margin-right: 5px;
-  }
-
-  .text {
-    word-break: break-all;
-    white-space: pre-wrap;
-  }
-
-  .error-text {
-    word-break: break-all;
-    color: red;
-    white-space: pre-wrap;
-    margin-top: 2px;
-  }
-}
-
-.prefix-icon {
-  margin-left: 2px;
-  display: inline-block;
-  padding: 0 2px;
-  height: 16px;
-
-  svg {
-    scale: 0.7;
-  }
-}
-
-
-.option-type-icon {
-  height: 14px;
-  margin-right: 6px;
-
-  svg {
-    width: 14px;
-    height: 14px;
-  }
-}
-
-.tree-select {
-  :global {
-    .semi-tree-select-selection {
-      padding-left: 4px;
-    }
-
-    .semi-tree-select-arrow,
-    .semi-tree-select-clearbtn {
-      width: 16px;
-
-      svg {
-        scale: 0.7;
-      }
-    }
-  }
-}
-
-
-.description-icon {
-  margin-left: 3px;
-  scale: 0.8;
-  vertical-align: text-top;
-}

+ 0 - 94
apps/demo-fixed-layout/src/plugins/sync-variable-plugin/variable-selector/index.tsx

@@ -1,94 +0,0 @@
-import React from 'react';
-
-import { type TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree';
-import { TreeSelect } from '@douyinfe/semi-ui';
-
-import { type JsonSchema } from '../../../typings';
-import { ValueDisplay } from '../../../form-components';
-import { useVariableTree } from './use-variable-tree';
-
-export interface VariableSelectorProps {
-  value?: string;
-  onChange: (value?: string) => void;
-  options?: {
-    size?: 'small' | 'large' | 'default';
-    emptyContent?: JSX.Element;
-    targetSchemas?: JsonSchema[];
-    strongEqualToTargetSchema?: boolean;
-  };
-  hasError?: boolean;
-  style?: React.CSSProperties;
-  readonly?: boolean;
-}
-
-export const VariableSelector = ({
-  value,
-  onChange,
-  options,
-  readonly,
-  style,
-  hasError,
-}: VariableSelectorProps) => {
-  const { size = 'small', emptyContent, targetSchemas, strongEqualToTargetSchema } = options || {};
-  if (readonly) {
-    return <ValueDisplay value={value as string} hasError={hasError} />;
-  }
-
-  const treeData = useVariableTree<TreeNodeData>({
-    targetSchemas,
-    strongEqual: strongEqualToTargetSchema,
-    ignoreReadonly: true,
-    getTreeData: ({ variable, key, icon, children, disabled, parentFields }) => ({
-      key,
-      value: key,
-      icon: (
-        <span
-          style={{
-            display: 'flex',
-            alignItems: 'center',
-            justifyContent: 'center',
-            marginRight: 4,
-          }}
-        >
-          {icon}
-        </span>
-      ),
-      label: variable.meta?.expressionTitle || variable.key || '',
-      disabled,
-      labelPath: [...parentFields, variable]
-        .map((_field) => _field.meta?.expressionTitle || _field.key || '')
-        .join('.'),
-      children,
-    }),
-  });
-
-  const renderEmpty = () => {
-    if (emptyContent) {
-      return emptyContent;
-    }
-
-    return 'nodata';
-  };
-
-  return (
-    <>
-      <TreeSelect
-        dropdownMatchSelectWidth={false}
-        treeData={treeData}
-        size={size}
-        value={value}
-        style={{
-          ...style,
-          outline: hasError ? '1px solid red' : undefined,
-        }}
-        validateStatus={hasError ? 'error' : undefined}
-        onChange={(option) => {
-          onChange(option as string);
-        }}
-        showClear
-        placeholder="Select Variable..."
-        emptyContent={renderEmpty()}
-      />
-    </>
-  );
-};

+ 0 - 134
apps/demo-fixed-layout/src/plugins/sync-variable-plugin/variable-selector/use-variable-tree.ts

@@ -1,134 +0,0 @@
-import { useCallback, useMemo } from 'react';
-
-import {
-  ASTFactory,
-  ASTKind,
-  type BaseType,
-  type UnionJSON,
-  useScopeAvailable,
-  ASTMatch,
-} from '@flowgram.ai/fixed-layout-editor';
-
-import { createASTFromJSONSchema } from '../utils';
-import { ArrayIcons, VariableTypeIcons } from '../icons';
-import { type JsonSchema } from '../../../typings';
-
-type VariableField = any;
-
-interface HooksParams<TreeData> {
-  // filter target type
-  targetSchemas?: JsonSchema[];
-  // Is it strongly type-checked?
-  strongEqual?: boolean;
-  // ignore global Config
-  ignoreReadonly?: boolean;
-  // render tree node
-  getTreeData: (props: {
-    key: string;
-    icon: JSX.Element | undefined;
-    variable: VariableField;
-    parentFields: VariableField[];
-    disabled?: boolean;
-    children?: TreeData[];
-  }) => TreeData;
-}
-
-export function useVariableTree<TreeData>({
-  targetSchemas = [],
-  strongEqual = false,
-  ignoreReadonly = false,
-  getTreeData,
-}: HooksParams<TreeData>): TreeData[] {
-  const available = useScopeAvailable();
-
-  const getVariableTypeIcon = useCallback((variable: VariableField) => {
-    const _type = variable.type;
-
-    if (ASTMatch.isArray(_type)) {
-      return (
-        (ArrayIcons as any)[_type.items?.kind.toLowerCase()] ||
-        VariableTypeIcons[ASTKind.Array.toLowerCase()]
-      );
-    }
-
-    if (ASTMatch.isCustomType(_type)) {
-      return VariableTypeIcons[_type.typeName.toLowerCase()];
-    }
-
-    return (VariableTypeIcons as any)[variable.type?.kind.toLowerCase()];
-  }, []);
-
-  const targetTypeAST: UnionJSON = useMemo(
-    () =>
-      ASTFactory.createUnion({
-        types: targetSchemas.map((_targetSchema) => {
-          const typeAst = createASTFromJSONSchema(_targetSchema)!;
-          return strongEqual ? typeAst : { ...typeAst, weak: true };
-        }),
-      }),
-    [strongEqual, ...targetSchemas]
-  );
-
-  const checkTypeFiltered = useCallback(
-    (type?: BaseType) => {
-      if (!type) {
-        return true;
-      }
-
-      if (targetTypeAST.types?.length) {
-        return !type.isTypeEqual(targetTypeAST);
-      }
-
-      return false;
-    },
-    [strongEqual, targetTypeAST]
-  );
-
-  const renderVariable = (
-    variable: VariableField,
-    parentFields: VariableField[] = []
-  ): TreeData | null => {
-    let type = variable?.type;
-
-    const isTypeFiltered = checkTypeFiltered(type);
-
-    let children: TreeData[] | undefined;
-    if (ASTMatch.isObject(type)) {
-      children = (type.properties || [])
-        .map((_property) => renderVariable(_property as VariableField, [...parentFields, variable]))
-        .filter(Boolean) as TreeData[];
-    }
-
-    if (isTypeFiltered && !children?.length) {
-      return null;
-    }
-
-    const currPath = [
-      ...parentFields.map((_field) => _field.meta?.titleKey || _field.key),
-      variable.meta?.titleKey || variable.key,
-    ].join('.');
-
-    return getTreeData({
-      key: currPath,
-      icon: getVariableTypeIcon(variable),
-      variable,
-      parentFields,
-      children,
-      disabled: isTypeFiltered,
-    });
-  };
-
-  return [
-    ...available.variables
-      .filter((_v) => {
-        if (ignoreReadonly) {
-          return !_v.meta?.readonly;
-        }
-        return true;
-      })
-      .slice(0)
-      .reverse(),
-  ]
-    .map((_variable) => renderVariable(_variable as VariableField))
-    .filter(Boolean) as TreeData[];
-}

+ 63 - 62
apps/demo-free-layout/package.json

@@ -1,64 +1,65 @@
 {
-    "name": "@flowgram.ai/demo-free-layout",
-    "version": "0.1.0",
-    "description": "",
-    "keywords": [],
-    "license": "MIT",
-    "main": "./src/index.ts",
-    "files": [
-        "src/",
-        ".eslintrc.js",
-        ".gitignore",
-        "index.html",
-        "package.json",
-        "rsbuild.config.ts",
-        "tsconfig.json"
-    ],
-    "scripts": {
-        "build": "exit 0",
-        "build:fast": "exit 0",
-        "build:watch": "exit 0",
-        "clean": "rimraf dist",
-        "dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
-        "lint": "eslint ./src --cache",
-        "lint:fix": "eslint ./src --fix",
-        "start": "cross-env NODE_ENV=development rsbuild dev --open",
-        "test": "exit",
-        "test:cov": "exit",
-        "watch": "exit 0"
-    },
-    "dependencies": {
-        "@douyinfe/semi-icons": "^2.72.3",
-        "@douyinfe/semi-ui": "^2.72.3",
-        "@flowgram.ai/free-layout-editor": "workspace:*",
-        "@flowgram.ai/free-snap-plugin": "workspace:*",
-        "@flowgram.ai/free-lines-plugin": "workspace:*",
-        "@flowgram.ai/free-node-panel-plugin": "workspace:*",
-        "@flowgram.ai/minimap-plugin": "workspace:*",
-        "@flowgram.ai/free-container-plugin": "workspace:*",
-        "@flowgram.ai/free-group-plugin": "workspace:*",
-        "lodash-es": "^4.17.21",
-        "nanoid": "^4.0.2",
-        "react": "^18",
-        "react-dom": "^18",
-        "styled-components": "^5"
-    },
-    "devDependencies": {
-        "@flowgram.ai/ts-config": "workspace:*",
-        "@flowgram.ai/eslint-config": "workspace:*",
-        "@rsbuild/core": "^1.2.16",
-        "@rsbuild/plugin-react": "^1.1.1",
-        "@rsbuild/plugin-less": "^1.1.1",
-        "@types/lodash-es": "^4.17.12",
-        "@types/node": "^18",
-        "@types/react": "^18",
-        "@types/react-dom": "^18",
-        "@types/styled-components": "^5",
-        "eslint": "^8.54.0",
-        "cross-env": "~7.0.3"
-    },
-    "publishConfig": {
-        "access": "public",
-        "registry": "https://registry.npmjs.org/"
-    }
+  "name": "@flowgram.ai/demo-free-layout",
+  "version": "0.1.0",
+  "description": "",
+  "keywords": [],
+  "license": "MIT",
+  "main": "./src/index.ts",
+  "files": [
+    "src/",
+    ".eslintrc.js",
+    ".gitignore",
+    "index.html",
+    "package.json",
+    "rsbuild.config.ts",
+    "tsconfig.json"
+  ],
+  "scripts": {
+    "build": "exit 0",
+    "build:fast": "exit 0",
+    "build:watch": "exit 0",
+    "clean": "rimraf dist",
+    "dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
+    "lint": "eslint ./src --cache",
+    "lint:fix": "eslint ./src --fix",
+    "start": "cross-env NODE_ENV=development rsbuild dev --open",
+    "test": "exit",
+    "test:cov": "exit",
+    "watch": "exit 0"
+  },
+  "dependencies": {
+    "@douyinfe/semi-icons": "^2.72.3",
+    "@douyinfe/semi-ui": "^2.72.3",
+    "@flowgram.ai/free-layout-editor": "workspace:*",
+    "@flowgram.ai/free-snap-plugin": "workspace:*",
+    "@flowgram.ai/free-lines-plugin": "workspace:*",
+    "@flowgram.ai/free-node-panel-plugin": "workspace:*",
+    "@flowgram.ai/minimap-plugin": "workspace:*",
+    "@flowgram.ai/free-container-plugin": "workspace:*",
+    "@flowgram.ai/free-group-plugin": "workspace:*",
+    "@flowgram.ai/form-materials": "workspace:*",
+    "lodash-es": "^4.17.21",
+    "nanoid": "^4.0.2",
+    "react": "^18",
+    "react-dom": "^18",
+    "styled-components": "^5"
+  },
+  "devDependencies": {
+    "@flowgram.ai/ts-config": "workspace:*",
+    "@flowgram.ai/eslint-config": "workspace:*",
+    "@rsbuild/core": "^1.2.16",
+    "@rsbuild/plugin-react": "^1.1.1",
+    "@rsbuild/plugin-less": "^1.1.1",
+    "@types/lodash-es": "^4.17.12",
+    "@types/node": "^18",
+    "@types/react": "^18",
+    "@types/react-dom": "^18",
+    "@types/styled-components": "^5",
+    "eslint": "^8.54.0",
+    "cross-env": "~7.0.3"
+  },
+  "publishConfig": {
+    "access": "public",
+    "registry": "https://registry.npmjs.org/"
+  }
 }

+ 1 - 1
apps/demo-free-layout/src/form-components/fx-expression/index.tsx

@@ -1,10 +1,10 @@
 import React, { type SVGProps } from 'react';
 
+import { VariableSelector } from '@flowgram.ai/form-materials';
 import { Input, Button } from '@douyinfe/semi-ui';
 
 import { ValueDisplay } from '../value-display';
 import { FlowRefValueSchema, FlowLiteralValueSchema } from '../../typings';
-import { VariableSelector } from '../../plugins/sync-variable-plugin/variable-selector';
 
 export function FxIcon(props: SVGProps<SVGSVGElement>) {
   return (

+ 1 - 1
apps/demo-free-layout/src/form-components/properties-edit/property-edit.tsx

@@ -1,11 +1,11 @@
 import React, { useState, useLayoutEffect } from 'react';
 
+import { VariableSelector } from '@flowgram.ai/form-materials';
 import { Input, Button } from '@douyinfe/semi-ui';
 import { IconCrossCircleStroked } from '@douyinfe/semi-icons';
 
 import { TypeSelector } from '../type-selector';
 import { JsonSchema } from '../../typings';
-import { VariableSelector } from '../../plugins/sync-variable-plugin/variable-selector';
 import { LeftColumn, Row } from './styles';
 
 export interface PropertyEditProps {

+ 1 - 2
apps/demo-free-layout/src/form-components/type-selector.tsx

@@ -1,9 +1,8 @@
 import React from 'react';
 
+import { VariableTypeIcons } from '@flowgram.ai/form-materials';
 import { Tag, Dropdown } from '@douyinfe/semi-ui';
 
-import { VariableTypeIcons } from '../plugins/sync-variable-plugin/icons';
-
 export interface TypeSelectorProps {
   value?: string;
   disabled?: boolean;

+ 1 - 2
apps/demo-free-layout/src/form-components/type-tag.tsx

@@ -1,8 +1,7 @@
 import styled from 'styled-components';
+import { VariableTypeIcons, ArrayIcons } from '@flowgram.ai/form-materials';
 import { Tag, Tooltip } from '@douyinfe/semi-ui';
 
-import { VariableTypeIcons, ArrayIcons } from '../plugins/sync-variable-plugin/icons';
-
 interface PropsType {
   name?: string | JSX.Element;
   type: string;

+ 8 - 7
apps/demo-free-layout/src/nodes/start/form-meta.tsx

@@ -5,10 +5,11 @@ import {
   FormMeta,
   ValidateTrigger,
 } from '@flowgram.ai/free-layout-editor';
+import { JsonSchemaEditor } from '@flowgram.ai/form-materials';
 
 import { FlowNodeJSON, JsonSchema } from '../../typings';
 import { useIsSidebar } from '../../hooks';
-import { FormHeader, FormContent, FormOutputs, PropertiesEdit } from '../../form-components';
+import { FormHeader, FormContent, FormOutputs } from '../../form-components';
 
 export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => {
   const isSidebar = useIsSidebar();
@@ -18,13 +19,13 @@ export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => {
         <FormHeader />
         <FormContent>
           <Field
-            name="outputs.properties"
-            render={({
-              field: { value, onChange },
-              fieldState,
-            }: FieldRenderProps<Record<string, JsonSchema>>) => (
+            name="outputs"
+            render={({ field: { value, onChange } }: FieldRenderProps<JsonSchema>) => (
               <>
-                <PropertiesEdit value={value} onChange={onChange} />
+                <JsonSchemaEditor
+                  value={value}
+                  onChange={(value) => onChange(value as JsonSchema)}
+                />
               </>
             )}
           />

File diff suppressed because it is too large
+ 0 - 243
apps/demo-free-layout/src/plugins/sync-variable-plugin/icons.tsx


+ 2 - 1
apps/demo-free-layout/src/plugins/sync-variable-plugin/sync-variable-plugin.ts

@@ -49,7 +49,8 @@ export const createSyncVariablePlugin: PluginCreator<SyncVariablePluginOptions>
             variableData.setVar(
               ASTFactory.createVariableDeclaration({
                 meta: {
-                  title: `${title}.outputs`,
+                  title: `${title}`,
+                  icon: node.getNodeRegistry()?.info?.icon,
                   // NOTICE: You can add more metadata here as needed
                 },
                 key: `${node.id}.outputs`,

+ 0 - 68
apps/demo-free-layout/src/plugins/sync-variable-plugin/variable-selector/index.module.less

@@ -1,68 +0,0 @@
-.option-select-item-container {
-  display: flex;
-  align-items: flex-start;
-
-  .icon {
-    flex-shrink: 0;
-    margin-top: 2px;
-    margin-right: 5px;
-  }
-
-  .text {
-    word-break: break-all;
-    white-space: pre-wrap;
-  }
-
-  .error-text {
-    word-break: break-all;
-    color: red;
-    white-space: pre-wrap;
-    margin-top: 2px;
-  }
-}
-
-.prefix-icon {
-  margin-left: 2px;
-  display: inline-block;
-  padding: 0 2px;
-  height: 16px;
-
-  svg {
-    scale: 0.7;
-  }
-}
-
-
-.option-type-icon {
-  height: 14px;
-  margin-right: 6px;
-
-  svg {
-    width: 14px;
-    height: 14px;
-  }
-}
-
-.tree-select {
-  :global {
-    .semi-tree-select-selection {
-      padding-left: 4px;
-    }
-
-    .semi-tree-select-arrow,
-    .semi-tree-select-clearbtn {
-      width: 16px;
-
-      svg {
-        scale: 0.7;
-      }
-    }
-  }
-}
-
-
-.description-icon {
-  margin-left: 3px;
-  scale: 0.8;
-  vertical-align: text-top;
-}

+ 0 - 94
apps/demo-free-layout/src/plugins/sync-variable-plugin/variable-selector/index.tsx

@@ -1,94 +0,0 @@
-import React from 'react';
-
-import { type TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree';
-import { TreeSelect } from '@douyinfe/semi-ui';
-
-import { type JsonSchema } from '../../../typings';
-import { ValueDisplay } from '../../../form-components';
-import { useVariableTree } from './use-variable-tree';
-
-export interface VariableSelectorProps {
-  value?: string;
-  onChange: (value?: string) => void;
-  options?: {
-    size?: 'small' | 'large' | 'default';
-    emptyContent?: JSX.Element;
-    targetSchemas?: JsonSchema[];
-    strongEqualToTargetSchema?: boolean;
-  };
-  hasError?: boolean;
-  style?: React.CSSProperties;
-  readonly?: boolean;
-}
-
-export const VariableSelector = ({
-  value,
-  onChange,
-  options,
-  readonly,
-  style,
-  hasError,
-}: VariableSelectorProps) => {
-  const { size = 'small', emptyContent, targetSchemas, strongEqualToTargetSchema } = options || {};
-  if (readonly) {
-    return <ValueDisplay value={value as string} hasError={hasError} />;
-  }
-
-  const treeData = useVariableTree<TreeNodeData>({
-    targetSchemas,
-    strongEqual: strongEqualToTargetSchema,
-    ignoreReadonly: true,
-    getTreeData: ({ variable, key, icon, children, disabled, parentFields }) => ({
-      key,
-      value: key,
-      icon: (
-        <span
-          style={{
-            display: 'flex',
-            alignItems: 'center',
-            justifyContent: 'center',
-            marginRight: 4,
-          }}
-        >
-          {icon}
-        </span>
-      ),
-      label: variable.meta?.expressionTitle || variable.key || '',
-      disabled,
-      labelPath: [...parentFields, variable]
-        .map((_field) => _field.meta?.expressionTitle || _field.key || '')
-        .join('.'),
-      children,
-    }),
-  });
-
-  const renderEmpty = () => {
-    if (emptyContent) {
-      return emptyContent;
-    }
-
-    return 'nodata';
-  };
-
-  return (
-    <>
-      <TreeSelect
-        dropdownMatchSelectWidth={false}
-        treeData={treeData}
-        size={size}
-        value={value}
-        style={{
-          ...style,
-          outline: hasError ? '1px solid red' : undefined,
-        }}
-        validateStatus={hasError ? 'error' : undefined}
-        onChange={(option) => {
-          onChange(option as string);
-        }}
-        showClear
-        placeholder="Select Variable..."
-        emptyContent={renderEmpty()}
-      />
-    </>
-  );
-};

+ 0 - 134
apps/demo-free-layout/src/plugins/sync-variable-plugin/variable-selector/use-variable-tree.ts

@@ -1,134 +0,0 @@
-import { useCallback, useMemo } from 'react';
-
-import {
-  ASTFactory,
-  ASTKind,
-  type BaseType,
-  type UnionJSON,
-  useScopeAvailable,
-  ASTMatch,
-} from '@flowgram.ai/free-layout-editor';
-
-import { createASTFromJSONSchema } from '../utils';
-import { ArrayIcons, VariableTypeIcons } from '../icons';
-import { type JsonSchema } from '../../../typings';
-
-type VariableField = any;
-
-interface HooksParams<TreeData> {
-  // filter target type
-  targetSchemas?: JsonSchema[];
-  // Is it strongly type-checked?
-  strongEqual?: boolean;
-  // ignore global Config
-  ignoreReadonly?: boolean;
-  // render tree node
-  getTreeData: (props: {
-    key: string;
-    icon: JSX.Element | undefined;
-    variable: VariableField;
-    parentFields: VariableField[];
-    disabled?: boolean;
-    children?: TreeData[];
-  }) => TreeData;
-}
-
-export function useVariableTree<TreeData>({
-  targetSchemas = [],
-  strongEqual = false,
-  ignoreReadonly = false,
-  getTreeData,
-}: HooksParams<TreeData>): TreeData[] {
-  const available = useScopeAvailable();
-
-  const getVariableTypeIcon = useCallback((variable: VariableField) => {
-    const _type = variable.type;
-
-    if (ASTMatch.isArray(_type)) {
-      return (
-        (ArrayIcons as any)[_type.items?.kind.toLowerCase()] ||
-        VariableTypeIcons[ASTKind.Array.toLowerCase()]
-      );
-    }
-
-    if (ASTMatch.isCustomType(_type)) {
-      return VariableTypeIcons[_type.typeName.toLowerCase()];
-    }
-
-    return (VariableTypeIcons as any)[variable.type?.kind.toLowerCase()];
-  }, []);
-
-  const targetTypeAST: UnionJSON = useMemo(
-    () =>
-      ASTFactory.createUnion({
-        types: targetSchemas.map((_targetSchema) => {
-          const typeAst = createASTFromJSONSchema(_targetSchema)!;
-          return strongEqual ? typeAst : { ...typeAst, weak: true };
-        }),
-      }),
-    [strongEqual, ...targetSchemas]
-  );
-
-  const checkTypeFiltered = useCallback(
-    (type?: BaseType) => {
-      if (!type) {
-        return true;
-      }
-
-      if (targetTypeAST.types?.length) {
-        return !type.isTypeEqual(targetTypeAST);
-      }
-
-      return false;
-    },
-    [strongEqual, targetTypeAST]
-  );
-
-  const renderVariable = (
-    variable: VariableField,
-    parentFields: VariableField[] = []
-  ): TreeData | null => {
-    let type = variable?.type;
-
-    const isTypeFiltered = checkTypeFiltered(type);
-
-    let children: TreeData[] | undefined;
-    if (ASTMatch.isObject(type)) {
-      children = (type.properties || [])
-        .map((_property) => renderVariable(_property as VariableField, [...parentFields, variable]))
-        .filter(Boolean) as TreeData[];
-    }
-
-    if (isTypeFiltered && !children?.length) {
-      return null;
-    }
-
-    const currPath = [
-      ...parentFields.map((_field) => _field.meta?.titleKey || _field.key),
-      variable.meta?.titleKey || variable.key,
-    ].join('.');
-
-    return getTreeData({
-      key: currPath,
-      icon: getVariableTypeIcon(variable),
-      variable,
-      parentFields,
-      children,
-      disabled: isTypeFiltered,
-    });
-  };
-
-  return [
-    ...available.variables
-      .filter((_v) => {
-        if (ignoreReadonly) {
-          return !_v.meta?.readonly;
-        }
-        return true;
-      })
-      .slice(0)
-      .reverse(),
-  ]
-    .map((_variable) => renderVariable(_variable as VariableField))
-    .filter(Boolean) as TreeData[];
-}

+ 81 - 14
common/config/rush/pnpm-lock.yaml

@@ -68,6 +68,9 @@ importers:
       '@flowgram.ai/fixed-semi-materials':
         specifier: workspace:*
         version: link:../../packages/materials/fixed-semi-materials
+      '@flowgram.ai/form-materials':
+        specifier: workspace:*
+        version: link:../../packages/materials/form-materials
       '@flowgram.ai/group-plugin':
         specifier: workspace:*
         version: link:../../packages/plugins/group-plugin
@@ -196,6 +199,9 @@ importers:
       '@douyinfe/semi-ui':
         specifier: ^2.72.3
         version: 2.72.3(acorn@8.14.0)(react-dom@18.3.1)(react@18.3.1)
+      '@flowgram.ai/form-materials':
+        specifier: workspace:*
+        version: link:../../packages/materials/form-materials
       '@flowgram.ai/free-container-plugin':
         specifier: workspace:*
         version: link:../../packages/plugins/free-container-plugin
@@ -1728,6 +1734,76 @@ importers:
         specifier: ^0.34.6
         version: 0.34.6(jsdom@22.1.0)
 
+  ../../packages/materials/form-materials:
+    dependencies:
+      '@douyinfe/semi-icons':
+        specifier: ^2.72.3
+        version: 2.72.3(react@18.3.1)
+      '@douyinfe/semi-illustrations':
+        specifier: ^2.36.0
+        version: 2.72.3(react@18.3.1)
+      '@douyinfe/semi-ui':
+        specifier: ^2.72.3
+        version: 2.72.3(acorn@8.14.0)(react-dom@18.3.1)(react@18.3.1)
+      '@flowgram.ai/editor':
+        specifier: workspace:*
+        version: link:../../client/editor
+      chalk:
+        specifier: ^5.3.0
+        version: 5.4.1
+      commander:
+        specifier: ^11.0.0
+        version: 11.1.0
+      inquirer:
+        specifier: ^9.2.7
+        version: 9.3.7
+      lodash:
+        specifier: ^4.17.21
+        version: 4.17.21
+      nanoid:
+        specifier: ^4.0.2
+        version: 4.0.2
+    devDependencies:
+      '@flowgram.ai/eslint-config':
+        specifier: workspace:*
+        version: link:../../../config/eslint-config
+      '@flowgram.ai/ts-config':
+        specifier: workspace:*
+        version: link:../../../config/ts-config
+      '@types/lodash':
+        specifier: ^4.14.137
+        version: 4.17.13
+      '@types/react':
+        specifier: ^18
+        version: 18.3.16
+      '@types/react-dom':
+        specifier: ^18
+        version: 18.3.5(@types/react@18.3.16)
+      '@types/styled-components':
+        specifier: ^5
+        version: 5.1.34
+      eslint:
+        specifier: ^8.54.0
+        version: 8.57.1
+      react:
+        specifier: ^18
+        version: 18.3.1
+      react-dom:
+        specifier: ^18
+        version: 18.3.1(react@18.3.1)
+      styled-components:
+        specifier: ^5
+        version: 5.3.11(@babel/core@7.26.10)(react-dom@18.3.1)(react-is@18.3.1)(react@18.3.1)
+      tsup:
+        specifier: ^8.0.1
+        version: 8.3.5(typescript@5.0.4)
+      typescript:
+        specifier: ^5.0.4
+        version: 5.0.4
+      vitest:
+        specifier: ^0.34.6
+        version: 0.34.6(jsdom@22.1.0)
+
   ../../packages/node-engine/form:
     dependencies:
       '@flowgram.ai/reactive':
@@ -3795,7 +3871,7 @@ packages:
       - supports-color
     dev: false
 
-  /@babel/helper-module-imports@7.25.9:
+  /@babel/helper-module-imports@7.25.9(supports-color@5.5.0):
     resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==}
     engines: {node: '>=6.9.0'}
     dependencies:
@@ -3804,15 +3880,6 @@ packages:
     transitivePeerDependencies:
       - supports-color
 
-  /@babel/helper-module-imports@7.25.9(supports-color@5.5.0):
-    resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==}
-    engines: {node: '>=6.9.0'}
-    dependencies:
-      '@babel/traverse': 7.26.4(supports-color@5.5.0)
-      '@babel/types': 7.26.3
-    transitivePeerDependencies:
-      - supports-color
-
   /@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0):
     resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==}
     engines: {node: '>=6.9.0'}
@@ -3820,7 +3887,7 @@ packages:
       '@babel/core': ^7.0.0
     dependencies:
       '@babel/core': 7.26.0
-      '@babel/helper-module-imports': 7.25.9
+      '@babel/helper-module-imports': 7.25.9(supports-color@5.5.0)
       '@babel/helper-validator-identifier': 7.25.9
       '@babel/traverse': 7.26.4(supports-color@5.5.0)
     transitivePeerDependencies:
@@ -3834,7 +3901,7 @@ packages:
       '@babel/core': ^7.0.0
     dependencies:
       '@babel/core': 7.26.10
-      '@babel/helper-module-imports': 7.25.9
+      '@babel/helper-module-imports': 7.25.9(supports-color@5.5.0)
       '@babel/helper-validator-identifier': 7.25.9
       '@babel/traverse': 7.26.4(supports-color@5.5.0)
     transitivePeerDependencies:
@@ -4341,7 +4408,7 @@ packages:
       '@babel/core': ^7.0.0-0
     dependencies:
       '@babel/core': 7.26.0
-      '@babel/helper-module-imports': 7.25.9
+      '@babel/helper-module-imports': 7.25.9(supports-color@5.5.0)
       '@babel/helper-plugin-utils': 7.25.9
       '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.26.0)
     transitivePeerDependencies:
@@ -4635,7 +4702,7 @@ packages:
     dependencies:
       '@babel/core': 7.26.0
       '@babel/helper-annotate-as-pure': 7.25.9
-      '@babel/helper-module-imports': 7.25.9
+      '@babel/helper-module-imports': 7.25.9(supports-color@5.5.0)
       '@babel/helper-plugin-utils': 7.25.9
       '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0)
       '@babel/types': 7.26.3

+ 11 - 0
packages/materials/form-materials/.eslintrc.js

@@ -0,0 +1,11 @@
+const { defineConfig } = require('@flowgram.ai/eslint-config');
+
+module.exports = defineConfig({
+  preset: 'web',
+  packageRoot: __dirname,
+  rules: {
+    'no-console': 'off',
+    'react/no-deprecated': 'off',
+    '@flowgram.ai/e2e-data-testid': 'off',
+  },
+});

+ 63 - 0
packages/materials/form-materials/bin/index.js

@@ -0,0 +1,63 @@
+import chalk from 'chalk';
+import { Command } from 'commander';
+import inquirer from 'inquirer';
+
+import { bfsMaterials, copyMaterial, listAllMaterials } from './materials.js';
+import { getProjectInfo, installDependencies } from './project.js';
+
+const program = new Command();
+
+program
+  .version('1.0.0')
+  .description('Add official materials to your project')
+  .action(async () => {
+    console.log(chalk.bgGreenBright('Welcome to @flowgram.ai/form-materials CLI!'));
+
+    const projectInfo = getProjectInfo();
+
+    console.log(chalk.bold('Project Info:'));
+    console.log(chalk.black(`  - Flowgram Version: ${projectInfo.flowgramVersion}`));
+    console.log(chalk.black(`  - Project Path: ${projectInfo.projectPath}`));
+
+    const materials = listAllMaterials();
+
+    // 2. User select one component
+    const { material } = await inquirer.prompt([
+      {
+        type: 'list',
+        name: 'material',
+        message: 'Select one material to add:',
+        choices: [
+          ...materials.map((_material) => ({
+            name: `${_material.type}/${_material.name}`,
+            value: _material,
+          })),
+        ],
+      },
+    ]);
+
+    console.log(material);
+
+    // 3. Get the component dependencies by BFS (include depMaterials and depPackages)
+    const { allMaterials, allPackages } = bfsMaterials(material, materials);
+
+    // 4. Install the dependencies
+    let flowgramPackage = `@flowgram.ai/editor`;
+    if (projectInfo.flowgramVersion !== 'workspace:*') {
+      flowgramPackage = `@flowgram.ai/editor@${projectInfo.flowgramVersion}`;
+    }
+    const packagesToInstall = [flowgramPackage, ...allPackages];
+
+    console.log(chalk.bold('These npm dependencies will be added to your project'));
+    console.log(packagesToInstall);
+    installDependencies(packagesToInstall, projectInfo);
+
+    // 5. Copy the materials to the project
+    console.log(chalk.bold('These Materials will be added to your project'));
+    console.log(allMaterials);
+    allMaterials.forEach((material) => {
+      copyMaterial(material, projectInfo);
+    });
+  });
+
+program.parse(process.argv);

+ 72 - 0
packages/materials/form-materials/bin/materials.js

@@ -0,0 +1,72 @@
+import fs from 'fs';
+import path from 'path';
+
+const _types = ['components'];
+
+export function listAllMaterials() {
+  const _materials = [];
+
+  for (const _type of _types) {
+    const materialsPath = path.join(import.meta.dirname, '..', 'src', _type);
+    _materials.push(
+      ...fs
+        .readdirSync(materialsPath)
+        .map((_path) => {
+          if (_path === 'index.ts') {
+            return null;
+          }
+
+          const config = fs.readFileSync(path.join(materialsPath, _path, 'config.json'), 'utf8');
+          return {
+            ...JSON.parse(config),
+            type: _type,
+            path: path.join(materialsPath, _path),
+          };
+        })
+        .filter(Boolean)
+    );
+  }
+
+  return _materials;
+}
+
+export function bfsMaterials(material, _materials = []) {
+  function findConfigByName(name) {
+    return _materials.find((_config) => _config.name === name);
+  }
+
+  const queue = [material];
+  const allMaterials = new Set();
+  const allPackages = new Set();
+
+  while (queue.length > 0) {
+    const _material = queue.shift();
+    if (allMaterials.has(_material)) {
+      continue;
+    }
+    allMaterials.add(_material);
+
+    if (_material.depPackages) {
+      for (const _package of _material.depPackages) {
+        allPackages.add(_package);
+      }
+    }
+
+    if (_material.depMaterials) {
+      for (const _materialName of _material.depMaterials) {
+        queue.push(findConfigByName(_materialName));
+      }
+    }
+  }
+
+  return {
+    allMaterials: Array.from(allMaterials),
+    allPackages: Array.from(allPackages),
+  };
+}
+
+export const copyMaterial = (material, projectInfo) => {
+  const sourceDir = material.path;
+  const targetDir = path.join(projectInfo.projectPath, `form-${material.type}`, material.name);
+  fs.cpSync(sourceDir, targetDir, { recursive: true });
+};

+ 72 - 0
packages/materials/form-materials/bin/project.js

@@ -0,0 +1,72 @@
+import { execSync } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+
+export function getProjectInfo() {
+  // get nearest package.json
+  let projectPath = process.cwd();
+
+  while (projectPath !== '/' && !fs.existsSync(path.join(projectPath, 'package.json'))) {
+    projectPath = path.join(projectPath, '..');
+  }
+
+  if (projectPath === '/') {
+    throw new Error('Please run this command in a valid project');
+  }
+
+  const packageJsonPath = path.join(projectPath, 'package.json');
+
+  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
+
+  // fixed layout or free layout
+  const flowgramVersion =
+    packageJson.dependencies['@flowgram.ai/fixed-layout-editor'] ||
+    packageJson.dependencies['@flowgram.ai/free-layout-editor'] ||
+    packageJson.dependencies['@flowgram.ai/editor'];
+
+  if (!flowgramVersion) {
+    throw new Error(
+      'Please install @flowgram.ai/fixed-layout-editor or @flowgram.ai/free-layout-editor'
+    );
+  }
+
+  return {
+    projectPath,
+    packageJsonPath,
+    packageJson,
+    flowgramVersion,
+  };
+}
+
+export function findRushJson(startPath) {
+  let currentPath = startPath;
+  while (currentPath !== '/' && !fs.existsSync(path.join(currentPath, 'rush.json'))) {
+    currentPath = path.join(currentPath, '..');
+  }
+  if (fs.existsSync(path.join(currentPath, 'rush.json'))) {
+    return path.join(currentPath, 'rush.json');
+  }
+  return null;
+}
+
+export function installDependencies(packages, projectInfo) {
+  if (fs.existsSync(path.join(projectInfo.projectPath, 'yarn.lock'))) {
+    execSync(`yarn add ${packages.join(' ')}`, { stdio: 'inherit' });
+    return;
+  }
+
+  if (fs.existsSync(path.join(projectInfo.projectPath, 'pnpm-lock.yaml'))) {
+    execSync(`pnpm add ${packages.join(' ')}`, { stdio: 'inherit' });
+    return;
+  }
+
+  //  rush monorepo
+  if (findRushJson(projectInfo.projectPath)) {
+    execSync(`rush add ${packages.map((pkg) => `--package ${pkg}`).join(' ')}`, {
+      stdio: 'inherit',
+    });
+    return;
+  }
+
+  execSync(`npm install ${packages.join(' ')}`, { stdio: 'inherit' });
+}

+ 69 - 0
packages/materials/form-materials/package.json

@@ -0,0 +1,69 @@
+{
+  "name": "@flowgram.ai/form-materials",
+  "version": "0.1.8",
+  "homepage": "https://flowgram.ai/",
+  "repository": "https://github.com/bytedance/flowgram.ai",
+  "license": "MIT",
+  "exports": {
+    "types": "./dist/index.d.ts",
+    "import": "./dist/esm/index.js",
+    "require": "./dist/index.js"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/esm/index.js",
+  "types": "./dist/index.d.ts",
+  "bin": {
+    "flowgram-form-materials": "./bin/index.js"
+  },
+  "files": [
+    "dist",
+    "bin",
+    "src"
+  ],
+  "scripts": {
+    "build": "npm run build:fast -- --dts-resolve",
+    "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
+    "build:watch": "npm run build:fast -- --dts-resolve",
+    "clean": "rimraf dist",
+    "test": "exit 0",
+    "test:cov": "exit 0",
+    "ts-check": "tsc --noEmit",
+    "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist",
+    "run-bin": "node bin/index.js"
+  },
+  "dependencies": {
+    "@douyinfe/semi-icons": "^2.72.3",
+    "@douyinfe/semi-illustrations": "^2.36.0",
+    "@douyinfe/semi-ui": "^2.72.3",
+    "@flowgram.ai/editor": "workspace:*",
+    "lodash": "^4.17.21",
+    "nanoid": "^4.0.2",
+    "commander": "^11.0.0",
+    "chalk": "^5.3.0",
+    "inquirer": "^9.2.7"
+  },
+  "devDependencies": {
+    "@flowgram.ai/eslint-config": "workspace:*",
+    "@flowgram.ai/ts-config": "workspace:*",
+    "@types/lodash": "^4.14.137",
+    "@types/react": "^18",
+    "@types/react-dom": "^18",
+    "@types/styled-components": "^5",
+    "eslint": "^8.54.0",
+    "react": "^18",
+    "react-dom": "^18",
+    "styled-components": "^5",
+    "tsup": "^8.0.1",
+    "typescript": "^5.0.4",
+    "vitest": "^0.34.6"
+  },
+  "peerDependencies": {
+    "react": ">=17",
+    "react-dom": ">=17",
+    "styled-components": ">=4"
+  },
+  "publishConfig": {
+    "access": "public",
+    "registry": "https://registry.npmjs.org/"
+  }
+}

+ 3 - 0
packages/materials/form-materials/src/components/index.ts

@@ -0,0 +1,3 @@
+export { VariableSelector } from './variable-selector';
+export { TypeSelector, JsonSchema, VariableTypeIcons, ArrayIcons } from './type-selector';
+export { JsonSchemaEditor } from './json-schema-editor';

+ 5 - 0
packages/materials/form-materials/src/components/json-schema-editor/config.json

@@ -0,0 +1,5 @@
+{
+  "name": "json-schema-editor",
+  "depMaterials": ["type-selector"],
+  "depPackages": ["@douyinfe/semi-ui", "@douyinfe/semi-icons", "styled-components"]
+}

+ 114 - 0
packages/materials/form-materials/src/components/json-schema-editor/hooks.tsx

@@ -0,0 +1,114 @@
+import { useEffect, useMemo, useState } from 'react';
+
+import { PropertyValueType } from './types';
+import { JsonSchema } from '../type-selector';
+
+let _id = 0;
+function genId() {
+  return _id++;
+}
+
+function getDrilldownSchema(
+  value?: PropertyValueType,
+  path?: (keyof PropertyValueType)[]
+): { schema?: PropertyValueType | null; path?: (keyof PropertyValueType)[] } {
+  if (!value) {
+    return {};
+  }
+
+  if (value.type === 'array' && value.items) {
+    return getDrilldownSchema(value.items, [...(path || []), 'items']);
+  }
+
+  return { schema: value, path };
+}
+
+export function usePropertiesEdit(
+  value?: PropertyValueType,
+  onChange?: (value: PropertyValueType) => void
+) {
+  // Get drilldown (array.items.items...)
+  const drilldown = useMemo(() => getDrilldownSchema(value), [value?.type, value?.items]);
+
+  const isDrilldownObject = drilldown.schema?.type === 'object';
+
+  // Generate Init Property List
+  const initPropertyList = useMemo(
+    () =>
+      isDrilldownObject
+        ? Object.entries(drilldown.schema?.properties || {}).map(
+            ([key, _value]) =>
+              ({
+                key: genId(),
+                name: key,
+                isPropertyRequired: value?.required?.includes(key) || false,
+                ..._value,
+              } as PropertyValueType)
+          )
+        : [],
+    [isDrilldownObject]
+  );
+
+  const [propertyList, setPropertyList] = useState<PropertyValueType[]>(initPropertyList);
+
+  const updatePropertyList = (updater: (list: PropertyValueType[]) => PropertyValueType[]) => {
+    setPropertyList((_list) => {
+      const next = updater(_list);
+
+      // onChange to parent
+      const nextProperties: Record<string, JsonSchema> = {};
+      const nextRequired: string[] = [];
+
+      for (const _property of next) {
+        if (!_property.name) {
+          continue;
+        }
+
+        nextProperties[_property.name] = _property;
+
+        if (_property.isPropertyRequired) {
+          nextRequired.push(_property.name);
+        }
+      }
+
+      let drilldownSchema = value || {};
+      if (drilldown.path) {
+        drilldownSchema = drilldown.path.reduce((acc, key) => acc[key], value || {});
+      }
+      drilldownSchema.properties = nextProperties;
+      drilldownSchema.required = nextRequired;
+
+      onChange?.(value || {});
+
+      return next;
+    });
+  };
+
+  const onAddProperty = () => {
+    updatePropertyList((_list) => [..._list, { key: genId(), name: '', type: 'string' }]);
+  };
+
+  const onRemoveProperty = (key: number) => {
+    updatePropertyList((_list) => _list.filter((_property) => _property.key !== key));
+  };
+
+  const onEditProperty = (key: number, nextValue: PropertyValueType) => {
+    updatePropertyList((_list) =>
+      _list.map((_property) => (_property.key === key ? nextValue : _property))
+    );
+  };
+
+  useEffect(() => {
+    if (!isDrilldownObject) {
+      setPropertyList([]);
+    }
+  }, [isDrilldownObject]);
+
+  return {
+    propertyList,
+    isDrilldownObject,
+    onAddProperty,
+    onRemoveProperty,
+    onEditProperty,
+  };
+}

+ 196 - 0
packages/materials/form-materials/src/components/json-schema-editor/index.tsx

@@ -0,0 +1,196 @@
+import React, { useMemo, useState } from 'react';
+
+import { Button, Checkbox, IconButton, Input } from '@douyinfe/semi-ui';
+import {
+  IconExpand,
+  IconShrink,
+  IconPlus,
+  IconChevronDown,
+  IconChevronRight,
+  IconMinus,
+} from '@douyinfe/semi-icons';
+
+import { JsonSchema } from '../type-selector/types';
+import { TypeSelector } from '../type-selector';
+import { PropertyValueType } from './types';
+import {
+  IconAddChildren,
+  UIActions,
+  UICollapseTrigger,
+  UICollapsible,
+  UIContainer,
+  UIExpandDetail,
+  UILabel,
+  UIProperties,
+  UIPropertyLeft,
+  UIPropertyMain,
+  UIPropertyRight,
+  UIRequired,
+  UIType,
+} from './styles';
+import { UIName } from './styles';
+import { UIRow } from './styles';
+import { usePropertiesEdit } from './hooks';
+
+export function JsonSchemaEditor(props: {
+  value?: JsonSchema;
+  onChange?: (value: JsonSchema) => void;
+}) {
+  const { value = { type: 'object' }, onChange: onChangeProps } = props;
+  const { propertyList, onAddProperty, onRemoveProperty, onEditProperty } = usePropertiesEdit(
+    value,
+    onChangeProps
+  );
+
+  return (
+    <UIContainer>
+      <UIProperties>
+        {propertyList.map((_property) => (
+          <PropertyEdit
+            key={_property.key}
+            value={_property}
+            onChange={(_v) => {
+              onEditProperty(_property.key!, _v);
+            }}
+            onRemove={() => {
+              onRemoveProperty(_property.key!);
+            }}
+          />
+        ))}
+      </UIProperties>
+      <Button size="small" style={{ marginTop: 10 }} icon={<IconPlus />} onClick={onAddProperty}>
+        Add
+      </Button>
+    </UIContainer>
+  );
+}
+
+function PropertyEdit(props: {
+  value?: PropertyValueType;
+  onChange?: (value: PropertyValueType) => void;
+  onRemove?: () => void;
+  $isLast?: boolean;
+  $showLine?: boolean;
+}) {
+  const { value, onChange: onChangeProps, onRemove, $isLast, $showLine } = props;
+
+  console.log('isLast', $isLast);
+
+  const [expand, setExpand] = useState(false);
+  const [collapse, setCollapse] = useState(false);
+
+  const { name, type, items, description, isPropertyRequired } = value || {};
+
+  const typeSelectorValue = useMemo(() => ({ type, items }), [type, items]);
+
+  const { propertyList, isDrilldownObject, onAddProperty, onRemoveProperty, onEditProperty } =
+    usePropertiesEdit(value, onChangeProps);
+
+  const onChange = (key: string, _value: any) => {
+    onChangeProps?.({
+      ...(value || {}),
+      [key]: _value,
+    });
+  };
+
+  const showCollapse = isDrilldownObject && propertyList.length > 0;
+
+  return (
+    <>
+      <UIPropertyLeft $isLast={$isLast} $showLine={$showLine}>
+        {showCollapse && (
+          <UICollapseTrigger onClick={() => setCollapse((_collapse) => !_collapse)}>
+            {collapse ? <IconChevronDown size="small" /> : <IconChevronRight size="small" />}
+          </UICollapseTrigger>
+        )}
+      </UIPropertyLeft>
+      <UIPropertyRight>
+        <UIPropertyMain $expand={expand}>
+          <UIRow>
+            <UIName>
+              <Input
+                placeholder="Input Variable Name"
+                size="small"
+                value={name}
+                onChange={(value) => onChange('name', value)}
+              />
+            </UIName>
+            <UIType>
+              <TypeSelector
+                value={typeSelectorValue}
+                onChange={(_value) => {
+                  onChangeProps?.({
+                    ...(value || {}),
+                    ..._value,
+                  });
+                }}
+              />
+            </UIType>
+            <UIRequired>
+              <Checkbox
+                checked={isPropertyRequired}
+                onChange={(e) => onChange('isPropertyRequired', e.target.checked)}
+              />
+            </UIRequired>
+            <UIActions>
+              <IconButton
+                size="small"
+                theme="borderless"
+                icon={expand ? <IconShrink size="small" /> : <IconExpand size="small" />}
+                onClick={() => setExpand((_expand) => !_expand)}
+              />
+              {isDrilldownObject && (
+                <IconButton
+                  size="small"
+                  theme="borderless"
+                  icon={<IconAddChildren />}
+                  onClick={() => {
+                    onAddProperty();
+                    setCollapse(true);
+                  }}
+                />
+              )}
+              <IconButton
+                size="small"
+                theme="borderless"
+                icon={<IconMinus size="small" />}
+                onClick={onRemove}
+              />
+            </UIActions>
+          </UIRow>
+          {expand && (
+            <UIExpandDetail>
+              <UILabel>Description</UILabel>
+              <Input
+                size="small"
+                value={description}
+                onChange={(value) => onChange('description', value)}
+                placeholder="Help LLM to understand the property"
+              />
+            </UIExpandDetail>
+          )}
+        </UIPropertyMain>
+        {showCollapse && (
+          <UICollapsible $collapse={collapse}>
+            <UIProperties $shrink={true}>
+              {propertyList.map((_property, index) => (
+                <PropertyEdit
+                  key={_property.key}
+                  value={_property}
+                  onChange={(_v) => {
+                    onEditProperty(_property.key!, _v);
+                  }}
+                  onRemove={() => {
+                    onRemoveProperty(_property.key!);
+                  }}
+                  $isLast={index === propertyList.length - 1}
+                  $showLine={true}
+                />
+              ))}
+            </UIProperties>
+          </UICollapsible>
+        )}
+      </UIPropertyRight>
+    </>
+  );
+}

+ 145 - 0
packages/materials/form-materials/src/components/json-schema-editor/styles.tsx

@@ -0,0 +1,145 @@
+import React from 'react';
+
+import styled, { css } from 'styled-components';
+import Icon from '@douyinfe/semi-icons';
+
+export const UIContainer = styled.div`
+  /* & .semi-input {
+    background-color: #fff;
+    border-radius: 6px;
+    height: 24px;
+  } */
+`;
+
+export const UIRow = styled.div`
+  display: flex;
+  align-items: center;
+  gap: 6px;
+`;
+
+export const UICollapseTrigger = styled.div`
+  cursor: pointer;
+  margin-right: 5px;
+`;
+
+export const UIExpandDetail = styled.div`
+  display: flex;
+  flex-direction: column;
+`;
+
+export const UILabel = styled.div`
+  font-size: 12px;
+  color: #999;
+  font-weight: 400;
+  margin-bottom: 2px;
+`;
+
+export const UIProperties = styled.div<{ $shrink?: boolean }>`
+  display: grid;
+  grid-template-columns: auto 1fr;
+
+  ${({ $shrink }) =>
+    $shrink &&
+    css`
+      padding-left: 10px;
+      margin-top: 10px;
+    `}
+`;
+
+export const UIPropertyLeft = styled.div<{ $isLast?: boolean; $showLine?: boolean }>`
+  grid-column: 1;
+  position: relative;
+
+  ${({ $showLine, $isLast }) =>
+    $showLine &&
+    css`
+      &::before {
+        /* 竖线 */
+        content: '';
+        position: absolute;
+        left: -22px;
+        top: -18px;
+        bottom: ${$isLast ? '12px' : '0px'};
+        width: 1px;
+        background: #d9d9d9;
+        display: block;
+      }
+
+      &::after {
+        /* 横线 */
+        content: '';
+        position: absolute;
+        left: -22px; // 横线起点和竖线对齐
+        top: 12px; // 跟随你的行高调整
+        width: 22px; // 横线长度
+        height: 1px;
+        background: #d9d9d9;
+        display: block;
+      }
+    `}
+`;
+
+export const UIPropertyRight = styled.div`
+  grid-column: 2;
+  margin-bottom: 10px;
+
+  &:last-child {
+    margin-bottom: 0px;
+  }
+`;
+
+export const UIPropertyMain = styled.div<{ $expand?: boolean }>`
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+
+  ${({ $expand }) =>
+    $expand &&
+    css`
+      background-color: #f5f5f5;
+      padding: 10px;
+      border-radius: 4px;
+    `}
+`;
+
+export const UICollapsible = styled.div<{ $collapse?: boolean }>`
+  display: none;
+
+  ${({ $collapse }) =>
+    $collapse &&
+    css`
+      display: block;
+    `}
+`;
+
+export const UIName = styled.div`
+  flex-grow: 1;
+`;
+
+export const UIType = styled.div``;
+
+export const UIRequired = styled.div``;
+
+export const UIActions = styled.div`
+  white-space: nowrap;
+`;
+
+const iconAddChildrenSvg = (
+  <svg
+    className="icon-icon icon-icon-coz_add_node "
+    width="1em"
+    height="1em"
+    viewBox="0 0 24 24"
+    fill="currentColor"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <path
+      fillRule="evenodd"
+      clipRule="evenodd"
+      d="M11 6.49988C11 8.64148 9.50397 10.4337 7.49995 10.8884V15.4998C7.49995 16.0521 7.94767 16.4998 8.49995 16.4998H11.208C11.0742 16.8061 11 17.1443 11 17.4998C11 17.8554 11.0742 18.1936 11.208 18.4998H8.49995C6.8431 18.4998 5.49995 17.1567 5.49995 15.4998V10.8884C3.49599 10.4336 2 8.64145 2 6.49988C2 4.0146 4.01472 1.99988 6.5 1.99988C8.98528 1.99988 11 4.0146 11 6.49988ZM6.5 8.99988C7.88071 8.99988 9 7.88059 9 6.49988C9 5.11917 7.88071 3.99988 6.5 3.99988C5.11929 3.99988 4 5.11917 4 6.49988C4 7.88059 5.11929 8.99988 6.5 8.99988Z"
+    ></path>
+    <path d="M17.5 12.4999C18.0523 12.4999 18.5 12.9476 18.5 13.4999V16.4999H21.5C22.0523 16.4999 22.5 16.9476 22.5 17.4999C22.5 18.0522 22.0523 18.4999 21.5 18.4999H18.5V21.4999C18.5 22.0522 18.0523 22.4999 17.5 22.4999C16.9477 22.4999 16.5 22.0522 16.5 21.4999V18.4999H13.5C12.9477 18.4999 12.5 18.0522 12.5 17.4999C12.5 16.9476 12.9477 16.4999 13.5 16.4999H16.5V13.4999C16.5 12.9476 16.9477 12.4999 17.5 12.4999Z"></path>
+  </svg>
+);
+
+export const IconAddChildren = () => <Icon size="small" svg={iconAddChildrenSvg} />;

+ 11 - 0
packages/materials/form-materials/src/components/json-schema-editor/types.ts

@@ -0,0 +1,11 @@
+import { JsonSchema } from '../type-selector/types';
+
+export interface PropertyValueType extends JsonSchema {
+  name?: string;
+  key?: number;
+  isPropertyRequired?: boolean;
+}
+
+export type PropertiesValueType = Pick<PropertyValueType, 'properties' | 'required'>;
+
+export type JsonSchemaProperties = JsonSchema['properties'];

+ 5 - 0
packages/materials/form-materials/src/components/type-selector/config.json

@@ -0,0 +1,5 @@
+{
+  "name": "type-selector",
+  "depMaterials": [],
+  "depPackages": ["@douyinfe/semi-ui", "@douyinfe/semi-icons"]
+}

+ 94 - 0
apps/demo-fixed-layout/src/plugins/sync-variable-plugin/icons.tsx → packages/materials/form-materials/src/components/type-selector/constants.tsx

@@ -1,3 +1,10 @@
+import React from 'react';
+
+import { CascaderData } from '@douyinfe/semi-ui/lib/es/cascader';
+import Icon from '@douyinfe/semi-icons';
+
+import { JsonSchema } from './types';
+
 export const VariableTypeIcons: { [key: string]: React.ReactNode } = {
   custom: (
     <svg
@@ -263,3 +270,90 @@ export const ArrayIcons: { [key: string]: React.ReactNode } = {
     </svg>
   ),
 };
+
+export const getSchemaIcon = (value?: Partial<JsonSchema>) => {
+  if (value?.type === 'array') {
+    return ArrayIcons[value.items?.type || 'object'];
+  }
+
+  return VariableTypeIcons[value?.type || 'object'];
+};
+
+const labelStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 5 };
+
+const firstUppercase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
+
+const baseOptions: CascaderData[] = [
+  {
+    label: (
+      <div style={labelStyle}>
+        <Icon size="small" svg={getSchemaIcon({ type: 'string' })} />
+        {firstUppercase('string')}
+      </div>
+    ),
+    value: 'string',
+  },
+  {
+    label: (
+      <div style={labelStyle}>
+        <Icon size="small" svg={getSchemaIcon({ type: 'integer' })} />
+        {firstUppercase('integer')}
+      </div>
+    ),
+    value: 'integer',
+  },
+  {
+    label: (
+      <div style={labelStyle}>
+        <Icon size="small" svg={getSchemaIcon({ type: 'number' })} />
+        {firstUppercase('number')}
+      </div>
+    ),
+    value: 'number',
+  },
+
+  {
+    label: (
+      <div style={labelStyle}>
+        <Icon size="small" svg={getSchemaIcon({ type: 'boolean' })} />
+        {firstUppercase('boolean')}
+      </div>
+    ),
+    value: 'boolean',
+  },
+  {
+    label: (
+      <div style={labelStyle}>
+        <Icon size="small" svg={getSchemaIcon({ type: 'object' })} />
+        {firstUppercase('object')}
+      </div>
+    ),
+    value: 'object',
+  },
+];
+
+export const options: CascaderData[] = [
+  ...baseOptions,
+  {
+    label: (
+      <div style={labelStyle}>
+        <Icon size="small" svg={getSchemaIcon({ type: 'array' })} />
+        {firstUppercase('array')}
+      </div>
+    ),
+    value: 'array',
+    children: baseOptions.map((_opt) => ({
+      ..._opt,
+      value: `${_opt.value}`,
+      label: (
+        <div style={labelStyle}>
+          <Icon
+            size="small"
+            svg={getSchemaIcon({ type: 'array', items: { type: _opt.value as string } })}
+          />
+          {firstUppercase(_opt.value as string)}
+        </div>
+      ),
+    })),
+  },
+];

+ 54 - 0
packages/materials/form-materials/src/components/type-selector/index.tsx

@@ -0,0 +1,54 @@
+import React, { useMemo } from 'react';
+
+import { Button, Cascader } from '@douyinfe/semi-ui';
+
+import { JsonSchema } from './types';
+import { ArrayIcons, VariableTypeIcons, getSchemaIcon, options } from './constants';
+
+interface PropTypes {
+  value?: Partial<JsonSchema>;
+  onChange: (value?: Partial<JsonSchema>) => void;
+}
+
+export const getTypeSelectValue = (value?: Partial<JsonSchema>): string[] | undefined => {
+  if (value?.type === 'array' && value?.items) {
+    return [value.type, ...(getTypeSelectValue(value.items) || [])];
+  }
+
+  return value?.type ? [value.type] : undefined;
+};
+
+export const parseTypeSelectValue = (value?: string[]): Partial<JsonSchema> | undefined => {
+  const [type, ...subTypes] = value || [];
+
+  if (type === 'array') {
+    return { type: 'array', items: parseTypeSelectValue(subTypes) };
+  }
+
+  return { type };
+};
+
+export function TypeSelector(props: PropTypes) {
+  const { value, onChange } = props;
+
+  const selectValue = useMemo(() => getTypeSelectValue(value), [value]);
+
+  return (
+    <Cascader
+      size="small"
+      triggerRender={() => (
+        <Button size="small" style={{ width: 50 }}>
+          {getSchemaIcon(value)}
+        </Button>
+      )}
+      treeData={options}
+      value={selectValue}
+      leafOnly={true}
+      onChange={(value) => {
+        onChange(parseTypeSelectValue(value as string[]));
+      }}
+    />
+  );
+}
+
+export { JsonSchema, VariableTypeIcons, ArrayIcons, getSchemaIcon };

+ 19 - 0
packages/materials/form-materials/src/components/type-selector/types.ts

@@ -0,0 +1,19 @@
+export type BasicType = 'boolean' | 'string' | 'integer' | 'number' | 'object' | 'array';
+
+export interface JsonSchema<T = string> {
+  type?: T;
+  default?: any;
+  title?: string;
+  description?: string;
+  enum?: (string | number)[];
+  properties?: Record<string, JsonSchema>;
+  additionalProperties?: JsonSchema;
+  items?: JsonSchema;
+  required?: string[];
+  $ref?: string;
+  extra?: {
+    order?: number;
+    literal?: boolean; // is literal type
+    formComponent?: string; // Set the render component
+  };
+}

+ 5 - 0
packages/materials/form-materials/src/components/variable-selector/config.json

@@ -0,0 +1,5 @@
+{
+  "name": "variable-selector",
+  "depMaterials": ["type-selector"],
+  "depPackages": ["@douyinfe/semi-ui"]
+}

+ 45 - 0
packages/materials/form-materials/src/components/variable-selector/index.tsx

@@ -0,0 +1,45 @@
+import React from 'react';
+
+import { TreeSelect } from '@douyinfe/semi-ui';
+
+import { useVariableTree } from './use-variable-tree';
+
+export interface PropTypes {
+  value?: string;
+  onChange: (value?: string) => void;
+  readonly?: boolean;
+  hasError?: boolean;
+  style?: React.CSSProperties;
+}
+
+export const VariableSelector = ({
+  value,
+  onChange,
+  style,
+  readonly = false,
+  hasError,
+}: PropTypes) => {
+  const treeData = useVariableTree();
+
+  return (
+    <>
+      <TreeSelect
+        dropdownMatchSelectWidth={false}
+        disabled={readonly}
+        treeData={treeData}
+        size="small"
+        value={value}
+        style={{
+          ...style,
+          outline: hasError ? '1px solid red' : undefined,
+        }}
+        validateStatus={hasError ? 'error' : undefined}
+        onChange={(option) => {
+          onChange(option as string);
+        }}
+        showClear
+        placeholder="Select Variable..."
+      />
+    </>
+  );
+};

+ 73 - 0
packages/materials/form-materials/src/components/variable-selector/use-variable-tree.tsx

@@ -0,0 +1,73 @@
+import React, { useCallback } from 'react';
+
+import { useScopeAvailable, ASTMatch, BaseVariableField } from '@flowgram.ai/editor';
+import { TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree';
+import { Icon } from '@douyinfe/semi-ui';
+
+import { ArrayIcons, VariableTypeIcons } from '../type-selector/constants';
+
+type VariableField = BaseVariableField<{ icon?: string | JSX.Element; title?: string }>;
+
+export function useVariableTree(): TreeNodeData[] {
+  const available = useScopeAvailable();
+
+  const getVariableTypeIcon = useCallback((variable: VariableField) => {
+    if (variable.meta.icon) {
+      if (typeof variable.meta.icon === 'string') {
+        return <img style={{ marginRight: 8 }} width={12} height={12} src={variable.meta.icon} />;
+      }
+
+      return variable.meta.icon;
+    }
+
+    const _type = variable.type;
+
+    if (ASTMatch.isArray(_type)) {
+      return (
+        <Icon
+          size="small"
+          svg={ArrayIcons[_type.items?.kind.toLowerCase()] || VariableTypeIcons.array}
+        />
+      );
+    }
+
+    if (ASTMatch.isCustomType(_type)) {
+      return <Icon size="small" svg={VariableTypeIcons[_type.typeName.toLowerCase()]} />;
+    }
+
+    return <Icon size="small" svg={VariableTypeIcons[variable.type?.kind.toLowerCase()]} />;
+  }, []);
+
+  const renderVariable = (
+    variable: VariableField,
+    parentFields: VariableField[] = []
+  ): TreeNodeData | null => {
+    let type = variable?.type;
+
+    let children: TreeNodeData[] | undefined;
+
+    if (ASTMatch.isObject(type)) {
+      children = (type.properties || [])
+        .map((_property) => renderVariable(_property as VariableField, [...parentFields, variable]))
+        .filter(Boolean) as TreeNodeData[];
+
+      if (!children?.length) {
+        return null;
+      }
+    }
+
+    const currPath = [...parentFields.map((_field) => _field.key), variable.key].join('.');
+
+    return {
+      key: currPath,
+      label: variable.meta.title || variable.key,
+      value: currPath,
+      icon: getVariableTypeIcon(variable),
+      children,
+    };
+  };
+
+  return [...available.variables.slice(0).reverse()]
+    .map((_variable) => renderVariable(_variable as VariableField))
+    .filter(Boolean) as TreeNodeData[];
+}

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

@@ -0,0 +1 @@
+export * from './components';

+ 8 - 0
packages/materials/form-materials/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
+  "compilerOptions": {
+    "jsx": "react",
+  },
+  "include": ["./src"],
+  "exclude": ["node_modules"]
+}

+ 26 - 0
packages/materials/form-materials/vitest.config.ts

@@ -0,0 +1,26 @@
+const path = require('path');
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  build: {
+    commonjsOptions: {
+      transformMixedEsModules: true,
+    },
+  },
+  test: {
+    globals: true,
+    mockReset: false,
+    environment: 'jsdom',
+    setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
+    include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
+    exclude: [
+      '**/__mocks__**',
+      '**/node_modules/**',
+      '**/dist/**',
+      '**/lib/**', // lib 编译结果忽略掉
+      '**/cypress/**',
+      '**/.{idea,git,cache,output,temp}/**',
+      '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
+    ],
+  },
+});

+ 1 - 0
packages/materials/form-materials/vitest.setup.ts

@@ -0,0 +1 @@
+import 'reflect-metadata';

+ 6 - 0
rush.json

@@ -713,6 +713,12 @@
             "versionPolicyName": "publishPolicy",
             "tags": ["level-1", "team-flow"]
         },
+        {
+          "packageName": "@flowgram.ai/form-materials",
+          "projectFolder": "packages/materials/form-materials",
+          "versionPolicyName": "publishPolicy",
+          "tags": ["level-1", "team-flow"]
+        },
         {
             "packageName": "@flowgram.ai/free-auto-layout-plugin",
             "projectFolder": "packages/plugins/free-auto-layout-plugin",

Some files were not shown because too many files changed in this diff