mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 21:06:04 +00:00
public commit
This commit is contained in:
158
app/src/ui/modules/flows/components/FlowCanvas.tsx
Normal file
158
app/src/ui/modules/flows/components/FlowCanvas.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
type Edge,
|
||||
MiniMap,
|
||||
type Node,
|
||||
type NodeChange,
|
||||
ReactFlow,
|
||||
addEdge,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useStore
|
||||
} from "@xyflow/react";
|
||||
import { type Execution, ExecutionEvent, ExecutionState, type Flow, type Task } from "flows";
|
||||
import { transform } from "lodash-es";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
//import "reactflow/dist/style.css";
|
||||
import { getFlowEdges, getFlowNodes, getNodeTypes } from "../utils";
|
||||
import { FetchTaskComponent } from "./tasks/FetchTaskComponent";
|
||||
import { TaskComponent } from "./tasks/TaskComponent";
|
||||
|
||||
export default function FlowCanvas({
|
||||
flow,
|
||||
execution,
|
||||
options = {
|
||||
theme: "dark"
|
||||
}
|
||||
}: {
|
||||
flow: Flow;
|
||||
execution: Execution | undefined;
|
||||
options?: { theme?: string };
|
||||
}) {
|
||||
const nodes = getFlowNodes(flow);
|
||||
const edges = getFlowEdges(flow);
|
||||
console.log("nodes", nodes);
|
||||
console.log("edges", edges);
|
||||
|
||||
const nodeTypes = getNodeTypes(flow);
|
||||
//console.log("nodeTypes", nodeTypes);
|
||||
|
||||
return (
|
||||
<RenderedFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
execution={execution}
|
||||
theme={options.theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderedFlow({ nodes, edges, nodeTypes, execution, theme }: any) {
|
||||
const [_nodes, setNodes, onNodesChange] = useNodesState(nodes);
|
||||
const [_edges, setEdges, onEdgesChange] = useEdgesState(edges);
|
||||
|
||||
const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), [setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
execution?.subscribe(async (event: ExecutionEvent | ExecutionState) => {
|
||||
if (event instanceof ExecutionEvent) {
|
||||
setNodes((nodes) => {
|
||||
return nodes.map((node) => {
|
||||
// @ts-ignore
|
||||
if (node.data.task && node.data.task.name === event.task().name) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
state: {
|
||||
// @ts-ignore
|
||||
...node.data.state,
|
||||
event
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
});
|
||||
} else if (event instanceof ExecutionState) {
|
||||
if (event.params.state === "started") {
|
||||
console.log("!!!!! started");
|
||||
setNodes((nodes) => {
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
state: {
|
||||
// @ts-ignore
|
||||
...node.data.state,
|
||||
event: undefined
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
console.log("---result", execution?.getResponse());
|
||||
}
|
||||
console.log("!!! ExecutionState", event, event.params.state);
|
||||
}
|
||||
/*console.log(
|
||||
"[event--]",
|
||||
event.isStart() ? "start" : "end",
|
||||
event.task().name,
|
||||
event.isStart() ? undefined : event.succeeded(),
|
||||
);*/
|
||||
});
|
||||
}, [execution]);
|
||||
|
||||
function handleNodeClick(event: React.MouseEvent, _node: Node) {
|
||||
console.log("node click", _node);
|
||||
}
|
||||
|
||||
function handleNodesChange(changes: NodeChange[]) {
|
||||
console.log("changes", changes);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={_nodes}
|
||||
edges={_edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgeClick={(e, edge) => console.log("edge clicked", edge)}
|
||||
onNodeClick={handleNodeClick}
|
||||
nodeDragThreshold={10}
|
||||
nodeTypes={nodeTypes}
|
||||
onConnect={onConnect}
|
||||
fitView
|
||||
fitViewOptions={{ maxZoom: 1 }}
|
||||
proOptions={{
|
||||
hideAttribution: true
|
||||
}}
|
||||
>
|
||||
<Controls>
|
||||
<ZoomState />
|
||||
</Controls>
|
||||
<MiniMap />
|
||||
<Background
|
||||
key={theme}
|
||||
color={theme === "dark" ? "rgba(255,255,255,.2)" : "rgba(0,0,0,.2)"}
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
size={1.5}
|
||||
/>
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
const zoomStore = (state) => {
|
||||
return state.transform[2];
|
||||
};
|
||||
const ZoomState = () => {
|
||||
const zoom = useStore(zoomStore);
|
||||
return <div>{Math.ceil(zoom * 100).toFixed(0)}%</div>;
|
||||
};
|
||||
71
app/src/ui/modules/flows/components/TriggerComponent.tsx
Normal file
71
app/src/ui/modules/flows/components/TriggerComponent.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
|
||||
import { Const, Type, transformObject } from "core/utils";
|
||||
import { type TaskRenderProps, type Trigger, TriggerMap } from "flows";
|
||||
import { Suspense, lazy } from "react";
|
||||
import type { IconType } from "react-icons";
|
||||
import { TbCircleLetterT } from "react-icons/tb";
|
||||
const JsonSchemaForm = lazy(() =>
|
||||
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
|
||||
default: m.JsonSchemaForm
|
||||
}))
|
||||
);
|
||||
|
||||
export type TaskComponentProps = NodeProps<Node<{ trigger: Trigger }>> & {
|
||||
Icon?: IconType;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const triggerSchemas = Object.values(
|
||||
transformObject(TriggerMap, (trigger, name) =>
|
||||
Type.Object(
|
||||
{
|
||||
type: Const(name),
|
||||
config: trigger.cls.schema
|
||||
},
|
||||
{ title: String(name), additionalProperties: false }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
export function TriggerComponent({
|
||||
children,
|
||||
Icon = TbCircleLetterT,
|
||||
...props
|
||||
}: TaskComponentProps) {
|
||||
const { trigger } = props.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-selected={props.selected ? 1 : undefined}
|
||||
className="flex flex-col rounded bg-background/80 border border-muted data-[selected]:bg-background data-[selected]:ring-2 ring-primary/40 w-[500px] cursor-auto"
|
||||
>
|
||||
<div className="flex flex-row gap-2 px-3 py-2 items-center justify-between drag-handle cursor-grab">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Icon size={18} />
|
||||
<div className="font-medium">{trigger.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-px bg-primary/10" />
|
||||
<div className="flex flex-col gap-2 px-3 py-2">
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<JsonSchemaForm
|
||||
className="legacy"
|
||||
schema={Type.Union(triggerSchemas)}
|
||||
onChange={console.log}
|
||||
formData={trigger}
|
||||
{...props}
|
||||
/*uiSchema={uiSchema}*/
|
||||
/*fields={{ template: TemplateField }}*/
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={props.sourcePosition ?? Position.Bottom}
|
||||
isConnectable={props.isConnectable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
app/src/ui/modules/flows/components/form/TaskForm.tsx
Normal file
41
app/src/ui/modules/flows/components/form/TaskForm.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Task } from "flows";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { TemplateField } from "./TemplateField";
|
||||
|
||||
const JsonSchemaForm = lazy(() =>
|
||||
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
|
||||
default: m.JsonSchemaForm
|
||||
}))
|
||||
);
|
||||
|
||||
export type TaskFormProps = {
|
||||
task: Task;
|
||||
onChange?: (values: any) => void;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export function TaskForm({ task, onChange, ...props }: TaskFormProps) {
|
||||
// @ts-ignore
|
||||
const schema = task.constructor.schema;
|
||||
const params = task.params;
|
||||
const uiSchema = Object.fromEntries(
|
||||
Object.keys(schema.properties).map((key) => {
|
||||
return [key, { "ui:field": "template", "ui:fieldReplacesAnyOrOneOf": true }];
|
||||
})
|
||||
);
|
||||
//console.log("uiSchema", uiSchema);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<JsonSchemaForm
|
||||
className="legacy"
|
||||
schema={schema}
|
||||
onChange={onChange}
|
||||
formData={params}
|
||||
{...props}
|
||||
/*uiSchema={uiSchema}*/
|
||||
/*fields={{ template: TemplateField }}*/
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
94
app/src/ui/modules/flows/components/form/TemplateField.tsx
Normal file
94
app/src/ui/modules/flows/components/form/TemplateField.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { FieldProps, FormContextType, RJSFSchema, StrictRJSFSchema } from "@rjsf/utils";
|
||||
import { SimpleRenderer } from "core";
|
||||
import { ucFirst, ucFirstAll } from "core/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
const modes = ["field", "code"] as const;
|
||||
type Mode = (typeof modes)[number];
|
||||
|
||||
export function TemplateField<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: FieldProps<T, S, F>) {
|
||||
const formData = props.formData;
|
||||
const hasMarkup = SimpleRenderer.hasMarkup(formData!);
|
||||
const [mode, setMode] = useState<Mode>(hasMarkup ? "code" : "field");
|
||||
const [values, setValues] = useState<Record<Mode, any>>({
|
||||
field: hasMarkup ? "" : formData,
|
||||
code: hasMarkup ? formData : ""
|
||||
});
|
||||
//console.log("TemplateField", props);
|
||||
const { SchemaField } = props.registry.fields;
|
||||
const { schema } = props;
|
||||
|
||||
function handleModeSwitch(mode: Mode) {
|
||||
setMode(mode);
|
||||
props.onChange(values[mode]);
|
||||
}
|
||||
|
||||
function onChange(value: any) {
|
||||
setValues({ ...values, [mode]: value });
|
||||
props.onChange(value);
|
||||
}
|
||||
|
||||
let _schema: any = schema;
|
||||
if (!("anyOf" in schema)) {
|
||||
_schema = {
|
||||
anyOf: [schema, { type: "string" }]
|
||||
};
|
||||
}
|
||||
|
||||
const [fieldSchema, codeSchema] = _schema.anyOf;
|
||||
const currentSchema = mode === "field" ? fieldSchema : codeSchema;
|
||||
const currentValue = values[mode];
|
||||
const uiSchema =
|
||||
mode === "field"
|
||||
? { "ui:label": false }
|
||||
: {
|
||||
"ui:label": false,
|
||||
"ui:widget": "textarea",
|
||||
"ui:options": { rows: 1 }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 flex-grow">
|
||||
<label className="flex flex-row gap-2 w-full justify-between">
|
||||
{ucFirstAll(props.name)}
|
||||
|
||||
<div className="flex flex-row gap-3 items-center">
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
data-active={m === mode ? 1 : undefined}
|
||||
className="leading-none text-sm pb-0.5 border-b border-b-transparent font-mono opacity-50 data-[active]:border-b-primary/50 data-[active]:opacity-100"
|
||||
role="button"
|
||||
key={m}
|
||||
onClick={() => handleModeSwitch(m)}
|
||||
>
|
||||
{ucFirst(m)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
<div className="flex flex-col flex-grow items-stretch justify-stretch">
|
||||
{/* @ts-ignore */}
|
||||
<SchemaField
|
||||
uiSchema={uiSchema}
|
||||
schema={currentSchema}
|
||||
registry={props.registry}
|
||||
idSchema={props.idSchema}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
formData={currentValue}
|
||||
onChange={onChange}
|
||||
disabled={props.disabled}
|
||||
readonly={props.readonly}
|
||||
required={props.required}
|
||||
autofocus={props.autofocus}
|
||||
rawErrors={props.rawErrors}
|
||||
name={props.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FetchTask, TaskRenderProps } from "flows";
|
||||
import { TbGlobe, TbWorld } from "react-icons/tb";
|
||||
import { TaskComponent } from "./TaskComponent";
|
||||
|
||||
export function FetchTaskComponent(props: TaskRenderProps<FetchTask<any>>) {
|
||||
const { task, state } = props.data;
|
||||
return (
|
||||
<TaskComponent {...props} Icon={TbWorld}>
|
||||
<div>
|
||||
<div>URL: {task.params.url}</div>
|
||||
<div>Method: {task.params.method}</div>
|
||||
<div>Headers: {JSON.stringify(task.params.headers)}</div>
|
||||
<div>Body: {JSON.stringify(task.params.body)}</div>
|
||||
</div>
|
||||
</TaskComponent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { TaskForm } from "../form/TaskForm";
|
||||
import { TaskComponent, type TaskComponentProps } from "./TaskComponent";
|
||||
|
||||
export function RenderTaskComponent(props: TaskComponentProps) {
|
||||
const { task } = props.data;
|
||||
return (
|
||||
<TaskComponent {...props}>
|
||||
<TaskForm
|
||||
task={task}
|
||||
onChange={console.log}
|
||||
uiSchema={{
|
||||
render: {
|
||||
"ui:field": "LiquidJsField"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TaskComponent>
|
||||
);
|
||||
}
|
||||
52
app/src/ui/modules/flows/components/tasks/TaskComponent.tsx
Normal file
52
app/src/ui/modules/flows/components/tasks/TaskComponent.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { TaskRenderProps } from "flows";
|
||||
import type { IconType } from "react-icons";
|
||||
import { TbCircleLetterT } from "react-icons/tb";
|
||||
import { TaskForm } from "../form/TaskForm";
|
||||
|
||||
export type TaskComponentProps = TaskRenderProps & {
|
||||
Icon?: IconType;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function TaskComponent({ children, Icon = TbCircleLetterT, ...props }: TaskComponentProps) {
|
||||
const { task, state } = props.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={props.targetPosition ?? Position.Top}
|
||||
isConnectable={props.isConnectable}
|
||||
/>
|
||||
<div
|
||||
data-selected={props.selected ? 1 : undefined}
|
||||
className="flex flex-col rounded bg-background/80 border border-muted data-[selected]:bg-background data-[selected]:ring-2 ring-primary/40 w-[500px] cursor-auto"
|
||||
>
|
||||
<div className="flex flex-row gap-2 px-3 py-2 items-center justify-between drag-handle cursor-grab">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Icon size={18} />
|
||||
<div className="font-medium">{task.label}</div>
|
||||
</div>
|
||||
<div
|
||||
data-state={state.event?.getState() ?? "idle"}
|
||||
className="px-1.5 bg-primary/10 rounded leading-0 data-[state=running]:bg-yellow-500/30 data-[state=success]:bg-green-800/30 data-[state=error]:bg-red-800"
|
||||
>
|
||||
{state.event?.getState() ?? "idle"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-px bg-primary/10" />
|
||||
<div className="flex flex-col gap-2 px-3 py-2">
|
||||
{children ?? <TaskForm task={task} onChange={console.log} />}
|
||||
</div>
|
||||
</div>
|
||||
{!state.isRespondingTask && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={props.sourcePosition ?? Position.Bottom}
|
||||
isConnectable={props.isConnectable}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
app/src/ui/modules/flows/components/tasks/index.ts
Normal file
2
app/src/ui/modules/flows/components/tasks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { FetchTaskComponent } from "./FetchTaskComponent";
|
||||
import { TaskComponent } from "./TaskComponent";
|
||||
109
app/src/ui/modules/flows/components2/form/KeyValueInput.tsx
Normal file
109
app/src/ui/modules/flows/components2/form/KeyValueInput.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Input, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
|
||||
const ITEM = { key: "", value: "" };
|
||||
|
||||
export type KeyValueInputProps = {
|
||||
label?: string;
|
||||
classNames?: {
|
||||
label?: string;
|
||||
itemWrapper?: string;
|
||||
};
|
||||
initialValue?: Record<string, string>;
|
||||
onChange?: (value: Record<string, string> | (typeof ITEM)[]) => void;
|
||||
mode?: "object" | "array";
|
||||
error?: string | any;
|
||||
};
|
||||
|
||||
function toItems(obj: Record<string, string>) {
|
||||
if (!obj || Array.isArray(obj)) return [ITEM];
|
||||
return Object.entries(obj).map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
|
||||
export const KeyValueInput: React.FC<KeyValueInputProps> = ({
|
||||
label,
|
||||
initialValue,
|
||||
onChange,
|
||||
error,
|
||||
classNames,
|
||||
mode = "object"
|
||||
}) => {
|
||||
const [items, setItems] = useState(initialValue ? toItems(initialValue) : [ITEM]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
if (mode === "object") {
|
||||
const value = items.reduce((acc, item) => {
|
||||
if (item.key && typeof item.value !== "undefined") {
|
||||
acc[item.key] = item.value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
onChange(value);
|
||||
} else {
|
||||
onChange(items);
|
||||
}
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
function handleAdd() {
|
||||
setItems((prev) => [...prev, ITEM]);
|
||||
}
|
||||
|
||||
function handleUpdate(i: number, attr: string) {
|
||||
return (e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setItems((prev) => {
|
||||
return prev.map((item, index) => {
|
||||
if (index === i) {
|
||||
return { ...item, [attr]: value };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function handleRemove(i: number) {
|
||||
return () => {
|
||||
setItems((prev) => prev.filter((_, index) => index !== i));
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Input.Wrapper className="w-full">
|
||||
<div className="flex flex-row w-full justify-between">
|
||||
{label ? <Input.Label className={classNames?.label}>{label}</Input.Label> : <div />}
|
||||
<IconButton Icon={IconPlus as any} size="xs" onClick={handleAdd} />
|
||||
</div>
|
||||
<div className={twMerge("flex flex-col gap-2", classNames?.itemWrapper)}>
|
||||
{items.map(({ key, value }, i) => (
|
||||
<div key={i} className="flex flex-row gap-2 items-center">
|
||||
{items.length > 1 && (
|
||||
<IconButton Icon={IconTrash as any} size="xs" onClick={handleRemove(i)} />
|
||||
)}
|
||||
<TextInput
|
||||
className="w-36"
|
||||
placeholder="key"
|
||||
value={key}
|
||||
classNames={{ wrapper: "font-mono pt-px" }}
|
||||
onChange={handleUpdate(i, "key")}
|
||||
/>
|
||||
<TextInput
|
||||
className="w-full"
|
||||
placeholder="value"
|
||||
value={value}
|
||||
classNames={{ wrapper: "font-mono pt-px" }}
|
||||
onChange={handleUpdate(i, "value")}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{error && <Input.Error>{error}</Input.Error>}
|
||||
</div>
|
||||
{/*<pre>{JSON.stringify(items, null, 2)}</pre>*/}
|
||||
</Input.Wrapper>
|
||||
);
|
||||
};
|
||||
79
app/src/ui/modules/flows/components2/hud/FlowPanel.tsx
Normal file
79
app/src/ui/modules/flows/components2/hud/FlowPanel.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ElementProps } from "@mantine/core";
|
||||
import { Panel, type PanelPosition } from "@xyflow/react";
|
||||
import { type HTMLAttributes, forwardRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton as _IconButton } from "ui/components/buttons/IconButton";
|
||||
|
||||
export type FlowPanel = HTMLAttributes<HTMLDivElement> & {
|
||||
position: PanelPosition;
|
||||
unstyled?: boolean;
|
||||
};
|
||||
|
||||
export function FlowPanel({ position, className, children, unstyled, ...props }: FlowPanel) {
|
||||
if (unstyled) {
|
||||
return (
|
||||
<Panel
|
||||
position={position}
|
||||
className={twMerge("flex flex-row p-1 gap-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel position={position} {...props}>
|
||||
<Wrapper className={className}>{children}</Wrapper>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = ({ children, className, ...props }: ElementProps<"div">) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row bg-lightest border ring-2 ring-muted/5 border-muted rounded-full items-center p-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const IconButton = ({
|
||||
Icon,
|
||||
size = "lg",
|
||||
variant = "ghost",
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
round,
|
||||
...rest
|
||||
}: ElementProps<typeof _IconButton> & { round?: boolean }) => (
|
||||
<_IconButton
|
||||
Icon={Icon}
|
||||
size={size}
|
||||
variant={variant}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={twMerge(round ? "rounded-full" : "", className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
const Text = forwardRef<any, ElementProps<"span"> & { mono?: boolean }>(
|
||||
({ children, className, mono, ...props }, ref) => (
|
||||
<span
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge("text-md font-medium leading-none", mono && "font-mono", className)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
|
||||
FlowPanel.Wrapper = Wrapper;
|
||||
FlowPanel.IconButton = IconButton;
|
||||
FlowPanel.Text = Text;
|
||||
148
app/src/ui/modules/flows/components2/nodes/BaseNode.tsx
Normal file
148
app/src/ui/modules/flows/components2/nodes/BaseNode.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { type ElementProps, Tabs } from "@mantine/core";
|
||||
import { IconBoltFilled } from "@tabler/icons-react";
|
||||
import type { Node, NodeProps } from "@xyflow/react";
|
||||
import { useState } from "react";
|
||||
import { TbDots, TbPlayerPlayFilled } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode";
|
||||
import type { TFlowNodeData } from "../../hooks/use-flow";
|
||||
|
||||
type BaseNodeProps = NodeProps<Node<TFlowNodeData>> & {
|
||||
children?: React.ReactNode | React.ReactNode[];
|
||||
className?: string;
|
||||
Icon?: React.FC<any>;
|
||||
onChangeName?: (name: string) => void;
|
||||
isInvalid?: boolean;
|
||||
tabs?: {
|
||||
id: string;
|
||||
label: string;
|
||||
content: () => React.ReactNode;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function BaseNode({ children, className, tabs, Icon, isInvalid, ...props }: BaseNodeProps) {
|
||||
const { data } = props;
|
||||
|
||||
function handleNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (props.onChangeName) {
|
||||
props.onChangeName(e.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultNode
|
||||
className={twMerge(
|
||||
"w-96",
|
||||
//props.selected && "ring-4 ring-blue-500/15",
|
||||
isInvalid && "ring-8 ring-red-500/15",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Header
|
||||
Icon={Icon ?? IconBoltFilled}
|
||||
initialValue={data.label}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
<DefaultNode.Content className="gap-3">{children}</DefaultNode.Content>
|
||||
<BaseNodeTabs tabs={tabs} />
|
||||
</DefaultNode>
|
||||
);
|
||||
}
|
||||
|
||||
const BaseNodeTabs = ({ tabs }: { tabs: BaseNodeProps["tabs"] }) => {
|
||||
const [active, setActive] = useState<number>();
|
||||
if (!tabs || tabs?.length === 0) return null;
|
||||
|
||||
function handleClick(i: number) {
|
||||
return () => {
|
||||
setActive((prev) => (prev === i ? undefined : i));
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-t-muted mt-1">
|
||||
<div className="flex flex-row justify-start bg-primary/5 px-3 py-2.5 gap-3">
|
||||
{tabs.map((tab, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={handleClick(i)}
|
||||
className={twMerge(
|
||||
"text-sm leading-none",
|
||||
i === active ? "font-bold opacity-80" : "font-medium opacity-50"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{typeof active !== "undefined" ? (
|
||||
<div className="border-t border-t-muted">{tabs[active]?.content()}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({
|
||||
Icon,
|
||||
iconProps,
|
||||
rightSection,
|
||||
initialValue,
|
||||
changable = false,
|
||||
onChange
|
||||
}: {
|
||||
Icon: React.FC<any>;
|
||||
iconProps?: ElementProps<"svg">;
|
||||
rightSection?: React.ReactNode;
|
||||
initialValue: string;
|
||||
changable?: boolean;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (!changable) return;
|
||||
const v = String(e.target.value);
|
||||
if (v.length > 0 && !/^[a-zA-Z_][a-zA-Z0-9_ ]*$/.test(v)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (v.length === 25) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clean = v.toLowerCase().replace(/ /g, "_").replace(/__+/g, "_");
|
||||
|
||||
setValue(clean);
|
||||
onChange?.({ ...e, target: { ...e.target, value: clean } });
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultNode.Header className="justify-between gap-10">
|
||||
<div className="flex flex-row flex-grow gap-1 items-center">
|
||||
<Icon {...{ width: 16, height: 16, ...(iconProps ?? {}) }} />
|
||||
{changable ? (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
disabled={!changable}
|
||||
onChange={handleChange}
|
||||
className={twMerge(
|
||||
"font-mono font-semibold bg-transparent rounded-lg outline-none pl-1.5 w-full hover:bg-lightest/30 transition-colors focus:bg-lightest/60"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-mono font-semibold bg-transparent rounded-lg outline-none pl-1.5">
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-1">
|
||||
{/*{rightSection}*/}
|
||||
<IconButton Icon={TbPlayerPlayFilled} size="sm" />
|
||||
<IconButton Icon={TbDots} size="sm" />
|
||||
</div>
|
||||
</DefaultNode.Header>
|
||||
);
|
||||
};
|
||||
31
app/src/ui/modules/flows/components2/nodes/Handle.tsx
Normal file
31
app/src/ui/modules/flows/components2/nodes/Handle.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { type HandleProps, Position, Handle as XYFlowHandle } from "@xyflow/react";
|
||||
|
||||
export function Handle(props: Omit<HandleProps, "position">) {
|
||||
const base = {
|
||||
top: 16,
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: "transparent",
|
||||
border: "2px solid #999"
|
||||
};
|
||||
const offset = -10;
|
||||
const styles = {
|
||||
target: {
|
||||
...base,
|
||||
left: offset
|
||||
},
|
||||
source: {
|
||||
...base,
|
||||
right: offset
|
||||
}
|
||||
};
|
||||
//console.log("type", props.type, styles[props.type]);
|
||||
|
||||
return (
|
||||
<XYFlowHandle
|
||||
{...props}
|
||||
position={props.type === "source" ? Position.Right : Position.Left}
|
||||
style={styles[props.type]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
app/src/ui/modules/flows/components2/nodes/SelectNode.tsx
Normal file
107
app/src/ui/modules/flows/components2/nodes/SelectNode.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode";
|
||||
import { useFlowCanvas } from "../../hooks/use-flow";
|
||||
import { Handle } from "./Handle";
|
||||
|
||||
const nodes = [
|
||||
{
|
||||
type: "fetch",
|
||||
label: "Fetch",
|
||||
description: "Fetch data from a URL",
|
||||
template: {
|
||||
type: "fetch",
|
||||
params: {
|
||||
method: "GET",
|
||||
headers: [],
|
||||
url: ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "render",
|
||||
label: "Render",
|
||||
description: "Render data using LiquidJS"
|
||||
}
|
||||
];
|
||||
|
||||
export function SelectNode(props) {
|
||||
const [selected, setSelected] = useState<string>();
|
||||
const reactflow = useReactFlow();
|
||||
const { actions } = useFlowCanvas();
|
||||
const old_id = props.id;
|
||||
|
||||
async function handleMake() {
|
||||
const node = nodes.find((n) => n.type === selected)!;
|
||||
const label = "untitled";
|
||||
|
||||
await actions.task.create(label, node.template);
|
||||
reactflow.setNodes((prev) =>
|
||||
prev.map((n) => {
|
||||
if (n.id === old_id) {
|
||||
return {
|
||||
...n,
|
||||
id: "task-" + label,
|
||||
type: "task",
|
||||
data: {
|
||||
...node.template,
|
||||
label
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return n;
|
||||
})
|
||||
);
|
||||
setTimeout(() => {
|
||||
reactflow.setEdges((prev) =>
|
||||
prev.map((e) => {
|
||||
console.log("edge?", e, old_id, e.target === old_id);
|
||||
if (e.target === old_id) {
|
||||
return {
|
||||
...e,
|
||||
id: "task-" + label,
|
||||
target: "task-" + label
|
||||
};
|
||||
}
|
||||
|
||||
return e;
|
||||
})
|
||||
);
|
||||
}, 100);
|
||||
|
||||
console.log("make", node);
|
||||
}
|
||||
|
||||
//console.log("SelectNode", props);
|
||||
return (
|
||||
<DefaultNode className="w-96">
|
||||
<Handle type="target" id="select-in" />
|
||||
|
||||
<DefaultNode.Header className="gap-3 justify-start py-2">
|
||||
<div className="bg-primary/10 rounded-full w-4 h-4" />
|
||||
<div className="bg-primary/5 rounded-full w-1/2 h-4" />
|
||||
</DefaultNode.Header>
|
||||
<DefaultNode.Content>
|
||||
<div>select</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{nodes.map((node) => (
|
||||
<button
|
||||
type="button"
|
||||
key={node.type}
|
||||
className={twMerge(
|
||||
"border border-primary/10 rounded-md py-2 px-4 hover:bg-primary/10",
|
||||
selected === node.type && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => setSelected(node.type)}
|
||||
>
|
||||
{node.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={handleMake}>make</button>
|
||||
</DefaultNode.Content>
|
||||
</DefaultNode>
|
||||
);
|
||||
}
|
||||
9
app/src/ui/modules/flows/components2/nodes/index.ts
Normal file
9
app/src/ui/modules/flows/components2/nodes/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { SelectNode } from "./SelectNode";
|
||||
import { TaskNode } from "./tasks/TaskNode";
|
||||
import { TriggerNode } from "./triggers/TriggerNode";
|
||||
|
||||
export const nodeTypes = {
|
||||
select: SelectNode,
|
||||
trigger: TriggerNode,
|
||||
task: TaskNode
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
||||
import { Input, NativeSelect, Select, TextInput } from "@mantine/core";
|
||||
import { useToggle } from "@mantine/hooks";
|
||||
import { IconMinus, IconPlus, IconWorld } from "@tabler/icons-react";
|
||||
import type { Node, NodeProps } from "@xyflow/react";
|
||||
import type { Static } from "core/utils";
|
||||
import { Type } from "core/utils";
|
||||
import { FetchTask } from "flows";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "ui";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { SegmentedControl } from "ui/components/form/SegmentedControl";
|
||||
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
|
||||
import { type TFlowNodeData, useFlowSelector } from "../../../hooks/use-flow";
|
||||
import { KeyValueInput } from "../../form/KeyValueInput";
|
||||
import { BaseNode } from "../BaseNode";
|
||||
|
||||
const schema = Type.Composite([
|
||||
FetchTask.schema,
|
||||
Type.Object({
|
||||
query: Type.Optional(Type.Record(Type.String(), Type.String()))
|
||||
})
|
||||
]);
|
||||
|
||||
type TFetchTaskSchema = Static<typeof FetchTask.schema>;
|
||||
type FetchTaskFormProps = NodeProps<Node<TFlowNodeData>> & {
|
||||
params: TFetchTaskSchema;
|
||||
onChange: (params: any) => void;
|
||||
};
|
||||
|
||||
export function FetchTaskForm({ onChange, params, ...props }: FetchTaskFormProps) {
|
||||
const [advanced, toggle] = useToggle([true, false]);
|
||||
const [bodyType, setBodyType] = useState("None");
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { isValid, errors },
|
||||
watch,
|
||||
control
|
||||
} = useForm({
|
||||
resolver: typeboxResolver(schema),
|
||||
defaultValues: params as Static<typeof schema>,
|
||||
mode: "onChange"
|
||||
//defaultValues: (state.relations?.create?.[0] ?? {}) as Static<typeof schema>
|
||||
});
|
||||
|
||||
function onSubmit(data) {
|
||||
console.log("submit task", data);
|
||||
onChange(data);
|
||||
}
|
||||
|
||||
//console.log("FetchTaskForm", watch());
|
||||
return (
|
||||
<BaseNode
|
||||
{...props}
|
||||
isInvalid={!isValid}
|
||||
className="w-[400px]"
|
||||
Icon={IconWorld}
|
||||
tabs={TaskNodeTabs({ watch })}
|
||||
onChangeName={console.log}
|
||||
>
|
||||
<form onBlur={handleSubmit(onSubmit)} className="flex flex-col gap-3">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<MantineSelect
|
||||
className="w-36"
|
||||
label="Method"
|
||||
defaultValue="GET"
|
||||
data={["GET", "POST", "PATCH", "PUT", "DEL"]}
|
||||
name="method"
|
||||
control={control}
|
||||
/>
|
||||
<TextInput
|
||||
className="w-full"
|
||||
label="Mapping Path"
|
||||
placeholder="/path/to-be/mapped"
|
||||
classNames={{ wrapper: "font-mono pt-px" }}
|
||||
{...register("url")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={toggle as any}
|
||||
className="justify-center"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
iconSize={14}
|
||||
IconLeft={advanced ? IconMinus : IconPlus}
|
||||
>
|
||||
More options
|
||||
</Button>
|
||||
|
||||
{advanced && (
|
||||
<>
|
||||
<KeyValueInput
|
||||
label="URL query"
|
||||
onChange={(items: any) => setValue("query", items)}
|
||||
error={errors.query?.message}
|
||||
/>
|
||||
<KeyValueInput label="Headers" />
|
||||
<div className="flex flex-row gap-2 items-center mt-2">
|
||||
<Input.Wrapper className="w-full">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Input.Label>Body</Input.Label>
|
||||
<SegmentedControl
|
||||
data={["None", "Form", "JSON", "Code"]}
|
||||
size="xs"
|
||||
defaultValue={bodyType}
|
||||
onChange={(value) => setBodyType(value)}
|
||||
/>
|
||||
</div>
|
||||
{bodyType === "Form" && <KeyValueInput label={undefined} />}
|
||||
{bodyType === "JSON" && <KeyValueInput label={undefined} />}
|
||||
</Input.Wrapper>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
const TaskNodeTabs = ({ watch }: any) => [
|
||||
{
|
||||
id: "json",
|
||||
label: "JSON",
|
||||
content: () => (
|
||||
<div className="scroll-auto">
|
||||
<JsonViewer json={watch()} expand={2} className="bg-white break-all" />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "test",
|
||||
label: "test",
|
||||
content: () => <div>test</div>
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IconWorld } from "@tabler/icons-react";
|
||||
import { LiquidJsEditor } from "ui/components/code/LiquidJsEditor";
|
||||
import { BaseNode } from "../BaseNode";
|
||||
|
||||
export function RenderNode(props) {
|
||||
return (
|
||||
<BaseNode {...props} onChangeName={console.log} Icon={IconWorld} className="w-[400px]">
|
||||
<form className="flex flex-col gap-3">
|
||||
<LiquidJsEditor value={props.params.render} onChange={console.log} />
|
||||
</form>
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { TypeRegistry } from "@sinclair/typebox";
|
||||
import { type Node, type NodeProps, Position } from "@xyflow/react";
|
||||
import { registerCustomTypeboxKinds } from "core/utils";
|
||||
import type { TAppFlowTaskSchema } from "flows/AppFlows";
|
||||
import { useFlowCanvas, useFlowSelector } from "../../../hooks/use-flow";
|
||||
import { Handle } from "../Handle";
|
||||
import { FetchTaskForm } from "./FetchTaskNode";
|
||||
import { RenderNode } from "./RenderNode";
|
||||
|
||||
registerCustomTypeboxKinds(TypeRegistry);
|
||||
|
||||
const TaskComponents = {
|
||||
fetch: FetchTaskForm,
|
||||
render: RenderNode
|
||||
};
|
||||
|
||||
export const TaskNode = (
|
||||
props: NodeProps<
|
||||
Node<
|
||||
TAppFlowTaskSchema & {
|
||||
label: string;
|
||||
last?: boolean;
|
||||
start?: boolean;
|
||||
responding?: boolean;
|
||||
}
|
||||
>
|
||||
>
|
||||
) => {
|
||||
const {
|
||||
data: { label, start, last, responding }
|
||||
} = props;
|
||||
const task = useFlowSelector((s) => s.flow!.tasks![label])!;
|
||||
const { actions } = useFlowCanvas();
|
||||
|
||||
const Component =
|
||||
task.type in TaskComponents ? TaskComponents[task.type] : () => <div>unsupported</div>;
|
||||
|
||||
function handleChange(params: any) {
|
||||
//console.log("TaskNode:update", task.type, label, params);
|
||||
actions.task.update(label, params);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Component {...props} params={task.params as any} onChange={handleChange} />
|
||||
|
||||
<Handle type="target" id={`${label}-in`} />
|
||||
<Handle type="source" id={`${label}-out`} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
||||
import { TextInput } from "@mantine/core";
|
||||
import { TypeRegistry } from "@sinclair/typebox";
|
||||
import { Clean } from "@sinclair/typebox/value";
|
||||
import { type Node, type NodeProps, Position } from "@xyflow/react";
|
||||
import {
|
||||
Const,
|
||||
type Static,
|
||||
StringEnum,
|
||||
Type,
|
||||
registerCustomTypeboxKinds,
|
||||
transformObject
|
||||
} from "core/utils";
|
||||
import { TriggerMap } from "flows";
|
||||
import type { TAppFlowTriggerSchema } from "flows/AppFlows";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { MantineSegmentedControl } from "ui/components/form/hook-form-mantine/MantineSegmentedControl";
|
||||
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
|
||||
import { useFlowCanvas, useFlowSelector } from "../../../hooks/use-flow";
|
||||
import { BaseNode } from "../BaseNode";
|
||||
import { Handle } from "../Handle";
|
||||
|
||||
// @todo: check if this could become an issue
|
||||
registerCustomTypeboxKinds(TypeRegistry);
|
||||
|
||||
const schema = Type.Object({
|
||||
trigger: Type.Union(
|
||||
Object.values(
|
||||
transformObject(TriggerMap, (trigger, name) =>
|
||||
Type.Object(
|
||||
{
|
||||
type: Const(name),
|
||||
config: trigger.cls.schema
|
||||
},
|
||||
{ title: String(name), additionalProperties: false }
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
});
|
||||
|
||||
export const TriggerNode = (props: NodeProps<Node<TAppFlowTriggerSchema & { label: string }>>) => {
|
||||
const {
|
||||
data: { label, ...trigger }
|
||||
} = props;
|
||||
//console.log("TriggerNode");
|
||||
const state = useFlowSelector((s) => s.flow!.trigger!);
|
||||
const { actions } = useFlowCanvas();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { isValid, errors },
|
||||
watch,
|
||||
control
|
||||
} = useForm({
|
||||
resolver: typeboxResolver(schema),
|
||||
defaultValues: { trigger: state } as Static<typeof schema>,
|
||||
mode: "onChange"
|
||||
});
|
||||
const data = watch("trigger");
|
||||
|
||||
async function onSubmit(data: Static<typeof schema>) {
|
||||
console.log("submit", data.trigger);
|
||||
// @ts-ignore
|
||||
await actions.trigger.update(data.trigger);
|
||||
}
|
||||
|
||||
async function onChangeName(name: string) {
|
||||
console.log("change name", name);
|
||||
await actions.flow.setName(name);
|
||||
}
|
||||
|
||||
/*useEffect(() => {
|
||||
console.log("trigger update", data);
|
||||
actions.trigger.update(data);
|
||||
}, [data]);*/
|
||||
|
||||
return (
|
||||
<BaseNode {...props} tabs={TriggerNodeTabs({ data })} onChangeName={onChangeName}>
|
||||
<form onBlur={handleSubmit(onSubmit)} className="flex flex-col gap-3">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<MantineSegmentedControl
|
||||
label="Trigger Type"
|
||||
defaultValue="manual"
|
||||
data={[
|
||||
{ label: "Manual", value: "manual" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
{ label: "Event", value: "event", disabled: true }
|
||||
]}
|
||||
name="trigger.type"
|
||||
control={control}
|
||||
/>
|
||||
<MantineSegmentedControl
|
||||
label="Execution Mode"
|
||||
defaultValue="async"
|
||||
data={[
|
||||
{ label: "Async", value: "async" },
|
||||
{ label: "Sync", value: "sync" }
|
||||
]}
|
||||
name="trigger.config.mode"
|
||||
control={control}
|
||||
/>
|
||||
</div>
|
||||
{data.type === "manual" && <Manual />}
|
||||
{data.type === "http" && (
|
||||
<Http form={{ watch, register, setValue, getValues, control }} />
|
||||
)}
|
||||
</form>
|
||||
<Handle type="source" id="trigger-out" />
|
||||
</BaseNode>
|
||||
);
|
||||
};
|
||||
|
||||
const Manual = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const Http = ({ form }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<MantineSelect
|
||||
className="w-36"
|
||||
label="Method"
|
||||
data={["GET", "POST", "PATCH", "PUT", "DEL"]}
|
||||
name="trigger.config.method"
|
||||
control={form.control}
|
||||
/>
|
||||
<TextInput
|
||||
className="w-full"
|
||||
label="Mapping Path"
|
||||
placeholder="/trigger_http"
|
||||
classNames={{ wrapper: "font-mono pt-px" }}
|
||||
{...form.register("trigger.config.path")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<MantineSegmentedControl
|
||||
className="w-full"
|
||||
label="Response Type"
|
||||
defaultValue="json"
|
||||
data={[
|
||||
{ label: "JSON", value: "json" },
|
||||
{ label: "HTML", value: "html" },
|
||||
{ label: "Text", value: "text" }
|
||||
]}
|
||||
name="trigger.config.response_type"
|
||||
control={form.control}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TriggerNodeTabs = ({ data, ...props }) => [
|
||||
{
|
||||
id: "json",
|
||||
label: "JSON",
|
||||
content: () => <JsonViewer json={data} expand={2} className="" />
|
||||
},
|
||||
{
|
||||
id: "test",
|
||||
label: "test",
|
||||
content: () => <div>test</div>
|
||||
}
|
||||
];
|
||||
209
app/src/ui/modules/flows/hooks/use-flow/index.tsx
Normal file
209
app/src/ui/modules/flows/hooks/use-flow/index.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { MarkerType, type Node } from "@xyflow/react";
|
||||
import type { TAppFlowSchema, TAppFlowTriggerSchema } from "flows/AppFlows";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import { isEqual } from "lodash-es";
|
||||
import type { ModuleSchemas } from "modules/ModuleManager";
|
||||
import { createContext, useCallback, useContext, useEffect } from "react";
|
||||
import { useBknd } from "ui/client";
|
||||
|
||||
export type TFlowNodeData = {
|
||||
label: string;
|
||||
type: string;
|
||||
last?: boolean;
|
||||
start?: boolean;
|
||||
responding?: boolean;
|
||||
};
|
||||
|
||||
export type FlowContextType = {
|
||||
name?: string;
|
||||
data?: TAppFlowSchema;
|
||||
schema: ModuleSchemas["flows"]["properties"]["flows"];
|
||||
actions: {
|
||||
flow: {
|
||||
setName: (name: string) => Promise<any>;
|
||||
};
|
||||
trigger: {
|
||||
update: (trigger: TAppFlowTriggerSchema) => Promise<any>;
|
||||
};
|
||||
task: {
|
||||
create: (type: string, defaults?: object) => Promise<any>;
|
||||
update: (name: string, params: any) => Promise<any>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type TFlowState = {
|
||||
dirty: boolean;
|
||||
name?: string;
|
||||
flow?: TAppFlowSchema;
|
||||
};
|
||||
export const flowStateAtom = atom<TFlowState>({
|
||||
dirty: false,
|
||||
name: undefined,
|
||||
flow: undefined
|
||||
});
|
||||
|
||||
const FlowCanvasContext = createContext<FlowContextType>(undefined!);
|
||||
|
||||
const DEFAULT_FLOW = { trigger: {}, tasks: {}, connections: {} };
|
||||
export function FlowCanvasProvider({ children, name }: { children: any; name?: string }) {
|
||||
//const [dirty, setDirty] = useState(false);
|
||||
const setFlowState = useSetAtom(flowStateAtom);
|
||||
const s = useBknd();
|
||||
const data = name ? (s.config.flows.flows[name] as TAppFlowSchema) : undefined;
|
||||
const schema = s.schema.flows.properties.flows;
|
||||
|
||||
useEffect(() => {
|
||||
if (name) {
|
||||
setFlowState({ dirty: false, name, flow: data });
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
const actions = {
|
||||
flow: {
|
||||
setName: async (name: string) => {
|
||||
console.log("set name", name);
|
||||
setFlowState((state) => ({ ...state, name, dirty: true }));
|
||||
}
|
||||
},
|
||||
trigger: {
|
||||
update: async (trigger: TAppFlowTriggerSchema | any) => {
|
||||
console.log("update trigger", trigger);
|
||||
setFlowState((state) => {
|
||||
const flow = state.flow || DEFAULT_FLOW;
|
||||
return { ...state, dirty: true, flow: { ...flow, trigger } };
|
||||
});
|
||||
//return s.actions.patch("flows", `flows.flows.${name}`, { trigger });
|
||||
}
|
||||
},
|
||||
task: {
|
||||
create: async (name: string, defaults: object = {}) => {
|
||||
console.log("create task", name, defaults);
|
||||
setFlowState((state) => {
|
||||
const flow = state.flow || (DEFAULT_FLOW as any);
|
||||
const tasks = { ...flow.tasks, [name]: defaults };
|
||||
return { ...state, dirty: true, flow: { ...flow, tasks } };
|
||||
});
|
||||
},
|
||||
|
||||
update: async (name: string, params: any) => {
|
||||
console.log("update task", name, params);
|
||||
setFlowState((state) => {
|
||||
const flow = state.flow || (DEFAULT_FLOW as any);
|
||||
const task = { ...state.flow?.tasks?.[name], params };
|
||||
return {
|
||||
...state,
|
||||
dirty: true,
|
||||
flow: { ...flow, tasks: { ...flow.tasks, [name]: task } }
|
||||
};
|
||||
});
|
||||
//return s.actions.patch("flows", `flows.flows.${name}.tasks.${name}`, task);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FlowCanvasContext.Provider value={{ name, data, schema, actions }}>
|
||||
{children}
|
||||
</FlowCanvasContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFlowCanvas() {
|
||||
return useContext(FlowCanvasContext);
|
||||
}
|
||||
|
||||
export function useFlowCanvasState() {
|
||||
return useAtomValue(flowStateAtom);
|
||||
}
|
||||
|
||||
export function useFlowSelector<Reduced = TFlowState>(
|
||||
selector: (state: TFlowState) => Reduced,
|
||||
equalityFn: (a: any, b: any) => boolean = isEqual
|
||||
) {
|
||||
const selected = selectAtom(flowStateAtom, useCallback(selector, []), equalityFn);
|
||||
return useAtom(selected)[0];
|
||||
}
|
||||
|
||||
export function flowToNodes(flow: TAppFlowSchema, name: string): Node<TFlowNodeData>[] {
|
||||
const nodes: Node<TFlowNodeData>[] = [
|
||||
{
|
||||
id: "trigger",
|
||||
data: { label: name, type: flow.trigger.type },
|
||||
type: "trigger",
|
||||
dragHandle: ".drag-handle",
|
||||
position: { x: 0, y: 0 }
|
||||
}
|
||||
];
|
||||
|
||||
let i = 1;
|
||||
const count = Object.keys(flow.tasks ?? {}).length;
|
||||
for (const [name, task] of Object.entries(flow.tasks ?? {})) {
|
||||
const last = i === count;
|
||||
const start = i === 1;
|
||||
const responding = last;
|
||||
|
||||
nodes.push({
|
||||
id: `task-${name}`,
|
||||
data: { label: name, type: task.type, last, start, responding },
|
||||
type: "task",
|
||||
dragHandle: ".drag-handle",
|
||||
// @todo: this is currently static
|
||||
position: { x: 450 * i + (i - 1) * 64, y: 0 }
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
/*nodes.push({
|
||||
id: "select",
|
||||
data: { label: "Select", type: "select" },
|
||||
type: "select",
|
||||
position: { x: 500 * i, y: 0 }
|
||||
});*/
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function flowToEdges(flow: TAppFlowSchema) {
|
||||
const tasks = Object.entries(flow.tasks ?? {});
|
||||
if (tasks.length === 0) return [];
|
||||
|
||||
const edges =
|
||||
tasks.length >= 1
|
||||
? [
|
||||
{
|
||||
id: "trigger-task",
|
||||
source: "trigger",
|
||||
target: `task-${tasks[0]?.[0]}`,
|
||||
//type: "smoothstep",
|
||||
style: {
|
||||
strokeWidth: 2
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 10,
|
||||
height: 10
|
||||
}
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
for (const [id, connection] of Object.entries(flow.connections ?? {})) {
|
||||
edges.push({
|
||||
id,
|
||||
source: "task-" + connection.source,
|
||||
target: "task-" + connection.target,
|
||||
style: {
|
||||
strokeWidth: 2
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 10,
|
||||
height: 10
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
6
app/src/ui/modules/flows/hooks/use-flow/state.ts
Normal file
6
app/src/ui/modules/flows/hooks/use-flow/state.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
const flowStateAtom = atom({
|
||||
dirty: false,
|
||||
flow: undefined
|
||||
});
|
||||
89
app/src/ui/modules/flows/utils/index.ts
Normal file
89
app/src/ui/modules/flows/utils/index.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { Flow } from "flows";
|
||||
import { TriggerComponent } from "../components/TriggerComponent";
|
||||
import { FetchTaskComponent } from "../components/tasks/FetchTaskComponent";
|
||||
import { RenderTaskComponent } from "../components/tasks/RenderTaskComponent";
|
||||
import { TaskComponent } from "../components/tasks/TaskComponent";
|
||||
|
||||
export function calculateTaskPositions(numTasks: number, offset: number): number[] {
|
||||
if (numTasks === 1) {
|
||||
return [0];
|
||||
}
|
||||
const positions: number[] = [];
|
||||
const totalOffset = (numTasks - 1) * offset;
|
||||
const startPosition = -totalOffset / 2;
|
||||
for (let i = 0; i < numTasks; i++) {
|
||||
positions.push(startPosition + i * offset);
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function getFlowNodes(flow: Flow): Node[] {
|
||||
const nodes: Node[] = [];
|
||||
const spacing = { x: 200, y: 400 };
|
||||
const spacePerLine = 26;
|
||||
|
||||
// add trigger
|
||||
nodes.push({
|
||||
id: "trigger",
|
||||
type: "trigger",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { trigger: flow.trigger },
|
||||
dragHandle: ".drag-handle"
|
||||
});
|
||||
console.log("adding node", { id: "trigger" });
|
||||
|
||||
// @todo: doesn't include unconnected tasks
|
||||
flow.getSequence().forEach((step, i) => {
|
||||
const step_count = step.length;
|
||||
const height = step.reduce((acc, task) => acc + task.name.length, 0) * spacePerLine;
|
||||
|
||||
step.forEach((task, j) => {
|
||||
const xs = calculateTaskPositions(step_count, spacing.x);
|
||||
const isRespondingTask = flow.respondingTask?.name === task.name;
|
||||
const isStartTask = flow.startTask.name === task.name;
|
||||
nodes.push({
|
||||
id: task.name,
|
||||
type: task.type,
|
||||
position: { x: xs[j]!, y: (i + 1) * spacing.y },
|
||||
data: { task, state: { i: 0, isRespondingTask, isStartTask, event: undefined } },
|
||||
dragHandle: ".drag-handle"
|
||||
});
|
||||
});
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function getFlowEdges(flow: Flow): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const startTask = flow.startTask;
|
||||
const trigger = flow.trigger;
|
||||
|
||||
// add trigger connection
|
||||
edges.push({
|
||||
id: `trigger-${startTask.name}${new Date().getTime()}`,
|
||||
source: "trigger",
|
||||
target: startTask.name
|
||||
//type: "",
|
||||
});
|
||||
|
||||
// add task connections
|
||||
flow.connections.forEach((c) => {
|
||||
edges.push({
|
||||
id: `${c.source.name}-${c.target.name}${new Date().getTime()}`,
|
||||
source: c.source.name,
|
||||
target: c.target.name
|
||||
//type: "",
|
||||
});
|
||||
});
|
||||
return edges;
|
||||
}
|
||||
|
||||
export function getNodeTypes(flow: Flow) {
|
||||
return {
|
||||
trigger: TriggerComponent,
|
||||
render: RenderTaskComponent,
|
||||
log: TaskComponent,
|
||||
fetch: TaskComponent
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user