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,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!" />;
}

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

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

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

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