mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
refactor(frontend): enhance builder UI for better performance (#10922)
### Changes 🏗️ This PR introduces a new high-performance builder interface for the AutoGPT platform, implementing a React Flow-based visual editor with optimized state management and rendering. #### Key Changes: 1. **New Flow Editor Implementation** - Built on React Flow for efficient graph rendering and interaction - Implements a node-based visual workflow builder with custom nodes and edges - Dynamic form generation using React JSON Schema Form (RJSF) for block inputs - Intelligent connection handling with visual feedback 2. **State Management Optimization** - Added Zustand for lightweight, performant state management - Separated node and edge stores for better data isolation - Reduced unnecessary re-renders through granular state updates 3. **Dual Builder View (Temporary)** - Added toggle between old and new builder implementations - Allows A/B testing and gradual migration - Feature flagged for controlled rollout 4. **Enhanced UI Components** - Custom form widgets for various input types (date, time, file, etc.) - Array and object editors with improved UX - Connection handles with visual state indicators - Advanced mode toggle for complex configurations 5. **Architecture Improvements** - Modular component structure for better code organization - Comprehensive documentation for the new system - Type-safe implementation with TypeScript #### Dependencies Added: - `zustand` (v5.0.2) - State management - `@rjsf/core` (v5.22.8) - JSON Schema Form core - `@rjsf/utils` (v5.22.8) - RJSF utilities - `@rjsf/validator-ajv8` (v5.22.8) - Schema validation ### Performance Improvements 🚀 - **Reduced Re-renders**: Zustand's shallow comparison and selective subscriptions minimize unnecessary component updates - **Optimized Graph Rendering**: React Flow provides efficient canvas-based rendering for large workflows - **Lazy Loading**: Components are loaded on-demand reducing initial bundle size - **Memoized Computations**: Heavy calculations are cached to avoid redundant processing ### Test Plan 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: #### Test Checklist: - [x] Create a new agent from scratch with at least 5 blocks - [x] Connect blocks and verify connections render correctly - [x] Switch between old and new builder views - [x] Test all form input types (text, number, boolean, array, object) - [x] Verify data persistence when switching views - [x] Test advanced mode toggle functionality - [x] Performance test with 50+ blocks to verify smooth interaction ### Migration Strategy The implementation includes a temporary toggle to switch between the old and new builder. This allows for: - Gradual user migration - A/B testing to measure performance improvements - Fallback option if issues are discovered - Collecting user feedback before full rollout ### Documentation Comprehensive documentation has been added: - `/components/FlowEditor/docs/README.md` - Architecture overview and store management - `/components/FlowEditor/docs/FORM_CREATOR.md` - Detailed form system documentation --------- Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,9 @@
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@rjsf/core": "5.24.13",
|
||||
"@rjsf/utils": "5.24.13",
|
||||
"@rjsf/validator-ajv8": "5.24.13",
|
||||
"@sentry/nextjs": "9.42.0",
|
||||
"@supabase/ssr": "0.6.1",
|
||||
"@supabase/supabase-js": "2.55.0",
|
||||
@@ -103,7 +106,8 @@
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"uuid": "11.1.0",
|
||||
"vaul": "1.1.2",
|
||||
"zod": "3.25.76"
|
||||
"zod": "3.25.76",
|
||||
"zustand": "5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "4.1.1",
|
||||
|
||||
163
autogpt_platform/frontend/pnpm-lock.yaml
generated
163
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -77,6 +77,15 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: 1.2.8
|
||||
version: 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@rjsf/core':
|
||||
specifier: 5.24.13
|
||||
version: 5.24.13(@rjsf/utils@5.24.13(react@18.3.1))(react@18.3.1)
|
||||
'@rjsf/utils':
|
||||
specifier: 5.24.13
|
||||
version: 5.24.13(react@18.3.1)
|
||||
'@rjsf/validator-ajv8':
|
||||
specifier: 5.24.13
|
||||
version: 5.24.13(@rjsf/utils@5.24.13(react@18.3.1))
|
||||
'@sentry/nextjs':
|
||||
specifier: 9.42.0
|
||||
version: 9.42.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.4.7(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.101.3(esbuild@0.25.9))
|
||||
@@ -245,6 +254,9 @@ importers:
|
||||
zod:
|
||||
specifier: 3.25.76
|
||||
version: 3.25.76
|
||||
zustand:
|
||||
specifier: 5.0.8
|
||||
version: 5.0.8(@types/react@18.3.17)(immer@10.1.3)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1))
|
||||
devDependencies:
|
||||
'@chromatic-com/storybook':
|
||||
specifier: 4.1.1
|
||||
@@ -2306,6 +2318,25 @@ packages:
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rjsf/core@5.24.13':
|
||||
resolution: {integrity: sha512-ONTr14s7LFIjx2VRFLuOpagL76sM/HPy6/OhdBfq6UukINmTIs6+aFN0GgcR0aXQHFDXQ7f/fel0o/SO05Htdg==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@rjsf/utils': ^5.24.x
|
||||
react: ^16.14.0 || >=17
|
||||
|
||||
'@rjsf/utils@5.24.13':
|
||||
resolution: {integrity: sha512-rNF8tDxIwTtXzz5O/U23QU73nlhgQNYJ+Sv5BAwQOIyhIE2Z3S5tUiSVMwZHt0julkv/Ryfwi+qsD4FiE5rOuw==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
react: ^16.14.0 || >=17
|
||||
|
||||
'@rjsf/validator-ajv8@5.24.13':
|
||||
resolution: {integrity: sha512-oWHP7YK581M8I5cF1t+UXFavnv+bhcqjtL1a7MG/Kaffi0EwhgcYjODrD8SsnrhncsEYMqSECr4ZOEoirnEUWw==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@rjsf/utils': ^5.24.x
|
||||
|
||||
'@rollup/plugin-commonjs@28.0.1':
|
||||
resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==}
|
||||
engines: {node: '>=16.0.0 || 14 >= 14.17'}
|
||||
@@ -3839,6 +3870,12 @@ packages:
|
||||
compare-versions@6.1.1:
|
||||
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
|
||||
|
||||
compute-gcd@1.2.1:
|
||||
resolution: {integrity: sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg==}
|
||||
|
||||
compute-lcm@1.1.2:
|
||||
resolution: {integrity: sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@@ -5136,6 +5173,13 @@ packages:
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
json-schema-compare@0.2.2:
|
||||
resolution: {integrity: sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ==}
|
||||
|
||||
json-schema-merge-allof@0.8.1:
|
||||
resolution: {integrity: sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
@@ -5248,6 +5292,9 @@ packages:
|
||||
resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
lodash-es@4.17.21:
|
||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||
|
||||
lodash.camelcase@4.3.0:
|
||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||
|
||||
@@ -5340,6 +5387,12 @@ packages:
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
markdown-to-jsx@7.7.13:
|
||||
resolution: {integrity: sha512-DiueEq2bttFcSxUs85GJcQVrOr0+VVsPfj9AEUPqmExJ3f8P/iQNvZHltV4tm1XVhu1kl0vWBZWT3l99izRMaA==}
|
||||
engines: {node: '>= 10'}
|
||||
peerDependencies:
|
||||
react: '>= 0.14.0'
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7216,6 +7269,21 @@ packages:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
hasBin: true
|
||||
|
||||
validate.io-array@1.0.6:
|
||||
resolution: {integrity: sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==}
|
||||
|
||||
validate.io-function@1.0.2:
|
||||
resolution: {integrity: sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ==}
|
||||
|
||||
validate.io-integer-array@1.0.0:
|
||||
resolution: {integrity: sha512-mTrMk/1ytQHtCY0oNO3dztafHYyGU88KL+jRxWuzfOmQb+4qqnWmI+gykvGp8usKZOM0H7keJHEbRaFiYA0VrA==}
|
||||
|
||||
validate.io-integer@1.0.5:
|
||||
resolution: {integrity: sha512-22izsYSLojN/P6bppBqhgUDjCkr5RY2jd+N2a3DCAUey8ydvrZ/OkGvFPR7qfOpwR2LC5p4Ngzxz36g5Vgr/hQ==}
|
||||
|
||||
validate.io-number@1.0.3:
|
||||
resolution: {integrity: sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg==}
|
||||
|
||||
validator@13.15.15:
|
||||
resolution: {integrity: sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -7403,6 +7471,24 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
zustand@5.0.8:
|
||||
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18.0.0'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=18.0.0'
|
||||
use-sync-external-store: '>=1.2.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
use-sync-external-store:
|
||||
optional: true
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
@@ -9584,6 +9670,32 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-redux: 9.2.0(@types/react@18.3.17)(react@18.3.1)(redux@5.0.1)
|
||||
|
||||
'@rjsf/core@5.24.13(@rjsf/utils@5.24.13(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@rjsf/utils': 5.24.13(react@18.3.1)
|
||||
lodash: 4.17.21
|
||||
lodash-es: 4.17.21
|
||||
markdown-to-jsx: 7.7.13(react@18.3.1)
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
|
||||
'@rjsf/utils@5.24.13(react@18.3.1)':
|
||||
dependencies:
|
||||
json-schema-merge-allof: 0.8.1
|
||||
jsonpointer: 5.0.1
|
||||
lodash: 4.17.21
|
||||
lodash-es: 4.17.21
|
||||
react: 18.3.1
|
||||
react-is: 18.3.1
|
||||
|
||||
'@rjsf/validator-ajv8@5.24.13(@rjsf/utils@5.24.13(react@18.3.1))':
|
||||
dependencies:
|
||||
'@rjsf/utils': 5.24.13(react@18.3.1)
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 2.1.1(ajv@8.17.1)
|
||||
lodash: 4.17.21
|
||||
lodash-es: 4.17.21
|
||||
|
||||
'@rollup/plugin-commonjs@28.0.1(rollup@4.46.2)':
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.2.0(rollup@4.46.2)
|
||||
@@ -11377,6 +11489,19 @@ snapshots:
|
||||
|
||||
compare-versions@6.1.1: {}
|
||||
|
||||
compute-gcd@1.2.1:
|
||||
dependencies:
|
||||
validate.io-array: 1.0.6
|
||||
validate.io-function: 1.0.2
|
||||
validate.io-integer-array: 1.0.0
|
||||
|
||||
compute-lcm@1.1.2:
|
||||
dependencies:
|
||||
compute-gcd: 1.2.1
|
||||
validate.io-array: 1.0.6
|
||||
validate.io-function: 1.0.2
|
||||
validate.io-integer-array: 1.0.0
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
concurrently@9.2.1:
|
||||
@@ -12915,6 +13040,16 @@ snapshots:
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-schema-compare@0.2.2:
|
||||
dependencies:
|
||||
lodash: 4.17.21
|
||||
|
||||
json-schema-merge-allof@0.8.1:
|
||||
dependencies:
|
||||
compute-lcm: 1.1.2
|
||||
json-schema-compare: 0.2.2
|
||||
lodash: 4.17.21
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
@@ -13028,6 +13163,8 @@ snapshots:
|
||||
dependencies:
|
||||
p-locate: 6.0.0
|
||||
|
||||
lodash-es@4.17.21: {}
|
||||
|
||||
lodash.camelcase@4.3.0: {}
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
@@ -13111,6 +13248,10 @@ snapshots:
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
markdown-to-jsx@7.7.13(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
md5.js@1.3.5:
|
||||
@@ -15457,6 +15598,21 @@ snapshots:
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
validate.io-array@1.0.6: {}
|
||||
|
||||
validate.io-function@1.0.2: {}
|
||||
|
||||
validate.io-integer-array@1.0.0:
|
||||
dependencies:
|
||||
validate.io-array: 1.0.6
|
||||
validate.io-integer: 1.0.5
|
||||
|
||||
validate.io-integer@1.0.5:
|
||||
dependencies:
|
||||
validate.io-number: 1.0.3
|
||||
|
||||
validate.io-number@1.0.3: {}
|
||||
|
||||
validator@13.15.15: {}
|
||||
|
||||
vaul@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
@@ -15683,4 +15839,11 @@ snapshots:
|
||||
immer: 10.1.3
|
||||
react: 18.3.1
|
||||
|
||||
zustand@5.0.8(@types/react@18.3.17)(immer@10.1.3)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1)):
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.17
|
||||
immer: 10.1.3
|
||||
react: 18.3.1
|
||||
use-sync-external-store: 1.5.0(react@18.3.1)
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/__legacy__/ui/tabs";
|
||||
|
||||
export type BuilderView = "old" | "new";
|
||||
|
||||
export function BuilderViewTabs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: BuilderView;
|
||||
onChange: (value: BuilderView) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="pointer-events-auto fixed right-4 top-20 z-50">
|
||||
<Tabs
|
||||
value={value}
|
||||
onValueChange={(v: string) => onChange(v as BuilderView)}
|
||||
>
|
||||
<TabsList className="w-fit bg-zinc-900">
|
||||
<TabsTrigger value="old" className="text-gray-100">
|
||||
Old
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="new" className="text-gray-100">
|
||||
New
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { BuilderView } from "./BuilderViewTabs";
|
||||
|
||||
export function useBuilderView() {
|
||||
const isNewFlowEditorEnabled = useGetFlag(Flag.NEW_FLOW_EDITOR);
|
||||
const isBuilderViewSwitchEnabled = useGetFlag(Flag.BUILDER_VIEW_SWITCH);
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const currentView = searchParams.get("view");
|
||||
const defaultView = "old";
|
||||
const selectedView = useMemo<BuilderView>(() => {
|
||||
if (currentView === "new" || currentView === "old") return currentView;
|
||||
return defaultView;
|
||||
}, [currentView, defaultView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBuilderViewSwitchEnabled === true) {
|
||||
if (currentView !== "new" && currentView !== "old") {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("view", defaultView);
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isBuilderViewSwitchEnabled, defaultView, pathname, router, searchParams]);
|
||||
|
||||
const setSelectedView = (value: BuilderView) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("view", value);
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
return {
|
||||
isSwitchEnabled: isBuilderViewSwitchEnabled === true,
|
||||
selectedView,
|
||||
setSelectedView,
|
||||
isNewFlowEditorEnabled: Boolean(isNewFlowEditorEnabled),
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ReactFlow, Background, Controls } from "@xyflow/react";
|
||||
import { useNodeStore } from "../../stores/nodeStore";
|
||||
|
||||
import NewControlPanel from "../NewBlockMenu/NewControlPanel/NewControlPanel";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMemo } from "react";
|
||||
import { CustomNode } from "./nodes/CustomNode";
|
||||
import { useCustomEdge } from "./edges/useCustomEdge";
|
||||
import CustomEdge from "./edges/CustomEdge";
|
||||
import { RightSidebar } from "../RIghtSidebar";
|
||||
|
||||
export const Flow = () => {
|
||||
// All these 3 are working perfectly
|
||||
const nodes = useNodeStore(useShallow((state) => state.nodes));
|
||||
const onNodesChange = useNodeStore(
|
||||
useShallow((state) => state.onNodesChange),
|
||||
);
|
||||
const nodeTypes = useMemo(() => ({ custom: CustomNode }), []);
|
||||
const { edges, onConnect, onEdgesChange } = useCustomEdge();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full dark:bg-slate-900">
|
||||
{/* Builder area - flexible width */}
|
||||
<div className="relative flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
onNodesChange={onNodesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edges={edges}
|
||||
onConnect={onConnect}
|
||||
onEdgesChange={onEdgesChange}
|
||||
edgeTypes={{ custom: CustomEdge }}
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<NewControlPanel />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
<div className="w-[30%]">
|
||||
<RightSidebar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ArrayFieldTemplateItemType, RJSFSchema } from "@rjsf/utils";
|
||||
import { generateHandleId, HandleIdType } from "../../handlers/helpers";
|
||||
import { ArrayEditorContext } from "./ArrayEditorContext";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
|
||||
export interface ArrayEditorProps {
|
||||
items?: ArrayFieldTemplateItemType<any, RJSFSchema, any>[];
|
||||
nodeId: string;
|
||||
canAdd: boolean | undefined;
|
||||
onAddClick?: () => void;
|
||||
disabled: boolean | undefined;
|
||||
readonly: boolean | undefined;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const ArrayEditor = ({
|
||||
items,
|
||||
nodeId,
|
||||
canAdd,
|
||||
onAddClick,
|
||||
disabled,
|
||||
readonly,
|
||||
id,
|
||||
}: ArrayEditorProps) => {
|
||||
const { isInputConnected } = useEdgeStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
{items?.map((element) => {
|
||||
const fieldKey = generateHandleId(
|
||||
id,
|
||||
[element.index.toString()],
|
||||
HandleIdType.ARRAY,
|
||||
);
|
||||
const isConnected = isInputConnected(nodeId, fieldKey);
|
||||
return (
|
||||
<div
|
||||
key={element.key}
|
||||
className="-ml-2 flex max-w-[400px] items-center gap-2"
|
||||
>
|
||||
<ArrayEditorContext.Provider
|
||||
value={{
|
||||
isArrayItem: true,
|
||||
fieldKey,
|
||||
isConnected,
|
||||
}}
|
||||
>
|
||||
{element.children}
|
||||
</ArrayEditorContext.Provider>
|
||||
|
||||
{element.hasRemove &&
|
||||
!readonly &&
|
||||
!disabled &&
|
||||
!isConnected && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="relative top-5"
|
||||
size="small"
|
||||
onClick={element.onDropIndexClick(element.index)}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canAdd && !readonly && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
onClick={onAddClick}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Add Item
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export const ArrayEditorContext = createContext<{
|
||||
isArrayItem: boolean;
|
||||
fieldKey: string;
|
||||
isConnected: boolean;
|
||||
}>({
|
||||
isArrayItem: false,
|
||||
fieldKey: "",
|
||||
isConnected: false,
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import NodeHandle from "../../handlers/NodeHandle";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { generateHandleId, HandleIdType } from "../../handlers/helpers";
|
||||
|
||||
export interface ObjectEditorProps {
|
||||
id: string;
|
||||
value?: Record<string, any>;
|
||||
onChange?: (value: Record<string, any>) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
nodeId: string;
|
||||
fieldKey: string;
|
||||
}
|
||||
|
||||
export const ObjectEditor = React.forwardRef<HTMLDivElement, ObjectEditorProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
value = {},
|
||||
onChange,
|
||||
placeholder = "Enter value",
|
||||
disabled = false,
|
||||
className,
|
||||
nodeId,
|
||||
fieldKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const setProperty = (key: string, propertyValue: any) => {
|
||||
if (!onChange) return;
|
||||
|
||||
const newData: Record<string, any> = { ...value };
|
||||
if (propertyValue === undefined || propertyValue === "") {
|
||||
delete newData[key];
|
||||
} else {
|
||||
newData[key] = propertyValue;
|
||||
}
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
const addProperty = () => {
|
||||
if (!onChange) return;
|
||||
onChange({ ...value, [""]: "" });
|
||||
};
|
||||
|
||||
const removeProperty = (key: string) => {
|
||||
if (!onChange) return;
|
||||
const newData = { ...value };
|
||||
delete newData[key];
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
const updateKey = (oldKey: string, newKey: string) => {
|
||||
if (!onChange || oldKey === newKey) return;
|
||||
|
||||
const propertyValue = value[oldKey];
|
||||
const newData: Record<string, any> = { ...value };
|
||||
delete newData[oldKey];
|
||||
newData[newKey] = propertyValue;
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
const hasEmptyKeys = Object.keys(value).some((key) => key.trim() === "");
|
||||
|
||||
const { isInputConnected } = useEdgeStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex flex-col gap-2 ${className || ""}`}
|
||||
id={id}
|
||||
>
|
||||
{Object.entries(value).map(([key, propertyValue], idx) => {
|
||||
const dynamicHandleId = generateHandleId(
|
||||
fieldKey,
|
||||
[key],
|
||||
HandleIdType.KEY_VALUE,
|
||||
);
|
||||
const isDynamicPropertyConnected = isInputConnected(
|
||||
nodeId,
|
||||
dynamicHandleId,
|
||||
);
|
||||
|
||||
console.log("dynamicHandleId", dynamicHandleId);
|
||||
console.log("key", key);
|
||||
console.log("fieldKey", fieldKey);
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex flex-col gap-2">
|
||||
<div className="-ml-2 flex items-center gap-1">
|
||||
<NodeHandle
|
||||
id={dynamicHandleId}
|
||||
isConnected={isDynamicPropertyConnected}
|
||||
side="left"
|
||||
/>
|
||||
|
||||
<Text variant="small" className="text-gray-600">
|
||||
#{key.trim() === "" ? "" : key}
|
||||
</Text>
|
||||
<Text variant="small" className="!text-green-500">
|
||||
(string)
|
||||
</Text>
|
||||
</div>
|
||||
{!isDynamicPropertyConnected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
hideLabel={true}
|
||||
label=""
|
||||
id={`key-${idx}`}
|
||||
size="small"
|
||||
value={key}
|
||||
onChange={(e) => updateKey(key, e.target.value)}
|
||||
placeholder="Key"
|
||||
wrapperClassName="mb-0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
hideLabel={true}
|
||||
label=""
|
||||
id={`value-${idx}`}
|
||||
size="small"
|
||||
value={propertyValue as string}
|
||||
onChange={(e) => setProperty(key, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
wrapperClassName="mb-0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => removeProperty(key)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
onClick={addProperty}
|
||||
className="w-full"
|
||||
disabled={hasEmptyKeys || disabled}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Property
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ObjectEditor.displayName = "ObjectEditor";
|
||||
@@ -0,0 +1,580 @@
|
||||
# Form Creator System
|
||||
|
||||
The Form Creator is a dynamic form generation system built on React JSON Schema Form (RJSF) that automatically creates interactive forms based on JSON schemas. It's the core component that powers the input handling in the FlowEditor.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Schema Processing](#schema-processing)
|
||||
- [Widget System](#widget-system)
|
||||
- [Field System](#field-system)
|
||||
- [Template System](#template-system)
|
||||
- [Customization Guide](#customization-guide)
|
||||
- [Advanced Features](#advanced-features)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The Form Creator system consists of several interconnected layers:
|
||||
|
||||
```
|
||||
FormCreator
|
||||
├── Schema Preprocessing
|
||||
│ └── input-schema-pre-processor.ts
|
||||
├── Widget System
|
||||
│ ├── TextInputWidget
|
||||
│ ├── SelectWidget
|
||||
│ ├── SwitchWidget
|
||||
│ └── ... (other widgets)
|
||||
├── Field System
|
||||
│ ├── AnyOfField
|
||||
│ ├── ObjectField
|
||||
│ └── CredentialsField
|
||||
├── Template System
|
||||
│ ├── FieldTemplate
|
||||
│ └── ArrayFieldTemplate
|
||||
└── UI Schema
|
||||
└── uiSchema.ts
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. **Schema Input**
|
||||
|
||||
The FormCreator receives a JSON schema that defines the structure of the form:
|
||||
|
||||
```typescript
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
title: "Message",
|
||||
description: "Enter your message",
|
||||
},
|
||||
count: {
|
||||
type: "number",
|
||||
title: "Count",
|
||||
minimum: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 2. **Schema Preprocessing**
|
||||
|
||||
The schema is preprocessed to ensure all properties have proper types:
|
||||
|
||||
```typescript
|
||||
// Before preprocessing
|
||||
{
|
||||
"properties": {
|
||||
"name": { "title": "Name" } // No type defined
|
||||
}
|
||||
}
|
||||
|
||||
// After preprocessing
|
||||
// if there is no type - that means it can accept any type
|
||||
{
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "number" },
|
||||
{ "type": "boolean" },
|
||||
{ "type": "array", "items": { "type": "string" } },
|
||||
{ "type": "object" },
|
||||
{ "type": "null" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **Widget Mapping**
|
||||
|
||||
Schema types are mapped to appropriate input widgets:
|
||||
|
||||
```typescript
|
||||
// Schema type -> Widget mapping
|
||||
"string" -> TextInputWidget
|
||||
"number" -> TextInputWidget (with number type)
|
||||
"boolean" -> SwitchWidget
|
||||
"array" -> ArrayFieldTemplate
|
||||
"object" -> ObjectField
|
||||
"enum" -> SelectWidget
|
||||
```
|
||||
|
||||
### 4. **Form Rendering**
|
||||
|
||||
RJSF renders the form using the mapped widgets and templates:
|
||||
|
||||
```typescript
|
||||
<Form
|
||||
schema={preprocessedSchema}
|
||||
validator={validator}
|
||||
fields={fields}
|
||||
templates={templates}
|
||||
widgets={widgets}
|
||||
formContext={{ nodeId }}
|
||||
onChange={handleChange}
|
||||
uiSchema={uiSchema}
|
||||
/>
|
||||
```
|
||||
|
||||
## Schema Processing
|
||||
|
||||
### Input Schema Preprocessor
|
||||
|
||||
The `preprocessInputSchema` function ensures all properties have proper types:
|
||||
|
||||
```typescript
|
||||
export function preprocessInputSchema(schema: RJSFSchema): RJSFSchema {
|
||||
// Recursively processes properties
|
||||
if (processedSchema.properties) {
|
||||
for (const [key, property] of Object.entries(processedSchema.properties)) {
|
||||
// Add type if none exists
|
||||
if (
|
||||
!processedProperty.type &&
|
||||
!processedProperty.anyOf &&
|
||||
!processedProperty.oneOf &&
|
||||
!processedProperty.allOf
|
||||
) {
|
||||
processedProperty.anyOf = [
|
||||
{ type: "string" },
|
||||
{ type: "number" },
|
||||
{ type: "integer" },
|
||||
{ type: "boolean" },
|
||||
{ type: "array", items: { type: "string" } },
|
||||
{ type: "object" },
|
||||
{ type: "null" },
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Type Safety**: Ensures all properties have types
|
||||
2. **Recursive Processing**: Handles nested objects and arrays
|
||||
3. **Array Item Processing**: Processes array item schemas
|
||||
4. **Schema Cleanup**: Removes titles and descriptions from root schema
|
||||
|
||||
## Widget System
|
||||
|
||||
Widgets are the actual input components that users interact with.
|
||||
|
||||
### Available Widgets
|
||||
|
||||
#### TextInputWidget
|
||||
|
||||
Handles text, number, password, and textarea inputs:
|
||||
|
||||
```typescript
|
||||
export const TextInputWidget = (props: WidgetProps) => {
|
||||
const { schema } = props;
|
||||
const mapped = mapJsonSchemaTypeToInputType(schema);
|
||||
|
||||
const inputConfig = {
|
||||
[InputType.TEXT_AREA]: {
|
||||
htmlType: "textarea",
|
||||
placeholder: "Enter text...",
|
||||
handleChange: (v: string) => (v === "" ? undefined : v),
|
||||
},
|
||||
[InputType.PASSWORD]: {
|
||||
htmlType: "password",
|
||||
placeholder: "Enter secret text...",
|
||||
handleChange: (v: string) => (v === "" ? undefined : v),
|
||||
},
|
||||
[InputType.NUMBER]: {
|
||||
htmlType: "number",
|
||||
placeholder: "Enter number value...",
|
||||
handleChange: (v: string) => (v === "" ? undefined : Number(v)),
|
||||
}
|
||||
};
|
||||
|
||||
return <Input {...config} />;
|
||||
};
|
||||
```
|
||||
|
||||
#### SelectWidget
|
||||
|
||||
Handles dropdown and multi-select inputs:
|
||||
|
||||
```typescript
|
||||
export const SelectWidget = (props: WidgetProps) => {
|
||||
const { options, value, onChange, schema } = props;
|
||||
const enumOptions = options.enumOptions || [];
|
||||
const type = mapJsonSchemaTypeToInputType(schema);
|
||||
|
||||
if (type === InputType.MULTI_SELECT) {
|
||||
return <MultiSelector values={value} onValuesChange={onChange} />;
|
||||
}
|
||||
|
||||
return <Select value={value} onValueChange={onChange} options={enumOptions} />;
|
||||
};
|
||||
```
|
||||
|
||||
#### SwitchWidget
|
||||
|
||||
Handles boolean toggles:
|
||||
|
||||
```typescript
|
||||
export function SwitchWidget(props: WidgetProps) {
|
||||
const { value = false, onChange, disabled, readonly } = props;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
disabled={disabled || readonly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Widget Registration
|
||||
|
||||
Widgets are registered in the widgets registry:
|
||||
|
||||
```typescript
|
||||
export const widgets: RegistryWidgetsType = {
|
||||
TextWidget: TextInputWidget,
|
||||
SelectWidget: SelectWidget,
|
||||
CheckboxWidget: SwitchWidget,
|
||||
FileWidget: FileWidget,
|
||||
DateWidget: DateInputWidget,
|
||||
TimeWidget: TimeInputWidget,
|
||||
DateTimeWidget: DateTimeInputWidget,
|
||||
};
|
||||
```
|
||||
|
||||
## Field System
|
||||
|
||||
Fields handle complex data structures and provide custom rendering logic.
|
||||
|
||||
### AnyOfField
|
||||
|
||||
Handles union types and nullable fields:
|
||||
|
||||
```typescript
|
||||
export const AnyOfField = ({ schema, formData, onChange, ...props }: FieldProps) => {
|
||||
const { isNullableType, selectedType, handleTypeChange, currentTypeOption } = useAnyOfField(schema, formData, onChange);
|
||||
|
||||
if (isNullableType) {
|
||||
return (
|
||||
<div>
|
||||
<NodeHandle id={fieldKey} isConnected={isConnected} side="left" />
|
||||
<Switch checked={isEnabled} onCheckedChange={handleNullableToggle} />
|
||||
{isEnabled && renderInput(nonNull)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<NodeHandle id={fieldKey} isConnected={isConnected} side="left" />
|
||||
<Select value={selectedType} onValueChange={handleTypeChange} />
|
||||
{renderInput(currentTypeOption)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### ObjectField
|
||||
|
||||
Handles free-form object editing:
|
||||
|
||||
```typescript
|
||||
export const ObjectField = (props: FieldProps) => {
|
||||
const { schema, formData = {}, onChange, name, idSchema, formContext } = props;
|
||||
|
||||
// Use default field for fixed-schema objects
|
||||
if (idSchema?.$id === "root" || !isFreeForm) {
|
||||
return <DefaultObjectField {...props} />;
|
||||
}
|
||||
|
||||
// Use custom ObjectEditor for free-form objects
|
||||
return (
|
||||
<ObjectEditor
|
||||
id={`${name}-input`}
|
||||
nodeId={nodeId}
|
||||
fieldKey={fieldKey}
|
||||
value={formData}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Field Registration
|
||||
|
||||
Fields are registered in the fields registry:
|
||||
|
||||
```typescript
|
||||
export const fields: RegistryFieldsType = {
|
||||
AnyOfField: AnyOfField,
|
||||
credentials: CredentialsField,
|
||||
ObjectField: ObjectField,
|
||||
};
|
||||
```
|
||||
|
||||
## Template System
|
||||
|
||||
Templates provide custom rendering for form structure elements.
|
||||
|
||||
### FieldTemplate
|
||||
|
||||
Custom field wrapper with connection handles:
|
||||
|
||||
```typescript
|
||||
const FieldTemplate: React.FC<FieldTemplateProps> = ({
|
||||
id, label, required, description, children, schema, formContext, uiSchema
|
||||
}) => {
|
||||
const { isInputConnected } = useEdgeStore();
|
||||
const { nodeId } = formContext;
|
||||
|
||||
const fieldKey = generateHandleId(id);
|
||||
const isConnected = isInputConnected(nodeId, fieldKey);
|
||||
|
||||
return (
|
||||
<div className="mt-4 w-[400px] space-y-1">
|
||||
{label && schema.type && (
|
||||
<label htmlFor={id} className="flex items-center gap-1">
|
||||
<NodeHandle id={fieldKey} isConnected={isConnected} side="left" />
|
||||
<Text variant="body">{label}</Text>
|
||||
<Text variant="small" className={colorClass}>({displayType})</Text>
|
||||
{required && <span style={{ color: "red" }}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
{!isConnected && <div className="pl-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### ArrayFieldTemplate
|
||||
|
||||
Custom array editing interface:
|
||||
|
||||
```typescript
|
||||
function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
|
||||
const { items, canAdd, onAddClick, disabled, readonly, formContext, idSchema } = props;
|
||||
const { nodeId } = formContext;
|
||||
|
||||
return (
|
||||
<ArrayEditor
|
||||
items={items}
|
||||
nodeId={nodeId}
|
||||
canAdd={canAdd}
|
||||
onAddClick={onAddClick}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
id={idSchema.$id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Customization Guide
|
||||
|
||||
### Adding a Custom Widget
|
||||
|
||||
1. **Create the Widget Component**:
|
||||
|
||||
```typescript
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
|
||||
export const MyCustomWidget = (props: WidgetProps) => {
|
||||
const { value, onChange, schema, disabled, readonly } = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={schema.placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
2. **Register the Widget**:
|
||||
|
||||
```typescript
|
||||
// In widgets/index.ts
|
||||
export const widgets: RegistryWidgetsType = {
|
||||
// ... existing widgets
|
||||
MyCustomWidget: MyCustomWidget,
|
||||
};
|
||||
```
|
||||
|
||||
3. **Use in Schema**:
|
||||
|
||||
```typescript
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
myField: {
|
||||
type: "string",
|
||||
"ui:widget": "MyCustomWidget",
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Adding a Custom Field
|
||||
|
||||
1. **Create the Field Component**:
|
||||
|
||||
```typescript
|
||||
import { FieldProps } from "@rjsf/utils";
|
||||
|
||||
export const MyCustomField = (props: FieldProps) => {
|
||||
const { schema, formData, onChange, name, idSchema, formContext } = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Custom field implementation */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
2. **Register the Field**:
|
||||
|
||||
```typescript
|
||||
// In fields/index.ts
|
||||
export const fields: RegistryFieldsType = {
|
||||
// ... existing fields
|
||||
MyCustomField: MyCustomField,
|
||||
};
|
||||
```
|
||||
|
||||
3. **Use in Schema**:
|
||||
|
||||
```typescript
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
myField: {
|
||||
type: "string",
|
||||
"ui:field": "MyCustomField",
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Customizing Templates
|
||||
|
||||
1. **Create Custom Template**:
|
||||
|
||||
```typescript
|
||||
const MyCustomFieldTemplate: React.FC<FieldTemplateProps> = (props) => {
|
||||
return (
|
||||
<div className="my-custom-field">
|
||||
{/* Custom template implementation */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
2. **Register Template**:
|
||||
|
||||
```typescript
|
||||
// In templates/index.ts
|
||||
export const templates = {
|
||||
FieldTemplate: MyCustomFieldTemplate,
|
||||
// ... other templates
|
||||
};
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Connection State Management
|
||||
|
||||
The Form Creator integrates with the edge store to show/hide input fields based on connection state:
|
||||
|
||||
```typescript
|
||||
const FieldTemplate = ({ id, children, formContext }) => {
|
||||
const { isInputConnected } = useEdgeStore();
|
||||
const { nodeId } = formContext;
|
||||
|
||||
const fieldKey = generateHandleId(id);
|
||||
const isConnected = isInputConnected(nodeId, fieldKey);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<NodeHandle id={fieldKey} isConnected={isConnected} side="left" />
|
||||
{!isConnected && children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Advanced Mode
|
||||
|
||||
Fields can be hidden/shown based on advanced mode:
|
||||
|
||||
```typescript
|
||||
const FieldTemplate = ({ schema, formContext }) => {
|
||||
const { nodeId } = formContext;
|
||||
const showAdvanced = useNodeStore(
|
||||
(state) => state.nodeAdvancedStates[nodeId] || false
|
||||
);
|
||||
|
||||
if (!showAdvanced && schema.advanced === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div>{/* field content */}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### Array Item Context
|
||||
|
||||
Array items have special context for connection handling:
|
||||
|
||||
```typescript
|
||||
const ArrayEditor = ({ items, nodeId }) => {
|
||||
return (
|
||||
<div>
|
||||
{items?.map((element) => {
|
||||
const fieldKey = generateHandleId(id, [element.index.toString()], HandleIdType.ARRAY);
|
||||
const isConnected = isInputConnected(nodeId, fieldKey);
|
||||
|
||||
return (
|
||||
<ArrayEditorContext.Provider
|
||||
value={{ isArrayItem: true, fieldKey, isConnected }}
|
||||
>
|
||||
{element.children}
|
||||
</ArrayEditorContext.Provider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Handle ID Generation
|
||||
|
||||
Handle IDs are generated based on field structure:
|
||||
|
||||
```typescript
|
||||
// Simple field
|
||||
generateHandleId("message"); // "message"
|
||||
|
||||
// Nested field
|
||||
generateHandleId("config", ["api_key"]); // "config.api_key"
|
||||
|
||||
// Array item
|
||||
generateHandleId("items", ["0"]); // "items_$_0"
|
||||
|
||||
// Key-value pair
|
||||
generateHandleId("headers", ["Authorization"]); // "headers_#_Authorization"
|
||||
```
|
||||
@@ -0,0 +1,159 @@
|
||||
# FlowEditor Component
|
||||
|
||||
The FlowEditor is a powerful visual flow builder component built on top of React Flow that allows users to create, connect, and manage nodes in a visual workflow. It provides a comprehensive form system with dynamic input handling, connection management, and advanced features.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [Store Management](#store-management)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The FlowEditor follows a modular architecture with clear separation of concerns:
|
||||
|
||||
```
|
||||
FlowEditor/
|
||||
├── Flow.tsx # Main component
|
||||
├── nodes/ # Node-related components
|
||||
│ ├── CustomNode.tsx # Main node component
|
||||
│ ├── FormCreator.tsx # Dynamic form generator
|
||||
│ ├── fields/ # Custom field components
|
||||
│ ├── widgets/ # Custom input widgets
|
||||
│ ├── templates/ # RJSF templates
|
||||
│ └── helpers.ts # Utility functions
|
||||
├── edges/ # Edge-related components
|
||||
│ ├── CustomEdge.tsx # Custom edge component
|
||||
│ ├── useCustomEdge.ts # Edge management hook
|
||||
│ └── helpers.ts # Edge utilities
|
||||
├── handlers/ # Connection handles
|
||||
│ ├── NodeHandle.tsx # Connection handle component
|
||||
│ └── helpers.ts # Handle utilities
|
||||
├── components/ # Shared components
|
||||
│ ├── ArrayEditor/ # Array editing components
|
||||
│ └── ObjectEditor/ # Object editing components
|
||||
└── processors/ # Data processors
|
||||
└── input-schema-pre-processor.ts
|
||||
```
|
||||
|
||||
## Store Management
|
||||
|
||||
The FlowEditor uses Zustand for state management with two main stores:
|
||||
|
||||
### NodeStore (`useNodeStore`)
|
||||
|
||||
Manages all node-related state and operations.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Node CRUD operations
|
||||
- Advanced state management per node
|
||||
- Form data persistence
|
||||
- Node counter for unique IDs
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { useNodeStore } from "../stores/nodeStore";
|
||||
|
||||
// Get nodes
|
||||
const nodes = useNodeStore(useShallow((state) => state.nodes));
|
||||
|
||||
// Add a new node
|
||||
const addNode = useNodeStore((state) => state.addNode);
|
||||
|
||||
// Update node data
|
||||
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
||||
|
||||
// Toggle advanced mode
|
||||
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
|
||||
```
|
||||
|
||||
**Store Methods:**
|
||||
|
||||
- `setNodes(nodes)` - Replace all nodes
|
||||
- `addNode(node)` - Add a single node
|
||||
- `addBlock(blockInfo)` - Add node from block info
|
||||
- `updateNodeData(nodeId, data)` - Update node data
|
||||
- `onNodesChange(changes)` - Handle node changes from React Flow
|
||||
- `setShowAdvanced(nodeId, show)` - Toggle advanced mode
|
||||
- `incrementNodeCounter()` - Get next node ID
|
||||
|
||||
### EdgeStore (`useEdgeStore`)
|
||||
|
||||
Manages all connection-related state and operations.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Connection CRUD operations
|
||||
- Connection validation
|
||||
- Backend link conversion
|
||||
- Connection state queries
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { useEdgeStore } from "../stores/edgeStore";
|
||||
|
||||
// Get connections
|
||||
const connections = useEdgeStore((state) => state.connections);
|
||||
|
||||
// Add connection
|
||||
const addConnection = useEdgeStore((state) => state.addConnection);
|
||||
|
||||
// Check if input is connected
|
||||
const isInputConnected = useEdgeStore((state) => state.isInputConnected);
|
||||
```
|
||||
|
||||
**Store Methods:**
|
||||
|
||||
- `setConnections(connections)` - Replace all connections
|
||||
- `addConnection(conn)` - Add a new connection
|
||||
- `removeConnection(edgeId)` - Remove connection by ID
|
||||
- `upsertMany(conns)` - Bulk update connections
|
||||
- `isInputConnected(nodeId, handle)` - Check input connection
|
||||
- `isOutputConnected(nodeId, handle)` - Check output connection
|
||||
- `getNodeConnections(nodeId)` - Get all connections for a node
|
||||
- `getBackendLinks()` - Convert to backend format
|
||||
|
||||
## Form Creator System
|
||||
|
||||
The FormCreator is a dynamic form generator built on React JSON Schema Form (RJSF) that automatically creates forms based on JSON schemas.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Schema Processing**: Input schemas are preprocessed to ensure all properties have types
|
||||
2. **Widget Mapping**: Schema types are mapped to appropriate input widgets
|
||||
3. **Field Rendering**: Custom fields handle complex data structures
|
||||
4. **State Management**: Form data is automatically synced with the node store
|
||||
|
||||
### Key Components
|
||||
|
||||
#### FormCreator
|
||||
|
||||
```typescript
|
||||
<FormCreator
|
||||
jsonSchema={preprocessedSchema}
|
||||
nodeId={nodeId}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Custom Widgets
|
||||
|
||||
- `TextInputWidget` - Text, number, password inputs
|
||||
- `SelectWidget` - Dropdown and multi-select
|
||||
- `SwitchWidget` - Boolean toggles
|
||||
- `FileWidget` - File upload
|
||||
- `DateInputWidget` - Date picker
|
||||
- `TimeInputWidget` - Time picker
|
||||
- `DateTimeInputWidget` - DateTime picker
|
||||
|
||||
#### Custom Fields
|
||||
|
||||
- `AnyOfField` - Union type handling
|
||||
- `ObjectField` - Free-form object editing
|
||||
- `CredentialsField` - API credential management
|
||||
|
||||
#### Templates
|
||||
|
||||
- `FieldTemplate` - Custom field wrapper with handles
|
||||
- `ArrayFieldTemplate` - Array editing interface
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
EdgeProps,
|
||||
getBezierPath,
|
||||
} from "@xyflow/react";
|
||||
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
|
||||
const CustomEdge = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
markerEnd,
|
||||
selected,
|
||||
}: EdgeProps) => {
|
||||
const removeConnection = useEdgeStore((state) => state.removeConnection);
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
className={
|
||||
selected ? "[stroke:#555]" : "[stroke:#555]80 hover:[stroke:#555]"
|
||||
}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<Button
|
||||
onClick={() => removeConnection(id)}
|
||||
className={`absolute z-10 min-w-0 p-1`}
|
||||
variant="secondary"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-3 w-3" weight="bold" />
|
||||
</Button>
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomEdge;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Link } from "@/app/api/__generated__/models/link";
|
||||
import { Connection } from "@xyflow/react";
|
||||
|
||||
export const convertConnectionsToBackendLinks = (
|
||||
connections: Connection[],
|
||||
): Link[] =>
|
||||
connections.map((c) => ({
|
||||
source_id: c.source || "",
|
||||
sink_id: c.target || "",
|
||||
source_name: c.sourceHandle || "",
|
||||
sink_name: c.targetHandle || "",
|
||||
}));
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Connection as RFConnection,
|
||||
Edge as RFEdge,
|
||||
MarkerType,
|
||||
EdgeChange,
|
||||
} from "@xyflow/react";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
export const useCustomEdge = () => {
|
||||
const connections = useEdgeStore((s) => s.connections);
|
||||
const addConnection = useEdgeStore((s) => s.addConnection);
|
||||
const removeConnection = useEdgeStore((s) => s.removeConnection);
|
||||
|
||||
const edges: RFEdge[] = useMemo(
|
||||
() =>
|
||||
connections.map((c) => ({
|
||||
id: c.edge_id,
|
||||
type: "custom",
|
||||
source: c.source,
|
||||
target: c.target,
|
||||
sourceHandle: c.sourceHandle,
|
||||
targetHandle: c.targetHandle,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
strokeWidth: 2,
|
||||
color: "#555",
|
||||
},
|
||||
})),
|
||||
[connections],
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(conn: RFConnection) => {
|
||||
if (
|
||||
!conn.source ||
|
||||
!conn.target ||
|
||||
!conn.sourceHandle ||
|
||||
!conn.targetHandle
|
||||
)
|
||||
return;
|
||||
const exists = connections.some(
|
||||
(c) =>
|
||||
c.source === conn.source &&
|
||||
c.target === conn.target &&
|
||||
c.sourceHandle === conn.sourceHandle &&
|
||||
c.targetHandle === conn.targetHandle,
|
||||
);
|
||||
if (exists) return;
|
||||
addConnection({
|
||||
source: conn.source,
|
||||
target: conn.target,
|
||||
sourceHandle: conn.sourceHandle,
|
||||
targetHandle: conn.targetHandle,
|
||||
});
|
||||
},
|
||||
[connections, addConnection],
|
||||
);
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange[]) => {
|
||||
changes.forEach((ch) => {
|
||||
if (ch.type === "remove") removeConnection(ch.id);
|
||||
});
|
||||
},
|
||||
[removeConnection],
|
||||
);
|
||||
|
||||
return { edges, onConnect, onEdgesChange };
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CircleIcon } from "@phosphor-icons/react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
|
||||
const NodeHandle = ({
|
||||
id,
|
||||
isConnected,
|
||||
side,
|
||||
}: {
|
||||
id: string;
|
||||
isConnected: boolean;
|
||||
side: "left" | "right";
|
||||
}) => {
|
||||
console.log("id", id);
|
||||
return (
|
||||
<Handle
|
||||
type={side === "left" ? "target" : "source"}
|
||||
position={side === "left" ? Position.Left : Position.Right}
|
||||
id={id}
|
||||
className={side === "left" ? "-ml-4 mr-2" : "-mr-2 ml-2"}
|
||||
>
|
||||
<div className="pointer-events-none">
|
||||
<CircleIcon
|
||||
size={16}
|
||||
weight={isConnected ? "fill" : "duotone"}
|
||||
className={"text-gray-400 opacity-100"}
|
||||
/>
|
||||
</div>
|
||||
</Handle>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeHandle;
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Handle ID Types for different input structures
|
||||
*
|
||||
* Examples:
|
||||
* SIMPLE: "message"
|
||||
* NESTED: "config.api_key"
|
||||
* ARRAY: "items_$_0", "items_$_1"
|
||||
* KEY_VALUE: "headers_#_Authorization", "params_#_limit"
|
||||
*
|
||||
* Note: All handle IDs are sanitized to remove spaces and special characters.
|
||||
* Spaces become underscores, and special characters are removed.
|
||||
* Example: "user name" becomes "user_name", "email@domain.com" becomes "emaildomaincom"
|
||||
*/
|
||||
export enum HandleIdType {
|
||||
SIMPLE = "SIMPLE",
|
||||
NESTED = "NESTED",
|
||||
ARRAY = "ARRAY",
|
||||
KEY_VALUE = "KEY_VALUE",
|
||||
}
|
||||
|
||||
const fromRjsfId = (id: string): string => {
|
||||
if (!id) return "";
|
||||
const parts = id.split("_");
|
||||
const filtered = parts.filter(
|
||||
(p) => p !== "root" && p !== "properties" && p.length > 0,
|
||||
);
|
||||
return filtered.join("_") || "";
|
||||
};
|
||||
|
||||
const sanitizeForHandleId = (str: string): string => {
|
||||
if (!str) return "";
|
||||
|
||||
return str
|
||||
.trim()
|
||||
.replace(/\s+/g, "_") // Replace spaces with underscores
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "") // Remove special characters except underscores and hyphens
|
||||
.replace(/_+/g, "_") // Replace multiple consecutive underscores with single underscore
|
||||
.replace(/^_|_$/g, ""); // Remove leading/trailing underscores
|
||||
};
|
||||
|
||||
export const generateHandleId = (
|
||||
mainKey: string,
|
||||
nestedValues: string[] = [],
|
||||
type: HandleIdType = HandleIdType.SIMPLE,
|
||||
): string => {
|
||||
if (!mainKey) return "";
|
||||
|
||||
mainKey = fromRjsfId(mainKey);
|
||||
mainKey = sanitizeForHandleId(mainKey);
|
||||
|
||||
if (type === HandleIdType.SIMPLE || nestedValues.length === 0) {
|
||||
return mainKey;
|
||||
}
|
||||
|
||||
const sanitizedNestedValues = nestedValues.map((value) =>
|
||||
sanitizeForHandleId(value),
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case HandleIdType.NESTED:
|
||||
return [mainKey, ...sanitizedNestedValues].join(".");
|
||||
|
||||
case HandleIdType.ARRAY:
|
||||
return [mainKey, ...sanitizedNestedValues].join("_$_");
|
||||
|
||||
case HandleIdType.KEY_VALUE:
|
||||
return [mainKey, ...sanitizedNestedValues].join("_#_");
|
||||
|
||||
default:
|
||||
return mainKey;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseHandleId = (
|
||||
handleId: string,
|
||||
): {
|
||||
mainKey: string;
|
||||
nestedValues: string[];
|
||||
type: HandleIdType;
|
||||
} => {
|
||||
if (!handleId) {
|
||||
return { mainKey: "", nestedValues: [], type: HandleIdType.SIMPLE };
|
||||
}
|
||||
|
||||
if (handleId.includes("_#_")) {
|
||||
const parts = handleId.split("_#_");
|
||||
return {
|
||||
mainKey: parts[0],
|
||||
nestedValues: parts.slice(1),
|
||||
type: HandleIdType.KEY_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId.includes("_$_")) {
|
||||
const parts = handleId.split("_$_");
|
||||
return {
|
||||
mainKey: parts[0],
|
||||
nestedValues: parts.slice(1),
|
||||
type: HandleIdType.ARRAY,
|
||||
};
|
||||
}
|
||||
|
||||
if (handleId.includes(".")) {
|
||||
const parts = handleId.split(".");
|
||||
return {
|
||||
mainKey: parts[0],
|
||||
nestedValues: parts.slice(1),
|
||||
type: HandleIdType.NESTED,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mainKey: handleId,
|
||||
nestedValues: [],
|
||||
type: HandleIdType.SIMPLE,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { Node as XYNode, NodeProps } from "@xyflow/react";
|
||||
import { FormCreator } from "./FormCreator";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||
import { preprocessInputSchema } from "../processors/input-schema-pre-processor";
|
||||
import { OutputHandler } from "./OutputHandler";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
|
||||
export type CustomNodeData = {
|
||||
hardcodedValues: {
|
||||
[key: string]: any;
|
||||
};
|
||||
title: string;
|
||||
description: string;
|
||||
inputSchema: RJSFSchema;
|
||||
outputSchema: RJSFSchema;
|
||||
};
|
||||
|
||||
export type CustomNode = XYNode<CustomNodeData, "custom">;
|
||||
|
||||
export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
({ data, id }) => {
|
||||
const showAdvanced = useNodeStore(
|
||||
(state) => state.nodeAdvancedStates[id] || false,
|
||||
);
|
||||
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200/60 bg-gradient-to-br from-white to-slate-50/30 shadow-lg shadow-slate-900/5 backdrop-blur-sm">
|
||||
{/* Header */}
|
||||
<div className="flex h-14 items-center justify-center rounded-xl border-b border-slate-200/50 bg-gradient-to-r from-slate-50/80 to-white/90">
|
||||
<Text
|
||||
variant="large-semibold"
|
||||
className="tracking-tight text-slate-800"
|
||||
>
|
||||
{data.title} #{id}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Input Handles */}
|
||||
<div className="bg-white/40 pb-6 pr-6">
|
||||
<FormCreator
|
||||
jsonSchema={preprocessInputSchema(data.inputSchema)}
|
||||
nodeId={id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Button */}
|
||||
<div className="flex items-center justify-between gap-2 rounded-b-xl border-t border-slate-200/50 bg-gradient-to-r from-slate-50/60 to-white/80 px-5 py-3.5">
|
||||
<Text variant="body" className="font-medium text-slate-700">
|
||||
Advanced
|
||||
</Text>
|
||||
<Switch
|
||||
onCheckedChange={(checked) => setShowAdvanced(id, checked)}
|
||||
checked={showAdvanced}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output Handles */}
|
||||
<OutputHandler outputSchema={data.outputSchema} nodeId={id} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CustomNode.displayName = "CustomNode";
|
||||
@@ -0,0 +1,33 @@
|
||||
import Form from "@rjsf/core";
|
||||
import validator from "@rjsf/validator-ajv8";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import React from "react";
|
||||
import { widgets } from "./widgets";
|
||||
import { fields } from "./fields";
|
||||
import { templates } from "./templates";
|
||||
import { uiSchema } from "./uiSchema";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
|
||||
export const FormCreator = React.memo(
|
||||
({ jsonSchema, nodeId }: { jsonSchema: RJSFSchema; nodeId: string }) => {
|
||||
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
||||
const handleChange = ({ formData }: any) => {
|
||||
updateNodeData(nodeId, { hardcodedValues: formData });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
schema={jsonSchema}
|
||||
validator={validator}
|
||||
fields={fields}
|
||||
templates={templates}
|
||||
widgets={widgets}
|
||||
formContext={{ nodeId: nodeId }}
|
||||
onChange={handleChange}
|
||||
uiSchema={uiSchema}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FormCreator.displayName = "FormCreator";
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CaretDownIcon, InfoIcon } from "@phosphor-icons/react";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
import NodeHandle from "../handlers/NodeHandle";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { getTypeDisplayInfo } from "./helpers";
|
||||
|
||||
export const OutputHandler = ({
|
||||
outputSchema,
|
||||
nodeId,
|
||||
}: {
|
||||
outputSchema: RJSFSchema;
|
||||
nodeId: string;
|
||||
}) => {
|
||||
const { isOutputConnected } = useEdgeStore();
|
||||
const properties = outputSchema?.properties || {};
|
||||
const [isOutputVisible, setIsOutputVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end justify-between gap-2 rounded-b-xl border-t border-slate-200/50 bg-white py-3.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mr-4 p-0"
|
||||
onClick={() => setIsOutputVisible(!isOutputVisible)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
className="flex items-center gap-2 font-medium text-slate-700"
|
||||
>
|
||||
Output{" "}
|
||||
<CaretDownIcon
|
||||
size={16}
|
||||
weight="bold"
|
||||
className={`transition-transform ${isOutputVisible ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{Object.entries(properties).map(([key, property]: [string, any]) => {
|
||||
const isConnected = isOutputConnected(nodeId, key);
|
||||
const shouldShow = isConnected || isOutputVisible;
|
||||
const { displayType, colorClass } = getTypeDisplayInfo(property);
|
||||
|
||||
return shouldShow ? (
|
||||
<div key={key} className="relative flex items-center gap-2">
|
||||
<Text
|
||||
variant="body"
|
||||
className="flex items-center gap-2 font-medium text-slate-700"
|
||||
>
|
||||
{property?.description && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
style={{ marginLeft: 6, cursor: "pointer" }}
|
||||
aria-label="info"
|
||||
tabIndex={0}
|
||||
>
|
||||
<InfoIcon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{property?.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{property?.title || key}{" "}
|
||||
<Text variant="small" as="span" className={colorClass}>
|
||||
({displayType})
|
||||
</Text>
|
||||
</Text>
|
||||
<NodeHandle id={key} isConnected={isConnected} side="right" />
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import React from "react";
|
||||
import { FieldProps, RJSFSchema } from "@rjsf/utils";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
import { InputType, mapJsonSchemaTypeToInputType } from "../../helpers";
|
||||
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import { useAnyOfField } from "./useAnyOfField";
|
||||
import NodeHandle from "../../../handlers/NodeHandle";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { generateHandleId } from "../../../handlers/helpers";
|
||||
import { getTypeDisplayInfo } from "../../helpers";
|
||||
import merge from "lodash/merge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
|
||||
type TypeOption = {
|
||||
type: string;
|
||||
title: string;
|
||||
index: number;
|
||||
format?: string;
|
||||
enum?: any[];
|
||||
secret?: boolean;
|
||||
schema: RJSFSchema;
|
||||
};
|
||||
|
||||
export const AnyOfField = ({
|
||||
schema,
|
||||
formData,
|
||||
onChange,
|
||||
name,
|
||||
idSchema,
|
||||
formContext,
|
||||
registry,
|
||||
uiSchema,
|
||||
disabled,
|
||||
onBlur,
|
||||
onFocus,
|
||||
}: FieldProps) => {
|
||||
const fieldKey = generateHandleId(idSchema.$id ?? "");
|
||||
const updatedFormContexrt = { ...formContext, fromAnyOf: true };
|
||||
|
||||
const { nodeId } = updatedFormContexrt;
|
||||
const { isInputConnected } = useEdgeStore();
|
||||
const isConnected = isInputConnected(nodeId, fieldKey);
|
||||
const {
|
||||
isNullableType,
|
||||
nonNull,
|
||||
selectedType,
|
||||
handleTypeChange,
|
||||
handleNullableToggle,
|
||||
handleValueChange,
|
||||
currentTypeOption,
|
||||
isEnabled,
|
||||
typeOptions,
|
||||
} = useAnyOfField(schema, formData, onChange);
|
||||
|
||||
const renderInput = (typeOption: TypeOption) => {
|
||||
const optionSchema = (typeOption.schema || {
|
||||
type: typeOption.type,
|
||||
format: typeOption.format,
|
||||
secret: typeOption.secret,
|
||||
enum: typeOption.enum,
|
||||
}) as RJSFSchema;
|
||||
const inputType = mapJsonSchemaTypeToInputType(optionSchema);
|
||||
|
||||
// Help us to tell the field under the anyOf field that you are a part of anyOf field.
|
||||
// We can't use formContext in this case that's why we are using this.
|
||||
// We could use context api here, but i think it's better to keep it simple.
|
||||
const uiSchemaFromAnyOf = merge({}, uiSchema, {
|
||||
"ui:options": { fromAnyOf: true },
|
||||
});
|
||||
|
||||
// We are using SchemaField to render the field recursively.
|
||||
if (inputType === InputType.ARRAY_EDITOR) {
|
||||
const SchemaField = registry.fields.SchemaField;
|
||||
return (
|
||||
<div className="-ml-2">
|
||||
<SchemaField
|
||||
schema={optionSchema}
|
||||
formData={formData}
|
||||
idSchema={idSchema}
|
||||
uiSchema={uiSchemaFromAnyOf}
|
||||
onChange={handleValueChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
name={name}
|
||||
registry={registry}
|
||||
disabled={disabled}
|
||||
formContext={updatedFormContexrt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SchemaField = registry.fields.SchemaField;
|
||||
return (
|
||||
<div className="-ml-2">
|
||||
<SchemaField
|
||||
schema={optionSchema}
|
||||
formData={formData}
|
||||
idSchema={idSchema}
|
||||
uiSchema={uiSchemaFromAnyOf}
|
||||
onChange={handleValueChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
name={name}
|
||||
registry={registry}
|
||||
disabled={disabled}
|
||||
formContext={updatedFormContexrt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// I am doing this, because we need different UI for optional types.
|
||||
if (isNullableType && nonNull) {
|
||||
const { displayType, colorClass } = getTypeDisplayInfo(nonNull);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="-ml-2 flex items-center gap-1">
|
||||
<NodeHandle id={fieldKey} isConnected={isConnected} side="left" />
|
||||
<Text variant="body">
|
||||
{name.charAt(0).toUpperCase() + name.slice(1)}
|
||||
</Text>
|
||||
<Text variant="small" className={colorClass}>
|
||||
({displayType} | null)
|
||||
</Text>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
<Switch
|
||||
className="z-10"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={handleNullableToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isConnected && isEnabled && renderInput(nonNull)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="-mb-3 -ml-2 flex items-center gap-1">
|
||||
<NodeHandle id={fieldKey} isConnected={isConnected} side="left" />
|
||||
<Text variant="body">
|
||||
{name.charAt(0).toUpperCase() + name.slice(1)}
|
||||
</Text>
|
||||
{!isConnected && (
|
||||
<Select
|
||||
label=""
|
||||
id={`${name}-type-select`}
|
||||
hideLabel={true}
|
||||
value={selectedType}
|
||||
onValueChange={handleTypeChange}
|
||||
options={typeOptions.map((o) => {
|
||||
const { displayType } = getTypeDisplayInfo(o);
|
||||
return { value: o.type, label: displayType };
|
||||
})}
|
||||
size="small"
|
||||
wrapperClassName="!mb-0 "
|
||||
className="h-6 w-fit gap-1 pl-3 pr-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{schema.description && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
style={{ marginLeft: 6, cursor: "pointer" }}
|
||||
aria-label="info"
|
||||
tabIndex={0}
|
||||
>
|
||||
<InfoIcon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{schema.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
{!isConnected && currentTypeOption && renderInput(currentTypeOption)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
|
||||
const getDefaultValueForType = (type?: string): any => {
|
||||
if (!type) return "";
|
||||
|
||||
switch (type) {
|
||||
case "string":
|
||||
return "";
|
||||
case "number":
|
||||
case "integer":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
case "array":
|
||||
return [];
|
||||
case "object":
|
||||
return {};
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const useAnyOfField = (
|
||||
schema: RJSFSchema,
|
||||
formData: any,
|
||||
onChange: (value: any) => void,
|
||||
) => {
|
||||
const typeOptions: any[] = useMemo(
|
||||
() =>
|
||||
schema.anyOf?.map((opt: any, i: number) => ({
|
||||
type: opt.type || "string",
|
||||
title: opt.title || `Option ${i + 1}`,
|
||||
index: i,
|
||||
format: opt.format,
|
||||
enum: opt.enum,
|
||||
secret: opt.secret,
|
||||
schema: opt,
|
||||
})) || [],
|
||||
[schema.anyOf],
|
||||
);
|
||||
|
||||
const isNullableType = useMemo(
|
||||
() =>
|
||||
typeOptions.length === 2 &&
|
||||
typeOptions.some((o) => o.type === "null") &&
|
||||
typeOptions.some((o) => o.type !== "null"),
|
||||
[typeOptions],
|
||||
);
|
||||
|
||||
const nonNull = useMemo(
|
||||
() => (isNullableType ? typeOptions.find((o) => o.type !== "null") : null),
|
||||
[isNullableType, typeOptions],
|
||||
);
|
||||
|
||||
const initialSelectedType = useMemo(() => {
|
||||
const def = schema.default;
|
||||
const first = typeOptions[0]?.type || "string";
|
||||
if (isNullableType) return nonNull?.type || "string";
|
||||
if (typeof def === "string" && typeOptions.some((o) => o.type === def))
|
||||
return def;
|
||||
return first;
|
||||
}, [schema.default, typeOptions, isNullableType, nonNull?.type]);
|
||||
|
||||
const [selectedType, setSelectedType] = useState<string>(initialSelectedType);
|
||||
|
||||
const isEnabled = formData !== null && formData !== undefined;
|
||||
|
||||
const handleTypeChange = (t: string) => {
|
||||
setSelectedType(t);
|
||||
onChange(undefined); // clear current value when switching type
|
||||
};
|
||||
|
||||
const handleNullableToggle = (checked: boolean) => {
|
||||
if (checked) {
|
||||
onChange(getDefaultValueForType(nonNull?.type));
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValueChange = (value: any) => onChange(value);
|
||||
|
||||
const currentTypeOption = typeOptions.find((o) => o.type === selectedType);
|
||||
|
||||
return {
|
||||
isNullableType,
|
||||
nonNull,
|
||||
selectedType,
|
||||
handleTypeChange,
|
||||
handleNullableToggle,
|
||||
handleValueChange,
|
||||
currentTypeOption,
|
||||
isEnabled,
|
||||
typeOptions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { FieldProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
|
||||
// We need to add all the logic for the credential fields here
|
||||
export const CredentialsField = (props: FieldProps) => {
|
||||
const { formData = {}, onChange, required: _required, schema } = props;
|
||||
|
||||
const _credentialProvider = schema.credentials_provider;
|
||||
const _credentialType = schema.credentials_types;
|
||||
const _description = schema.description;
|
||||
const _title = schema.title;
|
||||
|
||||
// Helper to update one property
|
||||
const setField = (key: string, value: any) =>
|
||||
onChange({ ...formData, [key]: value });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
hideLabel={true}
|
||||
label={""}
|
||||
id="credentials-id"
|
||||
type="text"
|
||||
value={formData.id || ""}
|
||||
onChange={(e) => setField("id", e.target.value)}
|
||||
placeholder="Enter your API Key"
|
||||
required
|
||||
size="small"
|
||||
wrapperClassName="mb-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { FieldProps } from "@rjsf/utils";
|
||||
import { getDefaultRegistry } from "@rjsf/core";
|
||||
import { generateHandleId } from "../../handlers/helpers";
|
||||
import { ObjectEditor } from "../../components/ObjectEditor/ObjectEditor";
|
||||
|
||||
export const ObjectField = (props: FieldProps) => {
|
||||
const {
|
||||
schema,
|
||||
formData = {},
|
||||
onChange,
|
||||
name,
|
||||
idSchema,
|
||||
formContext,
|
||||
} = props;
|
||||
const DefaultObjectField = getDefaultRegistry().fields.ObjectField;
|
||||
|
||||
// Let the default field render for root or fixed-schema objects
|
||||
const isFreeForm =
|
||||
!schema.properties ||
|
||||
Object.keys(schema.properties).length === 0 ||
|
||||
schema.additionalProperties === true;
|
||||
|
||||
if (idSchema?.$id === "root" || !isFreeForm) {
|
||||
return <DefaultObjectField {...props} />;
|
||||
}
|
||||
|
||||
const fieldKey = generateHandleId(idSchema.$id ?? "");
|
||||
const { nodeId } = formContext;
|
||||
|
||||
return (
|
||||
<ObjectEditor
|
||||
id={`${name}-input`}
|
||||
nodeId={nodeId}
|
||||
fieldKey={fieldKey}
|
||||
value={formData}
|
||||
onChange={onChange}
|
||||
placeholder={`Enter ${name || "Contact Data"}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RegistryFieldsType } from "@rjsf/utils";
|
||||
import { CredentialsField } from "./CredentialField";
|
||||
import { AnyOfField } from "./AnyOfField/AnyOfField";
|
||||
import { ObjectField } from "./ObjectField";
|
||||
|
||||
export const fields: RegistryFieldsType = {
|
||||
AnyOfField: AnyOfField,
|
||||
credentials: CredentialsField,
|
||||
ObjectField: ObjectField,
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
|
||||
export enum InputType {
|
||||
SINGLE_LINE_TEXT = "single-line-text",
|
||||
TEXT_AREA = "text-area",
|
||||
PASSWORD = "password",
|
||||
FILE = "file",
|
||||
DATE = "date",
|
||||
TIME = "time",
|
||||
DATE_TIME = "datetime",
|
||||
NUMBER = "number",
|
||||
INTEGER = "integer",
|
||||
SWITCH = "switch",
|
||||
ARRAY_EDITOR = "array-editor",
|
||||
SELECT = "select",
|
||||
MULTI_SELECT = "multi-select",
|
||||
OBJECT_EDITOR = "object-editor",
|
||||
ENUM = "enum",
|
||||
}
|
||||
|
||||
// This helper function maps a JSONSchema type to an InputType [help us to determine the type of the input]
|
||||
export function mapJsonSchemaTypeToInputType(
|
||||
schema: RJSFSchema,
|
||||
): InputType | undefined {
|
||||
if (schema.type === "string") {
|
||||
if (schema.secret) return InputType.PASSWORD;
|
||||
if (schema.format === "date") return InputType.DATE;
|
||||
if (schema.format === "time") return InputType.TIME;
|
||||
if (schema.format === "date-time") return InputType.DATE_TIME;
|
||||
if (schema.format === "long-text") return InputType.TEXT_AREA;
|
||||
if (schema.format === "short-text") return InputType.SINGLE_LINE_TEXT;
|
||||
if (schema.format === "file") return InputType.FILE;
|
||||
return InputType.SINGLE_LINE_TEXT;
|
||||
}
|
||||
|
||||
if (schema.type === "number") return InputType.NUMBER;
|
||||
if (schema.type === "integer") return InputType.INTEGER;
|
||||
if (schema.type === "boolean") return InputType.SWITCH;
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (
|
||||
schema.items &&
|
||||
typeof schema.items === "object" &&
|
||||
!Array.isArray(schema.items) &&
|
||||
schema.items.enum
|
||||
) {
|
||||
return InputType.MULTI_SELECT;
|
||||
}
|
||||
console.log("schema", schema);
|
||||
return InputType.ARRAY_EDITOR;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
return InputType.OBJECT_EDITOR;
|
||||
}
|
||||
|
||||
if (schema.enum) {
|
||||
return InputType.SELECT;
|
||||
}
|
||||
|
||||
if (schema.type === "null") return;
|
||||
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return InputType.SINGLE_LINE_TEXT;
|
||||
}
|
||||
|
||||
// Helper to extract options from schema
|
||||
export function extractOptions(
|
||||
schema: any,
|
||||
): { value: string; label: string }[] {
|
||||
if (schema.enum) {
|
||||
return schema.enum.map((value: any) => ({
|
||||
value: String(value),
|
||||
label: String(value),
|
||||
}));
|
||||
}
|
||||
|
||||
if (schema.type === "array" && schema.items?.enum) {
|
||||
return schema.items.enum.map((value: any) => ({
|
||||
value: String(value),
|
||||
label: String(value),
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// get display type and color for schema types [need for type display next to field name]
|
||||
export const getTypeDisplayInfo = (schema: any) => {
|
||||
if (schema?.type === "string" && schema?.format) {
|
||||
const formatMap: Record<
|
||||
string,
|
||||
{ displayType: string; colorClass: string }
|
||||
> = {
|
||||
file: { displayType: "file", colorClass: "!text-green-500" },
|
||||
date: { displayType: "date", colorClass: "!text-blue-500" },
|
||||
time: { displayType: "time", colorClass: "!text-blue-500" },
|
||||
"date-time": { displayType: "datetime", colorClass: "!text-blue-500" },
|
||||
"long-text": { displayType: "text", colorClass: "!text-green-500" },
|
||||
"short-text": { displayType: "text", colorClass: "!text-green-500" },
|
||||
};
|
||||
|
||||
const formatInfo = formatMap[schema.format];
|
||||
if (formatInfo) {
|
||||
return formatInfo;
|
||||
}
|
||||
}
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
string: "text",
|
||||
number: "number",
|
||||
integer: "integer",
|
||||
boolean: "true/false",
|
||||
object: "object",
|
||||
array: "list",
|
||||
null: "null",
|
||||
};
|
||||
|
||||
const displayType = typeMap[schema?.type] || schema?.type || "any";
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
string: "!text-green-500",
|
||||
number: "!text-blue-500",
|
||||
integer: "!text-blue-500",
|
||||
boolean: "!text-yellow-500",
|
||||
object: "!text-purple-500",
|
||||
array: "!text-indigo-500",
|
||||
null: "!text-gray-500",
|
||||
any: "!text-gray-500",
|
||||
};
|
||||
|
||||
const colorClass = colorMap[schema?.type] || "!text-gray-500";
|
||||
|
||||
return {
|
||||
displayType,
|
||||
colorClass,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { ArrayFieldTemplateProps } from "@rjsf/utils";
|
||||
import { ArrayEditor } from "../../components/ArrayEditor/ArrayEditor";
|
||||
|
||||
function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
|
||||
const {
|
||||
items,
|
||||
canAdd,
|
||||
onAddClick,
|
||||
disabled,
|
||||
readonly,
|
||||
formContext,
|
||||
idSchema,
|
||||
} = props;
|
||||
const { nodeId } = formContext;
|
||||
|
||||
return (
|
||||
<ArrayEditor
|
||||
items={items}
|
||||
nodeId={nodeId}
|
||||
canAdd={canAdd}
|
||||
onAddClick={onAddClick}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
id={idSchema.$id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArrayFieldTemplate;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useContext } from "react";
|
||||
import { FieldTemplateProps } from "@rjsf/utils";
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
import NodeHandle from "../../handlers/NodeHandle";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { generateHandleId } from "../../handlers/helpers";
|
||||
import { getTypeDisplayInfo } from "../helpers";
|
||||
import { ArrayEditorContext } from "../../components/ArrayEditor/ArrayEditorContext";
|
||||
|
||||
const FieldTemplate: React.FC<FieldTemplateProps> = ({
|
||||
id,
|
||||
label,
|
||||
required,
|
||||
description,
|
||||
children,
|
||||
schema,
|
||||
formContext,
|
||||
uiSchema,
|
||||
}) => {
|
||||
const { isInputConnected } = useEdgeStore();
|
||||
const { nodeId } = formContext;
|
||||
|
||||
const showAdvanced = useNodeStore(
|
||||
(state) => state.nodeAdvancedStates[nodeId] ?? false,
|
||||
);
|
||||
|
||||
const {
|
||||
isArrayItem,
|
||||
fieldKey: arrayFieldKey,
|
||||
isConnected: isArrayItemConnected,
|
||||
} = useContext(ArrayEditorContext);
|
||||
|
||||
let fieldKey = generateHandleId(id);
|
||||
let isConnected = isInputConnected(nodeId, fieldKey);
|
||||
if (isArrayItem) {
|
||||
fieldKey = arrayFieldKey;
|
||||
isConnected = isArrayItemConnected;
|
||||
}
|
||||
const isAnyOf = Array.isArray((schema as any)?.anyOf);
|
||||
const isOneOf = Array.isArray((schema as any)?.oneOf);
|
||||
const suppressHandle = isAnyOf || isOneOf;
|
||||
|
||||
if (!showAdvanced && schema.advanced === true && !isConnected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromAnyOf =
|
||||
Boolean((uiSchema as any)?.["ui:options"]?.fromAnyOf) ||
|
||||
Boolean((formContext as any)?.fromAnyOf);
|
||||
|
||||
const { displayType, colorClass } = getTypeDisplayInfo(schema);
|
||||
|
||||
return (
|
||||
<div className="mt-4 w-[400px] space-y-1">
|
||||
{label && schema.type && (
|
||||
<label htmlFor={id} className="flex items-center gap-1">
|
||||
{!suppressHandle && !fromAnyOf && (
|
||||
<NodeHandle id={fieldKey} isConnected={isConnected} side="left" />
|
||||
)}
|
||||
{!fromAnyOf && (
|
||||
<Text variant="body" className="line-clamp-1">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
{!fromAnyOf && (
|
||||
<Text variant="small" className={colorClass}>
|
||||
({displayType})
|
||||
</Text>
|
||||
)}
|
||||
{required && <span style={{ color: "red" }}>*</span>}
|
||||
{description?.props?.description && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
style={{ marginLeft: 6, cursor: "pointer" }}
|
||||
aria-label="info"
|
||||
tabIndex={0}
|
||||
>
|
||||
<InfoIcon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{description}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{(isAnyOf || !isConnected) && <div className="pl-2">{children}</div>}{" "}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldTemplate;
|
||||
@@ -0,0 +1,10 @@
|
||||
import ArrayFieldTemplate from "./ArrayFieldTemplate";
|
||||
import FieldTemplate from "./FieldTemplate";
|
||||
|
||||
const NoSubmitButton = () => null;
|
||||
|
||||
export const templates = {
|
||||
FieldTemplate,
|
||||
ButtonTemplates: { SubmitButton: NoSubmitButton },
|
||||
ArrayFieldTemplate,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export const uiSchema = {
|
||||
credentials: {
|
||||
"ui:field": "credentials",
|
||||
provider: { "ui:widget": "hidden" },
|
||||
type: { "ui:widget": "hidden" },
|
||||
id: { "ui:autofocus": true },
|
||||
title: { "ui:placeholder": "Optional title" },
|
||||
},
|
||||
properties: {
|
||||
"ui:field": "CustomObjectField",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { DateInput } from "@/components/atoms/DateInput/DateInput";
|
||||
|
||||
export const DateInputWidget = (props: WidgetProps) => {
|
||||
const { value, onChange, disabled, readonly, placeholder, autofocus, id } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<DateInput
|
||||
size="small"
|
||||
id={id}
|
||||
hideLabel={true}
|
||||
label={""}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
autoFocus={autofocus}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { DateTimeInput } from "@/components/atoms/DateTimeInput/DateTimeInput";
|
||||
|
||||
export const DateTimeInputWidget = (props: WidgetProps) => {
|
||||
const { value, onChange, disabled, readonly, placeholder, autofocus, id } =
|
||||
props;
|
||||
return (
|
||||
<DateTimeInput
|
||||
size="small"
|
||||
id={id}
|
||||
hideLabel={true}
|
||||
label={""}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
autoFocus={autofocus}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
|
||||
export const FileWidget = (props: WidgetProps) => {
|
||||
const { onChange, multiple = false, disabled, readonly, id } = props;
|
||||
|
||||
// TODO: It's temporary solution for file input, will complete it follow up prs
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
onChange(e.target?.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
disabled={disabled || readonly}
|
||||
onChange={handleChange}
|
||||
className="rounded-full"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { InputType, mapJsonSchemaTypeToInputType } from "../helpers";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
import {
|
||||
MultiSelector,
|
||||
MultiSelectorContent,
|
||||
MultiSelectorInput,
|
||||
MultiSelectorItem,
|
||||
MultiSelectorList,
|
||||
MultiSelectorTrigger,
|
||||
} from "@/components/__legacy__/ui/multiselect";
|
||||
|
||||
export const SelectWidget = (props: WidgetProps) => {
|
||||
const { options, value, onChange, disabled, readonly, id } = props;
|
||||
const enumOptions = options.enumOptions || [];
|
||||
const type = mapJsonSchemaTypeToInputType(props.schema);
|
||||
|
||||
const renderInput = () => {
|
||||
if (type === InputType.MULTI_SELECT) {
|
||||
return (
|
||||
<MultiSelector
|
||||
values={Array.isArray(value) ? value : []}
|
||||
onValuesChange={onChange}
|
||||
className="w-full"
|
||||
>
|
||||
<MultiSelectorTrigger>
|
||||
<MultiSelectorInput placeholder="Select options..." />
|
||||
</MultiSelectorTrigger>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorList>
|
||||
{enumOptions?.map((option) => (
|
||||
<MultiSelectorItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MultiSelectorItem>
|
||||
))}
|
||||
</MultiSelectorList>
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
label=""
|
||||
id={id}
|
||||
hideLabel={true}
|
||||
disabled={disabled || readonly}
|
||||
size="small"
|
||||
value={value ?? ""}
|
||||
onValueChange={onChange}
|
||||
options={
|
||||
enumOptions?.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})) || []
|
||||
}
|
||||
wrapperClassName="!mb-0 "
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return renderInput();
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||
|
||||
export function SwitchWidget(props: WidgetProps) {
|
||||
const { value = false, onChange, disabled, readonly, autofocus, id } = props;
|
||||
return (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
disabled={disabled || readonly}
|
||||
autoFocus={autofocus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { InputType, mapJsonSchemaTypeToInputType } from "../helpers";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
|
||||
export const TextInputWidget = (props: WidgetProps) => {
|
||||
const { schema } = props;
|
||||
const mapped = mapJsonSchemaTypeToInputType(schema);
|
||||
|
||||
type InputConfig = {
|
||||
htmlType: string;
|
||||
placeholder: string;
|
||||
handleChange: (v: string) => any;
|
||||
};
|
||||
|
||||
const inputConfig: Partial<Record<InputType, InputConfig>> = {
|
||||
[InputType.TEXT_AREA]: {
|
||||
htmlType: "textarea",
|
||||
placeholder: "Enter text...",
|
||||
handleChange: (v: string) => (v === "" ? undefined : v),
|
||||
},
|
||||
[InputType.PASSWORD]: {
|
||||
htmlType: "password",
|
||||
placeholder: "Enter secret text...",
|
||||
handleChange: (v: string) => (v === "" ? undefined : v),
|
||||
},
|
||||
[InputType.NUMBER]: {
|
||||
htmlType: "number",
|
||||
placeholder: "Enter number value...",
|
||||
handleChange: (v: string) => (v === "" ? undefined : Number(v)),
|
||||
},
|
||||
[InputType.INTEGER]: {
|
||||
htmlType: "account",
|
||||
placeholder: "Enter integer value...",
|
||||
handleChange: (v: string) => (v === "" ? undefined : Number(v)),
|
||||
},
|
||||
};
|
||||
|
||||
const defaultConfig: InputConfig = {
|
||||
htmlType: "text",
|
||||
placeholder: "Enter string value...",
|
||||
handleChange: (v: string) => (v === "" ? undefined : v),
|
||||
};
|
||||
|
||||
const config = (mapped && inputConfig[mapped]) || defaultConfig;
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
const v = e.target.value;
|
||||
return props.onChange(config.handleChange(v));
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={props.id}
|
||||
hideLabel={true}
|
||||
type={config.htmlType as any}
|
||||
label={""}
|
||||
size="small"
|
||||
wrapperClassName="mb-0"
|
||||
value={props.value ?? ""}
|
||||
onChange={handleChange as any}
|
||||
placeholder={schema.placeholder || config.placeholder}
|
||||
required={props.required}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { TimeInput } from "@/components/atoms/TimeInput/TimeInput";
|
||||
|
||||
export const TimeInputWidget = (props: WidgetProps) => {
|
||||
const { value, onChange, disabled, readonly, placeholder, id } = props;
|
||||
return (
|
||||
<TimeInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
label={""}
|
||||
id={id}
|
||||
hideLabel={true}
|
||||
size="small"
|
||||
wrapperClassName="!mb-0 "
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { RegistryWidgetsType } from "@rjsf/utils";
|
||||
import { SelectWidget } from "./SelectWidget";
|
||||
import { TextInputWidget } from "./TextInputWidget";
|
||||
import { SwitchWidget } from "./SwitchWidget";
|
||||
import { FileWidget } from "./FileWidget";
|
||||
import { DateInputWidget } from "./DateInputWidget";
|
||||
import { TimeInputWidget } from "./TimeInputWidget";
|
||||
import { DateTimeInputWidget } from "./DateTimeInputWidget";
|
||||
|
||||
export const widgets: RegistryWidgetsType = {
|
||||
TextWidget: TextInputWidget,
|
||||
SelectWidget: SelectWidget,
|
||||
CheckboxWidget: SwitchWidget,
|
||||
FileWidget: FileWidget,
|
||||
DateWidget: DateInputWidget,
|
||||
TimeWidget: TimeInputWidget,
|
||||
DateTimeWidget: DateTimeInputWidget,
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
|
||||
/**
|
||||
* Pre-processes the input schema to ensure all properties have a type defined.
|
||||
* If a property doesn't have a type, it assigns a union of all supported JSON Schema types.
|
||||
*/
|
||||
export function preprocessInputSchema(schema: RJSFSchema): RJSFSchema {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const processedSchema = { ...schema };
|
||||
|
||||
// Recursively process properties
|
||||
if (processedSchema.properties) {
|
||||
processedSchema.properties = { ...processedSchema.properties };
|
||||
|
||||
for (const [key, property] of Object.entries(processedSchema.properties)) {
|
||||
if (property && typeof property === "object") {
|
||||
const processedProperty = { ...property };
|
||||
|
||||
// Only add type if no type is defined AND no anyOf/oneOf/allOf is present
|
||||
if (
|
||||
!processedProperty.type &&
|
||||
!processedProperty.anyOf &&
|
||||
!processedProperty.oneOf &&
|
||||
!processedProperty.allOf
|
||||
) {
|
||||
processedProperty.anyOf = [
|
||||
{ type: "string" },
|
||||
{ type: "number" },
|
||||
{ type: "integer" },
|
||||
{ type: "boolean" },
|
||||
{ type: "array", items: { type: "string" } },
|
||||
{ type: "object" },
|
||||
{ type: "null" },
|
||||
];
|
||||
}
|
||||
|
||||
// when encountering an array with items missing type
|
||||
if (processedProperty.type === "array" && processedProperty.items) {
|
||||
const items = processedProperty.items as RJSFSchema;
|
||||
if (!items.type && !items.anyOf && !items.oneOf && !items.allOf) {
|
||||
processedProperty.items = {
|
||||
type: "string",
|
||||
title: items.title ?? "",
|
||||
};
|
||||
} else {
|
||||
processedProperty.items = preprocessInputSchema(items);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process nested objects
|
||||
if (
|
||||
processedProperty.type === "object" ||
|
||||
(Array.isArray(processedProperty.type) &&
|
||||
processedProperty.type.includes("object"))
|
||||
) {
|
||||
processedProperty.properties = processProperties(
|
||||
processedProperty.properties,
|
||||
);
|
||||
}
|
||||
|
||||
// Process array items
|
||||
if (
|
||||
processedProperty.type === "array" ||
|
||||
(Array.isArray(processedProperty.type) &&
|
||||
processedProperty.type.includes("array"))
|
||||
) {
|
||||
if (processedProperty.items) {
|
||||
processedProperty.items = preprocessInputSchema(
|
||||
processedProperty.items as RJSFSchema,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
processedSchema.properties[key] = processedProperty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process array items at root level
|
||||
if (processedSchema.items) {
|
||||
processedSchema.items = preprocessInputSchema(
|
||||
processedSchema.items as RJSFSchema,
|
||||
);
|
||||
}
|
||||
|
||||
processedSchema.title = ""; // Otherwise our form creator will show the title of the schema in the input field
|
||||
processedSchema.description = ""; // Otherwise our form creator will show the description of the schema in the input field
|
||||
|
||||
return processedSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to process properties object
|
||||
*/
|
||||
function processProperties(properties: any): any {
|
||||
if (!properties || typeof properties !== "object") {
|
||||
return properties;
|
||||
}
|
||||
|
||||
const processedProperties = { ...properties };
|
||||
|
||||
for (const [key, property] of Object.entries(processedProperties)) {
|
||||
if (property && typeof property === "object") {
|
||||
processedProperties[key] = preprocessInputSchema(property as RJSFSchema);
|
||||
}
|
||||
}
|
||||
|
||||
return processedProperties;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { beautifyString } from "@/lib/utils";
|
||||
import { useAllBlockContent } from "./useAllBlockContent";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { blockMenuContainerStyle } from "../style";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
|
||||
export const AllBlocksContent = () => {
|
||||
const {
|
||||
@@ -18,6 +19,8 @@ export const AllBlocksContent = () => {
|
||||
isErrorOnLoadingMore,
|
||||
} = useAllBlockContent();
|
||||
|
||||
const addBlock = useNodeStore((state) => state.addBlock);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={blockMenuContainerStyle}>
|
||||
@@ -71,6 +74,7 @@ export const AllBlocksContent = () => {
|
||||
key={`${category.name}-${block.id}`}
|
||||
title={block.name as string}
|
||||
description={block.name as string}
|
||||
onClick={() => addBlock(block)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import React from "react";
|
||||
import { Block } from "../Block";
|
||||
import { blockMenuContainerStyle } from "../style";
|
||||
|
||||
export interface BlockType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
type?: string;
|
||||
provider?: string;
|
||||
}
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||
|
||||
interface BlocksListProps {
|
||||
blocks: BlockType[];
|
||||
blocks: BlockInfo[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,6 +13,7 @@ export const BlocksList: React.FC<BlocksListProps> = ({
|
||||
blocks,
|
||||
loading = false,
|
||||
}) => {
|
||||
const { addBlock } = useNodeStore();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={blockMenuContainerStyle}>
|
||||
@@ -30,6 +24,11 @@ export const BlocksList: React.FC<BlocksListProps> = ({
|
||||
);
|
||||
}
|
||||
return blocks.map((block) => (
|
||||
<Block key={block.id} title={block.name} description={block.description} />
|
||||
<Block
|
||||
key={block.id}
|
||||
title={block.name}
|
||||
description={block.description}
|
||||
onClick={() => addBlock(block)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import { BlockMenuStateProvider } from "../block-menu-provider";
|
||||
import { LegoIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface BlockMenuProps {
|
||||
pinBlocksPopover: boolean;
|
||||
// pinBlocksPopover: boolean;
|
||||
blockMenuSelected: "save" | "block" | "search" | "";
|
||||
setBlockMenuSelected: React.Dispatch<
|
||||
React.SetStateAction<"" | "save" | "block" | "search">
|
||||
@@ -19,16 +19,17 @@ interface BlockMenuProps {
|
||||
}
|
||||
|
||||
export const BlockMenu: React.FC<BlockMenuProps> = ({
|
||||
pinBlocksPopover,
|
||||
// pinBlocksPopover,
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
}) => {
|
||||
const { open, onOpen } = useBlockMenu({
|
||||
pinBlocksPopover,
|
||||
const { open: _open, onOpen } = useBlockMenu({
|
||||
// pinBlocksPopover,
|
||||
setBlockMenuSelected,
|
||||
});
|
||||
return (
|
||||
<Popover open={pinBlocksPopover ? true : open} onOpenChange={onOpen}>
|
||||
// pinBlocksPopover ? true : open
|
||||
<Popover onOpenChange={onOpen}>
|
||||
<PopoverTrigger className="hover:cursor-pointer">
|
||||
<ControlPanelButton
|
||||
data-id="blocks-control-popover-trigger"
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface useBlockMenuProps {
|
||||
pinBlocksPopover: boolean;
|
||||
// pinBlocksPopover: boolean;
|
||||
setBlockMenuSelected: React.Dispatch<
|
||||
React.SetStateAction<"" | "save" | "block" | "search">
|
||||
>;
|
||||
}
|
||||
|
||||
export const useBlockMenu = ({
|
||||
pinBlocksPopover,
|
||||
// pinBlocksPopover,
|
||||
setBlockMenuSelected,
|
||||
}: useBlockMenuProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const onOpen = (newOpen: boolean) => {
|
||||
if (!pinBlocksPopover) {
|
||||
setOpen(newOpen);
|
||||
setBlockMenuSelected(newOpen ? "block" : "");
|
||||
}
|
||||
// if (!pinBlocksPopover) {
|
||||
setOpen(newOpen);
|
||||
setBlockMenuSelected(newOpen ? "block" : "");
|
||||
// }
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { useIntegrationBlocks } from "./useIntegrationBlocks";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
|
||||
export const IntegrationBlocks = () => {
|
||||
const { integration, setIntegration } = useBlockMenuContext();
|
||||
@@ -20,6 +21,7 @@ export const IntegrationBlocks = () => {
|
||||
error,
|
||||
refetch,
|
||||
} = useIntegrationBlocks();
|
||||
const addBlock = useNodeStore((state) => state.addBlock);
|
||||
|
||||
if (blocksLoading) {
|
||||
return (
|
||||
@@ -92,6 +94,7 @@ export const IntegrationBlocks = () => {
|
||||
title={block.name}
|
||||
description={block.description}
|
||||
icon_url={`/integrations/${integration}.png`}
|
||||
onClick={() => addBlock(block)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
// import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React, { useMemo } from "react";
|
||||
import { BlockMenu } from "../BlockMenu/BlockMenu";
|
||||
import { useNewControlPanel } from "./useNewControlPanel";
|
||||
import { NewSaveControl } from "../SaveControl/NewSaveControl";
|
||||
// import { NewSaveControl } from "../SaveControl/NewSaveControl";
|
||||
import { GraphExecutionID } from "@/lib/autogpt-server-api";
|
||||
import { history } from "@/app/(platform)/build/components/legacy-builder/history";
|
||||
import { ControlPanelButton } from "../ControlPanelButton";
|
||||
// import { ControlPanelButton } from "../ControlPanelButton";
|
||||
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
|
||||
import { GraphSearchMenu } from "../GraphMenu/GraphMenu";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
// import { GraphSearchMenu } from "../GraphMenu/GraphMenu";
|
||||
import { CustomNode } from "../../FlowEditor/nodes/CustomNode";
|
||||
import { history } from "@/app/(platform)/build/components/legacy-builder/history";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
export type Control = {
|
||||
@@ -19,44 +19,41 @@ export type Control = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
interface ControlPanelProps {
|
||||
className?: string;
|
||||
flowExecutionID: GraphExecutionID | undefined;
|
||||
visualizeBeads: "no" | "static" | "animate";
|
||||
pinSavePopover: boolean;
|
||||
pinBlocksPopover: boolean;
|
||||
nodes: CustomNode[];
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
onNodeHover?: (nodeId: string | null) => void;
|
||||
}
|
||||
|
||||
export type NewControlPanelProps = {
|
||||
flowExecutionID?: GraphExecutionID | undefined;
|
||||
visualizeBeads?: "no" | "static" | "animate";
|
||||
pinSavePopover?: boolean;
|
||||
pinBlocksPopover?: boolean;
|
||||
nodes?: CustomNode[];
|
||||
onNodeSelect?: (nodeId: string) => void;
|
||||
onNodeHover?: (nodeId: string) => void;
|
||||
};
|
||||
export const NewControlPanel = ({
|
||||
flowExecutionID,
|
||||
visualizeBeads,
|
||||
pinSavePopover,
|
||||
pinBlocksPopover,
|
||||
nodes,
|
||||
onNodeSelect,
|
||||
onNodeHover,
|
||||
className,
|
||||
}: ControlPanelProps) => {
|
||||
const isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
|
||||
flowExecutionID: _flowExecutionID,
|
||||
visualizeBeads: _visualizeBeads,
|
||||
pinSavePopover: _pinSavePopover,
|
||||
pinBlocksPopover: _pinBlocksPopover,
|
||||
nodes: _nodes,
|
||||
onNodeSelect: _onNodeSelect,
|
||||
onNodeHover: _onNodeHover,
|
||||
}: NewControlPanelProps) => {
|
||||
const _isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
|
||||
|
||||
const {
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
agentDescription,
|
||||
setAgentDescription,
|
||||
saveAgent,
|
||||
agentName,
|
||||
setAgentName,
|
||||
savedAgent,
|
||||
isSaving,
|
||||
isRunning,
|
||||
isStopping,
|
||||
} = useNewControlPanel({ flowExecutionID, visualizeBeads });
|
||||
// agentDescription,
|
||||
// setAgentDescription,
|
||||
// saveAgent,
|
||||
// agentName,
|
||||
// setAgentName,
|
||||
// savedAgent,
|
||||
// isSaving,
|
||||
// isRunning,
|
||||
// isStopping,
|
||||
} = useNewControlPanel({});
|
||||
|
||||
const controls: Control[] = useMemo(
|
||||
const _controls: Control[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "Undo",
|
||||
@@ -77,17 +74,16 @@ export const NewControlPanel = ({
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"absolute left-4 top-24 z-10 w-[4.25rem] overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
|
||||
className,
|
||||
"top- absolute left-4 z-10 w-[4.25rem] overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
|
||||
<BlockMenu
|
||||
pinBlocksPopover={pinBlocksPopover}
|
||||
// pinBlocksPopover={pinBlocksPopover}
|
||||
blockMenuSelected={blockMenuSelected}
|
||||
setBlockMenuSelected={setBlockMenuSelected}
|
||||
/>
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
{/* <Separator className="text-[#E1E1E1]" />
|
||||
{isGraphSearchEnabled && (
|
||||
<>
|
||||
<GraphSearchMenu
|
||||
@@ -124,7 +120,7 @@ export const NewControlPanel = ({
|
||||
pinSavePopover={pinSavePopover}
|
||||
blockMenuSelected={blockMenuSelected}
|
||||
setBlockMenuSelected={setBlockMenuSelected}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,53 +1,54 @@
|
||||
import useAgentGraph from "@/hooks/useAgentGraph";
|
||||
import { GraphExecutionID, GraphID } from "@/lib/autogpt-server-api";
|
||||
import { GraphID } from "@/lib/autogpt-server-api";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface NewControlPanelProps {
|
||||
flowExecutionID: GraphExecutionID | undefined;
|
||||
visualizeBeads: "no" | "static" | "animate";
|
||||
// flowExecutionID: GraphExecutionID | undefined;
|
||||
visualizeBeads?: "no" | "static" | "animate";
|
||||
}
|
||||
|
||||
export const useNewControlPanel = ({
|
||||
flowExecutionID,
|
||||
visualizeBeads,
|
||||
// flowExecutionID,
|
||||
visualizeBeads: _visualizeBeads,
|
||||
}: NewControlPanelProps) => {
|
||||
const [blockMenuSelected, setBlockMenuSelected] = useState<
|
||||
"save" | "block" | "search" | ""
|
||||
>("");
|
||||
const query = useSearchParams();
|
||||
const _graphVersion = query.get("flowVersion");
|
||||
const graphVersion = _graphVersion ? parseInt(_graphVersion) : undefined;
|
||||
const _graphVersionParsed = _graphVersion
|
||||
? parseInt(_graphVersion)
|
||||
: undefined;
|
||||
|
||||
const flowID = (query.get("flowID") as GraphID | null) ?? undefined;
|
||||
const {
|
||||
agentDescription,
|
||||
setAgentDescription,
|
||||
saveAgent,
|
||||
agentName,
|
||||
setAgentName,
|
||||
savedAgent,
|
||||
isSaving,
|
||||
isRunning,
|
||||
isStopping,
|
||||
} = useAgentGraph(
|
||||
flowID,
|
||||
graphVersion,
|
||||
flowExecutionID,
|
||||
visualizeBeads !== "no",
|
||||
);
|
||||
const _flowID = (query.get("flowID") as GraphID | null) ?? undefined;
|
||||
// const {
|
||||
// agentDescription,
|
||||
// setAgentDescription,
|
||||
// saveAgent,
|
||||
// agentName,
|
||||
// setAgentName,
|
||||
// savedAgent,
|
||||
// isSaving,
|
||||
// isRunning,
|
||||
// isStopping,
|
||||
// } = useAgentGraph(
|
||||
// flowID,
|
||||
// graphVersion,
|
||||
// flowExecutionID,
|
||||
// visualizeBeads !== "no",
|
||||
// );
|
||||
|
||||
return {
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
agentDescription,
|
||||
setAgentDescription,
|
||||
saveAgent,
|
||||
agentName,
|
||||
setAgentName,
|
||||
savedAgent,
|
||||
isSaving,
|
||||
isRunning,
|
||||
isStopping,
|
||||
// agentDescription,
|
||||
// setAgentDescription,
|
||||
// saveAgent,
|
||||
// agentName,
|
||||
// setAgentName,
|
||||
// savedAgent,
|
||||
// isSaving,
|
||||
// isRunning,
|
||||
// isStopping,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,10 +5,12 @@ import { DefaultStateType, useBlockMenuContext } from "../block-menu-provider";
|
||||
import { useSuggestionContent } from "./useSuggestionContent";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { blockMenuContainerStyle } from "../style";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
|
||||
export const SuggestionContent = () => {
|
||||
const { setIntegration, setDefaultState } = useBlockMenuContext();
|
||||
const { data, isLoading, isError, error, refetch } = useSuggestionContent();
|
||||
const addBlock = useNodeStore((state) => state.addBlock);
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
@@ -73,6 +75,7 @@ export const SuggestionContent = () => {
|
||||
key={`block-${index}`}
|
||||
title={block.name}
|
||||
description={block.description}
|
||||
onClick={() => addBlock(block)}
|
||||
/>
|
||||
))
|
||||
: Array(3)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Link } from "@/app/api/__generated__/models/link";
|
||||
import { useEdgeStore } from "../stores/edgeStore";
|
||||
import { useNodeStore } from "../stores/nodeStore";
|
||||
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const RightSidebar = () => {
|
||||
const connections = useEdgeStore((s) => s.connections);
|
||||
const nodes = useNodeStore((s) => s.nodes);
|
||||
|
||||
const backendLinks: Link[] = useMemo(
|
||||
() =>
|
||||
connections.map((c) => ({
|
||||
source_id: c.source,
|
||||
sink_id: c.target,
|
||||
source_name: c.sourceHandle,
|
||||
sink_name: c.targetHandle,
|
||||
})),
|
||||
[connections],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col border-l border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900",
|
||||
scrollbarStyles,
|
||||
)}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200">
|
||||
Flow Debug Panel
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
Nodes ({nodes.length})
|
||||
</h3>
|
||||
<div className="mb-6 space-y-3">
|
||||
{nodes.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className="rounded border p-2 text-xs dark:border-slate-700"
|
||||
>
|
||||
<div className="mb-1 font-medium">
|
||||
#{n.id} {n.data?.title ? `– ${n.data.title}` : ""}
|
||||
</div>
|
||||
<div className="text-slate-500 dark:text-slate-400">
|
||||
hardcodedValues
|
||||
</div>
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded bg-slate-50 p-2 dark:bg-slate-800">
|
||||
{JSON.stringify(n.data?.hardcodedValues ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
Links ({backendLinks.length})
|
||||
</h3>
|
||||
<div className="mb-6 space-y-3">
|
||||
{connections.map((c) => (
|
||||
<div
|
||||
key={c.edge_id}
|
||||
className="rounded border p-2 text-xs dark:border-slate-700"
|
||||
>
|
||||
<div className="font-medium">
|
||||
{c.source}[{c.sourceHandle}] → {c.target}[{c.targetHandle}]
|
||||
</div>
|
||||
<div className="mt-1 text-slate-500 dark:text-slate-400">
|
||||
edge_id: {c.edge_id}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h4 className="mb-2 text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||||
Backend Links JSON
|
||||
</h4>
|
||||
<pre className="max-h-64 overflow-auto rounded bg-slate-50 p-2 text-[11px] dark:bg-slate-800">
|
||||
{JSON.stringify(backendLinks, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||
import { CustomNodeData } from "./FlowEditor/nodes/CustomNode";
|
||||
|
||||
export const convertBlockInfoIntoCustomNodeData = (block: BlockInfo) => {
|
||||
const customNodeData: CustomNodeData = {
|
||||
hardcodedValues: {},
|
||||
title: block.name,
|
||||
description: block.description,
|
||||
inputSchema: block.inputSchema,
|
||||
outputSchema: block.outputSchema,
|
||||
};
|
||||
return customNodeData;
|
||||
};
|
||||
@@ -858,7 +858,7 @@ const FlowEditor: React.FC<{
|
||||
visualizeBeads={visualizeBeads}
|
||||
pinSavePopover={pinSavePopover}
|
||||
pinBlocksPopover={pinBlocksPopover}
|
||||
nodes={nodes}
|
||||
// nodes={nodes}
|
||||
onNodeSelect={navigateToNode}
|
||||
onNodeHover={highlightNode}
|
||||
/>
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
|
||||
import FlowEditor from "@/app/(platform)/build/components/legacy-builder/Flow/Flow";
|
||||
import LoadingBox from "@/components/__legacy__/ui/loading";
|
||||
// import LoadingBox from "@/components/__legacy__/ui/loading";
|
||||
import { GraphID } from "@/lib/autogpt-server-api/types";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Flow } from "./components/FlowEditor/Flow";
|
||||
import { BuilderViewTabs } from "./components/BuilderViewTabs/BuilderViewTabs";
|
||||
import { useBuilderView } from "./components/BuilderViewTabs/useBuilderViewTabs";
|
||||
|
||||
function BuilderContent() {
|
||||
const query = useSearchParams();
|
||||
@@ -19,7 +22,7 @@ function BuilderContent() {
|
||||
const graphVersion = _graphVersion ? parseInt(_graphVersion) : undefined;
|
||||
return (
|
||||
<FlowEditor
|
||||
className="flow-container"
|
||||
className="flex h-full w-full"
|
||||
flowID={(query.get("flowID") as GraphID | null) ?? undefined}
|
||||
flowVersion={graphVersion}
|
||||
/>
|
||||
@@ -27,9 +30,22 @@ function BuilderContent() {
|
||||
}
|
||||
|
||||
export default function BuilderPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingBox className="h-[80vh]" />}>
|
||||
<BuilderContent />
|
||||
</Suspense>
|
||||
);
|
||||
const {
|
||||
isSwitchEnabled,
|
||||
selectedView,
|
||||
setSelectedView,
|
||||
isNewFlowEditorEnabled,
|
||||
} = useBuilderView();
|
||||
|
||||
// Switch is temporary, we will remove it once our new flow editor is ready
|
||||
if (isSwitchEnabled) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<BuilderViewTabs value={selectedView} onChange={setSelectedView} />
|
||||
{selectedView === "new" ? <Flow /> : <BuilderContent />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isNewFlowEditorEnabled ? <Flow /> : <BuilderContent />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { create } from "zustand";
|
||||
import { convertConnectionsToBackendLinks } from "../components/FlowEditor/edges/helpers";
|
||||
|
||||
export type Connection = {
|
||||
edge_id: string;
|
||||
source: string;
|
||||
sourceHandle: string;
|
||||
target: string;
|
||||
targetHandle: string;
|
||||
};
|
||||
|
||||
type EdgeStore = {
|
||||
connections: Connection[];
|
||||
|
||||
setConnections: (connections: Connection[]) => void;
|
||||
addConnection: (
|
||||
conn: Omit<Connection, "edge_id"> & { edge_id?: string },
|
||||
) => Connection;
|
||||
removeConnection: (edge_id: string) => void;
|
||||
upsertMany: (conns: Connection[]) => void;
|
||||
|
||||
getNodeConnections: (nodeId: string) => Connection[];
|
||||
isInputConnected: (nodeId: string, handle: string) => boolean;
|
||||
isOutputConnected: (nodeId: string, handle: string) => boolean;
|
||||
};
|
||||
|
||||
function makeEdgeId(conn: Omit<Connection, "edge_id">) {
|
||||
return `${conn.source}:${conn.sourceHandle}->${conn.target}:${conn.targetHandle}`;
|
||||
}
|
||||
|
||||
export const useEdgeStore = create<EdgeStore>((set, get) => ({
|
||||
connections: [],
|
||||
|
||||
setConnections: (connections) => set({ connections }),
|
||||
|
||||
addConnection: (conn) => {
|
||||
const edge_id = conn.edge_id || makeEdgeId(conn);
|
||||
const newConn: Connection = { edge_id, ...conn };
|
||||
|
||||
set((state) => {
|
||||
const exists = state.connections.some(
|
||||
(c) =>
|
||||
c.source === newConn.source &&
|
||||
c.target === newConn.target &&
|
||||
c.sourceHandle === newConn.sourceHandle &&
|
||||
c.targetHandle === newConn.targetHandle,
|
||||
);
|
||||
if (exists) return state;
|
||||
return { connections: [...state.connections, newConn] };
|
||||
});
|
||||
|
||||
return { edge_id, ...conn };
|
||||
},
|
||||
|
||||
removeConnection: (edge_id) =>
|
||||
set((state) => ({
|
||||
connections: state.connections.filter((c) => c.edge_id !== edge_id),
|
||||
})),
|
||||
|
||||
upsertMany: (conns) =>
|
||||
set((state) => {
|
||||
const byKey = new Map(state.connections.map((c) => [c.edge_id, c]));
|
||||
conns.forEach((c) => {
|
||||
byKey.set(c.edge_id, c);
|
||||
});
|
||||
return { connections: Array.from(byKey.values()) };
|
||||
}),
|
||||
|
||||
getNodeConnections: (nodeId) =>
|
||||
get().connections.filter((c) => c.source === nodeId || c.target === nodeId),
|
||||
|
||||
isInputConnected: (nodeId, handle) =>
|
||||
get().connections.some(
|
||||
(c) => c.target === nodeId && c.targetHandle === handle,
|
||||
),
|
||||
|
||||
isOutputConnected: (nodeId, handle) =>
|
||||
get().connections.some(
|
||||
(c) => c.source === nodeId && c.sourceHandle === handle,
|
||||
),
|
||||
getBackendLinks: () => convertConnectionsToBackendLinks(get().connections),
|
||||
}));
|
||||
@@ -0,0 +1,75 @@
|
||||
import { create } from "zustand";
|
||||
import { NodeChange, applyNodeChanges } from "@xyflow/react";
|
||||
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode";
|
||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||
import { convertBlockInfoIntoCustomNodeData } from "../components/helper";
|
||||
|
||||
type NodeStore = {
|
||||
nodes: CustomNode[];
|
||||
nodeCounter: number;
|
||||
nodeAdvancedStates: Record<string, boolean>;
|
||||
setNodes: (nodes: CustomNode[]) => void;
|
||||
onNodesChange: (changes: NodeChange<CustomNode>[]) => void;
|
||||
addNode: (node: CustomNode) => void;
|
||||
addBlock: (block: BlockInfo) => void;
|
||||
incrementNodeCounter: () => void;
|
||||
updateNodeData: (nodeId: string, data: Partial<CustomNode["data"]>) => void;
|
||||
toggleAdvanced: (nodeId: string) => void;
|
||||
setShowAdvanced: (nodeId: string, show: boolean) => void;
|
||||
getShowAdvanced: (nodeId: string) => boolean;
|
||||
};
|
||||
|
||||
export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
nodes: [],
|
||||
setNodes: (nodes) => set({ nodes }),
|
||||
nodeCounter: 0,
|
||||
nodeAdvancedStates: {},
|
||||
incrementNodeCounter: () =>
|
||||
set((state) => ({
|
||||
nodeCounter: state.nodeCounter + 1,
|
||||
})),
|
||||
onNodesChange: (changes) =>
|
||||
set((state) => ({
|
||||
nodes: applyNodeChanges(changes, state.nodes),
|
||||
})),
|
||||
addNode: (node) =>
|
||||
set((state) => ({
|
||||
nodes: [...state.nodes, node],
|
||||
})),
|
||||
addBlock: (block: BlockInfo) => {
|
||||
const customNodeData = convertBlockInfoIntoCustomNodeData(block);
|
||||
get().incrementNodeCounter();
|
||||
const nodeNumber = get().nodeCounter;
|
||||
const customNode: CustomNode = {
|
||||
id: nodeNumber.toString(),
|
||||
data: customNodeData,
|
||||
type: "custom",
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
set((state) => ({
|
||||
nodes: [...state.nodes, customNode],
|
||||
}));
|
||||
},
|
||||
updateNodeData: (nodeId, data) =>
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n,
|
||||
),
|
||||
})),
|
||||
toggleAdvanced: (nodeId: string) =>
|
||||
set((state) => ({
|
||||
nodeAdvancedStates: {
|
||||
...state.nodeAdvancedStates,
|
||||
[nodeId]: !state.nodeAdvancedStates[nodeId],
|
||||
},
|
||||
})),
|
||||
setShowAdvanced: (nodeId: string, show: boolean) =>
|
||||
set((state) => ({
|
||||
nodeAdvancedStates: {
|
||||
...state.nodeAdvancedStates,
|
||||
[nodeId]: show,
|
||||
},
|
||||
})),
|
||||
getShowAdvanced: (nodeId: string) =>
|
||||
get().nodeAdvancedStates[nodeId] || false,
|
||||
}));
|
||||
@@ -3,9 +3,9 @@ import { ReactNode } from "react";
|
||||
|
||||
export default function PlatformLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<main className="flex h-screen w-screen flex-col">
|
||||
<Navbar />
|
||||
<main>{children}</main>
|
||||
</>
|
||||
<section className="flex-1">{children}</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Calendar } from "@/components/__legacy__/ui/calendar";
|
||||
|
||||
function toLocalISODateString(d: Date) {
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function parseISODateString(s?: string): Date | undefined {
|
||||
if (!s) return undefined;
|
||||
// Expecting "YYYY-MM-DD"
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||
if (!m) return undefined;
|
||||
const [_, y, mo, d] = m;
|
||||
const date = new Date(Number(y), Number(mo) - 1, Number(d));
|
||||
return isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
export interface DateInputProps {
|
||||
value?: string;
|
||||
onChange?: (value?: string) => void;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
hideLabel?: boolean;
|
||||
error?: string;
|
||||
id?: string;
|
||||
size?: "default" | "small";
|
||||
}
|
||||
|
||||
export const DateInput = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
readonly,
|
||||
placeholder,
|
||||
autoFocus,
|
||||
className,
|
||||
label,
|
||||
hideLabel = false,
|
||||
error,
|
||||
id,
|
||||
size = "default",
|
||||
}: DateInputProps) => {
|
||||
const selected = React.useMemo(() => parseISODateString(value), [value]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const setDate = (d?: Date) => {
|
||||
onChange?.(d ? toLocalISODateString(d) : undefined);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const buttonText =
|
||||
selected?.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}) ||
|
||||
placeholder ||
|
||||
"Pick a date";
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
|
||||
const triggerStyles = cn(
|
||||
// Base styles matching other form components
|
||||
"rounded-3xl border border-zinc-200 bg-white px-4 shadow-none",
|
||||
"font-normal text-black w-full text-sm",
|
||||
"placeholder:font-normal !placeholder:text-zinc-400",
|
||||
// Focus and hover states
|
||||
"focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0",
|
||||
// Error state
|
||||
error &&
|
||||
"border-1.5 border-red-500 focus:border-red-500 focus:ring-red-500",
|
||||
// Placeholder styling
|
||||
!selected && "text-zinc-400",
|
||||
"justify-start text-left",
|
||||
// Size variants
|
||||
size === "default" && "h-[2.875rem] py-2.5",
|
||||
className,
|
||||
size === "small" && [
|
||||
"min-h-[2.25rem]", // 36px minimum
|
||||
"py-2",
|
||||
"text-sm leading-[22px]",
|
||||
"placeholder:text-sm placeholder:leading-[22px]",
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && !hideLabel && (
|
||||
<label htmlFor={id} className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={triggerStyles}
|
||||
disabled={isDisabled}
|
||||
autoFocus={autoFocus}
|
||||
id={id}
|
||||
{...(hideLabel && label ? { "aria-label": label } : {})}
|
||||
>
|
||||
<CalendarIcon
|
||||
className={cn("mr-2", size === "default" ? "h-4 w-4" : "h-3 w-3")}
|
||||
/>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" sideOffset={6}>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={setDate}
|
||||
showOutsideDays
|
||||
// Prevent selection when disabled/readonly
|
||||
modifiersClassNames={{
|
||||
disabled: "pointer-events-none opacity-50",
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{error && <span className="text-sm text-red-500">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Calendar as CalendarIcon, Clock } from "lucide-react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Text } from "../Text/Text";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Calendar } from "@/components/__legacy__/ui/calendar";
|
||||
|
||||
function toLocalISODateTimeString(d: Date) {
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const minutes = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function parseISODateTimeString(s?: string): Date | undefined {
|
||||
if (!s) return undefined;
|
||||
// Expecting "YYYY-MM-DDTHH:MM" or "YYYY-MM-DD HH:MM"
|
||||
const normalized = s.replace(" ", "T");
|
||||
const date = new Date(normalized);
|
||||
return isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
export interface DateTimeInputProps {
|
||||
value?: string;
|
||||
onChange?: (value?: string) => void;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
hideLabel?: boolean;
|
||||
error?: string;
|
||||
hint?: React.ReactNode;
|
||||
id?: string;
|
||||
size?: "default" | "small";
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
export const DateTimeInput = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
placeholder,
|
||||
autoFocus,
|
||||
className,
|
||||
label,
|
||||
hideLabel = false,
|
||||
error,
|
||||
hint,
|
||||
id,
|
||||
size = "default",
|
||||
wrapperClassName,
|
||||
}: DateTimeInputProps) => {
|
||||
const selected = React.useMemo(() => parseISODateTimeString(value), [value]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [timeValue, setTimeValue] = React.useState("");
|
||||
|
||||
// Update time value when selected date changes
|
||||
React.useEffect(() => {
|
||||
if (selected) {
|
||||
const hours = String(selected.getHours()).padStart(2, "0");
|
||||
const minutes = String(selected.getMinutes()).padStart(2, "0");
|
||||
setTimeValue(`${hours}:${minutes}`);
|
||||
} else {
|
||||
setTimeValue("");
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
const setDate = (d?: Date) => {
|
||||
if (!d) {
|
||||
onChange?.(undefined);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a time value, apply it to the selected date
|
||||
if (timeValue) {
|
||||
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||
if (!isNaN(hours) && !isNaN(minutes)) {
|
||||
d.setHours(hours, minutes, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
onChange?.(toLocalISODateTimeString(d));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleTimeChange = (time: string) => {
|
||||
setTimeValue(time);
|
||||
|
||||
if (selected && time) {
|
||||
const [hours, minutes] = time.split(":").map(Number);
|
||||
if (!isNaN(hours) && !isNaN(minutes)) {
|
||||
const newDate = new Date(selected);
|
||||
newDate.setHours(hours, minutes, 0, 0);
|
||||
onChange?.(toLocalISODateTimeString(newDate));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buttonText = selected
|
||||
? selected.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}) +
|
||||
" " +
|
||||
selected.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: placeholder || "Pick date and time";
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
|
||||
const triggerStyles = cn(
|
||||
// Base styles matching other form components
|
||||
"rounded-3xl border border-zinc-200 bg-white px-4 shadow-none",
|
||||
"font-normal text-black w-full text-sm",
|
||||
"placeholder:font-normal !placeholder:text-zinc-400",
|
||||
// Focus and hover states
|
||||
"focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0",
|
||||
// Error state
|
||||
error &&
|
||||
"border-1.5 border-red-500 focus:border-red-500 focus:ring-red-500",
|
||||
// Placeholder styling
|
||||
!selected && "text-zinc-400",
|
||||
"justify-start text-left",
|
||||
// Size variants
|
||||
size === "default" && "h-[2.875rem] py-2.5",
|
||||
size === "small" && [
|
||||
"min-h-[2.25rem]", // 36px minimum
|
||||
"py-2",
|
||||
"text-sm leading-[22px]",
|
||||
"placeholder:text-sm placeholder:leading-[22px]",
|
||||
],
|
||||
className,
|
||||
);
|
||||
|
||||
const timeInputStyles = cn(
|
||||
// Base styles
|
||||
"rounded-3xl border border-zinc-200 bg-white px-4 shadow-none",
|
||||
"font-normal text-black w-full",
|
||||
"placeholder:font-normal placeholder:text-zinc-400",
|
||||
// Focus and hover states
|
||||
"focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0",
|
||||
// Size variants
|
||||
size === "small" && [
|
||||
"h-[2.25rem]", // 36px
|
||||
"py-2",
|
||||
"text-sm leading-[22px]", // 14px font, 22px line height
|
||||
"placeholder:text-sm placeholder:leading-[22px]",
|
||||
],
|
||||
size === "default" && [
|
||||
"h-[2.875rem]", // 46px
|
||||
"py-2.5",
|
||||
],
|
||||
);
|
||||
|
||||
const inputWithError = (
|
||||
<div className={cn("relative", error ? "mb-6" : "", wrapperClassName)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={triggerStyles}
|
||||
disabled={isDisabled}
|
||||
autoFocus={autoFocus}
|
||||
id={id}
|
||||
{...(hideLabel && label ? { "aria-label": label } : {})}
|
||||
>
|
||||
<CalendarIcon
|
||||
className={cn("mr-2", size === "default" ? "h-4 w-4" : "h-3 w-3")}
|
||||
/>
|
||||
<Clock
|
||||
className={cn("mr-2", size === "default" ? "h-4 w-4" : "h-3 w-3")}
|
||||
/>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" sideOffset={6}>
|
||||
<div className="p-3">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={setDate}
|
||||
showOutsideDays
|
||||
modifiersClassNames={{
|
||||
disabled: "pointer-events-none opacity-50",
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className={timeInputStyles}
|
||||
disabled={isDisabled}
|
||||
placeholder="HH:MM"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{error && (
|
||||
<Text
|
||||
variant="small-medium"
|
||||
as="span"
|
||||
className={cn(
|
||||
"absolute left-0 top-full mt-1 !text-red-500 transition-opacity duration-200",
|
||||
error ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{error || " "}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return hideLabel || !label ? (
|
||||
inputWithError
|
||||
) : (
|
||||
<label htmlFor={id} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="body-medium" as="span" className="text-black">
|
||||
{label}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text variant="small" as="span" className="!text-zinc-400">
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
{inputWithError}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -36,6 +36,7 @@ export interface SelectFieldProps {
|
||||
options: SelectOption[];
|
||||
size?: "small" | "medium";
|
||||
renderItem?: (option: SelectOption) => React.ReactNode;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
@@ -52,6 +53,7 @@ export function Select({
|
||||
options,
|
||||
size = "medium",
|
||||
renderItem,
|
||||
wrapperClassName,
|
||||
}: SelectFieldProps) {
|
||||
const triggerStyles = cn(
|
||||
// Base styles matching Input
|
||||
@@ -117,7 +119,7 @@ export function Select({
|
||||
);
|
||||
|
||||
const selectWithError = (
|
||||
<div className="relative mb-6">
|
||||
<div className={cn("relative mb-6", wrapperClassName)}>
|
||||
{select}
|
||||
<Text
|
||||
variant="small-medium"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Text } from "../Text/Text";
|
||||
|
||||
interface TimeInputProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
id?: string;
|
||||
hideLabel?: boolean;
|
||||
error?: string;
|
||||
hint?: ReactNode;
|
||||
size?: "small" | "medium";
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
export const TimeInput: React.FC<TimeInputProps> = ({
|
||||
value = "",
|
||||
onChange,
|
||||
className,
|
||||
disabled = false,
|
||||
placeholder = "HH:MM",
|
||||
label,
|
||||
id,
|
||||
hideLabel = false,
|
||||
error,
|
||||
hint,
|
||||
size = "medium",
|
||||
wrapperClassName,
|
||||
}) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e.target.value);
|
||||
};
|
||||
|
||||
const baseStyles = cn(
|
||||
// Base styles
|
||||
"rounded-3xl border border-zinc-200 bg-white px-4 shadow-none",
|
||||
"font-normal text-black",
|
||||
"placeholder:font-normal placeholder:text-zinc-400",
|
||||
// Focus and hover states
|
||||
"focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0",
|
||||
className,
|
||||
);
|
||||
|
||||
const errorStyles =
|
||||
error && "!border !border-red-500 focus:border-red-500 focus:ring-red-500";
|
||||
|
||||
const input = (
|
||||
<div className={cn("relative", wrapperClassName)}>
|
||||
<input
|
||||
type="time"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className={cn(
|
||||
baseStyles,
|
||||
errorStyles,
|
||||
// Size variants
|
||||
size === "small" && [
|
||||
"h-[2.25rem]", // 36px
|
||||
"py-2",
|
||||
"text-sm leading-[22px]", // 14px font, 22px line height
|
||||
"placeholder:text-sm placeholder:leading-[22px]",
|
||||
],
|
||||
size === "medium" && [
|
||||
"h-[2.875rem]", // 46px (current default)
|
||||
"py-2.5",
|
||||
],
|
||||
)}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder || label}
|
||||
{...(hideLabel ? { "aria-label": label } : {})}
|
||||
id={id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const inputWithError = (
|
||||
<div className={cn("relative mb-6", wrapperClassName)}>
|
||||
{input}
|
||||
<Text
|
||||
variant="small-medium"
|
||||
as="span"
|
||||
className={cn(
|
||||
"absolute left-0 top-full mt-1 !text-red-500 transition-opacity duration-200",
|
||||
error ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{error || " "}{" "}
|
||||
{/* Always render with space to maintain consistent height calculation */}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
return hideLabel || !label ? (
|
||||
inputWithError
|
||||
) : (
|
||||
<label htmlFor={id} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="body-medium" as="span" className="text-black">
|
||||
{label}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text variant="small" as="span" className="!text-zinc-400">
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
{inputWithError}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -27,7 +27,7 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="sticky top-0 z-40 inline-flex h-16 items-center border border-white/50 bg-[#f3f4f6]/20 p-3 backdrop-blur-[26px]">
|
||||
<nav className="sticky top-0 z-40 inline-flex h-16 w-full items-center border border-white/50 bg-[#f3f4f6]/20 p-3 backdrop-blur-[26px]">
|
||||
{/* Left section */}
|
||||
<div className="hidden flex-1 items-center gap-3 md:flex md:gap-5">
|
||||
{isLoggedIn
|
||||
|
||||
@@ -10,6 +10,8 @@ export enum Flag {
|
||||
NEW_AGENT_RUNS = "new-agent-runs",
|
||||
GRAPH_SEARCH = "graph-search",
|
||||
ENABLE_ENHANCED_OUTPUT_HANDLING = "enable-enhanced-output-handling",
|
||||
NEW_FLOW_EDITOR = "new-flow-editor",
|
||||
BUILDER_VIEW_SWITCH = "builder-view-switch",
|
||||
SHARE_EXECUTION_RESULTS = "share-execution-results",
|
||||
AGENT_FAVORITING = "agent-favoriting",
|
||||
}
|
||||
@@ -21,6 +23,8 @@ export type FlagValues = {
|
||||
[Flag.NEW_AGENT_RUNS]: boolean;
|
||||
[Flag.GRAPH_SEARCH]: boolean;
|
||||
[Flag.ENABLE_ENHANCED_OUTPUT_HANDLING]: boolean;
|
||||
[Flag.NEW_FLOW_EDITOR]: boolean;
|
||||
[Flag.BUILDER_VIEW_SWITCH]: boolean;
|
||||
[Flag.SHARE_EXECUTION_RESULTS]: boolean;
|
||||
[Flag.AGENT_FAVORITING]: boolean;
|
||||
};
|
||||
@@ -34,6 +38,8 @@ const mockFlags = {
|
||||
[Flag.NEW_AGENT_RUNS]: false,
|
||||
[Flag.GRAPH_SEARCH]: true,
|
||||
[Flag.ENABLE_ENHANCED_OUTPUT_HANDLING]: false,
|
||||
[Flag.NEW_FLOW_EDITOR]: false,
|
||||
[Flag.BUILDER_VIEW_SWITCH]: false,
|
||||
[Flag.SHARE_EXECUTION_RESULTS]: false,
|
||||
[Flag.AGENT_FAVORITING]: false,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user