mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +00:00
public commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
];
|
||||
Reference in New Issue
Block a user