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