diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c6dd2366..b34df23a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,9 @@ # 文件路径与代码负责人分配 # 对整个仓库设置代码负责人 -* @xiamidaxia @luics +* @xiamidaxia @luics @dragooncjw # 对特定目录设置代码负责人 -/docs/ @xiamidaxia @dragooncjw +/apps/docs/ @xiamidaxia @dragooncjw @YuanHeDx +/apps/demo-node-form/ @YuanHeDx +/packages/node-engine/ @YuanHeDx +/packages/plugins/node-core-plugin/ @YuanHeDx diff --git a/apps/demo-node-form/.eslintrc.js b/apps/demo-node-form/.eslintrc.js new file mode 100644 index 00000000..9f8bd759 --- /dev/null +++ b/apps/demo-node-form/.eslintrc.js @@ -0,0 +1,15 @@ +const { defineConfig } = require('@flowgram.ai/eslint-config'); + +module.exports = defineConfig({ + preset: 'web', + packageRoot: __dirname, + rules: { + 'no-console': 'off', + 'react/prop-types': 'off', + }, + settings: { + react: { + version: 'detect', // 自动检测 React 版本 + }, + }, +}); diff --git a/apps/demo-node-form/index.html b/apps/demo-node-form/index.html new file mode 100644 index 00000000..7327a045 --- /dev/null +++ b/apps/demo-node-form/index.html @@ -0,0 +1,12 @@ + + + + + + + Flow FreeLayoutEditor Demo + + +
+ + diff --git a/apps/demo-node-form/package.json b/apps/demo-node-form/package.json new file mode 100644 index 00000000..f4e53eb2 --- /dev/null +++ b/apps/demo-node-form/package.json @@ -0,0 +1,56 @@ +{ + "name": "@flowgram.ai/demo-node-form", + "version": "0.1.0", + "description": "", + "keywords": [], + "license": "MIT", + "main": "./src/index.tsx", + "files": [ + "src/", + ".eslintrc.js", + ".gitignore", + "index.html", + "package.json", + "rspack.config.js", + "tsconfig.json" + ], + "scripts": { + "build": "exit 0", + "build:fast": "exit 0", + "build:watch": "exit 0", + "clean": "rimraf dist", + "dev": "MODE=app NODE_ENV=development rspack serve", + "lint": "eslint ./src --cache", + "lint:fix": "eslint ./src --fix", + "start": "NODE_ENV=development rspack serve", + "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/minimap-plugin": "workspace:*", + "react": "^18", + "react-dom": "^18", + "styled-components": "^5" + }, + "devDependencies": { + "@flowgram.ai/ts-config": "workspace:*", + "@flowgram.ai/eslint-config": "workspace:*", + "@rspack/cli": "0.2.1", + "@types/lodash-es": "^4.17.12", + "@types/node": "^18", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/styled-components": "^5", + "@typescript-eslint/parser": "^6.10.0", + "eslint": "^8.54.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/apps/demo-node-form/rspack.config.js b/apps/demo-node-form/rspack.config.js new file mode 100644 index 00000000..e2f05408 --- /dev/null +++ b/apps/demo-node-form/rspack.config.js @@ -0,0 +1,46 @@ +const path = require('path'); + +const isCI = process.env.CI === 'true'; +const isSCM = !!process.env.BUILD_BRANCH; +const isProd = process.env.NODE_ENV === 'production'; +/** + * @type {import('@rspack/cli').Configuration} + */ +module.exports = { + mode: process.env.NODE_ENV, + context: __dirname, + target: ['web'], + entry: { + main: './src/app.tsx', + }, + builtins: { + // https://www.rspack.dev/config/builtins.html#builtinshtml + html: [ + { + template: './index.html', + }, + ], + progress: !isSCM ? {} : false, + treeShaking: isProd, + }, + module: { + // https://www.rspack.dev/config/module.html#rule + rules: [ + { + test: /\.(png|gif|jpg|jpeg|svg|woff2)$/, + type: 'asset', + }, + ], + }, + plugins: [], + /** module is too large now, we may need better way to tackle this in the future */ + stats: isCI + ? { all: false, modules: true, assets: true, chunks: true, warnings: true, errors: true } + : { + modules: false, + all: false, + warnings: false, + errors: true, + timings: true, + }, +}; diff --git a/apps/demo-node-form/src/app.tsx b/apps/demo-node-form/src/app.tsx new file mode 100644 index 00000000..150c56b5 --- /dev/null +++ b/apps/demo-node-form/src/app.tsx @@ -0,0 +1,7 @@ +import { createRoot } from 'react-dom/client'; + +import { Editor } from './editor'; + +const app = createRoot(document.getElementById('root')!); + +app.render(); diff --git a/apps/demo-node-form/src/components/field-title.tsx b/apps/demo-node-form/src/components/field-title.tsx new file mode 100644 index 00000000..3bc680d9 --- /dev/null +++ b/apps/demo-node-form/src/components/field-title.tsx @@ -0,0 +1,5 @@ +import styled from 'styled-components'; + +export const FieldTitle = styled.div` + padding-bottom: 4px; +`; diff --git a/apps/demo-node-form/src/components/field-wrapper.css b/apps/demo-node-form/src/components/field-wrapper.css new file mode 100644 index 00000000..0963e991 --- /dev/null +++ b/apps/demo-node-form/src/components/field-wrapper.css @@ -0,0 +1,24 @@ +.error-message { + color: #f5222d !important; +} + +.required { + color: #f5222d !important; + padding-left: 4px +} + +.field-wrapper { + width: 100%; + margin-bottom: 12px; +} + +.field-title { + margin-bottom: 6px; +} + +.field-note{ + color: #a3a0a0 !important; + font-size: 12px; + margin: 6px 0; +} + diff --git a/apps/demo-node-form/src/components/field-wrapper.tsx b/apps/demo-node-form/src/components/field-wrapper.tsx new file mode 100644 index 00000000..69f33c58 --- /dev/null +++ b/apps/demo-node-form/src/components/field-wrapper.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import './field-wrapper.css'; + +interface FieldWrapperProps { + required?: boolean; + title: string; + children?: React.ReactNode; + error?: string; + note?: string; +} + +export const FieldWrapper = ({ required, title, children, error, note }: FieldWrapperProps) => ( +
+
+ {title} + {note ?

{note}

: null} + {required ? * : null} +
+ {children} +

{error}

+ {note ?
: null} +
+); diff --git a/apps/demo-node-form/src/components/index.ts b/apps/demo-node-form/src/components/index.ts new file mode 100644 index 00000000..f04829e6 --- /dev/null +++ b/apps/demo-node-form/src/components/index.ts @@ -0,0 +1,2 @@ +export { FieldTitle } from './field-title'; +export { FieldWrapper } from './field-wrapper'; diff --git a/apps/demo-node-form/src/constant.ts b/apps/demo-node-form/src/constant.ts new file mode 100644 index 00000000..12ed43f2 --- /dev/null +++ b/apps/demo-node-form/src/constant.ts @@ -0,0 +1,65 @@ +export const fieldWrapperTs = `import React from 'react'; + +import './field-wrapper.css'; + +interface FieldWrapperProps { + required?: boolean; + title: string; + children?: React.ReactNode; + error?: string; + note?: string; +} + +export const FieldWrapper = ({ required, title, children, error, note }: FieldWrapperProps) => ( +
+
+ {title} + {note ?

{note}

: null} + {required ? * : null} +
+ {children} +

{error}

+ {note ?
: null} +
+); +`; + +export const fieldWrapperCss = `.error-message { + color: #f5222d !important; +} + +.required { + color: #f5222d !important; + padding-left: 4px +} + +.field-wrapper { + width: 100%; + margin-bottom: 12px; +} + +.field-title { + margin-bottom: 6px; +} + +.field-note{ + color: #a3a0a0 !important; + font-size: 12px; + margin: 6px 0; +} +`; + +export const defaultInitialDataTs = `import { WorkflowJSON } from '@flowgram.ai/free-layout-editor'; + +export const DEFAULT_INITIAL_DATA: WorkflowJSON = { + nodes: [ + { + id: 'node_0', + type: 'custom', + meta: { + position: { x: 400, y: 0 }, + }, + }, + ], + edges: [], +};`; diff --git a/apps/demo-node-form/src/editor.tsx b/apps/demo-node-form/src/editor.tsx new file mode 100644 index 00000000..ad395ec6 --- /dev/null +++ b/apps/demo-node-form/src/editor.tsx @@ -0,0 +1,27 @@ +import { + EditorRenderer, + FlowNodeRegistry, + FreeLayoutEditorProvider, + WorkflowJSON, +} from '@flowgram.ai/free-layout-editor'; + +import { useEditorProps } from './hooks/use-editor-props'; +import '@flowgram.ai/free-layout-editor/index.css'; +import './index.css'; +interface EditorProps { + registry: FlowNodeRegistry; + initialData: WorkflowJSON; +} + +export const Editor = ({ registry, initialData }: EditorProps) => { + const editorProps = useEditorProps({ registries: [registry], initialData }); + return ( + +
+
+ +
+
+
+ ); +}; diff --git a/apps/demo-node-form/src/form-meta.tsx b/apps/demo-node-form/src/form-meta.tsx new file mode 100644 index 00000000..8cf213e0 --- /dev/null +++ b/apps/demo-node-form/src/form-meta.tsx @@ -0,0 +1,51 @@ +import { + Field, + FieldRenderProps, + FormMeta, + ValidateTrigger, +} from '@flowgram.ai/free-layout-editor'; +import { Input } from '@douyinfe/semi-ui'; + +// FieldWrapper is not provided by sdk, and can be customized +import { FieldWrapper } from './components'; + +const render = () => ( +
+
Basic Node
+ + {({ field, fieldState }: FieldRenderProps) => ( + + + + )} + + + + {({ field, fieldState }: FieldRenderProps) => ( + + + + )} + +
+); + +const formMeta: FormMeta = { + render, + defaultValues: { name: 'Tina', city: 'Hangzhou' }, + validateTrigger: ValidateTrigger.onChange, + validate: { + name: ({ value }) => { + if (!value) { + return 'Name is required'; + } + }, + city: ({ value }) => { + if (!value) { + return 'City is required'; + } + }, + }, +}; + +export const DEFAULT_FORM_META = formMeta; diff --git a/apps/demo-node-form/src/hooks/use-editor-props.tsx b/apps/demo-node-form/src/hooks/use-editor-props.tsx new file mode 100644 index 00000000..5184355b --- /dev/null +++ b/apps/demo-node-form/src/hooks/use-editor-props.tsx @@ -0,0 +1,165 @@ +import { useMemo } from 'react'; + +import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin'; +import { createFreeSnapPlugin } from '@flowgram.ai/free-snap-plugin'; +import { + FreeLayoutProps, + WorkflowNodeProps, + WorkflowNodeRenderer, + Field, + useNodeRender, + FlowNodeRegistry, + WorkflowJSON, +} from '@flowgram.ai/free-layout-editor'; + +import { DEFAULT_DEMO_REGISTRY } from '../node-registries'; +import { DEFAULT_INITIAL_DATA } from '../initial-data'; + +interface EditorProps { + registries: FlowNodeRegistry[]; + initialData: WorkflowJSON; +} + +export const useEditorProps = ({ + registries = [DEFAULT_DEMO_REGISTRY], + initialData = DEFAULT_INITIAL_DATA, +}: EditorProps) => + useMemo( + () => ({ + /** + * Whether to enable the background + */ + background: true, + /** + * Whether it is read-only or not, the node cannot be dragged in read-only mode + */ + readonly: false, + /** + * Initial data + * 初始化数据 + */ + initialData, + /** + * Node registries + * 节点注册 + */ + nodeRegistries: registries, + /** + * Get the default node registry, which will be merged with the 'nodeRegistries' + * 提供默认的节点注册,这个会和 nodeRegistries 做合并 + */ + getNodeDefaultRegistry(type) { + return { + type, + meta: { + defaultExpanded: true, + }, + formMeta: { + /** + * Render form + */ + render: () => ( + <> + name="title"> + {({ field }) =>
{field.value}
} + +
+ name="content"> + + +
+ + ), + }, + }; + }, + materials: { + /** + * Render Node + */ + renderDefaultNode: (props: WorkflowNodeProps) => { + const { form } = useNodeRender(); + return ( + + {form?.render()} + + ); + }, + }, + /** + * Content change + */ + onContentChange(ctx, event) { + // console.log('Auto Save: ', event, ctx.document.toJSON()); + }, + // /** + // * Node engine enable, you can configure formMeta in the FlowNodeRegistry + // */ + nodeEngine: { + enable: true, + }, + /** + * Redo/Undo enable + */ + history: { + enable: true, + enableChangeNode: true, // Listen Node engine data change + }, + /** + * Playground init + */ + onInit: (ctx) => {}, + /** + * Playground render + */ + onAllLayersRendered(ctx) { + // Fitview + ctx.document.fitView(false); + }, + /** + * Playground dispose + */ + onDispose() { + console.log('---- Playground Dispose ----'); + }, + plugins: () => [ + /** + * Minimap plugin + * 缩略图插件 + */ + createMinimapPlugin({ + disableLayer: true, + canvasStyle: { + canvasWidth: 182, + canvasHeight: 102, + canvasPadding: 50, + canvasBackground: 'rgba(245, 245, 245, 1)', + canvasBorderRadius: 10, + viewportBackground: 'rgba(235, 235, 235, 1)', + viewportBorderRadius: 4, + viewportBorderColor: 'rgba(201, 201, 201, 1)', + viewportBorderWidth: 1, + viewportBorderDashLength: 2, + nodeColor: 'rgba(255, 255, 255, 1)', + nodeBorderRadius: 2, + nodeBorderWidth: 0.145, + nodeBorderColor: 'rgba(6, 7, 9, 0.10)', + overlayColor: 'rgba(255, 255, 255, 0)', + }, + inactiveDebounceTime: 1, + }), + /** + * Snap plugin + * 自动对齐及辅助线插件 + */ + createFreeSnapPlugin({ + edgeColor: '#00B2B2', + alignColor: '#00B2B2', + edgeLineWidth: 1, + alignLineWidth: 1, + alignCrossWidth: 8, + }), + ], + }), + [] + ); diff --git a/apps/demo-node-form/src/index.css b/apps/demo-node-form/src/index.css new file mode 100644 index 00000000..15728111 --- /dev/null +++ b/apps/demo-node-form/src/index.css @@ -0,0 +1,113 @@ +.demo-free-node { + display: flex; + min-width: 300px; + min-height: 100px; + flex-direction: column; + align-items: flex-start; + box-sizing: border-box; + border-radius: 8px; + border: 1px solid var(--light-usage-border-color-border, rgba(28, 31, 35, 0.08)); + background: #fff; + box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.1); +} + +.demo-node-content { + padding: 8px 12px; + flex-grow: 1; + width: 100%; +} + +.demo-node-title { + font-weight: 500; + font-size: 14px; + width: 100%; + margin: 4px 0px 12px 0px; +} +.demo-free-node-content { + padding: 4px 12px; + flex-grow: 1; + width: 100%; +} +.demo-free-node::before { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: -1; + background-color: white; + border-radius: 7px; +} + +.demo-free-node:hover:before { + -webkit-filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.3)) drop-shadow(0 4px 14px rgba(0, 0, 0, 0.1)); + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.3)) drop-shadow(0 4px 14px rgba(0, 0, 0, 0.1)); +} + +.demo-free-node.activated:before, +.demo-free-node.selected:before { + outline: 2px solid var(--light-usage-primary-color-primary, #4d53e8); + -webkit-filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.3)) drop-shadow(0 4px 14px rgba(0, 0, 0, 0.1)); + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.3)) drop-shadow(0 4px 14px rgba(0, 0, 0, 0.1)); +} + +.demo-free-sidebar { + height: 100%; + overflow-y: auto; + padding: 12px 16px 0; + box-sizing: border-box; + background: #f7f7fa; + border-right: 1px solid rgba(29, 28, 35, 0.08); +} + +.demo-free-right-top-panel { + position: fixed; + right: 10px; + top: 70px; + width: 300px; + z-index: 999; +} + +.demo-free-card { + width: 140px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + background: #fff; + border-radius: 8px; + box-shadow: 0 6px 8px 0 rgba(28, 31, 35, 0.03); + cursor: -webkit-grab; + cursor: grab; + line-height: 16px; + margin-bottom: 12px; + overflow: hidden; + padding: 16px; + position: relative; + color: black; +} + +.demo-free-layout { + display: flex; + flex-direction: row; + flex-grow: 1; +} + +.demo-free-editor { + flex-grow: 1; + position: relative; + height: 100%; +} + +.demo-free-container { + position: absolute; + left: 0; + top: 0; + display: flex; + width: 100%; + height: 100%; + flex-direction: column; +} + diff --git a/apps/demo-node-form/src/index.tsx b/apps/demo-node-form/src/index.tsx new file mode 100644 index 00000000..ecd5a5c2 --- /dev/null +++ b/apps/demo-node-form/src/index.tsx @@ -0,0 +1,6 @@ +export { Editor } from './editor'; +export { FieldTitle, FieldWrapper } from './components'; +export { DEFAULT_FORM_META } from './form-meta'; +export { DEFAULT_DEMO_REGISTRY } from './node-registries'; +export { DEFAULT_INITIAL_DATA } from './initial-data'; +export { fieldWrapperTs, fieldWrapperCss, defaultInitialDataTs } from './constant'; diff --git a/apps/demo-node-form/src/initial-data.ts b/apps/demo-node-form/src/initial-data.ts new file mode 100644 index 00000000..0a0c8cdb --- /dev/null +++ b/apps/demo-node-form/src/initial-data.ts @@ -0,0 +1,14 @@ +import { WorkflowJSON } from '@flowgram.ai/free-layout-editor'; + +export const DEFAULT_INITIAL_DATA: WorkflowJSON = { + nodes: [ + { + id: 'node_0', + type: 'custom', + meta: { + position: { x: 400, y: 0 }, + }, + }, + ], + edges: [], +}; diff --git a/apps/demo-node-form/src/node-registries.tsx b/apps/demo-node-form/src/node-registries.tsx new file mode 100644 index 00000000..1afa4cab --- /dev/null +++ b/apps/demo-node-form/src/node-registries.tsx @@ -0,0 +1,10 @@ +import { WorkflowNodeRegistry } from '@flowgram.ai/free-layout-editor'; + +import { DEFAULT_FORM_META } from './form-meta'; + +export const DEFAULT_DEMO_REGISTRY: WorkflowNodeRegistry = { + type: 'custom', + meta: {}, + defaultPorts: [{ type: 'output' }, { type: 'input' }], + formMeta: DEFAULT_FORM_META, +}; diff --git a/apps/demo-node-form/tsconfig.json b/apps/demo-node-form/tsconfig.json new file mode 100644 index 00000000..171a60f5 --- /dev/null +++ b/apps/demo-node-form/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "experimentalDecorators": true, + "target": "es2020", + "module": "esnext", + "strictPropertyInitialization": false, + "strict": true, + "esModuleInterop": true, + "moduleResolution": "node", + "skipLibCheck": true, + "noUnusedLocals": true, + "noImplicitAny": true, + "allowJs": true, + "resolveJsonModule": true, + "types": ["node"], + "jsx": "react-jsx", + "lib": ["es6", "dom", "es2020", "es2019.Array"] + }, + "include": ["./src"], +} diff --git a/apps/docs/components/index.ts b/apps/docs/components/index.ts index 1ef1ecbd..371c2dd9 100644 --- a/apps/docs/components/index.ts +++ b/apps/docs/components/index.ts @@ -5,3 +5,4 @@ export { FreeLayoutSimple } from './free-layout-simple'; export { FreeLayoutSimplePreview } from './free-layout-simple/preview'; export { FixedLayoutSimple } from './fixed-layout-simple'; export { FixedLayoutSimplePreview } from './fixed-layout-simple/preview'; +export { NodeFormBasicPreview, NodeFormEffectPreview } from './node-form'; diff --git a/apps/docs/components/node-form/basic-preview.tsx b/apps/docs/components/node-form/basic-preview.tsx new file mode 100644 index 00000000..e31724c4 --- /dev/null +++ b/apps/docs/components/node-form/basic-preview.tsx @@ -0,0 +1,87 @@ +import { + DEFAULT_DEMO_REGISTRY, + DEFAULT_INITIAL_DATA, + defaultInitialDataTs, + fieldWrapperCss, + fieldWrapperTs, +} from '@flowgram.ai/demo-node-form'; + +import { PreviewEditor } from '../preview-editor'; +import { Editor } from './editor'; + +const registryCode = { + code: `import { + Field, + FieldRenderProps, + FormMeta, + ValidateTrigger, +} from '@flowgram.ai/free-layout-editor'; +import { Input } from '@douyinfe/semi-ui'; + +// FieldWrapper is not provided by sdk, it can be customized +import { FieldWrapper } from './components'; + +const render = () => ( +
+
Basic Node
+ + {({ field, fieldState }: FieldRenderProps) => ( + + + + )} + + + + {({ field, fieldState }: FieldRenderProps) => ( + + + + )} + +
+); + +const formMeta: FormMeta = { + render, + defaultValues: { name: 'Tina', city: 'Hangzhou' }, + validateTrigger: ValidateTrigger.onChange, + validate: { + name: ({ value }) => { + if (!value) { + return 'Name is required'; + } + }, + city: ({ value }) => { + if (!value) { + return 'City is required'; + } + } + } +}; + + + +export const nodeRegistry: WorkflowNodeRegistry = { + type: 'custom', + meta: {}, + defaultPorts: [{ type: 'output' }, { type: 'input' }], + formMeta +}; +`, + active: true, +}; + +export const NodeFormBasicPreview = () => { + const files = { + 'node-registry.tsx': registryCode, + 'initial-data.ts': { code: defaultInitialDataTs, active: true }, + 'field-wrapper.tsx': { code: fieldWrapperTs, active: true }, + 'field-wrapper.css': { code: fieldWrapperCss, active: true }, + }; + return ( + + + + ); +}; diff --git a/apps/docs/components/node-form/editor.tsx b/apps/docs/components/node-form/editor.tsx new file mode 100644 index 00000000..24153819 --- /dev/null +++ b/apps/docs/components/node-form/editor.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +// https://github.com/web-infra-dev/rspress/issues/553 +const Editor = React.lazy(() => + import('@flowgram.ai/demo-node-form').then((module) => ({ + default: module.Editor, + })) +); + +export { Editor }; diff --git a/apps/docs/components/node-form/effect/node-registry.tsx b/apps/docs/components/node-form/effect/node-registry.tsx new file mode 100644 index 00000000..004f0d73 --- /dev/null +++ b/apps/docs/components/node-form/effect/node-registry.tsx @@ -0,0 +1,82 @@ +import { + DataEvent, + EffectFuncProps, + Field, + FieldRenderProps, + FormMeta, + ValidateTrigger, + WorkflowNodeRegistry, +} from '@flowgram.ai/free-layout-editor'; +import { FieldWrapper } from '@flowgram.ai/demo-node-form'; +import { Input } from '@douyinfe/semi-ui'; +import '../index.css'; + +const render = () => ( +
+
Effect Examples
+ + {({ field }: FieldRenderProps) => ( + + + + )} + + + + {({ field }: FieldRenderProps) => ( + + + + )} + + + {({ field }: FieldRenderProps) => ( + + + + )} + +
+); + +interface FormData { + field1: string; + field2: string; + field3: string; +} + +const formMeta: FormMeta = { + render, + validateTrigger: ValidateTrigger.onChange, + effect: { + field1: [ + { + event: DataEvent.onValueChange, + effect: ({ value }: EffectFuncProps) => { + console.log('field1 value:', value); + }, + }, + ], + field2: [ + { + event: DataEvent.onValueChange, + effect: ({ value, form }: EffectFuncProps) => { + form.setValueIn('field3', 'field2 value is ' + value); + }, + }, + ], + }, +}; + +export const nodeRegistry: WorkflowNodeRegistry = { + type: 'custom', + meta: {}, + defaultPorts: [{ type: 'output' }, { type: 'input' }], + formMeta, +}; diff --git a/apps/docs/components/node-form/effect/preview.tsx b/apps/docs/components/node-form/effect/preview.tsx new file mode 100644 index 00000000..45b02e60 --- /dev/null +++ b/apps/docs/components/node-form/effect/preview.tsx @@ -0,0 +1,112 @@ +import { + DEFAULT_INITIAL_DATA, + defaultInitialDataTs, + fieldWrapperCss, + fieldWrapperTs, +} from '@flowgram.ai/demo-node-form'; + +import { Editor } from '../editor.tsx'; +import { PreviewEditor } from '../../preview-editor.tsx'; +import { nodeRegistry } from './node-registry.tsx'; + +const nodeRegistryFile = { + code: `import { + DataEvent, + EffectFuncProps, + Field, + FieldRenderProps, + FormMeta, + ValidateTrigger, + WorkflowNodeRegistry, +} from '@flowgram.ai/free-layout-editor'; +import { FieldWrapper } from '@flowgram.ai/demo-node-form'; +import { Input } from '@douyinfe/semi-ui'; +import '../index.css'; + +const render = () => ( +
+
Effect Examples
+ + {({ field }: FieldRenderProps) => ( + + + + )} + + + + {({ field }: FieldRenderProps) => ( + + + + )} + + + {({ field }: FieldRenderProps) => ( + + + + )} + +
+); + +interface FormData { + field1: string; + field2: string; + field3: string; +} + +const formMeta: FormMeta = { + render, + validateTrigger: ValidateTrigger.onChange, + effect: { + field1: [ + { + event: DataEvent.onValueChange, + effect: ({ value }: EffectFuncProps) => { + console.log('field1 value:', value); + }, + }, + ], + field2: [ + { + event: DataEvent.onValueChange, + effect: ({ value, form }: EffectFuncProps) => { + form.setValueIn('field3', 'field2 value is ' + value); + }, + }, + ], + }, +}; + +export const nodeRegistry: WorkflowNodeRegistry = { + type: 'custom', + meta: {}, + defaultPorts: [{ type: 'output' }, { type: 'input' }], + formMeta, +}; + +`, + active: true, +}; + +export const NodeFormEffectPreview = () => { + const files = { + 'node-registry.tsx': nodeRegistryFile, + 'initial-data.ts': { code: defaultInitialDataTs, active: true }, + 'field-wrapper.tsx': { code: fieldWrapperTs, active: true }, + 'field-wrapper.css': { code: fieldWrapperCss, active: true }, + }; + return ( + + + + ); +}; diff --git a/apps/docs/components/node-form/index.css b/apps/docs/components/node-form/index.css new file mode 100644 index 00000000..c9f4fd27 --- /dev/null +++ b/apps/docs/components/node-form/index.css @@ -0,0 +1,12 @@ +.demo-node-content { + padding: 8px 12px; + flex-grow: 1; + width: 100%; +} + +.demo-node-title { + font-weight: 500; + font-size: 14px; + width: 100%; + margin: 4px 0px 12px 0px; +} diff --git a/apps/docs/components/node-form/index.ts b/apps/docs/components/node-form/index.ts new file mode 100644 index 00000000..6b593144 --- /dev/null +++ b/apps/docs/components/node-form/index.ts @@ -0,0 +1,2 @@ +export { NodeFormBasicPreview } from './basic-preview'; +export { NodeFormEffectPreview } from './effect/preview'; diff --git a/apps/docs/package.json b/apps/docs/package.json index b9a4f66a..cab3a7ef 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -19,6 +19,7 @@ "@flowgram.ai/demo-free-layout": "workspace:*", "@flowgram.ai/demo-free-layout-simple": "workspace:*", "@flowgram.ai/demo-fixed-layout-simple": "workspace:*", + "@flowgram.ai/demo-node-form": "workspace:*", "@flowgram.ai/fixed-layout-editor": "workspace:*", "@flowgram.ai/fixed-semi-materials": "workspace:*", "@flowgram.ai/free-layout-editor": "workspace:*", diff --git a/apps/docs/src/en/guide/getting-started/create-free-layout-simple.mdx b/apps/docs/src/en/guide/getting-started/create-free-layout-simple.mdx index 7d18036c..43025e1e 100644 --- a/apps/docs/src/en/guide/getting-started/create-free-layout-simple.mdx +++ b/apps/docs/src/en/guide/getting-started/create-free-layout-simple.mdx @@ -40,7 +40,7 @@ Canvas configuration is declarative, providing data, rendering, event, and plugi ```tsx pure title="use-editor-props.tsx" import { useMemo } from 'react'; -import { type FixedLayoutProps } from '@flowgram.ai/free-layout-editor'; +import { type FreeLayoutProps } from '@flowgram.ai/free-layout-editor'; import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin'; import { intialData } from './initial-data' // 初始化数据 @@ -48,8 +48,8 @@ import { nodeRegistries } from './node-registries' // 节点声明配置 import { BaseNode } from './base-node' // 节点渲染 export function useEditorProps( -): FixedLayoutProps { - return useMemo( +): FreeLayoutProps { + return useMemo( () => ({ /** * Initialize data @@ -271,7 +271,7 @@ export const BaseNode = () => { */ const { form } = useNodeRender() /** - * WorkflowNodeRenderer will add node drag events and port rendering, for deep customization, + * WorkflowNodeRenderer will add node drag events and port rendering, for deep customization, * you can check the component source code at: * https://github.com/bytedance/flowgram.ai/blob/main/packages/client/free-layout-editor/src/components/workflow-node-renderer.tsx */ diff --git a/apps/docs/src/zh/examples/_meta.json b/apps/docs/src/zh/examples/_meta.json index 4bed1a45..009f1f8b 100644 --- a/apps/docs/src/zh/examples/_meta.json +++ b/apps/docs/src/zh/examples/_meta.json @@ -13,5 +13,10 @@ "type": "dir", "name": "free-layout", "label": "自由布局" + }, + { + "type": "dir", + "name": "node-form", + "label": "节点表单" } ] diff --git a/apps/docs/src/zh/examples/node-form/_meta.json b/apps/docs/src/zh/examples/node-form/_meta.json new file mode 100644 index 00000000..aa824cf5 --- /dev/null +++ b/apps/docs/src/zh/examples/node-form/_meta.json @@ -0,0 +1,4 @@ +[ + "basic", + "effect" +] diff --git a/apps/docs/src/zh/examples/node-form/basic.mdx b/apps/docs/src/zh/examples/node-form/basic.mdx new file mode 100644 index 00000000..07d8cc30 --- /dev/null +++ b/apps/docs/src/zh/examples/node-form/basic.mdx @@ -0,0 +1,10 @@ +--- +outline: false +--- + + +# 基础用法 + +import { NodeFormBasicPreview } from '../../../../components'; + + diff --git a/apps/docs/src/zh/examples/node-form/effect.mdx b/apps/docs/src/zh/examples/node-form/effect.mdx new file mode 100644 index 00000000..f6142722 --- /dev/null +++ b/apps/docs/src/zh/examples/node-form/effect.mdx @@ -0,0 +1,10 @@ +--- +outline: false +--- + + +# 表单项变更副作用 ( effect) + +import { NodeFormEffectPreview } from '../../../../components'; + + diff --git a/apps/docs/src/zh/guide/getting-started/create-free-layout-simple.mdx b/apps/docs/src/zh/guide/getting-started/create-free-layout-simple.mdx index d4a0eb59..d2ffc176 100644 --- a/apps/docs/src/zh/guide/getting-started/create-free-layout-simple.mdx +++ b/apps/docs/src/zh/guide/getting-started/create-free-layout-simple.mdx @@ -42,7 +42,7 @@ function App() { ```tsx pure title="use-editor-props.tsx" import { useMemo } from 'react'; -import { type FixedLayoutProps } from '@flowgram.ai/free-layout-editor'; +import { type FreeLayoutProps } from '@flowgram.ai/free-layout-editor'; import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin'; import { intialData } from './initial-data' // 初始化数据 @@ -50,8 +50,8 @@ import { nodeRegistries } from './node-registries' // 节点声明配置 import { BaseNode } from './base-node' // 节点渲染 export function useEditorProps( -): FixedLayoutProps { - return useMemo( +): FreeLayoutProps { + return useMemo( () => ({ /** * 初始化数据 diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 10981df2..42c28623 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -98,7 +98,7 @@ importers: version: link:../../config/ts-config '@rspack/cli': specifier: 0.2.1 - version: 0.2.1(react-refresh@0.14.0)(webpack@5.76.0) + version: 0.2.1(react-refresh@0.16.0)(webpack@5.76.0) '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -232,7 +232,7 @@ importers: version: link:../../config/ts-config '@rspack/cli': specifier: 0.2.1 - version: 0.2.1(react-refresh@0.16.0)(webpack@5.76.0) + version: 0.2.1(react-refresh@0.14.0)(webpack@5.76.0) '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -310,6 +310,64 @@ importers: specifier: ^8.54.0 version: 8.57.1 + ../../apps/demo-node-form: + dependencies: + '@douyinfe/semi-icons': + specifier: ^2.72.3 + 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/free-layout-editor': + specifier: workspace:* + version: link:../../packages/client/free-layout-editor + '@flowgram.ai/free-snap-plugin': + specifier: workspace:* + version: link:../../packages/plugins/free-snap-plugin + '@flowgram.ai/minimap-plugin': + specifier: workspace:* + version: link:../../packages/plugins/minimap-plugin + 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.0)(react-dom@18.3.1)(react-is@18.3.1)(react@18.3.1) + devDependencies: + '@flowgram.ai/eslint-config': + specifier: workspace:* + version: link:../../config/eslint-config + '@flowgram.ai/ts-config': + specifier: workspace:* + version: link:../../config/ts-config + '@rspack/cli': + specifier: 0.2.1 + version: 0.2.1(react-refresh@0.16.0)(webpack@5.76.0) + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 + '@types/node': + specifier: ^18 + version: 18.19.68 + '@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 + '@typescript-eslint/parser': + specifier: ^6.10.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.0.4) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + ../../apps/docs: dependencies: '@codesandbox/sandpack-react': @@ -333,6 +391,9 @@ importers: '@flowgram.ai/demo-free-layout-simple': specifier: workspace:* version: link:../demo-free-layout-simple + '@flowgram.ai/demo-node-form': + specifier: workspace:* + version: link:../demo-node-form '@flowgram.ai/fixed-layout-editor': specifier: workspace:* version: link:../../packages/client/fixed-layout-editor diff --git a/packages/node-engine/form/__tests__/validate.test.ts b/packages/node-engine/form/__tests__/validate.test.ts new file mode 100644 index 00000000..b1230d33 --- /dev/null +++ b/packages/node-engine/form/__tests__/validate.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { hasError } from '../src/utils/validate'; +import { FeedbackLevel, FieldError } from '../src/types'; + +describe('utils/validate', () => { + describe('hasError', () => { + it('should return false when errors is empty', () => { + expect(hasError({ xxx: [] })).toBe(false); + expect(hasError({ xxx: undefined })).toBe(false); + expect(hasError({})).toBe(false); + expect(hasError({ aaa: [], bbb: [] })).toBe(false); + expect(hasError({ aaa: undefined, bbb: [] })).toBe(false); + }); + it('should return true when errors is not empty', () => { + const mockError: FieldError = { name: 'xxx', level: FeedbackLevel.Error, message: 'err' }; + expect(hasError({ xxx: [mockError] })).toBe(true); + expect(hasError({ aaa: [mockError], bbb: [mockError] })).toBe(true); + expect(hasError({ aaa: undefined, bbb: [mockError] })).toBe(true); + }); + }); +}); diff --git a/packages/node-engine/form/src/utils/validate.ts b/packages/node-engine/form/src/utils/validate.ts index 117f87c2..dd25a5f2 100644 --- a/packages/node-engine/form/src/utils/validate.ts +++ b/packages/node-engine/form/src/utils/validate.ts @@ -33,11 +33,5 @@ export function feedbackToFieldErrorsOrWarnings(name: string, feedback?: Feed } as T; } -export const hasError = (errors: Errors) => { - for (let fieldErrors in errors) { - if (fieldErrors.length) { - return true; - } - } - return false; -}; +export const hasError = (errors: Errors) => + Object.keys(errors).some((key) => errors[key]?.length > 0); diff --git a/packages/node-engine/node/src/form-model-v2.ts b/packages/node-engine/node/src/form-model-v2.ts index 7c457131..5f66b32e 100644 --- a/packages/node-engine/node/src/form-model-v2.ts +++ b/packages/node-engine/node/src/form-model-v2.ts @@ -21,6 +21,7 @@ import { Glob, IField, IFieldArray, + toForm, } from '@flowgram.ai/form'; import { FlowNodeEntity } from '@flowgram.ai/document'; import { PlaygroundContext } from '@flowgram.ai/core'; @@ -256,6 +257,7 @@ export class FormModelV2 extends FormModel implements Disposable { value: get(values, currentName), prevValue: get(prevValues, currentName), formValues: values, + form: toForm(this.nativeFormModel!), context: this.nodeContext, }); @@ -298,6 +300,7 @@ export class FormModelV2 extends FormModel implements Disposable { value: get(values, path), formValues: values, prevValue: get(prevValues, path), + form: toForm(this.nativeFormModel!), context: this.nodeContext, }); @@ -332,6 +335,7 @@ export class FormModelV2 extends FormModel implements Disposable { effect({ ...props, formValues: nativeFormModel.values, + form: toForm(this.nativeFormModel!), context: this.nodeContext, }) ); diff --git a/packages/node-engine/node/src/types.ts b/packages/node-engine/node/src/types.ts index e885ac5d..e7fcf474 100644 --- a/packages/node-engine/node/src/types.ts +++ b/packages/node-engine/node/src/types.ts @@ -2,7 +2,12 @@ import * as React from 'react'; import { FormModel, IFormMeta, NodeFormContext } from '@flowgram.ai/form-core'; import { FieldName, FieldValue } from '@flowgram.ai/form/src/types'; -import { FormRenderProps, Validate as FormValidate, ValidateTrigger } from '@flowgram.ai/form'; +import { + FormRenderProps, + IForm, + Validate as FormValidate, + ValidateTrigger, +} from '@flowgram.ai/form'; import { FormPlugin } from './form-plugin'; import { FormModelV2 } from './form-model-v2'; @@ -46,19 +51,25 @@ export enum DataEvent { export type EffectReturn = () => void; -export type Effect = (props: { +export interface EffectFuncProps { name: FieldName; value: TFieldValue; prevValue?: TFieldValue; formValues: TFormValues; + form: IForm; context: NodeContext; -}) => void | EffectReturn; +} + +export type Effect = ( + props: EffectFuncProps +) => void | EffectReturn; export type ArrayAppendEffect = (props: { index: number; value: TFieldValue; arrayValues: Array; formValues: TFormValues; + form: IForm; context: NodeContext; }) => void | EffectReturn; @@ -66,6 +77,7 @@ export type ArrayDeleteEffect = (props: { index: number; arrayValue: Array; formValues: TFormValues; + form: IForm; context: NodeContext; }) => void | EffectReturn; diff --git a/rush.json b/rush.json index 866c9a58..95dc775c 100644 --- a/rush.json +++ b/rush.json @@ -774,6 +774,15 @@ "team-flow", "demo" ] + }, + { + "packageName": "@flowgram.ai/demo-node-form", + "projectFolder": "apps/demo-node-form", + "tags": [ + "level-1", + "team-flow", + "demo" + ] } ] }