mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 21:06:04 +00:00
public commit
This commit is contained in:
67
app/src/ui/routes/flows/_flows.root.tsx
Normal file
67
app/src/ui/routes/flows/_flows.root.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { IconHierarchy2 } from "@tabler/icons-react";
|
||||
import { isDebug } from "core";
|
||||
import { TbSettings } from "react-icons/tb";
|
||||
import { useBknd } from "../../client/BkndProvider";
|
||||
import { IconButton } from "../../components/buttons/IconButton";
|
||||
import { Empty } from "../../components/display/Empty";
|
||||
import { Link } from "../../components/wouter/Link";
|
||||
import { useBrowserTitle } from "../../hooks/use-browser-title";
|
||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||
import { routes } from "../../lib/routes";
|
||||
|
||||
const ComingSoon = () => (
|
||||
<span className="text-xs bg-primary/10 flex rounded-full px-2.5 py-1 leading-none">
|
||||
coming soon
|
||||
</span>
|
||||
);
|
||||
|
||||
export function FlowsRoot(props) {
|
||||
const debug = isDebug();
|
||||
//const debug = false;
|
||||
return debug ? <FlowsActual {...props} /> : <FlowsEmpty />;
|
||||
}
|
||||
|
||||
export function FlowsActual({ children }) {
|
||||
const { app } = useBknd();
|
||||
return (
|
||||
<>
|
||||
<AppShell.Sidebar>
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<Link href={app.getSettingsPath(["flows"])}>
|
||||
<IconButton Icon={TbSettings} />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
Flows
|
||||
</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable initialOffset={96}>
|
||||
<div className="flex flex-col flex-grow p-3 gap-3">
|
||||
<nav className="flex flex-col flex-1 gap-1">
|
||||
<AppShell.SidebarLink as={Link} href={routes.flows.flows.list()}>
|
||||
All Flows
|
||||
</AppShell.SidebarLink>
|
||||
<AppShell.SidebarLink disabled className="justify-between">
|
||||
Endpoints
|
||||
<ComingSoon />
|
||||
</AppShell.SidebarLink>
|
||||
<AppShell.SidebarLink disabled className="justify-between">
|
||||
Executions
|
||||
<ComingSoon />
|
||||
</AppShell.SidebarLink>
|
||||
<AppShell.SidebarLink as={Link} href={app.getSettingsPath(["flows"])}>
|
||||
Settings
|
||||
</AppShell.SidebarLink>
|
||||
</nav>
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
</AppShell.Sidebar>
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlowsEmpty() {
|
||||
useBrowserTitle(["Flows"]);
|
||||
return <Empty Icon={IconHierarchy2} title="Flows" description="Flows are coming very soon!" />;
|
||||
}
|
||||
136
app/src/ui/routes/flows/components/FlowCreateModal.tsx
Normal file
136
app/src/ui/routes/flows/components/FlowCreateModal.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
||||
import { TextInput } from "@mantine/core";
|
||||
import { useFocusTrap } from "@mantine/hooks";
|
||||
import { TypeRegistry } from "@sinclair/typebox";
|
||||
import {
|
||||
type Static,
|
||||
StringEnum,
|
||||
StringIdentifier,
|
||||
Type,
|
||||
registerCustomTypeboxKinds
|
||||
} from "core/utils";
|
||||
import { TRIGGERS } from "flows/flows-schema";
|
||||
import { forwardRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFlows } from "ui/client/schema/flows/use-flows";
|
||||
import { MantineSegmentedControl } from "ui/components/form/hook-form-mantine/MantineSegmentedControl";
|
||||
import {
|
||||
Modal2,
|
||||
type Modal2Ref,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalTitle
|
||||
} from "../../../components/modal/Modal2";
|
||||
import { Step, Steps, useStepContext } from "../../../components/steps/Steps";
|
||||
|
||||
registerCustomTypeboxKinds(TypeRegistry);
|
||||
|
||||
export type TCreateFlowModalSchema = any;
|
||||
const triggerNames = Object.keys(TRIGGERS) as unknown as (keyof typeof TRIGGERS)[];
|
||||
|
||||
const schema = Type.Object({
|
||||
name: StringIdentifier,
|
||||
trigger: StringEnum(triggerNames),
|
||||
mode: StringEnum(["async", "sync"])
|
||||
});
|
||||
|
||||
export const FlowCreateModal = forwardRef<Modal2Ref>(function FlowCreateModal(props, ref) {
|
||||
const [path, setPath] = useState<string[]>([]);
|
||||
|
||||
function close() {
|
||||
// @ts-ignore
|
||||
ref?.current?.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal2 ref={ref} size="lg">
|
||||
<Steps path={path} lastBack={close}>
|
||||
<Step id="select">
|
||||
<ModalTitle path={["Create New Flow"]} onClose={close} />
|
||||
<StepCreate />
|
||||
</Step>
|
||||
</Steps>
|
||||
</Modal2>
|
||||
);
|
||||
});
|
||||
|
||||
export function StepCreate() {
|
||||
const focusTrapRef = useFocusTrap();
|
||||
const { actions } = useFlows();
|
||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateFlowModalSchema>();
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
register,
|
||||
formState: { isValid, errors }
|
||||
} = useForm({
|
||||
resolver: typeboxResolver(schema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
trigger: "manual",
|
||||
mode: "async"
|
||||
},
|
||||
mode: "onSubmit"
|
||||
});
|
||||
|
||||
async function onSubmit(data: Static<typeof schema>) {
|
||||
console.log(data, isValid);
|
||||
actions.flow.create(data.name, {
|
||||
trigger: {
|
||||
type: data.trigger,
|
||||
config: {
|
||||
mode: data.mode
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log("errors", errors);
|
||||
|
||||
return (
|
||||
<form ref={focusTrapRef} onSubmit={handleSubmit(onSubmit as any)}>
|
||||
<ModalBody className="min-h-40">
|
||||
<div>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
label="Flow Name"
|
||||
placeholder="Enter flow name"
|
||||
error={errors.name?.message as any}
|
||||
{...register("name", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<MantineSegmentedControl
|
||||
label="Trigger Type"
|
||||
name="trigger"
|
||||
data={[
|
||||
{ label: "Manual", value: "manual" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
{ label: "Event", value: "event" }
|
||||
]}
|
||||
control={control}
|
||||
/>
|
||||
<MantineSegmentedControl
|
||||
label="Execution mode"
|
||||
name="mode"
|
||||
data={[
|
||||
{ label: "Async", value: "async" },
|
||||
{ label: "Sync", value: "sync" }
|
||||
]}
|
||||
control={control}
|
||||
/>
|
||||
</div>
|
||||
<pre>{JSON.stringify(watch(), null, 2)}</pre>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
next={{
|
||||
type: "submit",
|
||||
disabled: !isValid
|
||||
}}
|
||||
nextLabel="Create"
|
||||
prev={{ onClick: stepBack }}
|
||||
prevLabel="Cancel"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
241
app/src/ui/routes/flows/flows.edit.$name.tsx
Normal file
241
app/src/ui/routes/flows/flows.edit.$name.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Slider, Tabs } from "@mantine/core";
|
||||
import { MarkerType, ReactFlowProvider } from "@xyflow/react";
|
||||
import { objectDepth } from "core/utils";
|
||||
import { useAtom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { pick, throttle } from "lodash-es";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TbArrowLeft } from "react-icons/tb";
|
||||
import { Canvas } from "ui/components/canvas/Canvas";
|
||||
import { Panels } from "ui/components/canvas/panels";
|
||||
import { Panel } from "ui/components/canvas/panels/Panel";
|
||||
import { nodeTypes } from "ui/modules/flows/components2/nodes";
|
||||
import {
|
||||
FlowCanvasProvider,
|
||||
flowToEdges,
|
||||
flowToNodes,
|
||||
useFlowCanvas,
|
||||
useFlowCanvasState,
|
||||
useFlowSelector
|
||||
} from "ui/modules/flows/hooks/use-flow";
|
||||
import { JsonViewer } from "../../components/code/JsonViewer";
|
||||
import { routes, useGoBack, useNavigate } from "../../lib/routes";
|
||||
|
||||
/**
|
||||
* @todo: AppFlows config must be updated to have fixed ids per task and connection
|
||||
* ideally in array format
|
||||
*
|
||||
*/
|
||||
|
||||
export function FlowsEdit(props) {
|
||||
return (
|
||||
<FlowCanvasProvider name={props.params.flow}>
|
||||
<ReactFlowProvider>
|
||||
<FlowsEditInner />
|
||||
</ReactFlowProvider>
|
||||
</FlowCanvasProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowsEditInner() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [rect, setRect] = useState<DOMRect>();
|
||||
const $flow = useFlowCanvas();
|
||||
if (!$flow.data || !$flow.name) return "no flow";
|
||||
|
||||
useEffect(() => {
|
||||
// get width and height of ref object
|
||||
console.log("ref", ref.current?.getBoundingClientRect());
|
||||
setRect(ref.current?.getBoundingClientRect());
|
||||
}, []);
|
||||
|
||||
const nodes = flowToNodes($flow.data, $flow.name);
|
||||
const edges = flowToEdges($flow.data) as any;
|
||||
console.log("nodes", nodes);
|
||||
console.log("edges", edges);
|
||||
|
||||
const triggerHeight = 260;
|
||||
const offset = 50;
|
||||
const viewport = {
|
||||
zoom: 1,
|
||||
x: rect?.width ? rect.width * 0.1 : 0,
|
||||
y: rect?.height ? rect.height / 2 - triggerHeight / 2 - offset : 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full" ref={ref}>
|
||||
{rect && (
|
||||
<>
|
||||
<Canvas
|
||||
externalProvider
|
||||
backgroundStyle="dots"
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
nodeDragThreshold={0}
|
||||
fitView={false}
|
||||
defaultViewport={viewport}
|
||||
nodesConnectable={true}
|
||||
onDropNewNode={(node) => ({
|
||||
...node,
|
||||
type: "select",
|
||||
data: { label: "" }
|
||||
})}
|
||||
onDropNewEdge={(edge) => ({
|
||||
...edge,
|
||||
style: {
|
||||
strokeWidth: 2
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 10,
|
||||
height: 10
|
||||
}
|
||||
})}
|
||||
>
|
||||
<FlowPanels />
|
||||
</Canvas>
|
||||
<Debugger />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowPanels() {
|
||||
const state = useFlowSelector((s) => pick(s, ["name", "dirty"]));
|
||||
|
||||
const [navigate] = useNavigate();
|
||||
const { goBack } = useGoBack(() => navigate(routes.flows.flows.list()));
|
||||
return (
|
||||
<Panels minimap zoom>
|
||||
<Panel position="top-left" className="gap-2 pr-6">
|
||||
<Panel.IconButton Icon={TbArrowLeft} round onClick={goBack} />
|
||||
<Panel.Text>
|
||||
{state.name} {state.dirty ? "*" : ""}
|
||||
</Panel.Text>
|
||||
</Panel>
|
||||
</Panels>
|
||||
);
|
||||
}
|
||||
/*
|
||||
function PanelsOld() {
|
||||
//console.log("Panels");
|
||||
const state = useFlowSelector((s) => pick(s, ["name", "dirty"]));
|
||||
|
||||
const [navigate] = useNavigate();
|
||||
const { goBack } = useGoBack(() => navigate(routes.flows.flows.list()));
|
||||
|
||||
const [minimap, setMinimap] = useState(false);
|
||||
const reactFlow = useReactFlow();
|
||||
const { zoom, x, y } = useViewport();
|
||||
const percent = Math.round(zoom * 100);
|
||||
|
||||
const handleZoomIn = async () => await reactFlow.zoomIn();
|
||||
const handleZoomReset = async () => reactFlow.zoomTo(1);
|
||||
const handleZoomOut = async () => await reactFlow.zoomOut();
|
||||
function toggleMinimap() {
|
||||
setMinimap((p) => !p);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlowPanel position="top-left" className="gap-2 pr-6">
|
||||
<FlowPanel.IconButton Icon={TbArrowLeft} round onClick={goBack} />
|
||||
<FlowPanel.Text>
|
||||
{state.name} {state.dirty ? "*" : ""}
|
||||
</FlowPanel.Text>
|
||||
</FlowPanel>
|
||||
<FlowPanel position="bottom-center">
|
||||
<FlowPanel.Text className="px-2" mono>
|
||||
{x.toFixed(2)},{y.toFixed(2)}
|
||||
</FlowPanel.Text>
|
||||
</FlowPanel>
|
||||
<FlowPanel unstyled position="bottom-right">
|
||||
<FlowPanel.Wrapper className="px-1.5">
|
||||
<FlowPanel.IconButton Icon={TbPlus} round onClick={handleZoomIn} />
|
||||
<FlowPanel.Text className="px-2" mono onClick={handleZoomReset}>
|
||||
{percent}%
|
||||
</FlowPanel.Text>
|
||||
<FlowPanel.IconButton Icon={TbMinus} round onClick={handleZoomOut} />
|
||||
<FlowPanel.IconButton Icon={TbMaximize} round onClick={handleZoomReset} />
|
||||
</FlowPanel.Wrapper>
|
||||
<FlowPanel.Wrapper>
|
||||
<FlowPanel.IconButton
|
||||
Icon={minimap ? TbSitemap : TbSitemap}
|
||||
round
|
||||
onClick={toggleMinimap}
|
||||
variant={minimap ? "default" : "ghost"}
|
||||
/>
|
||||
</FlowPanel.Wrapper>
|
||||
{minimap && <MiniMap style={{ bottom: 50, right: -5 }} ariaLabel={null} />}
|
||||
</FlowPanel>
|
||||
</>
|
||||
);
|
||||
}*/
|
||||
|
||||
type DebuggerTabProps = {
|
||||
tab: string | null;
|
||||
store?: Record<string, any>;
|
||||
};
|
||||
|
||||
const debuggerTabAtom = atomWithStorage<DebuggerTabProps>("__dev_flow_debugger_tab", { tab: null });
|
||||
const Debugger = () => {
|
||||
const [_state, _setState] = useAtom(debuggerTabAtom);
|
||||
const $flow = useFlowCanvas();
|
||||
const state = useFlowCanvasState();
|
||||
|
||||
function handleTabChange(tab: string | null) {
|
||||
_setState((prev) => ({ ...prev, tab: prev.tab === tab ? null : tab }));
|
||||
}
|
||||
|
||||
const expand = _state.store?.expand || 3;
|
||||
|
||||
return (
|
||||
<div className="flex fixed left-5 bottom-5 z-20">
|
||||
<Tabs value={_state.tab} onChange={handleTabChange}>
|
||||
<div className="max-h-96 overflow-y-scroll bg-background/70">
|
||||
<Tabs.Panel value="store">
|
||||
<div className="flex flex-row text-sm">
|
||||
<JsonViewer
|
||||
className="max-w-96 break-all"
|
||||
title="Context"
|
||||
json={{
|
||||
name: $flow.name,
|
||||
...$flow.data
|
||||
}}
|
||||
expand={expand}
|
||||
/>
|
||||
<JsonViewer
|
||||
className="max-w-96 break-all"
|
||||
title="State"
|
||||
json={{
|
||||
name: state.name,
|
||||
...state.flow
|
||||
}}
|
||||
expand={expand}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
className="w-36"
|
||||
defaultValue={expand}
|
||||
min={0}
|
||||
max={objectDepth(state.flow ?? {})}
|
||||
onChange={throttle(
|
||||
(n) =>
|
||||
_setState((prev) => ({
|
||||
...prev,
|
||||
store: { ...prev.store, expand: n }
|
||||
})),
|
||||
250
|
||||
)}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</div>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="store">Store</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
72
app/src/ui/routes/flows/flows.list.tsx
Normal file
72
app/src/ui/routes/flows/flows.list.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
import { useRef } from "react";
|
||||
import { TbTrash } from "react-icons/tb";
|
||||
import { useFlows } from "../../client/schema/flows/use-flows";
|
||||
import { Button } from "../../components/buttons/Button";
|
||||
import { CellValue, DataTable } from "../../components/table/DataTable";
|
||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||
import { routes, useNavigate } from "../../lib/routes";
|
||||
import { FlowCreateModal, type TCreateFlowModalSchema } from "./components/FlowCreateModal";
|
||||
|
||||
export function FlowsList() {
|
||||
const createModalRef = useRef<TCreateFlowModalSchema>(null);
|
||||
const [navigate] = useNavigate();
|
||||
const { flows } = useFlows();
|
||||
console.log("flows", flows);
|
||||
|
||||
const data = flows.map((flow) => ({
|
||||
flow: flow.name,
|
||||
trigger: flow.trigger.type,
|
||||
mode: flow.trigger.config.mode,
|
||||
tasks: Object.keys(flow.tasks).length,
|
||||
start_task: flow.startTask?.name
|
||||
}));
|
||||
|
||||
function handleClick(row) {
|
||||
navigate(routes.flows.flows.edit(row.flow));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlowCreateModal ref={createModalRef} />
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<Button variant="primary" onClick={() => createModalRef.current?.open()}>
|
||||
Create new
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
All Flows
|
||||
</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-col flex-grow p-3 gap-3">
|
||||
<DataTable
|
||||
data={data}
|
||||
renderValue={renderValue}
|
||||
renderHeader={ucFirstAllSnakeToPascalWithSpaces}
|
||||
onClickRow={handleClick}
|
||||
/>
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderValue = ({ value, property }) => {
|
||||
if (["is_default", "implicit_allow"].includes(property)) {
|
||||
return value ? <span>Yes</span> : <span className="opacity-50">No</span>;
|
||||
}
|
||||
|
||||
if (property === "permissions") {
|
||||
return [...(value || [])].map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
return <CellValue value={value} property={property} />;
|
||||
};
|
||||
15
app/src/ui/routes/flows/index.tsx
Normal file
15
app/src/ui/routes/flows/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Route, Switch } from "wouter";
|
||||
import { FlowsRoot } from "./_flows.root";
|
||||
import { FlowsEdit } from "./flows.edit.$name";
|
||||
import { FlowsList } from "./flows.list";
|
||||
|
||||
export default function FlowsRoutes() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/flow/:flow" component={FlowsEdit} />
|
||||
<FlowsRoot>
|
||||
<Route path="/" component={FlowsList} />
|
||||
</FlowsRoot>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user