public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

View 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>;
};

View 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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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}
/>
)}
</>
);
}

View File

@@ -0,0 +1,2 @@
import { FetchTaskComponent } from "./FetchTaskComponent";
import { TaskComponent } from "./TaskComponent";

View 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>
);
};

View 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;

View 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>
);
};

View 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]}
/>
);
}

View 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>
);
}

View 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
};

View File

@@ -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>
}
];

View File

@@ -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>
);
}

View File

@@ -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`} />
</>
);
};

View File

@@ -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>
}
];

View 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;
}

View File

@@ -0,0 +1,6 @@
import { atom } from "jotai";
const flowStateAtom = atom({
dirty: false,
flow: undefined
});

View 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
};
}