mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 12:37:20 +00:00
public commit
This commit is contained in:
17
app/src/ui/components/Context.tsx
Normal file
17
app/src/ui/components/Context.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useBaseUrl } from "../client/ClientProvider";
|
||||
|
||||
export function Context() {
|
||||
const baseurl = useBaseUrl();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{JSON.stringify(
|
||||
{
|
||||
baseurl
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
app/src/ui/components/buttons/Button.tsx
Normal file
75
app/src/ui/components/buttons/Button.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type React from "react";
|
||||
import { forwardRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
|
||||
const sizes = {
|
||||
small: "px-2 py-1.5 rounded-md gap-1.5 text-sm",
|
||||
default: "px-3 py-2.5 rounded-md gap-2.5",
|
||||
large: "px-4 py-3 rounded-md gap-3 text-lg"
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
small: 15,
|
||||
default: 18,
|
||||
large: 22
|
||||
};
|
||||
|
||||
const styles = {
|
||||
default: "bg-primary/5 hover:bg-primary/10 link text-primary/70",
|
||||
primary: "bg-primary hover:bg-primary/80 link text-background",
|
||||
ghost: "bg-transparent hover:bg-primary/5 link text-primary/70",
|
||||
outline: "border border-primary/70 bg-transparent hover:bg-primary/5 link text-primary/70",
|
||||
red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70",
|
||||
subtlered:
|
||||
"dark:text-red-950 text-red-700 dark:hover:bg-red-900 bg-transparent hover:bg-red-50 link"
|
||||
};
|
||||
|
||||
export type BaseProps = {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
IconLeft?: React.ComponentType<any>;
|
||||
IconRight?: React.ComponentType<any>;
|
||||
iconSize?: number;
|
||||
iconProps?: Record<string, any>;
|
||||
size?: keyof typeof sizes;
|
||||
variant?: keyof typeof styles;
|
||||
labelClassName?: string;
|
||||
};
|
||||
|
||||
const Base = ({
|
||||
children,
|
||||
size,
|
||||
variant,
|
||||
IconLeft,
|
||||
IconRight,
|
||||
iconSize = iconSizes[size ?? "default"],
|
||||
iconProps,
|
||||
labelClassName,
|
||||
...props
|
||||
}: BaseProps) => ({
|
||||
...props,
|
||||
className: twMerge(
|
||||
"flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed",
|
||||
sizes[size ?? "default"],
|
||||
styles[variant ?? "default"],
|
||||
props.className
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{IconLeft && <IconLeft size={iconSize} {...iconProps} />}
|
||||
{children && <span className={twMerge("leading-none", labelClassName)}>{children}</span>}
|
||||
{IconRight && <IconRight size={iconSize} {...iconProps} />}
|
||||
</>
|
||||
)
|
||||
});
|
||||
|
||||
export type ButtonProps = React.ComponentPropsWithoutRef<"button"> & BaseProps;
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => (
|
||||
<button type="button" ref={ref} {...Base(props)} />
|
||||
));
|
||||
|
||||
export type ButtonLinkProps = React.ComponentPropsWithoutRef<"a"> & BaseProps & { href: string };
|
||||
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>((props, ref) => (
|
||||
<Link ref={ref} href="#" {...Base(props)} />
|
||||
));
|
||||
42
app/src/ui/components/buttons/IconButton.tsx
Normal file
42
app/src/ui/components/buttons/IconButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Icon, IconProps } from "@tabler/icons-react";
|
||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||
import type { IconType as RI_IconType } from "react-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button, type ButtonProps } from "./Button";
|
||||
|
||||
export type IconType =
|
||||
| RI_IconType
|
||||
| React.ForwardRefExoticComponent<IconProps & React.RefAttributes<Icon>>;
|
||||
|
||||
const styles = {
|
||||
xs: { className: "p-0.5", size: 13 },
|
||||
sm: { className: "p-0.5", size: 16 },
|
||||
md: { className: "p-1", size: 20 },
|
||||
lg: { className: "p-1.5", size: 24 }
|
||||
} as const;
|
||||
|
||||
interface IconButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
Icon: IconType;
|
||||
iconProps?: Record<string, any>;
|
||||
variant?: ButtonProps["variant"];
|
||||
size?: keyof typeof styles;
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
({ Icon, size, variant = "ghost", onClick, disabled, iconProps, ...rest }, ref) => {
|
||||
const style = styles[size ?? "md"];
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
iconSize={style.size}
|
||||
iconProps={iconProps}
|
||||
IconLeft={Icon}
|
||||
className={twMerge(style.className, rest.className)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
203
app/src/ui/components/canvas/Canvas.tsx
Normal file
203
app/src/ui/components/canvas/Canvas.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
type MiniMapProps,
|
||||
ReactFlow,
|
||||
type ReactFlowProps,
|
||||
ReactFlowProvider,
|
||||
addEdge,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow
|
||||
} from "@xyflow/react";
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
externalProvider?: boolean;
|
||||
backgroundStyle?: "lines" | "dots";
|
||||
minimap?: boolean | MiniMapProps;
|
||||
children?: JSX.Element | ReactNode;
|
||||
onDropNewNode?: (base: any) => any;
|
||||
onDropNewEdge?: (base: any) => any;
|
||||
};
|
||||
|
||||
export function Canvas({
|
||||
nodes: _nodes,
|
||||
edges: _edges,
|
||||
externalProvider,
|
||||
backgroundStyle = "lines",
|
||||
minimap = false,
|
||||
children,
|
||||
onDropNewNode,
|
||||
onDropNewEdge,
|
||||
...props
|
||||
}: CanvasProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { theme } = useBkndSystemTheme();
|
||||
|
||||
const [isCommandPressed, setIsCommandPressed] = useState(false);
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
const [isPointerPressed, setIsPointerPressed] = useState(false);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.metaKey) {
|
||||
setIsCommandPressed(true);
|
||||
}
|
||||
if (event.key === " ") {
|
||||
//event.preventDefault(); // Prevent default space scrolling behavior
|
||||
setIsSpacePressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (!event.metaKey) {
|
||||
setIsCommandPressed(false);
|
||||
}
|
||||
if (event.key === " ") {
|
||||
setIsSpacePressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = () => {
|
||||
if (isSpacePressed) {
|
||||
setIsPointerPressed(false);
|
||||
return;
|
||||
}
|
||||
setIsPointerPressed(true);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (isSpacePressed) {
|
||||
setIsPointerPressed(false);
|
||||
return;
|
||||
}
|
||||
setIsPointerPressed(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.querySelector("html")?.classList.add("fixed");
|
||||
|
||||
// Add global key listeners
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
// Add global pointer listeners
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
|
||||
// Cleanup event listeners on component unmount
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
document.querySelector("html")?.classList.remove("fixed");
|
||||
};
|
||||
}, []);
|
||||
|
||||
//console.log("mode", { cmd: isCommandPressed, space: isSpacePressed, mouse: isPointerPressed });
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(_nodes ?? []);
|
||||
setEdges(_edges ?? []);
|
||||
}, [_nodes, _edges]);
|
||||
|
||||
const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []);
|
||||
|
||||
const onConnectEnd = useCallback(
|
||||
(event, connectionState) => {
|
||||
if (!onDropNewNode || !onDropNewEdge) return;
|
||||
|
||||
const { fromNode, fromHandle, fromPosition } = connectionState;
|
||||
// when a connection is dropped on the pane it's not valid
|
||||
if (!connectionState.isValid) {
|
||||
console.log("conn", { event, connectionState });
|
||||
// we need to remove the wrapper bounds, in order to get the correct position
|
||||
|
||||
const { clientX, clientY } =
|
||||
"changedTouches" in event ? event.changedTouches[0] : event;
|
||||
const newNode = onDropNewNode({
|
||||
id: "select",
|
||||
type: "default",
|
||||
data: { label: "" },
|
||||
position: screenToFlowPosition({
|
||||
x: clientX,
|
||||
y: clientY
|
||||
}),
|
||||
origin: [0.0, 0.0]
|
||||
});
|
||||
|
||||
setNodes((nds) => nds.concat(newNode as any));
|
||||
setEdges((eds) =>
|
||||
eds.concat(
|
||||
onDropNewNode({
|
||||
id: newNode.id,
|
||||
source: connectionState.fromNode.id,
|
||||
target: newNode.id
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[screenToFlowPosition]
|
||||
);
|
||||
//console.log("edges1", edges);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
colorMode={theme}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={onConnectEnd}
|
||||
className={
|
||||
isCommandPressed
|
||||
? "cursor-zoom-in"
|
||||
: isSpacePressed
|
||||
? isPointerPressed
|
||||
? "cursor-grabbing"
|
||||
: "cursor-grab"
|
||||
: ""
|
||||
}
|
||||
proOptions={{
|
||||
hideAttribution: true
|
||||
}}
|
||||
fitView
|
||||
fitViewOptions={{
|
||||
maxZoom: 1.5,
|
||||
...props.fitViewOptions
|
||||
}}
|
||||
nodeDragThreshold={25}
|
||||
panOnScrollSpeed={1}
|
||||
snapToGrid
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodesConnectable={false}
|
||||
/*panOnDrag={isSpacePressed}*/
|
||||
panOnDrag={true}
|
||||
zoomOnScroll={isCommandPressed}
|
||||
panOnScroll={!isCommandPressed}
|
||||
zoomOnDoubleClick={false}
|
||||
selectionOnDrag={!isSpacePressed}
|
||||
{...props}
|
||||
>
|
||||
{backgroundStyle === "lines" && (
|
||||
<Background
|
||||
color={theme === "light" ? "rgba(0,0,0,.1)" : "rgba(255,255,255,.1)"}
|
||||
gap={[50, 50]}
|
||||
variant={BackgroundVariant.Lines}
|
||||
/>
|
||||
)}
|
||||
{backgroundStyle === "dots" && (
|
||||
<Background color={theme === "light" ? "rgba(0,0,0,.5)" : "rgba(255,255,255,.2)"} />
|
||||
)}
|
||||
{minimap && <MiniMap {...(typeof minimap === "object" ? minimap : {})} />}
|
||||
{children}
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ElementProps } from "@mantine/core";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
type TDefaultNodeProps = ElementProps<"div"> & {
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export function DefaultNode({ selected, children, className, ...props }: TDefaultNodeProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"relative w-80 shadow-lg rounded-lg bg-background",
|
||||
selected && "outline outline-blue-500/25",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TDefaultNodeHeaderProps = ElementProps<"div"> & {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const Header: React.FC<TDefaultNodeHeaderProps> = ({ className, label, children, ...props }) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row bg-primary/15 justify-center items-center rounded-tl-lg rounded-tr-lg py-1 px-2 drag-handle",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span className="font-semibold opacity-75 font-mono">{label ?? "Untitled node"}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Content: React.FC<ElementProps<"div">> = ({ children, className, ...props }) => (
|
||||
<div {...props} className={twMerge("px-2 py-1.5 pb-2 flex flex-col", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
DefaultNode.Header = Header;
|
||||
DefaultNode.Content = Content;
|
||||
55
app/src/ui/components/canvas/layouts/index.ts
Normal file
55
app/src/ui/components/canvas/layouts/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
|
||||
type Position = "top" | "right" | "bottom" | "left";
|
||||
type Node = {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
|
||||
type Edge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
export type LayoutProps = {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
graph?: Dagre.GraphLabel;
|
||||
};
|
||||
|
||||
export const layoutWithDagre = ({ nodes, edges, graph }: LayoutProps) => {
|
||||
const dagreGraph = new Dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
dagreGraph.setGraph(graph || {});
|
||||
/*dagreGraph.setGraph({
|
||||
rankdir: "LR",
|
||||
align: "UR",
|
||||
nodesep: NODE_SEP,
|
||||
ranksep: RANK_SEP
|
||||
});*/
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, {
|
||||
width: node.width,
|
||||
height: node.height
|
||||
});
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.target, edge.source);
|
||||
});
|
||||
|
||||
Dagre.layout(dagreGraph);
|
||||
|
||||
return {
|
||||
nodes: nodes.map((node) => {
|
||||
const position = dagreGraph.node(node.id);
|
||||
return { ...node, x: position.x, y: position.y };
|
||||
}),
|
||||
edges
|
||||
};
|
||||
};
|
||||
78
app/src/ui/components/canvas/panels/Panel.tsx
Normal file
78
app/src/ui/components/canvas/panels/Panel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { type PanelPosition, Panel as XYPanel } from "@xyflow/react";
|
||||
import { type ComponentPropsWithoutRef, type HTMLAttributes, forwardRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton as _IconButton } from "ui/components/buttons/IconButton";
|
||||
|
||||
export type PanelProps = HTMLAttributes<HTMLDivElement> & {
|
||||
position: PanelPosition;
|
||||
unstyled?: boolean;
|
||||
};
|
||||
|
||||
export function Panel({ position, className, children, unstyled, ...props }: PanelProps) {
|
||||
if (unstyled) {
|
||||
return (
|
||||
<XYPanel
|
||||
position={position}
|
||||
className={twMerge("flex flex-row p-1 gap-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</XYPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<XYPanel position={position} {...props}>
|
||||
<Wrapper className={className}>{children}</Wrapper>
|
||||
</XYPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = ({ children, className, ...props }: ComponentPropsWithoutRef<"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
|
||||
}: ComponentPropsWithoutRef<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, ComponentPropsWithoutRef<"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>
|
||||
)
|
||||
);
|
||||
|
||||
Panel.Wrapper = Wrapper;
|
||||
Panel.IconButton = IconButton;
|
||||
Panel.Text = Text;
|
||||
65
app/src/ui/components/canvas/panels/index.tsx
Normal file
65
app/src/ui/components/canvas/panels/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { MiniMap, useReactFlow, useViewport } from "@xyflow/react";
|
||||
import { useState } from "react";
|
||||
import { TbMaximize, TbMinus, TbPlus, TbSitemap } from "react-icons/tb";
|
||||
import { Panel } from "ui/components/canvas/panels/Panel";
|
||||
|
||||
export type PanelsProps = {
|
||||
children?: React.ReactNode;
|
||||
coordinates?: boolean;
|
||||
minimap?: boolean;
|
||||
zoom?: boolean;
|
||||
};
|
||||
|
||||
export function Panels({ children, ...props }: PanelsProps) {
|
||||
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 (
|
||||
<>
|
||||
{children}
|
||||
{props.coordinates && (
|
||||
<Panel position="bottom-center">
|
||||
<Panel.Text className="px-2" mono>
|
||||
{x.toFixed(2)},{y.toFixed(2)}
|
||||
</Panel.Text>
|
||||
</Panel>
|
||||
)}
|
||||
<Panel unstyled position="bottom-right">
|
||||
{props.zoom && (
|
||||
<>
|
||||
<Panel.Wrapper className="px-1.5">
|
||||
<Panel.IconButton Icon={TbPlus} round onClick={handleZoomIn} />
|
||||
<Panel.Text className="px-2" mono onClick={handleZoomReset}>
|
||||
{percent}%
|
||||
</Panel.Text>
|
||||
<Panel.IconButton Icon={TbMinus} round onClick={handleZoomOut} />
|
||||
<Panel.IconButton Icon={TbMaximize} round onClick={handleZoomReset} />
|
||||
</Panel.Wrapper>
|
||||
</>
|
||||
)}
|
||||
{props.minimap && (
|
||||
<>
|
||||
<Panel.Wrapper>
|
||||
<Panel.IconButton
|
||||
Icon={minimap ? TbSitemap : TbSitemap}
|
||||
round
|
||||
onClick={toggleMinimap}
|
||||
variant={minimap ? "default" : "ghost"}
|
||||
/>
|
||||
</Panel.Wrapper>
|
||||
{minimap && <MiniMap style={{ bottom: 50, right: -5 }} ariaLabel={null} />}
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
app/src/ui/components/code/CodeEditor.tsx
Normal file
27
app/src/ui/components/code/CodeEditor.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { useBknd } from "ui/client";
|
||||
const CodeMirror = lazy(() => import("@uiw/react-codemirror"));
|
||||
|
||||
export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) {
|
||||
const b = useBknd();
|
||||
const theme = b.app.getAdminConfig().color_scheme;
|
||||
const _basicSetup: Partial<ReactCodeMirrorProps["basicSetup"]> = !editable
|
||||
? {
|
||||
...(typeof basicSetup === "object" ? basicSetup : {}),
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false
|
||||
}
|
||||
: basicSetup;
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<CodeMirror
|
||||
theme={theme === "dark" ? "dark" : "light"}
|
||||
editable={editable}
|
||||
basicSetup={_basicSetup}
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
22
app/src/ui/components/code/JsonEditor.tsx
Normal file
22
app/src/ui/components/code/JsonEditor.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||
|
||||
export function JsonEditor({ editable, className, ...props }: ReactCodeMirrorProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CodeEditor
|
||||
className={twMerge(
|
||||
"flex w-full border border-muted",
|
||||
!editable && "opacity-70",
|
||||
className
|
||||
)}
|
||||
editable={editable}
|
||||
extensions={[json()]}
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
71
app/src/ui/components/code/JsonViewer.tsx
Normal file
71
app/src/ui/components/code/JsonViewer.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { IconCopy } from "@tabler/icons-react";
|
||||
import { TbCopy } from "react-icons/tb";
|
||||
import { JsonView } from "react-json-view-lite";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "../buttons/IconButton";
|
||||
|
||||
export const JsonViewer = ({
|
||||
json,
|
||||
title,
|
||||
expand = 0,
|
||||
showSize = false,
|
||||
showCopy = false,
|
||||
className
|
||||
}: {
|
||||
json: object;
|
||||
title?: string;
|
||||
expand?: number;
|
||||
showSize?: boolean;
|
||||
showCopy?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const size = showSize ? JSON.stringify(json).length : undefined;
|
||||
const showContext = size || title || showCopy;
|
||||
|
||||
function onCopy() {
|
||||
navigator.clipboard?.writeText(JSON.stringify(json, null, 2));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={twMerge("bg-primary/5 py-3 relative overflow-hidden", className)}>
|
||||
{showContext && (
|
||||
<div className="absolute right-4 top-4 font-mono text-zinc-400 flex flex-row gap-2 items-center">
|
||||
{(title || size) && (
|
||||
<div className="flex flex-row">
|
||||
{title && <span>{title}</span>} {size && <span>({size} Bytes)</span>}
|
||||
</div>
|
||||
)}
|
||||
{showCopy && (
|
||||
<div>
|
||||
<IconButton Icon={TbCopy} onClick={onCopy} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<JsonView
|
||||
data={json}
|
||||
shouldExpandNode={(level) => level < expand}
|
||||
style={
|
||||
{
|
||||
basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20",
|
||||
container: "ml-[-10px]",
|
||||
label: "text-primary/90 font-bold font-mono mr-2",
|
||||
stringValue: "text-emerald-500 font-mono select-text",
|
||||
numberValue: "text-sky-400 font-mono",
|
||||
nullValue: "text-zinc-400 font-mono",
|
||||
undefinedValue: "text-zinc-400 font-mono",
|
||||
otherValue: "text-zinc-400 font-mono",
|
||||
booleanValue: "text-orange-400 font-mono",
|
||||
punctuation: "text-zinc-400 font-bold font-mono m-0.5",
|
||||
collapsedContent: "text-zinc-400 font-mono after:content-['...']",
|
||||
collapseIcon:
|
||||
"text-zinc-400 font-mono font-bold text-lg after:content-['▾'] mr-1.5",
|
||||
expandIcon:
|
||||
"text-zinc-400 font-mono font-bold text-lg after:content-['▸'] mr-1.5",
|
||||
noQuotesForStringValues: false
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
123
app/src/ui/components/code/LiquidJsEditor.tsx
Normal file
123
app/src/ui/components/code/LiquidJsEditor.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { liquid } from "@codemirror/lang-liquid";
|
||||
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||
|
||||
const filters = [
|
||||
{ label: "abs" },
|
||||
{ label: "append" },
|
||||
{ label: "array_to_sentence_string" },
|
||||
{ label: "at_least" },
|
||||
{ label: "at_most" },
|
||||
{ label: "capitalize" },
|
||||
{ label: "ceil" },
|
||||
{ label: "cgi_escape" },
|
||||
{ label: "compact" },
|
||||
{ label: "concat" },
|
||||
{ label: "date" },
|
||||
{ label: "date_to_long_string" },
|
||||
{ label: "date_to_rfc822" },
|
||||
{ label: "date_to_string" },
|
||||
{ label: "date_to_xmlschema" },
|
||||
{ label: "default" },
|
||||
{ label: "divided_by" },
|
||||
{ label: "downcase" },
|
||||
{ label: "escape" },
|
||||
{ label: "escape_once" },
|
||||
{ label: "find" },
|
||||
{ label: "find_exp" },
|
||||
{ label: "first" },
|
||||
{ label: "floor" },
|
||||
{ label: "group_by" },
|
||||
{ label: "group_by_exp" },
|
||||
{ label: "inspect" },
|
||||
{ label: "join" },
|
||||
{ label: "json" },
|
||||
{ label: "jsonify" },
|
||||
{ label: "last" },
|
||||
{ label: "lstrip" },
|
||||
{ label: "map" },
|
||||
{ label: "minus" },
|
||||
{ label: "modulo" },
|
||||
{ label: "newline_to_br" },
|
||||
{ label: "normalize_whitespace" },
|
||||
{ label: "number_of_words" },
|
||||
{ label: "plus" },
|
||||
{ label: "pop" },
|
||||
{ label: "push" },
|
||||
{ label: "prepend" },
|
||||
{ label: "raw" },
|
||||
{ label: "remove" },
|
||||
{ label: "remove_first" },
|
||||
{ label: "remove_last" },
|
||||
{ label: "replace" },
|
||||
{ label: "replace_first" },
|
||||
{ label: "replace_last" },
|
||||
{ label: "reverse" },
|
||||
{ label: "round" },
|
||||
{ label: "rstrip" },
|
||||
{ label: "shift" },
|
||||
{ label: "size" },
|
||||
{ label: "slice" },
|
||||
{ label: "slugify" },
|
||||
{ label: "sort" },
|
||||
{ label: "sort_natural" },
|
||||
{ label: "split" },
|
||||
{ label: "strip" },
|
||||
{ label: "strip_html" },
|
||||
{ label: "strip_newlines" },
|
||||
{ label: "sum" },
|
||||
{ label: "times" },
|
||||
{ label: "to_integer" },
|
||||
{ label: "truncate" },
|
||||
{ label: "truncatewords" },
|
||||
{ label: "uniq" },
|
||||
{ label: "unshift" },
|
||||
{ label: "upcase" },
|
||||
{ label: "uri_escape" },
|
||||
{ label: "url_decode" },
|
||||
{ label: "url_encode" },
|
||||
{ label: "where" },
|
||||
{ label: "where_exp" },
|
||||
{ label: "xml_escape" }
|
||||
];
|
||||
|
||||
const tags = [
|
||||
{ label: "assign" },
|
||||
{ label: "capture" },
|
||||
{ label: "case" },
|
||||
{ label: "comment" },
|
||||
{ label: "cycle" },
|
||||
{ label: "decrement" },
|
||||
{ label: "echo" },
|
||||
{ label: "else" },
|
||||
{ label: "elsif" },
|
||||
{ label: "for" },
|
||||
{ label: "if" },
|
||||
{ label: "include" },
|
||||
{ label: "increment" },
|
||||
{ label: "layout" },
|
||||
{ label: "liquid" },
|
||||
{ label: "raw" },
|
||||
{ label: "render" },
|
||||
{ label: "tablerow" },
|
||||
{ label: "unless" },
|
||||
{ label: "when" }
|
||||
];
|
||||
|
||||
export function LiquidJsEditor({ editable, ...props }: ReactCodeMirrorProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CodeEditor
|
||||
className={twMerge(
|
||||
"flex w-full border border-muted bg-white rounded-lg",
|
||||
!editable && "opacity-70"
|
||||
)}
|
||||
editable={editable}
|
||||
extensions={[liquid({ filters, tags })]}
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
33
app/src/ui/components/display/Empty.tsx
Normal file
33
app/src/ui/components/display/Empty.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Button } from "../buttons/Button";
|
||||
|
||||
type EmptyProps = {
|
||||
Icon?: any;
|
||||
title?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
buttonOnClick?: () => void;
|
||||
};
|
||||
export const Empty: React.FC<EmptyProps> = ({
|
||||
Icon = undefined,
|
||||
title = undefined,
|
||||
description = "Check back later my friend.",
|
||||
buttonText,
|
||||
buttonOnClick
|
||||
}) => (
|
||||
<div className="flex flex-col h-full w-full justify-center items-center">
|
||||
<div className="flex flex-col gap-3 items-center max-w-80">
|
||||
{Icon && <Icon size={48} className="opacity-50" stroke={1} />}
|
||||
<div className="flex flex-col gap-1">
|
||||
{title && <h3 className="text-center text-lg font-bold">{title}</h3>}
|
||||
<p className="text-center text-primary/60">{description}</p>
|
||||
</div>
|
||||
{buttonText && (
|
||||
<div className="mt-1.5">
|
||||
<Button variant="primary" onClick={buttonOnClick}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
31
app/src/ui/components/display/Logo.tsx
Normal file
31
app/src/ui/components/display/Logo.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useBknd } from "../../client/BkndProvider";
|
||||
|
||||
export function Logo({ scale = 0.2, fill }: { scale?: number; fill?: string }) {
|
||||
const { app } = useBknd();
|
||||
const theme = app.getAdminConfig().color_scheme;
|
||||
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
|
||||
|
||||
const dim = {
|
||||
width: Math.round(578 * scale),
|
||||
height: Math.round(188 * scale)
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div style={dim}>
|
||||
<svg
|
||||
width={dim.width}
|
||||
height={dim.height}
|
||||
viewBox="0 0 578 188"
|
||||
fill={svgFill}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M41.5 34C37.0817 34 33.5 37.5817 33.5 42V146C33.5 150.418 37.0817 154 41.5 154H158.5C162.918 154 166.5 150.418 166.5 146V42C166.5 37.5817 162.918 34 158.5 34H41.5ZM123.434 113.942C124.126 111.752 124.5 109.42 124.5 107C124.5 94.2975 114.203 84 101.5 84C99.1907 84 96.9608 84.3403 94.8579 84.9736L87.2208 65.1172C90.9181 63.4922 93.5 59.7976 93.5 55.5C93.5 49.701 88.799 45 83 45C77.201 45 72.5 49.701 72.5 55.5C72.5 61.299 77.201 66 83 66C83.4453 66 83.8841 65.9723 84.3148 65.9185L92.0483 86.0256C87.1368 88.2423 83.1434 92.1335 80.7957 96.9714L65.4253 91.1648C65.4746 90.7835 65.5 90.3947 65.5 90C65.5 85.0294 61.4706 81 56.5 81C51.5294 81 47.5 85.0294 47.5 90C47.5 94.9706 51.5294 99 56.5 99C60.0181 99 63.0648 96.9814 64.5449 94.0392L79.6655 99.7514C78.9094 102.03 78.5 104.467 78.5 107C78.5 110.387 79.2321 113.603 80.5466 116.498L69.0273 123.731C67.1012 121.449 64.2199 120 61 120C55.201 120 50.5 124.701 50.5 130.5C50.5 136.299 55.201 141 61 141C66.799 141 71.5 136.299 71.5 130.5C71.5 128.997 71.1844 127.569 70.6158 126.276L81.9667 119.149C86.0275 125.664 93.2574 130 101.5 130C110.722 130 118.677 124.572 122.343 116.737L132.747 120.899C132.585 121.573 132.5 122.276 132.5 123C132.5 127.971 136.529 132 141.5 132C146.471 132 150.5 127.971 150.5 123C150.5 118.029 146.471 114 141.5 114C138.32 114 135.525 115.649 133.925 118.139L123.434 113.942Z"
|
||||
/>
|
||||
<path d="M243.9 151.5C240.4 151.5 237 151 233.7 150C230.4 149 227.4 147.65 224.7 145.95C222 144.15 219.75 142.15 217.95 139.95C216.15 137.65 215 135.3 214.5 132.9L219.3 131.1L218.25 149.7H198.15V39H219.45V89.25L215.4 87.6C216 85.2 217.15 82.9 218.85 80.7C220.55 78.4 222.7 76.4 225.3 74.7C227.9 72.9 230.75 71.5 233.85 70.5C236.95 69.5 240.15 69 243.45 69C250.35 69 256.5 70.8 261.9 74.4C267.3 77.9 271.55 82.75 274.65 88.95C277.85 95.15 279.45 102.25 279.45 110.25C279.45 118.25 277.9 125.35 274.8 131.55C271.7 137.75 267.45 142.65 262.05 146.25C256.75 149.75 250.7 151.5 243.9 151.5ZM238.8 133.35C242.8 133.35 246.25 132.4 249.15 130.5C252.15 128.5 254.5 125.8 256.2 122.4C257.9 118.9 258.75 114.85 258.75 110.25C258.75 105.75 257.9 101.75 256.2 98.25C254.6 94.75 252.3 92.05 249.3 90.15C246.3 88.25 242.8 87.3 238.8 87.3C234.8 87.3 231.3 88.25 228.3 90.15C225.3 92.05 222.95 94.75 221.25 98.25C219.55 101.75 218.7 105.75 218.7 110.25C218.7 114.85 219.55 118.9 221.25 122.4C222.95 125.8 225.3 128.5 228.3 130.5C231.3 132.4 234.8 133.35 238.8 133.35ZM308.312 126.15L302.012 108.6L339.512 70.65H367.562L308.312 126.15ZM288.062 150V39H309.362V150H288.062ZM341.762 150L313.262 114.15L328.262 102.15L367.412 150H341.762ZM371.675 150V70.65H392.075L392.675 86.85L388.475 88.65C389.575 85.05 391.525 81.8 394.325 78.9C397.225 75.9 400.675 73.5 404.675 71.7C408.675 69.9 412.875 69 417.275 69C423.275 69 428.275 70.2 432.275 72.6C436.375 75 439.425 78.65 441.425 83.55C443.525 88.35 444.575 94.3 444.575 101.4V150H423.275V103.05C423.275 99.45 422.775 96.45 421.775 94.05C420.775 91.65 419.225 89.9 417.125 88.8C415.125 87.6 412.625 87.1 409.625 87.3C407.225 87.3 404.975 87.7 402.875 88.5C400.875 89.2 399.125 90.25 397.625 91.65C396.225 93.05 395.075 94.65 394.175 96.45C393.375 98.25 392.975 100.2 392.975 102.3V150H382.475C380.175 150 378.125 150 376.325 150C374.525 150 372.975 150 371.675 150ZM488.536 151.5C481.636 151.5 475.436 149.75 469.936 146.25C464.436 142.65 460.086 137.8 456.886 131.7C453.786 125.5 452.236 118.35 452.236 110.25C452.236 102.35 453.786 95.3 456.886 89.1C460.086 82.9 464.386 78 469.786 74.4C475.286 70.8 481.536 69 488.536 69C492.236 69 495.786 69.6 499.186 70.8C502.686 71.9 505.786 73.45 508.486 75.45C511.286 77.45 513.536 79.7 515.236 82.2C516.936 84.6 517.886 87.15 518.086 89.85L512.686 90.75V39H533.986V150H513.886L512.986 131.7L517.186 132.15C516.986 134.65 516.086 137.05 514.486 139.35C512.886 141.65 510.736 143.75 508.036 145.65C505.436 147.45 502.436 148.9 499.036 150C495.736 151 492.236 151.5 488.536 151.5ZM493.336 133.8C497.336 133.8 500.836 132.8 503.836 130.8C506.836 128.8 509.186 126.05 510.886 122.55C512.586 119.05 513.436 114.95 513.436 110.25C513.436 105.65 512.586 101.6 510.886 98.1C509.186 94.5 506.836 91.75 503.836 89.85C500.836 87.85 497.336 86.85 493.336 86.85C489.336 86.85 485.836 87.85 482.836 89.85C479.936 91.75 477.636 94.5 475.936 98.1C474.336 101.6 473.536 105.65 473.536 110.25C473.536 114.95 474.336 119.05 475.936 122.55C477.636 126.05 479.936 128.8 482.836 130.8C485.836 132.8 489.336 133.8 493.336 133.8Z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
app/src/ui/components/form/FloatingSelect/FloatingSelect.tsx
Normal file
55
app/src/ui/components/form/FloatingSelect/FloatingSelect.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { FloatingIndicator, Input, UnstyledButton } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type FloatingSelectProps = {
|
||||
data: string[];
|
||||
description?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function FloatingSelect({ data, label, description }: FloatingSelectProps) {
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||
const [controlsRefs, setControlsRefs] = useState<Record<string, HTMLButtonElement | null>>({});
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
const setControlRef = (index: number) => (node: HTMLButtonElement) => {
|
||||
controlsRefs[index] = node;
|
||||
setControlsRefs(controlsRefs);
|
||||
};
|
||||
|
||||
const controls = data.map((item, index) => (
|
||||
<button
|
||||
key={item}
|
||||
className={twMerge(
|
||||
"transition-colors duration-100 px-2.5 py-2 leading-none rounded-lg text-md",
|
||||
active === index && "text-white"
|
||||
)}
|
||||
ref={setControlRef(index)}
|
||||
onClick={() => setActive(index)}
|
||||
>
|
||||
<span className="relative z-[1]">{item}</span>
|
||||
</button>
|
||||
));
|
||||
|
||||
return (
|
||||
<Input.Wrapper className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<div className="flex flex-col">
|
||||
<Input.Label>{label}</Input.Label>
|
||||
{description && <Input.Description>{description}</Input.Description>}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-fit bg-primary/5 px-1.5 py-1 rounded-lg" ref={setRootRef}>
|
||||
{controls}
|
||||
|
||||
<FloatingIndicator
|
||||
target={controlsRefs[active]}
|
||||
parent={rootRef}
|
||||
className="bg-primary rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
{/*<Input.Error>Input error</Input.Error>*/}
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
176
app/src/ui/components/form/Formy.tsx
Normal file
176
app/src/ui/components/form/Formy.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Switch } from "@mantine/core";
|
||||
import { getBrowser } from "core/utils";
|
||||
import type { Field } from "data";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
import { IconButton } from "../buttons/IconButton";
|
||||
|
||||
export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({
|
||||
error,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col gap-1.5",
|
||||
|
||||
error && "text-red-500",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export const formElementFactory = (element: string, props: any) => {
|
||||
switch (element) {
|
||||
case "date":
|
||||
return DateInput;
|
||||
case "boolean":
|
||||
return BooleanInput;
|
||||
case "textarea":
|
||||
return Textarea;
|
||||
default:
|
||||
return Input;
|
||||
}
|
||||
};
|
||||
|
||||
export const Label: React.FC<React.ComponentProps<"label">> = (props) => <label {...props} />;
|
||||
|
||||
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
|
||||
field,
|
||||
...props
|
||||
}) => {
|
||||
const desc = field.getDescription();
|
||||
return (
|
||||
<Label {...props} title={desc} className="flex flex-row gap-2 items-center">
|
||||
{field.getLabel()}
|
||||
{desc && <TbInfoCircle className="opacity-50" />}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>((props, ref) => {
|
||||
const disabledOrReadonly = props.disabled || props.readOnly;
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none",
|
||||
disabledOrReadonly && "bg-muted/50 text-primary/50",
|
||||
!disabledOrReadonly &&
|
||||
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
rows={3}
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const DateInput = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
(props, ref) => {
|
||||
const innerRef = useRef<HTMLInputElement>(null);
|
||||
const browser = getBrowser();
|
||||
useImperativeHandle(ref, () => innerRef.current!);
|
||||
|
||||
const handleClick = useEvent(() => {
|
||||
if (innerRef?.current) {
|
||||
innerRef.current.focus();
|
||||
if (["Safari"].includes(browser)) {
|
||||
innerRef.current.click();
|
||||
} else {
|
||||
innerRef.current.showPicker();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="absolute h-full right-3 top-0 bottom-0 flex items-center">
|
||||
<IconButton Icon={TbCalendar} onClick={handleClick} />
|
||||
</div>
|
||||
<Input
|
||||
{...props}
|
||||
type={props.type ?? "date"}
|
||||
ref={innerRef}
|
||||
className="w-full appearance-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
(props, ref) => {
|
||||
const [checked, setChecked] = useState(Boolean(props.value));
|
||||
|
||||
useEffect(() => {
|
||||
setChecked(Boolean(props.value));
|
||||
}, [props.value]);
|
||||
|
||||
function handleCheck(e) {
|
||||
setChecked(e.target.checked);
|
||||
props.onChange?.(e.target.checked);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<Switch
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
onChange={handleCheck}
|
||||
disabled={props.disabled}
|
||||
id={props.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
/*return (
|
||||
<div className="h-11 flex items-center">
|
||||
<input
|
||||
{...props}
|
||||
type="checkbox"
|
||||
ref={ref}
|
||||
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 scale-150 ml-1"
|
||||
checked={checked}
|
||||
onChange={handleCheck}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</div>
|
||||
);*/
|
||||
}
|
||||
);
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, React.ComponentProps<"select">>(
|
||||
(props, ref) => (
|
||||
<div className="flex w-full relative">
|
||||
<select
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||
"appearance-none h-11 w-full",
|
||||
"border-r-8 border-r-transparent",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
<TbChevronDown className="absolute right-3 top-0 bottom-0 h-full opacity-70" size={18} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
16
app/src/ui/components/form/SearchInput.tsx
Normal file
16
app/src/ui/components/form/SearchInput.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ElementProps } from "@mantine/core";
|
||||
import { TbSearch } from "react-icons/tb";
|
||||
|
||||
export const SearchInput = (props: ElementProps<"input">) => (
|
||||
<div className="w-full relative shadow-sm">
|
||||
<div className="absolute h-full flex items-center px-3 mt-[0.5px] text-zinc-500">
|
||||
<TbSearch size={18} />
|
||||
</div>
|
||||
<input
|
||||
className="bg-transparent border-muted border rounded-md py-2 pl-10 pr-3 w-full outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all duration-200 ease-in-out"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
24
app/src/ui/components/form/SegmentedControl.tsx
Normal file
24
app/src/ui/components/form/SegmentedControl.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
Input,
|
||||
SegmentedControl as MantineSegmentedControl,
|
||||
type SegmentedControlProps as MantineSegmentedControlProps
|
||||
} from "@mantine/core";
|
||||
|
||||
type SegmentedControlProps = MantineSegmentedControlProps & {
|
||||
label?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function SegmentedControl({ label, description, size, ...props }: SegmentedControlProps) {
|
||||
return (
|
||||
<Input.Wrapper className="relative">
|
||||
{label && (
|
||||
<div className="flex flex-col">
|
||||
<Input.Label size={size}>{label}</Input.Label>
|
||||
{description && <Input.Description size={size}>{description}</Input.Description>}
|
||||
</div>
|
||||
)}
|
||||
<MantineSegmentedControl {...props} size={size} />
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
NumberInput as $NumberInput,
|
||||
type NumberInputProps as $NumberInputProps
|
||||
} from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type MantineNumberInputProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$NumberInputProps, "value" | "defaultValue">;
|
||||
|
||||
export function MantineNumberInput<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: MantineNumberInputProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$NumberInput
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Radio as $Radio,
|
||||
RadioGroup as $RadioGroup,
|
||||
type RadioGroupProps as $RadioGroupProps,
|
||||
type RadioProps as $RadioProps
|
||||
} from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type RadioProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$RadioProps, "value" | "defaultValue">;
|
||||
|
||||
export function MantineRadio<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: RadioProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field }
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$Radio
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type RadioGroupProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$RadioGroupProps, "value" | "defaultValue">;
|
||||
|
||||
function RadioGroup<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: RadioGroupProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$RadioGroup
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
MantineRadio.Group = RadioGroup;
|
||||
MantineRadio.Item = $Radio;
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
SegmentedControl as $SegmentedControl,
|
||||
type SegmentedControlProps as $SegmentedControlProps,
|
||||
Input
|
||||
} from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type MantineSegmentedControlProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$SegmentedControlProps, "values" | "defaultValues"> & {
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function MantineSegmentedControl<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
label,
|
||||
size,
|
||||
description,
|
||||
error,
|
||||
...props
|
||||
}: MantineSegmentedControlProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field }
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<Input.Wrapper className="relative">
|
||||
{label && (
|
||||
<div className="flex flex-col">
|
||||
<Input.Label size={size}>{label}</Input.Label>
|
||||
{description && <Input.Description size={size}>{description}</Input.Description>}
|
||||
</div>
|
||||
)}
|
||||
<$SegmentedControl
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
size={size}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Select, type SelectProps } from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type MantineSelectProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<SelectProps, "value" | "defaultValue">;
|
||||
|
||||
// @todo: change is not triggered correctly
|
||||
export function MantineSelect<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: MantineSelectProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={async (e) => {
|
||||
//console.log("change1", name, field.name, e);
|
||||
await fieldOnChange({
|
||||
...new Event("change", { bubbles: true, cancelable: true }),
|
||||
target: {
|
||||
value: e,
|
||||
name: field.name
|
||||
}
|
||||
});
|
||||
// @ts-ignore
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Switch as $Switch, type SwitchProps as $SwitchProps } from "@mantine/core";
|
||||
import { type FieldValues, type UseControllerProps, useController } from "react-hook-form";
|
||||
|
||||
export type SwitchProps<T extends FieldValues> = UseControllerProps<T> &
|
||||
Omit<$SwitchProps, "value" | "checked" | "defaultValue">;
|
||||
|
||||
export function MantineSwitch<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
onChange,
|
||||
...props
|
||||
}: SwitchProps<T>) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState
|
||||
} = useController<T>({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister
|
||||
});
|
||||
|
||||
return (
|
||||
<$Switch
|
||||
value={value}
|
||||
checked={value}
|
||||
onChange={(e) => {
|
||||
fieldOnChange(e);
|
||||
onChange?.(e);
|
||||
}}
|
||||
error={fieldState.error?.message}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
159
app/src/ui/components/form/json-schema/JsonSchemaForm.tsx
Normal file
159
app/src/ui/components/form/json-schema/JsonSchemaForm.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { Schema } from "@cfworker/json-schema";
|
||||
import Form from "@rjsf/core";
|
||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
import { forwardRef, useId, useImperativeHandle, useRef, useState } from "react";
|
||||
//import { JsonSchemaValidator } from "./JsonSchemaValidator";
|
||||
import { fields as Fields } from "./fields";
|
||||
import { templates as Templates } from "./templates";
|
||||
import { widgets as Widgets } from "./widgets";
|
||||
import "./styles.css";
|
||||
import { filterKeys } from "core/utils";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { RJSFTypeboxValidator } from "./typebox/RJSFTypeboxValidator";
|
||||
|
||||
const validator = new RJSFTypeboxValidator();
|
||||
|
||||
// @todo: don't import FormProps, instead, copy it here instead of "any"
|
||||
export type JsonSchemaFormProps = any & {
|
||||
schema: RJSFSchema | Schema;
|
||||
uiSchema?: any;
|
||||
direction?: "horizontal" | "vertical";
|
||||
onChange?: (value: any) => void;
|
||||
};
|
||||
|
||||
export type JsonSchemaFormRef = {
|
||||
formData: () => any;
|
||||
validateForm: () => boolean;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
direction = "vertical",
|
||||
schema,
|
||||
onChange,
|
||||
uiSchema,
|
||||
templates,
|
||||
fields,
|
||||
widgets,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const formRef = useRef<Form<any, RJSFSchema, any>>(null);
|
||||
const id = useId();
|
||||
const [value, setValue] = useState<any>(props.formData);
|
||||
|
||||
const onSubmit = ({ formData }: any, e) => {
|
||||
e.preventDefault();
|
||||
console.log("Data submitted: ", formData);
|
||||
props.onSubmit?.(formData);
|
||||
return false;
|
||||
};
|
||||
const handleChange = ({ formData }: any, e) => {
|
||||
const clean = JSON.parse(JSON.stringify(formData));
|
||||
//console.log("Data changed: ", clean, JSON.stringify(formData, null, 2));
|
||||
onChange?.(clean);
|
||||
setValue(clean);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
formData: () => value,
|
||||
validateForm: () => formRef.current!.validateForm(),
|
||||
cancel: () => formRef.current!.reset()
|
||||
}),
|
||||
[value]
|
||||
);
|
||||
|
||||
const _uiSchema: UiSchema = {
|
||||
...uiSchema,
|
||||
"ui:globalOptions": {
|
||||
...uiSchema?.["ui:globalOptions"],
|
||||
enableMarkdownInDescription: true
|
||||
},
|
||||
"ui:submitButtonOptions": {
|
||||
norender: true
|
||||
}
|
||||
};
|
||||
const _fields: any = {
|
||||
...Fields,
|
||||
...fields
|
||||
};
|
||||
const _templates: any = {
|
||||
...Templates,
|
||||
...templates
|
||||
};
|
||||
const _widgets: any = {
|
||||
...Widgets,
|
||||
...widgets
|
||||
};
|
||||
//console.log("schema", schema, removeTitleFromSchema(schema));
|
||||
|
||||
return (
|
||||
<Form
|
||||
tagName="div"
|
||||
idSeparator="--"
|
||||
idPrefix={id}
|
||||
{...props}
|
||||
ref={formRef}
|
||||
className={["json-form", direction, className].join(" ")}
|
||||
showErrorList={false}
|
||||
schema={schema as RJSFSchema}
|
||||
fields={_fields}
|
||||
templates={_templates}
|
||||
widgets={_widgets}
|
||||
uiSchema={_uiSchema}
|
||||
onChange={handleChange}
|
||||
onSubmit={onSubmit}
|
||||
validator={validator as any}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
function removeTitleFromSchema(schema: any): any {
|
||||
// Create a deep copy of the schema using lodash
|
||||
const newSchema = cloneDeep(schema);
|
||||
|
||||
function removeTitle(schema: any): void {
|
||||
if (typeof schema !== "object" || schema === null) return;
|
||||
|
||||
// Remove title if present
|
||||
// biome-ignore lint/performance/noDelete: <explanation>
|
||||
delete schema.title;
|
||||
|
||||
// Check nested schemas in anyOf, allOf, and oneOf
|
||||
const nestedKeywords = ["anyOf", "allOf", "oneOf"];
|
||||
nestedKeywords.forEach((keyword) => {
|
||||
if (Array.isArray(schema[keyword])) {
|
||||
schema[keyword].forEach((nestedSchema: any) => {
|
||||
removeTitle(nestedSchema);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Recursively remove title from properties
|
||||
if (schema.properties && typeof schema.properties === "object") {
|
||||
Object.values(schema.properties).forEach((propertySchema: any) => {
|
||||
removeTitle(propertySchema);
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively remove title from items
|
||||
if (schema.items) {
|
||||
if (Array.isArray(schema.items)) {
|
||||
schema.items.forEach((itemSchema: any) => {
|
||||
removeTitle(itemSchema);
|
||||
});
|
||||
} else {
|
||||
removeTitle(schema.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeTitle(newSchema);
|
||||
return newSchema;
|
||||
}
|
||||
121
app/src/ui/components/form/json-schema/JsonSchemaValidator.ts
Normal file
121
app/src/ui/components/form/json-schema/JsonSchemaValidator.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { type OutputUnit, Validator } from "@cfworker/json-schema";
|
||||
import type {
|
||||
CustomValidator,
|
||||
ErrorSchema,
|
||||
ErrorTransformer,
|
||||
FormContextType,
|
||||
RJSFSchema,
|
||||
RJSFValidationError,
|
||||
StrictRJSFSchema,
|
||||
UiSchema,
|
||||
ValidationData,
|
||||
ValidatorType
|
||||
} from "@rjsf/utils";
|
||||
import { toErrorSchema } from "@rjsf/utils";
|
||||
import get from "lodash-es/get";
|
||||
|
||||
function removeUndefinedKeys(obj: any): any {
|
||||
if (!obj) return obj;
|
||||
|
||||
if (typeof obj === "object") {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (obj[key] === undefined) {
|
||||
delete obj[key];
|
||||
} else if (typeof obj[key] === "object") {
|
||||
removeUndefinedKeys(obj[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.filter((item) => item !== undefined);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function onlyKeepMostSpecific(errors: OutputUnit[]) {
|
||||
const mostSpecific = errors.filter((error) => {
|
||||
return !errors.some((other) => {
|
||||
return error !== other && other.instanceLocation.startsWith(error.instanceLocation);
|
||||
});
|
||||
});
|
||||
return mostSpecific;
|
||||
}
|
||||
|
||||
const debug = true;
|
||||
const validate = true;
|
||||
|
||||
export class JsonSchemaValidator<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
> implements ValidatorType
|
||||
{
|
||||
// @ts-ignore
|
||||
rawValidation<Result extends OutputUnit = OutputUnit>(schema: S, formData?: T) {
|
||||
if (!validate) return { errors: [], validationError: null };
|
||||
|
||||
debug && console.log("JsonSchemaValidator.rawValidation", schema, formData);
|
||||
const validator = new Validator(schema as any);
|
||||
const validation = validator.validate(removeUndefinedKeys(formData));
|
||||
const specificErrors = onlyKeepMostSpecific(validation.errors);
|
||||
|
||||
return { errors: specificErrors, validationError: null as any };
|
||||
}
|
||||
|
||||
validateFormData(
|
||||
formData: T | undefined,
|
||||
schema: S,
|
||||
customValidate?: CustomValidator,
|
||||
transformErrors?: ErrorTransformer,
|
||||
uiSchema?: UiSchema
|
||||
): ValidationData<T> {
|
||||
if (!validate) return { errors: [], errorSchema: {} as any };
|
||||
|
||||
debug &&
|
||||
console.log(
|
||||
"JsonSchemaValidator.validateFormData",
|
||||
formData,
|
||||
schema,
|
||||
customValidate,
|
||||
transformErrors,
|
||||
uiSchema
|
||||
);
|
||||
const { errors } = this.rawValidation(schema, formData);
|
||||
debug && console.log("errors", { errors });
|
||||
|
||||
const transformedErrors = errors
|
||||
//.filter((error) => error.keyword !== "properties")
|
||||
.map((error) => {
|
||||
const schemaLocation = error.keywordLocation.replace(/^#\/?/, "").split("/").join(".");
|
||||
const propertyError = get(schema, schemaLocation);
|
||||
const errorText = `${error.error.replace(/\.$/, "")}${propertyError ? ` "${propertyError}"` : ""}`;
|
||||
//console.log(error, schemaLocation, get(schema, schemaLocation));
|
||||
return {
|
||||
name: error.keyword,
|
||||
message: errorText,
|
||||
property: "." + error.instanceLocation.replace(/^#\/?/, "").split("/").join("."),
|
||||
schemaPath: error.keywordLocation,
|
||||
stack: error.error
|
||||
};
|
||||
});
|
||||
debug && console.log("transformed", transformedErrors);
|
||||
|
||||
return {
|
||||
errors: transformedErrors,
|
||||
errorSchema: toErrorSchema(transformedErrors)
|
||||
} as any;
|
||||
}
|
||||
|
||||
toErrorList(errorSchema?: ErrorSchema<T>, fieldPath?: string[]): RJSFValidationError[] {
|
||||
debug && console.log("JsonSchemaValidator.toErrorList", errorSchema, fieldPath);
|
||||
return [];
|
||||
}
|
||||
|
||||
isValid(schema: S, formData: T | undefined, rootSchema: S): boolean {
|
||||
if (!validate) return true;
|
||||
debug && console.log("JsonSchemaValidator.isValid", schema, formData, rootSchema);
|
||||
return this.rawValidation(schema, formData).errors.length === 0;
|
||||
}
|
||||
}
|
||||
32
app/src/ui/components/form/json-schema/fields/JsonField.tsx
Normal file
32
app/src/ui/components/form/json-schema/fields/JsonField.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { FieldProps } from "@rjsf/utils";
|
||||
import { JsonEditor } from "../../../code/JsonEditor";
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
|
||||
// @todo: move editor to lazy loading component
|
||||
export default function JsonField({
|
||||
formData,
|
||||
onChange,
|
||||
disabled,
|
||||
readonly,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
const value = JSON.stringify(formData, null, 2);
|
||||
|
||||
function handleChange(data) {
|
||||
try {
|
||||
onChange(JSON.parse(data));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
const id = props.idSchema.$id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label label={props.name} id={id} />
|
||||
<JsonEditor value={value} editable={!isDisabled} onChange={handleChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FieldProps } from "@rjsf/utils";
|
||||
import { LiquidJsEditor } from "../../../code/LiquidJsEditor";
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
|
||||
// @todo: move editor to lazy loading component
|
||||
export default function LiquidJsField({
|
||||
formData,
|
||||
onChange,
|
||||
disabled,
|
||||
readonly,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
function handleChange(data) {
|
||||
onChange(data);
|
||||
}
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
const id = props.idSchema.$id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label label={props.name} id={id} />
|
||||
<LiquidJsEditor value={formData} editable={!isDisabled} onChange={handleChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
ANY_OF_KEY,
|
||||
ERRORS_KEY,
|
||||
type FieldProps,
|
||||
type FormContextType,
|
||||
ONE_OF_KEY,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
TranslatableString,
|
||||
type UiSchema,
|
||||
deepEquals,
|
||||
getDiscriminatorFieldFromSchema,
|
||||
getUiOptions,
|
||||
getWidget,
|
||||
mergeSchemas
|
||||
} from "@rjsf/utils";
|
||||
import get from "lodash-es/get";
|
||||
import isEmpty from "lodash-es/isEmpty";
|
||||
import omit from "lodash-es/omit";
|
||||
import { Component } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
|
||||
/** Type used for the state of the `AnyOfField` component */
|
||||
type AnyOfFieldState<S extends StrictRJSFSchema = RJSFSchema> = {
|
||||
/** The currently selected option */
|
||||
selectedOption: number;
|
||||
/** The option schemas after retrieving all $refs */
|
||||
retrievedOptions: S[];
|
||||
};
|
||||
|
||||
/** The `AnyOfField` component is used to render a field in the schema that is an `anyOf`, `allOf` or `oneOf`. It tracks
|
||||
* the currently selected option and cleans up any irrelevant data in `formData`.
|
||||
*
|
||||
* @param props - The `FieldProps` for this template
|
||||
*/
|
||||
class MultiSchemaField<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
> extends Component<FieldProps<T, S, F>, AnyOfFieldState<S>> {
|
||||
/** Constructs an `AnyOfField` with the given `props` to initialize the initially selected option in state
|
||||
*
|
||||
* @param props - The `FieldProps` for this template
|
||||
*/
|
||||
constructor(props: FieldProps<T, S, F>) {
|
||||
super(props);
|
||||
|
||||
const {
|
||||
formData,
|
||||
options,
|
||||
registry: { schemaUtils }
|
||||
} = this.props;
|
||||
// cache the retrieved options in state in case they have $refs to save doing it later
|
||||
//console.log("multi schema", { formData, options, props });
|
||||
const retrievedOptions = options.map((opt: S) => schemaUtils.retrieveSchema(opt, formData));
|
||||
|
||||
this.state = {
|
||||
retrievedOptions,
|
||||
selectedOption: this.getMatchingOption(0, formData, retrievedOptions)
|
||||
};
|
||||
}
|
||||
|
||||
/** React lifecycle method that is called when the props and/or state for this component is updated. It recomputes the
|
||||
* currently selected option based on the overall `formData`
|
||||
*
|
||||
* @param prevProps - The previous `FieldProps` for this template
|
||||
* @param prevState - The previous `AnyOfFieldState` for this template
|
||||
*/
|
||||
override componentDidUpdate(
|
||||
prevProps: Readonly<FieldProps<T, S, F>>,
|
||||
prevState: Readonly<AnyOfFieldState>
|
||||
) {
|
||||
const { formData, options, idSchema } = this.props;
|
||||
const { selectedOption } = this.state;
|
||||
let newState = this.state;
|
||||
if (!deepEquals(prevProps.options, options)) {
|
||||
const {
|
||||
registry: { schemaUtils }
|
||||
} = this.props;
|
||||
// re-cache the retrieved options in state in case they have $refs to save doing it later
|
||||
const retrievedOptions = options.map((opt: S) =>
|
||||
schemaUtils.retrieveSchema(opt, formData)
|
||||
);
|
||||
newState = { selectedOption, retrievedOptions };
|
||||
}
|
||||
if (!deepEquals(formData, prevProps.formData) && idSchema.$id === prevProps.idSchema.$id) {
|
||||
const { retrievedOptions } = newState;
|
||||
const matchingOption = this.getMatchingOption(selectedOption, formData, retrievedOptions);
|
||||
|
||||
if (prevState && matchingOption !== selectedOption) {
|
||||
newState = { selectedOption: matchingOption, retrievedOptions };
|
||||
}
|
||||
}
|
||||
if (newState !== this.state) {
|
||||
this.setState(newState);
|
||||
}
|
||||
}
|
||||
|
||||
/** Determines the best matching option for the given `formData` and `options`.
|
||||
*
|
||||
* @param formData - The new formData
|
||||
* @param options - The list of options to choose from
|
||||
* @return - The index of the `option` that best matches the `formData`
|
||||
*/
|
||||
getMatchingOption(selectedOption: number, formData: T | undefined, options: S[]) {
|
||||
const {
|
||||
schema,
|
||||
registry: { schemaUtils }
|
||||
} = this.props;
|
||||
|
||||
const discriminator = getDiscriminatorFieldFromSchema<S>(schema);
|
||||
const option = schemaUtils.getClosestMatchingOption(
|
||||
formData,
|
||||
options,
|
||||
selectedOption,
|
||||
discriminator
|
||||
);
|
||||
return option;
|
||||
}
|
||||
|
||||
/** Callback handler to remember what the currently selected option is. In addition to that the `formData` is updated
|
||||
* to remove properties that are not part of the newly selected option schema, and then the updated data is passed to
|
||||
* the `onChange` handler.
|
||||
*
|
||||
* @param option - The new option value being selected
|
||||
*/
|
||||
onOptionChange = (option?: string) => {
|
||||
const { selectedOption, retrievedOptions } = this.state;
|
||||
const { formData, onChange, registry } = this.props;
|
||||
console.log("onOptionChange", { state: { selectedOption, retrievedOptions }, option });
|
||||
|
||||
const { schemaUtils } = registry;
|
||||
const intOption = option !== undefined ? Number.parseInt(option, 10) : -1;
|
||||
if (intOption === selectedOption) {
|
||||
return;
|
||||
}
|
||||
const newOption = intOption >= 0 ? retrievedOptions[intOption] : undefined;
|
||||
const oldOption = selectedOption >= 0 ? retrievedOptions[selectedOption] : undefined;
|
||||
|
||||
let newFormData = schemaUtils.sanitizeDataForNewSchema(newOption, oldOption, formData);
|
||||
if (newFormData && newOption) {
|
||||
// Call getDefaultFormState to make sure defaults are populated on change. Pass "excludeObjectChildren"
|
||||
// so that only the root objects themselves are created without adding undefined children properties
|
||||
newFormData = schemaUtils.getDefaultFormState(
|
||||
newOption,
|
||||
newFormData,
|
||||
"excludeObjectChildren"
|
||||
) as T;
|
||||
}
|
||||
onChange(newFormData, undefined, this.getFieldId());
|
||||
|
||||
this.setState({ selectedOption: intOption });
|
||||
};
|
||||
|
||||
getFieldId() {
|
||||
const { idSchema, schema } = this.props;
|
||||
return `${idSchema.$id}${schema.oneOf ? "__oneof_select" : "__anyof_select"}`;
|
||||
}
|
||||
|
||||
/** Renders the `AnyOfField` selector along with a `SchemaField` for the value of the `formData`
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
name,
|
||||
disabled = false,
|
||||
errorSchema = {},
|
||||
formContext,
|
||||
onBlur,
|
||||
onFocus,
|
||||
registry,
|
||||
schema,
|
||||
uiSchema,
|
||||
readonly
|
||||
} = this.props;
|
||||
|
||||
const { widgets, fields, translateString, globalUiOptions, schemaUtils } = registry;
|
||||
const { SchemaField: _SchemaField } = fields;
|
||||
const { selectedOption, retrievedOptions } = this.state;
|
||||
const {
|
||||
widget = "select",
|
||||
placeholder,
|
||||
autofocus,
|
||||
autocomplete,
|
||||
title = schema.title,
|
||||
flexDirection,
|
||||
wrap,
|
||||
...uiOptions
|
||||
} = getUiOptions<T, S, F>(uiSchema, globalUiOptions);
|
||||
/* console.log("multi schema", {
|
||||
name,
|
||||
schema,
|
||||
uiSchema,
|
||||
uiOptions,
|
||||
globalUiOptions,
|
||||
disabled,
|
||||
flexDirection,
|
||||
props: this.props
|
||||
}); */
|
||||
const Widget = getWidget<T, S, F>({ type: "number" }, widget, widgets);
|
||||
const rawErrors = get(errorSchema, ERRORS_KEY, []);
|
||||
const fieldErrorSchema = omit(errorSchema, [ERRORS_KEY]);
|
||||
const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions);
|
||||
|
||||
const option = selectedOption >= 0 ? retrievedOptions[selectedOption] || null : null;
|
||||
let optionSchema: S | undefined | null;
|
||||
|
||||
if (option) {
|
||||
// merge top level required field
|
||||
const { required } = schema;
|
||||
// Merge in all the non-oneOf/anyOf properties and also skip the special ADDITIONAL_PROPERTY_FLAG property
|
||||
optionSchema = required ? (mergeSchemas({ required }, option) as S) : option;
|
||||
}
|
||||
|
||||
// First we will check to see if there is an anyOf/oneOf override for the UI schema
|
||||
let optionsUiSchema: UiSchema<T, S, F>[] = [];
|
||||
if (ONE_OF_KEY in schema && uiSchema && ONE_OF_KEY in uiSchema) {
|
||||
if (Array.isArray(uiSchema[ONE_OF_KEY])) {
|
||||
optionsUiSchema = uiSchema[ONE_OF_KEY];
|
||||
} else {
|
||||
console.warn(`uiSchema.oneOf is not an array for "${title || name}"`);
|
||||
}
|
||||
} else if (ANY_OF_KEY in schema && uiSchema && ANY_OF_KEY in uiSchema) {
|
||||
if (Array.isArray(uiSchema[ANY_OF_KEY])) {
|
||||
optionsUiSchema = uiSchema[ANY_OF_KEY];
|
||||
} else {
|
||||
console.warn(`uiSchema.anyOf is not an array for "${title || name}"`);
|
||||
}
|
||||
}
|
||||
// Then we pick the one that matches the selected option index, if one exists otherwise default to the main uiSchema
|
||||
let optionUiSchema = uiSchema;
|
||||
if (selectedOption >= 0 && optionsUiSchema.length > selectedOption) {
|
||||
optionUiSchema = optionsUiSchema[selectedOption];
|
||||
}
|
||||
|
||||
const translateEnum: TranslatableString = title
|
||||
? TranslatableString.TitleOptionPrefix
|
||||
: TranslatableString.OptionPrefix;
|
||||
const translateParams = title ? [title] : [];
|
||||
const enumOptions = retrievedOptions.map((opt: { title?: string }, index: number) => {
|
||||
// Also see if there is an override title in the uiSchema for each option, otherwise use the title from the option
|
||||
const { title: uiTitle = opt.title } = getUiOptions<T, S, F>(optionsUiSchema[index]);
|
||||
return {
|
||||
label:
|
||||
uiTitle || translateString(translateEnum, translateParams.concat(String(index + 1))),
|
||||
value: index
|
||||
};
|
||||
});
|
||||
|
||||
//console.log("sub component", { optionSchema, props: this.props, optionUiSchema });
|
||||
const SubComponent = optionSchema && (
|
||||
// @ts-ignore
|
||||
<_SchemaField
|
||||
{...this.props}
|
||||
schema={optionSchema}
|
||||
uiSchema={{
|
||||
...optionUiSchema,
|
||||
"ui:options": {
|
||||
...optionUiSchema?.["ui:options"],
|
||||
hideLabel: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"panel multischema flex",
|
||||
flexDirection === "row" ? "flex-row gap-3" : "flex-col gap-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center panel-select">
|
||||
<Label
|
||||
label={this.props.name}
|
||||
required={this.props.required}
|
||||
id={this.getFieldId()}
|
||||
/>
|
||||
<Widget
|
||||
id={this.getFieldId()}
|
||||
name={`${name}${schema.oneOf ? "__oneof_select" : "__anyof_select"}`}
|
||||
schema={{ type: "number", default: 0 } as S}
|
||||
onChange={this.onOptionChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
disabled={disabled || isEmpty(enumOptions) || readonly}
|
||||
multiple={false}
|
||||
rawErrors={rawErrors}
|
||||
errorSchema={fieldErrorSchema}
|
||||
value={selectedOption >= 0 ? selectedOption : undefined}
|
||||
options={{ enumOptions, ...uiOptions }}
|
||||
registry={registry}
|
||||
formContext={formContext}
|
||||
placeholder={placeholder}
|
||||
autocomplete={autocomplete}
|
||||
autofocus={autofocus}
|
||||
label={""}
|
||||
hideLabel={!displayLabel}
|
||||
/>
|
||||
</div>
|
||||
{wrap ? <fieldset>{SubComponent}</fieldset> : SubComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MultiSchemaField;
|
||||
10
app/src/ui/components/form/json-schema/fields/index.ts
Normal file
10
app/src/ui/components/form/json-schema/fields/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import JsonField from "./JsonField";
|
||||
import LiquidJsField from "./LiquidJsField";
|
||||
import MultiSchemaField from "./MultiSchemaField";
|
||||
|
||||
export const fields = {
|
||||
AnyOfField: MultiSchemaField,
|
||||
OneOfField: MultiSchemaField,
|
||||
JsonField,
|
||||
LiquidJsField
|
||||
};
|
||||
264
app/src/ui/components/form/json-schema/styles.css
Normal file
264
app/src/ui/components/form/json-schema/styles.css
Normal file
@@ -0,0 +1,264 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.json-form {
|
||||
@apply flex flex-col flex-grow;
|
||||
|
||||
/* dirty fix preventing the first fieldset to wrap */
|
||||
&.mute-root {
|
||||
& > div > div > div > fieldset:first-child {
|
||||
@apply border-none p-0;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.fieldset-alternative) {
|
||||
fieldset {
|
||||
@apply flex flex-grow flex-col gap-3.5 border border-solid border-muted p-3 rounded;
|
||||
|
||||
.title-field {
|
||||
@apply bg-primary/10 px-3 text-sm font-medium py-1 rounded-full;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* alternative */
|
||||
&.fieldset-alternative {
|
||||
fieldset {
|
||||
@apply flex flex-grow flex-col gap-3.5;
|
||||
&:has(> legend) {
|
||||
@apply mt-3 border-l-4 border-solid border-muted/50 p-3 pb-0 pt-0;
|
||||
}
|
||||
|
||||
.title-field {
|
||||
@apply bg-muted/50 text-sm font-medium py-1 table ml-[-14px] pl-4 pr-3 mb-3 mt-3;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.multischema {
|
||||
@apply mt-3;
|
||||
|
||||
fieldset {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.hide-required-mark {
|
||||
.control-label span.required {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@apply flex flex-col gap-1;
|
||||
&:not(.field) {
|
||||
@apply flex-grow;
|
||||
}
|
||||
|
||||
/* hide empty description if markdown is enabled */
|
||||
.field-description:has(> span:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control-label span.required {
|
||||
@apply ml-1 opacity-50;
|
||||
}
|
||||
|
||||
&.field.has-error {
|
||||
@apply text-red-500;
|
||||
|
||||
.control-label {
|
||||
@apply font-bold;
|
||||
}
|
||||
.error-detail:not(:only-child) {
|
||||
@apply font-bold list-disc pl-6;
|
||||
}
|
||||
.error-detail:only-child {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-description {
|
||||
@apply text-primary/70 text-sm;
|
||||
}
|
||||
|
||||
/* input but not radio */
|
||||
input:not([type="radio"]):not([type="checkbox"]) {
|
||||
@apply flex bg-muted/40 h-11 rounded-md outline-none;
|
||||
@apply py-2.5 px-4;
|
||||
width: 100%;
|
||||
|
||||
&:not([disabled]):not([readonly]) {
|
||||
@apply focus:outline-none focus:ring-2 focus:bg-muted focus:ring-zinc-500 focus:border-transparent transition-all;
|
||||
}
|
||||
&[disabled], &[readonly] {
|
||||
@apply bg-muted/50 text-primary/50 cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
@apply flex bg-muted/40 focus:bg-muted rounded-md outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50;
|
||||
@apply py-2.5 px-4;
|
||||
width: 100%;
|
||||
}
|
||||
.checkbox {
|
||||
label, label > span {
|
||||
@apply flex flex-row gap-2;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
@apply bg-muted/40 focus:bg-muted rounded-md py-2.5 pr-4 pl-2.5 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all;
|
||||
@apply disabled:bg-muted/70 disabled:text-primary/70;
|
||||
@apply w-full border-r-8 border-r-transparent;
|
||||
|
||||
&:not([multiple]) {
|
||||
@apply h-11;
|
||||
}
|
||||
|
||||
&[multiple] {
|
||||
option {
|
||||
@apply py-1.5 px-2.5 bg-transparent;
|
||||
&:checked {
|
||||
@apply bg-primary/20;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply w-5 h-5 bg-amber-500;
|
||||
}
|
||||
|
||||
.field-radio-group {
|
||||
@apply flex flex-row gap-2;
|
||||
}
|
||||
|
||||
&.noborder-first-fieldset {
|
||||
fieldset#root {
|
||||
@apply border-none p-0;
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
.form-group {
|
||||
@apply flex-row gap-2;
|
||||
}
|
||||
.form-control, .panel {
|
||||
@apply flex-grow;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
@apply w-32 flex h-11 items-center;
|
||||
}
|
||||
input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
fieldset#root {
|
||||
@apply gap-6;
|
||||
}
|
||||
|
||||
fieldset.object-field {
|
||||
@apply gap-2;
|
||||
}
|
||||
|
||||
.additional-children {
|
||||
.checkbox {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.hide-multi-labels {
|
||||
.control-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.multischema {
|
||||
.form-control {
|
||||
@apply flex-shrink;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
/*@apply flex flex-col gap-2;*/
|
||||
|
||||
/*.control-label { display: none; }*/
|
||||
|
||||
& > .field-radio-group {
|
||||
@apply flex flex-row gap-3;
|
||||
|
||||
.radio, .radio-inline {
|
||||
@apply text-sm border-b border-b-transparent;
|
||||
@apply font-mono text-primary/70;
|
||||
|
||||
input {
|
||||
@apply appearance-none;
|
||||
}
|
||||
&.checked {
|
||||
@apply border-b-primary/70 text-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* :not(.panel-select) .control-label {
|
||||
display: none;
|
||||
} */
|
||||
|
||||
.panel-select select {
|
||||
@apply py-1 pr-1 pl-1.5 text-sm;
|
||||
@apply h-auto w-auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.legacy {
|
||||
/* first fieldset */
|
||||
& > .form-group.field-object>div>fieldset {
|
||||
@apply border-none p-0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
.col-xs-5 {
|
||||
display: flex;
|
||||
width: 50%;
|
||||
}
|
||||
.form-additional {
|
||||
fieldset {
|
||||
/* padding: 0;
|
||||
border: none; */
|
||||
|
||||
/* legend {
|
||||
display: none;
|
||||
} */
|
||||
}
|
||||
&.additional-start {
|
||||
> label {
|
||||
display: none;
|
||||
}
|
||||
/* > label + div > fieldset:first-child {
|
||||
display: none;
|
||||
} */
|
||||
}
|
||||
}
|
||||
.field-object + .field-object {
|
||||
@apply mt-3 pt-4 border-t border-muted;
|
||||
}
|
||||
.panel>.field-object>label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
ArrayFieldTemplateItemType,
|
||||
FormContextType,
|
||||
RJSFSchema,
|
||||
StrictRJSFSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { type CSSProperties, Children, cloneElement, isValidElement } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/** The `ArrayFieldItemTemplate` component is the template used to render an items of an array.
|
||||
*
|
||||
* @param props - The `ArrayFieldTemplateItemType` props for the component
|
||||
*/
|
||||
export default function ArrayFieldItemTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any,
|
||||
>(props: ArrayFieldTemplateItemType<T, S, F>) {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
hasToolbar,
|
||||
hasMoveDown,
|
||||
hasMoveUp,
|
||||
hasRemove,
|
||||
hasCopy,
|
||||
index,
|
||||
onCopyIndexClick,
|
||||
onDropIndexClick,
|
||||
onReorderClick,
|
||||
readonly,
|
||||
registry,
|
||||
uiSchema,
|
||||
} = props;
|
||||
const { CopyButton, MoveDownButton, MoveUpButton, RemoveButton } =
|
||||
registry.templates.ButtonTemplates;
|
||||
|
||||
return (
|
||||
<div className={twMerge("flex flex-row w-full overflow-hidden", className)}>
|
||||
{hasToolbar && (
|
||||
<div className="flex flex-col gap-1 p-1 mr-2">
|
||||
{(hasMoveUp || hasMoveDown) && (
|
||||
<MoveUpButton
|
||||
disabled={disabled || readonly || !hasMoveUp}
|
||||
onClick={onReorderClick(index, index - 1)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{(hasMoveUp || hasMoveDown) && (
|
||||
<MoveDownButton
|
||||
disabled={disabled || readonly || !hasMoveDown}
|
||||
onClick={onReorderClick(index, index + 1)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{hasCopy && (
|
||||
<CopyButton
|
||||
disabled={disabled || readonly}
|
||||
onClick={onCopyIndexClick(index)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{hasRemove && (
|
||||
<RemoveButton
|
||||
disabled={disabled || readonly}
|
||||
onClick={onDropIndexClick(index)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col flex-grow">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
type ArrayFieldTemplateItemType,
|
||||
type ArrayFieldTemplateProps,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
getTemplate,
|
||||
getUiOptions
|
||||
} from "@rjsf/utils";
|
||||
import { cloneElement } from "react";
|
||||
|
||||
/** The `ArrayFieldTemplate` component is the template used to render all items in an array.
|
||||
*
|
||||
* @param props - The `ArrayFieldTemplateItemType` props for the component
|
||||
*/
|
||||
export default function ArrayFieldTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: ArrayFieldTemplateProps<T, S, F>) {
|
||||
const {
|
||||
canAdd,
|
||||
className,
|
||||
disabled,
|
||||
idSchema,
|
||||
uiSchema,
|
||||
items,
|
||||
onAddClick,
|
||||
readonly,
|
||||
registry,
|
||||
required,
|
||||
schema,
|
||||
title
|
||||
} = props;
|
||||
const uiOptions = getUiOptions<T, S, F>(uiSchema);
|
||||
const ArrayFieldDescriptionTemplate = getTemplate<"ArrayFieldDescriptionTemplate", T, S, F>(
|
||||
"ArrayFieldDescriptionTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
const ArrayFieldItemTemplate = getTemplate<"ArrayFieldItemTemplate", T, S, F>(
|
||||
"ArrayFieldItemTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
const ArrayFieldTitleTemplate = getTemplate<"ArrayFieldTitleTemplate", T, S, F>(
|
||||
"ArrayFieldTitleTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
// Button templates are not overridden in the uiSchema
|
||||
const {
|
||||
ButtonTemplates: { AddButton }
|
||||
} = registry.templates;
|
||||
return (
|
||||
<fieldset className={className} id={idSchema.$id}>
|
||||
<ArrayFieldTitleTemplate
|
||||
idSchema={idSchema}
|
||||
title={uiOptions.title || title}
|
||||
required={required}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
<ArrayFieldDescriptionTemplate
|
||||
idSchema={idSchema}
|
||||
description={uiOptions.description || schema.description}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
{items && items.length > 0 && (
|
||||
<div className="flex flex-col gap-3 array-items">
|
||||
{items.map(
|
||||
({ key, children, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>) => {
|
||||
const newChildren = cloneElement(children, {
|
||||
...children.props,
|
||||
name: undefined,
|
||||
title: undefined
|
||||
});
|
||||
|
||||
return (
|
||||
<ArrayFieldItemTemplate key={key} {...itemProps} children={newChildren} />
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canAdd && (
|
||||
<AddButton
|
||||
className="array-item-add"
|
||||
onClick={onAddClick}
|
||||
disabled={disabled || readonly}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
type BaseInputTemplateProps,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
ariaDescribedByIds,
|
||||
examplesId,
|
||||
getInputProps
|
||||
} from "@rjsf/utils";
|
||||
import { type ChangeEvent, type FocusEvent, useCallback } from "react";
|
||||
import { Label } from "./FieldTemplate";
|
||||
|
||||
/** The `BaseInputTemplate` is the template to use to render the basic `<input>` component for the `core` theme.
|
||||
* It is used as the template for rendering many of the <input> based widgets that differ by `type` and callbacks only.
|
||||
* It can be customized/overridden for other themes or individual implementations as needed.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this template
|
||||
*/
|
||||
export default function BaseInputTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: BaseInputTemplateProps<T, S, F>) {
|
||||
const {
|
||||
id,
|
||||
name, // remove this from ...rest
|
||||
value,
|
||||
readonly,
|
||||
disabled,
|
||||
autofocus,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
onChangeOverride,
|
||||
options,
|
||||
schema,
|
||||
uiSchema,
|
||||
formContext,
|
||||
registry,
|
||||
rawErrors,
|
||||
type,
|
||||
hideLabel, // remove this from ...rest
|
||||
hideError, // remove this from ...rest
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
// Note: since React 15.2.0 we can't forward unknown element attributes, so we
|
||||
// exclude the "options" and "schema" ones here.
|
||||
if (!id) {
|
||||
console.log("No id for", props);
|
||||
throw new Error(`no id for props ${JSON.stringify(props)}`);
|
||||
}
|
||||
const inputProps = {
|
||||
...rest,
|
||||
...getInputProps<T, S, F>(schema, type, options)
|
||||
};
|
||||
|
||||
let inputValue;
|
||||
if (inputProps.type === "number" || inputProps.type === "integer") {
|
||||
inputValue = value || value === 0 ? value : "";
|
||||
} else {
|
||||
inputValue = value == null ? "" : value;
|
||||
}
|
||||
|
||||
const _onChange = useCallback(
|
||||
({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(value === "" ? options.emptyValue : value),
|
||||
[onChange, options]
|
||||
);
|
||||
const _onBlur = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) => onBlur(id, target && target.value),
|
||||
[onBlur, id]
|
||||
);
|
||||
const _onFocus = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) => onFocus(id, target && target.value),
|
||||
[onFocus, id]
|
||||
);
|
||||
|
||||
const shouldHideLabel =
|
||||
!props.label ||
|
||||
// @ts-ignore
|
||||
uiSchema["ui:options"]?.hideLabel ||
|
||||
props.options?.hideLabel ||
|
||||
props.hideLabel;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!shouldHideLabel && <Label label={props.label} required={props.required} id={id} />}
|
||||
<input
|
||||
id={id}
|
||||
name={id}
|
||||
className="form-control"
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
autoFocus={autofocus}
|
||||
value={inputValue}
|
||||
{...inputProps}
|
||||
placeholder={props.label}
|
||||
list={schema.examples ? examplesId<T>(id) : undefined}
|
||||
onChange={onChangeOverride || _onChange}
|
||||
onBlur={_onBlur}
|
||||
onFocus={_onFocus}
|
||||
aria-describedby={ariaDescribedByIds<T>(id, !!schema.examples)}
|
||||
/>
|
||||
{Array.isArray(schema.examples) && (
|
||||
<datalist key={`datalist_${id}`} id={examplesId<T>(id)}>
|
||||
{(schema.examples as string[])
|
||||
.concat(
|
||||
schema.default && !schema.examples.includes(schema.default)
|
||||
? ([schema.default] as string[])
|
||||
: []
|
||||
)
|
||||
.map((example: any) => {
|
||||
return <option key={example} value={example} />;
|
||||
})}
|
||||
</datalist>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TbArrowDown, TbArrowUp, TbPlus, TbTrash } from "react-icons/tb";
|
||||
import { Button } from "../../../buttons/Button";
|
||||
import { IconButton } from "../../../buttons/IconButton";
|
||||
|
||||
export const AddButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<Button onClick={onClick} disabled={disabled} IconLeft={TbPlus}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const RemoveButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<IconButton onClick={onClick} disabled={disabled} Icon={TbTrash} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MoveUpButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<IconButton onClick={onClick} disabled={disabled} Icon={TbArrowUp} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MoveDownButton = ({ onClick, disabled, ...rest }) => (
|
||||
<div className="flex flex-row">
|
||||
<IconButton onClick={onClick} disabled={disabled} Icon={TbArrowDown} />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
type FieldTemplateProps,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
getTemplate,
|
||||
getUiOptions
|
||||
} from "@rjsf/utils";
|
||||
import { ucFirstAll, ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const REQUIRED_FIELD_SYMBOL = "*";
|
||||
|
||||
export type LabelProps = {
|
||||
/** The label for the field */
|
||||
label?: string;
|
||||
/** A boolean value stating if the field is required */
|
||||
required?: boolean;
|
||||
/** The id of the input field being labeled */
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/** Renders a label for a field
|
||||
*
|
||||
* @param props - The `LabelProps` for this component
|
||||
*/
|
||||
export function Label(props: LabelProps) {
|
||||
const { label, required, id } = props;
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<label className="control-label" htmlFor={id}>
|
||||
{ucFirstAllSnakeToPascalWithSpaces(label)}
|
||||
{required && <span className="required">{REQUIRED_FIELD_SYMBOL}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** The `FieldTemplate` component is the template used by `SchemaField` to render any field. It renders the field
|
||||
* content, (label, description, children, errors and help) inside of a `WrapIfAdditional` component.
|
||||
*
|
||||
* @param props - The `FieldTemplateProps` for this component
|
||||
*/
|
||||
export function FieldTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: FieldTemplateProps<T, S, F>) {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
errors,
|
||||
help,
|
||||
description,
|
||||
hidden,
|
||||
required,
|
||||
displayLabel,
|
||||
registry,
|
||||
uiSchema
|
||||
} = props;
|
||||
const uiOptions = getUiOptions(uiSchema, registry.globalUiOptions);
|
||||
//console.log("field---", uiOptions);
|
||||
const WrapIfAdditionalTemplate = getTemplate<"WrapIfAdditionalTemplate", T, S, F>(
|
||||
"WrapIfAdditionalTemplate",
|
||||
registry,
|
||||
uiOptions
|
||||
);
|
||||
if (hidden) {
|
||||
return <div className="hidden">{children}</div>;
|
||||
}
|
||||
//console.log("FieldTemplate", props);
|
||||
|
||||
return (
|
||||
<WrapIfAdditionalTemplate {...props}>
|
||||
{/*<Label label={label} required={required} id={id} />*/}
|
||||
<div className="flex flex-col flex-grow gap-2 additional">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-grow additional-children",
|
||||
uiOptions.flexDirection === "row"
|
||||
? "flex-row items-center gap-3"
|
||||
: "flex-col flex-grow gap-2"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{displayLabel && description ? description : null}
|
||||
</div>
|
||||
{errors}
|
||||
{help}
|
||||
</WrapIfAdditionalTemplate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type ObjectFieldTemplatePropertyType,
|
||||
type ObjectFieldTemplateProps,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
canExpand,
|
||||
descriptionId,
|
||||
getTemplate,
|
||||
getUiOptions,
|
||||
titleId
|
||||
} from "@rjsf/utils";
|
||||
|
||||
/** The `ObjectFieldTemplate` is the template to use to render all the inner properties of an object along with the
|
||||
* title and description if available. If the object is expandable, then an `AddButton` is also rendered after all
|
||||
* the properties.
|
||||
*
|
||||
* @param props - The `ObjectFieldTemplateProps` for this component
|
||||
*/
|
||||
export default function ObjectFieldTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: ObjectFieldTemplateProps<T, S, F>) {
|
||||
const {
|
||||
description,
|
||||
disabled,
|
||||
formData,
|
||||
idSchema,
|
||||
onAddClick,
|
||||
properties,
|
||||
readonly,
|
||||
registry,
|
||||
required,
|
||||
schema,
|
||||
title,
|
||||
uiSchema
|
||||
} = props;
|
||||
const options = getUiOptions<T, S, F>(uiSchema);
|
||||
const TitleFieldTemplate = getTemplate<"TitleFieldTemplate", T, S, F>(
|
||||
"TitleFieldTemplate",
|
||||
registry,
|
||||
options
|
||||
);
|
||||
const DescriptionFieldTemplate = getTemplate<"DescriptionFieldTemplate", T, S, F>(
|
||||
"DescriptionFieldTemplate",
|
||||
registry,
|
||||
options
|
||||
);
|
||||
|
||||
/* if (properties.length === 0) {
|
||||
return null;
|
||||
} */
|
||||
const _canExpand = canExpand(schema, uiSchema, formData);
|
||||
if (properties.length === 0 && !_canExpand) {
|
||||
return null;
|
||||
}
|
||||
//console.log("multi:properties", uiSchema, props, options);
|
||||
|
||||
// Button templates are not overridden in the uiSchema
|
||||
const {
|
||||
ButtonTemplates: { AddButton }
|
||||
} = registry.templates;
|
||||
|
||||
return (
|
||||
<>
|
||||
<fieldset id={idSchema.$id} className="object-field">
|
||||
{title && (
|
||||
<TitleFieldTemplate
|
||||
id={titleId<T>(idSchema)}
|
||||
title={title}
|
||||
required={required}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
{description && (
|
||||
<DescriptionFieldTemplate
|
||||
id={descriptionId<T>(idSchema)}
|
||||
description={description}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{properties.map((prop: ObjectFieldTemplatePropertyType) => prop.content)}
|
||||
{_canExpand && (
|
||||
<AddButton
|
||||
className="object-property-expand"
|
||||
onClick={onAddClick(schema)}
|
||||
disabled={disabled || readonly}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FormContextType, RJSFSchema, StrictRJSFSchema, TitleFieldProps } from "@rjsf/utils";
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
|
||||
const REQUIRED_FIELD_SYMBOL = "*";
|
||||
|
||||
/** The `TitleField` is the template to use to render the title of a field
|
||||
*
|
||||
* @param props - The `TitleFieldProps` for this component
|
||||
*/
|
||||
export default function TitleField<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: TitleFieldProps<T, S, F>) {
|
||||
const { id, title, required } = props;
|
||||
return (
|
||||
<legend id={id} className="title-field">
|
||||
{ucFirstAllSnakeToPascalWithSpaces(title)}
|
||||
{/*{required && <span className="required">{REQUIRED_FIELD_SYMBOL}</span>}*/}
|
||||
</legend>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
ADDITIONAL_PROPERTY_FLAG,
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
TranslatableString,
|
||||
type WrapIfAdditionalTemplateProps
|
||||
} from "@rjsf/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
/** The `WrapIfAdditional` component is used by the `FieldTemplate` to rename, or remove properties that are
|
||||
* part of an `additionalProperties` part of a schema.
|
||||
*
|
||||
* @param props - The `WrapIfAdditionalProps` for this component
|
||||
*/
|
||||
export default function WrapIfAdditionalTemplate<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>(props: WrapIfAdditionalTemplateProps<T, S, F>) {
|
||||
const {
|
||||
id,
|
||||
classNames,
|
||||
style,
|
||||
disabled,
|
||||
label,
|
||||
onKeyChange,
|
||||
onDropPropertyClick,
|
||||
readonly,
|
||||
required,
|
||||
schema,
|
||||
children,
|
||||
uiSchema,
|
||||
registry
|
||||
} = props;
|
||||
const { templates, translateString } = registry;
|
||||
// Button templates are not overridden in the uiSchema
|
||||
const { RemoveButton } = templates.ButtonTemplates;
|
||||
const keyLabel = translateString(TranslatableString.KeyLabel, [label]);
|
||||
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
if (!additional) {
|
||||
return (
|
||||
<div className={classNames} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames} style={style}>
|
||||
<div className="flex flex-col">
|
||||
<fieldset>
|
||||
<legend className="flex flex-row justify-between gap-3">
|
||||
<RemoveButton
|
||||
className="array-item-remove btn-block"
|
||||
style={{ border: "0" }}
|
||||
disabled={disabled || readonly}
|
||||
onClick={onDropPropertyClick(label)}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control"
|
||||
type="text"
|
||||
id={`${id}-key`}
|
||||
onBlur={(event) => onKeyChange(event.target.value)}
|
||||
defaultValue={label}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => setExpanded((prev) => !prev)}>
|
||||
{expanded ? "collapse" : "expand"}
|
||||
</button>
|
||||
</legend>
|
||||
{expanded && (
|
||||
<div className="form-additional additional-start form-group">{children}</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
app/src/ui/components/form/json-schema/templates/index.ts
Normal file
19
app/src/ui/components/form/json-schema/templates/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import ArrayFieldItemTemplate from "./ArrayFieldItemTemplate";
|
||||
import ArrayFieldTemplate from "./ArrayFieldTemplate";
|
||||
import BaseInputTemplate from "./BaseInputTemplate";
|
||||
import * as ButtonTemplates from "./ButtonTemplates";
|
||||
import { FieldTemplate } from "./FieldTemplate";
|
||||
import ObjectFieldTemplate from "./ObjectFieldTemplate";
|
||||
import TitleFieldTemplate from "./TitleFieldTemplate";
|
||||
import WrapIfAdditionalTemplate from "./WrapIfAdditionalTemplate";
|
||||
|
||||
export const templates = {
|
||||
ButtonTemplates,
|
||||
ArrayFieldItemTemplate,
|
||||
ArrayFieldTemplate,
|
||||
FieldTemplate,
|
||||
TitleFieldTemplate,
|
||||
ObjectFieldTemplate,
|
||||
BaseInputTemplate,
|
||||
WrapIfAdditionalTemplate
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Check, Errors } from "core/utils";
|
||||
import { FromSchema } from "./from-schema";
|
||||
|
||||
import type {
|
||||
CustomValidator,
|
||||
ErrorTransformer,
|
||||
RJSFSchema,
|
||||
RJSFValidationError,
|
||||
StrictRJSFSchema,
|
||||
UiSchema,
|
||||
ValidationData,
|
||||
ValidatorType
|
||||
} from "@rjsf/utils";
|
||||
import { toErrorSchema } from "@rjsf/utils";
|
||||
|
||||
const validate = true;
|
||||
|
||||
export class RJSFTypeboxValidator<T = any, S extends StrictRJSFSchema = RJSFSchema>
|
||||
implements ValidatorType
|
||||
{
|
||||
// @ts-ignore
|
||||
rawValidation(schema: S, formData?: T) {
|
||||
if (!validate) {
|
||||
return { errors: [], validationError: null as any };
|
||||
}
|
||||
const tbSchema = FromSchema(schema as unknown);
|
||||
|
||||
//console.log("--validation", tbSchema, formData);
|
||||
|
||||
if (Check(tbSchema, formData)) {
|
||||
return { errors: [], validationError: null as any };
|
||||
}
|
||||
|
||||
return {
|
||||
errors: [...Errors(tbSchema, formData)],
|
||||
validationError: null as any
|
||||
};
|
||||
}
|
||||
|
||||
validateFormData(
|
||||
formData: T | undefined,
|
||||
schema: S,
|
||||
customValidate?: CustomValidator,
|
||||
transformErrors?: ErrorTransformer,
|
||||
uiSchema?: UiSchema
|
||||
): ValidationData<T> {
|
||||
const { errors } = this.rawValidation(schema, formData);
|
||||
|
||||
const transformedErrors = errors.map((error) => {
|
||||
const schemaLocation = error.path.substring(1).split("/").join(".");
|
||||
|
||||
return {
|
||||
name: "any",
|
||||
message: error.message,
|
||||
property: "." + schemaLocation,
|
||||
schemaPath: error.path,
|
||||
stack: error.message
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
errors: transformedErrors,
|
||||
errorSchema: toErrorSchema(transformedErrors)
|
||||
} as any;
|
||||
}
|
||||
|
||||
isValid(schema: S, formData: T | undefined, rootSchema: S): boolean {
|
||||
const validation = this.rawValidation(schema, formData);
|
||||
|
||||
return validation.errors.length === 0;
|
||||
}
|
||||
|
||||
toErrorList(): RJSFValidationError[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
299
app/src/ui/components/form/json-schema/typebox/from-schema.ts
Normal file
299
app/src/ui/components/form/json-schema/typebox/from-schema.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/*--------------------------------------------------------------------------
|
||||
|
||||
@sinclair/typebox/prototypes
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017-2024 Haydn Paterson (sinclair) <haydn.developer@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
---------------------------------------------------------------------------*/
|
||||
|
||||
import * as Type from "@sinclair/typebox";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Schematics
|
||||
// ------------------------------------------------------------------
|
||||
const IsExact = (value: unknown, expect: unknown) => value === expect;
|
||||
const IsSValue = (value: unknown): value is SValue =>
|
||||
Type.ValueGuard.IsString(value) ||
|
||||
Type.ValueGuard.IsNumber(value) ||
|
||||
Type.ValueGuard.IsBoolean(value);
|
||||
const IsSEnum = (value: unknown): value is SEnum =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
Type.ValueGuard.IsArray(value.enum) &&
|
||||
value.enum.every((value) => IsSValue(value));
|
||||
const IsSAllOf = (value: unknown): value is SAllOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.allOf);
|
||||
const IsSAnyOf = (value: unknown): value is SAnyOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.anyOf);
|
||||
const IsSOneOf = (value: unknown): value is SOneOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.oneOf);
|
||||
const IsSTuple = (value: unknown): value is STuple =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
IsExact(value.type, "array") &&
|
||||
Type.ValueGuard.IsArray(value.items);
|
||||
const IsSArray = (value: unknown): value is SArray =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
IsExact(value.type, "array") &&
|
||||
!Type.ValueGuard.IsArray(value.items) &&
|
||||
Type.ValueGuard.IsObject(value.items);
|
||||
const IsSConst = (value: unknown): value is SConst =>
|
||||
// biome-ignore lint: reason
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsObject(value["const"]);
|
||||
const IsSString = (value: unknown): value is SString =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "string");
|
||||
const IsSNumber = (value: unknown): value is SNumber =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "number");
|
||||
const IsSInteger = (value: unknown): value is SInteger =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "integer");
|
||||
const IsSBoolean = (value: unknown): value is SBoolean =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "boolean");
|
||||
const IsSNull = (value: unknown): value is SBoolean =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "null");
|
||||
const IsSProperties = (value: unknown): value is SProperties => Type.ValueGuard.IsObject(value);
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
const IsSObject = (value: unknown): value is SObject => Type.ValueGuard.IsObject(value) && IsExact(value.type, 'object') && IsSProperties(value.properties) && (value.required === undefined || Type.ValueGuard.IsArray(value.required) && value.required.every((value: unknown) => Type.ValueGuard.IsString(value)))
|
||||
type SValue = string | number | boolean;
|
||||
type SEnum = Readonly<{ enum: readonly SValue[] }>;
|
||||
type SAllOf = Readonly<{ allOf: readonly unknown[] }>;
|
||||
type SAnyOf = Readonly<{ anyOf: readonly unknown[] }>;
|
||||
type SOneOf = Readonly<{ oneOf: readonly unknown[] }>;
|
||||
type SProperties = Record<PropertyKey, unknown>;
|
||||
type SObject = Readonly<{
|
||||
type: "object";
|
||||
properties: SProperties;
|
||||
required?: readonly string[];
|
||||
}>;
|
||||
type STuple = Readonly<{ type: "array"; items: readonly unknown[] }>;
|
||||
type SArray = Readonly<{ type: "array"; items: unknown }>;
|
||||
type SConst = Readonly<{ const: SValue }>;
|
||||
type SString = Readonly<{ type: "string" }>;
|
||||
type SNumber = Readonly<{ type: "number" }>;
|
||||
type SInteger = Readonly<{ type: "integer" }>;
|
||||
type SBoolean = Readonly<{ type: "boolean" }>;
|
||||
type SNull = Readonly<{ type: "null" }>;
|
||||
// ------------------------------------------------------------------
|
||||
// FromRest
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromRest<T extends readonly unknown[], Acc extends Type.TSchema[] = []> = (
|
||||
// biome-ignore lint: reason
|
||||
T extends readonly [infer L extends unknown, ...infer R extends unknown[]]
|
||||
? TFromSchema<L> extends infer S extends Type.TSchema
|
||||
? TFromRest<R, [...Acc, S]>
|
||||
: TFromRest<R, [...Acc]>
|
||||
: Acc
|
||||
)
|
||||
function FromRest<T extends readonly unknown[]>(T: T): TFromRest<T> {
|
||||
return T.map((L) => FromSchema(L)) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// FromEnumRest
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromEnumRest<T extends readonly SValue[], Acc extends Type.TSchema[] = []> = (
|
||||
T extends readonly [infer L extends SValue, ...infer R extends SValue[]]
|
||||
? TFromEnumRest<R, [...Acc, Type.TLiteral<L>]>
|
||||
: Acc
|
||||
)
|
||||
function FromEnumRest<T extends readonly SValue[]>(T: T): TFromEnumRest<T> {
|
||||
return T.map((L) => Type.Literal(L)) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// AllOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromAllOf<T extends SAllOf> = (
|
||||
TFromRest<T['allOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TIntersectEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromAllOf<T extends SAllOf>(T: T): TFromAllOf<T> {
|
||||
return Type.IntersectEvaluated(FromRest(T.allOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// AnyOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromAnyOf<T extends SAnyOf> = (
|
||||
TFromRest<T['anyOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromAnyOf<T extends SAnyOf>(T: T): TFromAnyOf<T> {
|
||||
return Type.UnionEvaluated(FromRest(T.anyOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// OneOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromOneOf<T extends SOneOf> = (
|
||||
TFromRest<T['oneOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromOneOf<T extends SOneOf>(T: T): TFromOneOf<T> {
|
||||
return Type.UnionEvaluated(FromRest(T.oneOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Enum
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromEnum<T extends SEnum> = (
|
||||
TFromEnumRest<T['enum']> extends infer Elements extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Elements>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromEnum<T extends SEnum>(T: T): TFromEnum<T> {
|
||||
return Type.UnionEvaluated(FromEnumRest(T.enum));
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Tuple
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromTuple<T extends STuple> = (
|
||||
TFromRest<T['items']> extends infer Elements extends Type.TSchema[]
|
||||
? Type.TTuple<Elements>
|
||||
: Type.TTuple<[]>
|
||||
)
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
function FromTuple<T extends STuple>(T: T): TFromTuple<T> {
|
||||
return Type.Tuple(FromRest(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Array
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromArray<T extends SArray> = (
|
||||
TFromSchema<T['items']> extends infer Items extends Type.TSchema
|
||||
? Type.TArray<Items>
|
||||
: Type.TArray<Type.TUnknown>
|
||||
)
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
function FromArray<T extends SArray>(T: T): TFromArray<T> {
|
||||
return Type.Array(FromSchema(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Const
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromConst<T extends SConst> = (
|
||||
Type.Ensure<Type.TLiteral<T['const']>>
|
||||
)
|
||||
function FromConst<T extends SConst>(T: T) {
|
||||
return Type.Literal(T.const, T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Object
|
||||
// ------------------------------------------------------------------
|
||||
type TFromPropertiesIsOptional<
|
||||
K extends PropertyKey,
|
||||
R extends string | unknown
|
||||
> = unknown extends R ? true : K extends R ? false : true;
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromProperties<T extends SProperties, R extends string | unknown> = Type.Evaluate<{
|
||||
-readonly [K in keyof T]: TFromPropertiesIsOptional<K, R> extends true
|
||||
? Type.TOptional<TFromSchema<T[K]>>
|
||||
: TFromSchema<T[K]>
|
||||
}>
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
type TFromObject<T extends SObject> = (
|
||||
TFromProperties<T['properties'], Exclude<T['required'], undefined>[number]> extends infer Properties extends Type.TProperties
|
||||
? Type.TObject<Properties>
|
||||
: Type.TObject<{}>
|
||||
)
|
||||
function FromObject<T extends SObject>(T: T): TFromObject<T> {
|
||||
const properties = globalThis.Object.getOwnPropertyNames(T.properties).reduce((Acc, K) => {
|
||||
return {
|
||||
// biome-ignore lint:
|
||||
...Acc,
|
||||
[K]:
|
||||
// biome-ignore lint: reason
|
||||
T.required && T.required.includes(K)
|
||||
? FromSchema(T.properties[K])
|
||||
: Type.Optional(FromSchema(T.properties[K]))
|
||||
};
|
||||
}, {} as Type.TProperties);
|
||||
|
||||
if ("additionalProperties" in T) {
|
||||
return Type.Object(properties, {
|
||||
additionalProperties: FromSchema(T.additionalProperties)
|
||||
}) as never;
|
||||
}
|
||||
|
||||
return Type.Object(properties, T) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// FromSchema
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
export type TFromSchema<T> = (
|
||||
T extends SAllOf ? TFromAllOf<T> :
|
||||
T extends SAnyOf ? TFromAnyOf<T> :
|
||||
T extends SOneOf ? TFromOneOf<T> :
|
||||
T extends SEnum ? TFromEnum<T> :
|
||||
T extends SObject ? TFromObject<T> :
|
||||
T extends STuple ? TFromTuple<T> :
|
||||
T extends SArray ? TFromArray<T> :
|
||||
T extends SConst ? TFromConst<T> :
|
||||
T extends SString ? Type.TString :
|
||||
T extends SNumber ? Type.TNumber :
|
||||
T extends SInteger ? Type.TInteger :
|
||||
T extends SBoolean ? Type.TBoolean :
|
||||
T extends SNull ? Type.TNull :
|
||||
Type.TUnknown
|
||||
)
|
||||
/** Parses a TypeBox type from raw JsonSchema */
|
||||
export function FromSchema<T>(T: T): TFromSchema<T> {
|
||||
// prettier-ignore
|
||||
// biome-ignore format:
|
||||
return (
|
||||
IsSAllOf(T) ? FromAllOf(T) :
|
||||
IsSAnyOf(T) ? FromAnyOf(T) :
|
||||
IsSOneOf(T) ? FromOneOf(T) :
|
||||
IsSEnum(T) ? FromEnum(T) :
|
||||
IsSObject(T) ? FromObject(T) :
|
||||
IsSTuple(T) ? FromTuple(T) :
|
||||
IsSArray(T) ? FromArray(T) :
|
||||
IsSConst(T) ? FromConst(T) :
|
||||
IsSString(T) ? Type.String(T) :
|
||||
IsSNumber(T) ? Type.Number(T) :
|
||||
IsSInteger(T) ? Type.Integer(T) :
|
||||
IsSBoolean(T) ? Type.Boolean(T) :
|
||||
IsSNull(T) ? Type.Null(T) :
|
||||
Type.Unknown(T || {})
|
||||
) as never
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Switch } from "@mantine/core";
|
||||
import type { FormContextType, RJSFSchema, StrictRJSFSchema, WidgetProps } from "@rjsf/utils";
|
||||
import { type ChangeEvent, useCallback } from "react";
|
||||
|
||||
export function CheckboxWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
schema,
|
||||
uiSchema,
|
||||
options,
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
label,
|
||||
hideLabel,
|
||||
autofocus = false,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
registry,
|
||||
...props
|
||||
}: WidgetProps<T, S, F>) {
|
||||
/*console.log("addprops", value, props, label, {
|
||||
label,
|
||||
name: props.name,
|
||||
hideLabel,
|
||||
label_lower: label.toLowerCase(),
|
||||
name_lower: props.name.toLowerCase(),
|
||||
equals: label.toLowerCase() === props.name.toLowerCase()
|
||||
});*/
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => onChange(event.target.checked),
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
id={id}
|
||||
onChange={handleChange}
|
||||
defaultChecked={value}
|
||||
disabled={disabled || readonly}
|
||||
label={label.toLowerCase() === props.name.toLowerCase() ? undefined : label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
type WidgetProps,
|
||||
ariaDescribedByIds,
|
||||
enumOptionsDeselectValue,
|
||||
enumOptionsIsSelected,
|
||||
enumOptionsSelectValue,
|
||||
enumOptionsValueForIndex,
|
||||
optionId
|
||||
} from "@rjsf/utils";
|
||||
import { type ChangeEvent, type FocusEvent, useCallback } from "react";
|
||||
|
||||
/** The `CheckboxesWidget` is a widget for rendering checkbox groups.
|
||||
* It is typically used to represent an array of enums.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this component
|
||||
*/
|
||||
function CheckboxesWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
id,
|
||||
disabled,
|
||||
options: { inline = false, enumOptions, enumDisabled, emptyValue },
|
||||
value,
|
||||
autofocus = false,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus
|
||||
}: WidgetProps<T, S, F>) {
|
||||
const checkboxesValues = Array.isArray(value) ? value : [value];
|
||||
|
||||
const handleBlur = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) =>
|
||||
onBlur(id, enumOptionsValueForIndex<S>(target?.value, enumOptions, emptyValue)),
|
||||
[onBlur, id]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
({ target }: FocusEvent<HTMLInputElement>) =>
|
||||
onFocus(id, enumOptionsValueForIndex<S>(target?.value, enumOptions, emptyValue)),
|
||||
[onFocus, id]
|
||||
);
|
||||
return (
|
||||
<div className="checkboxes" id={id}>
|
||||
{Array.isArray(enumOptions) &&
|
||||
enumOptions.map((option, index) => {
|
||||
const checked = enumOptionsIsSelected<S>(option.value, checkboxesValues);
|
||||
const itemDisabled =
|
||||
Array.isArray(enumDisabled) && enumDisabled.indexOf(option.value) !== -1;
|
||||
const disabledCls = disabled || itemDisabled || readonly ? "disabled" : "";
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.checked) {
|
||||
onChange(enumOptionsSelectValue<S>(index, checkboxesValues, enumOptions));
|
||||
} else {
|
||||
onChange(enumOptionsDeselectValue<S>(index, checkboxesValues, enumOptions));
|
||||
}
|
||||
};
|
||||
|
||||
const checkbox = (
|
||||
// biome-ignore lint/correctness/useJsxKeyInIterable: it's wrapped
|
||||
<span>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={optionId(id, index)}
|
||||
name={id}
|
||||
checked={checked}
|
||||
value={String(index)}
|
||||
disabled={disabled || itemDisabled || readonly}
|
||||
autoFocus={autofocus && index === 0}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
aria-describedby={ariaDescribedByIds<T>(id)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
);
|
||||
return inline ? (
|
||||
<label key={index} className={`checkbox-inline ${disabledCls}`}>
|
||||
{checkbox}
|
||||
</label>
|
||||
) : (
|
||||
<div key={index} className={`checkbox ${disabledCls}`}>
|
||||
<label>{checkbox}</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxesWidget;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function JsonWidget({ value, onChange, disabled, readonly, ...props }: WidgetProps) {
|
||||
const [val, setVal] = useState(JSON.stringify(value, null, 2));
|
||||
|
||||
function handleChange(e) {
|
||||
setVal(e.target.value);
|
||||
try {
|
||||
onChange(JSON.parse(e.target.value));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea value={val} rows={10} disabled={disabled || readonly} onChange={handleChange} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
type WidgetProps,
|
||||
ariaDescribedByIds,
|
||||
enumOptionsIsSelected,
|
||||
enumOptionsValueForIndex,
|
||||
optionId
|
||||
} from "@rjsf/utils";
|
||||
import { type FocusEvent, useCallback } from "react";
|
||||
|
||||
/** The `RadioWidget` is a widget for rendering a radio group.
|
||||
* It is typically used with a string property constrained with enum options.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this component
|
||||
*/
|
||||
function RadioWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
options,
|
||||
value,
|
||||
required,
|
||||
disabled,
|
||||
readonly,
|
||||
autofocus = false,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
id
|
||||
}: WidgetProps<T, S, F>) {
|
||||
const { enumOptions, enumDisabled, inline, emptyValue } = options;
|
||||
|
||||
const handleBlur = useCallback(
|
||||
({ target: { value } }: FocusEvent<HTMLInputElement>) =>
|
||||
onBlur(id, enumOptionsValueForIndex<S>(value, enumOptions, emptyValue)),
|
||||
[onBlur, id]
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
({ target: { value } }: FocusEvent<HTMLInputElement>) =>
|
||||
onFocus(id, enumOptionsValueForIndex<S>(value, enumOptions, emptyValue)),
|
||||
[onFocus, id]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="field-radio-group" id={id}>
|
||||
{Array.isArray(enumOptions) &&
|
||||
enumOptions.map((option, i) => {
|
||||
const checked = enumOptionsIsSelected<S>(option.value, value);
|
||||
const itemDisabled =
|
||||
Array.isArray(enumDisabled) && enumDisabled.indexOf(option.value) !== -1;
|
||||
const disabledCls = disabled || itemDisabled || readonly ? "disabled" : "";
|
||||
|
||||
const handleChange = () => onChange(option.value);
|
||||
|
||||
const radio = (
|
||||
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
|
||||
<span>
|
||||
<input
|
||||
type="radio"
|
||||
id={optionId(id, i)}
|
||||
checked={checked}
|
||||
name={id}
|
||||
required={required}
|
||||
value={String(i)}
|
||||
disabled={disabled || itemDisabled || readonly}
|
||||
autoFocus={autofocus && i === 0}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
aria-describedby={ariaDescribedByIds<T>(id)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return inline ? (
|
||||
<label
|
||||
key={i}
|
||||
className={`radio-inline ${checked ? "checked" : ""} ${disabledCls}`}
|
||||
>
|
||||
{radio}
|
||||
</label>
|
||||
) : (
|
||||
<div key={i} className={`radio ${checked ? "checked" : ""} ${disabledCls}`}>
|
||||
<label>{radio}</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RadioWidget;
|
||||
105
app/src/ui/components/form/json-schema/widgets/SelectWidget.tsx
Normal file
105
app/src/ui/components/form/json-schema/widgets/SelectWidget.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
type FormContextType,
|
||||
type RJSFSchema,
|
||||
type StrictRJSFSchema,
|
||||
type WidgetProps,
|
||||
ariaDescribedByIds,
|
||||
enumOptionsIndexForValue,
|
||||
enumOptionsValueForIndex
|
||||
} from "@rjsf/utils";
|
||||
import { type ChangeEvent, type FocusEvent, type SyntheticEvent, useCallback } from "react";
|
||||
|
||||
function getValue(event: SyntheticEvent<HTMLSelectElement>, multiple: boolean) {
|
||||
if (multiple) {
|
||||
return Array.from((event.target as HTMLSelectElement).options)
|
||||
.slice()
|
||||
.filter((o) => o.selected)
|
||||
.map((o) => o.value);
|
||||
}
|
||||
return (event.target as HTMLSelectElement).value;
|
||||
}
|
||||
|
||||
/** The `SelectWidget` is a widget for rendering dropdowns.
|
||||
* It is typically used with string properties constrained with enum options.
|
||||
*
|
||||
* @param props - The `WidgetProps` for this component
|
||||
*/
|
||||
function SelectWidget<
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = any
|
||||
>({
|
||||
schema,
|
||||
id,
|
||||
options,
|
||||
value,
|
||||
required,
|
||||
disabled,
|
||||
readonly,
|
||||
multiple = false,
|
||||
autofocus = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder
|
||||
}: WidgetProps<T, S, F>) {
|
||||
const { enumOptions, enumDisabled, emptyValue: optEmptyVal } = options;
|
||||
const emptyValue = multiple ? [] : "";
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event: FocusEvent<HTMLSelectElement>) => {
|
||||
const newValue = getValue(event, multiple);
|
||||
return onFocus(id, enumOptionsValueForIndex<S>(newValue, enumOptions, optEmptyVal));
|
||||
},
|
||||
[onFocus, id, schema, multiple, enumOptions, optEmptyVal]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event: FocusEvent<HTMLSelectElement>) => {
|
||||
const newValue = getValue(event, multiple);
|
||||
return onBlur(id, enumOptionsValueForIndex<S>(newValue, enumOptions, optEmptyVal));
|
||||
},
|
||||
[onBlur, id, schema, multiple, enumOptions, optEmptyVal]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const newValue = getValue(event, multiple);
|
||||
return onChange(enumOptionsValueForIndex<S>(newValue, enumOptions, optEmptyVal));
|
||||
},
|
||||
[onChange, schema, multiple, enumOptions, optEmptyVal]
|
||||
);
|
||||
|
||||
const selectedIndexes = enumOptionsIndexForValue<S>(value, enumOptions, multiple);
|
||||
const showPlaceholderOption = !multiple && schema.default === undefined;
|
||||
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={id}
|
||||
multiple={multiple}
|
||||
className="form-control"
|
||||
value={typeof selectedIndexes === "undefined" ? emptyValue : selectedIndexes}
|
||||
required={required}
|
||||
disabled={disabled || readonly}
|
||||
autoFocus={autofocus}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleChange}
|
||||
aria-describedby={ariaDescribedByIds<T>(id)}
|
||||
>
|
||||
{showPlaceholderOption && <option value="">{placeholder}</option>}
|
||||
{Array.isArray(enumOptions) &&
|
||||
enumOptions.map(({ value, label }, i) => {
|
||||
const disabled = enumDisabled && enumDisabled.indexOf(value) !== -1;
|
||||
return (
|
||||
<option key={i} value={String(i)} disabled={disabled}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectWidget;
|
||||
30
app/src/ui/components/form/json-schema/widgets/index.tsx
Normal file
30
app/src/ui/components/form/json-schema/widgets/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Label } from "../templates/FieldTemplate";
|
||||
import { CheckboxWidget } from "./CheckboxWidget";
|
||||
import CheckboxesWidget from "./CheckboxesWidget";
|
||||
import JsonWidget from "./JsonWidget";
|
||||
import RadioWidget from "./RadioWidget";
|
||||
import SelectWidget from "./SelectWidget";
|
||||
|
||||
const WithLabel = (WrappedComponent, kind?: string) => {
|
||||
return (props) => {
|
||||
const hideLabel =
|
||||
!props.label ||
|
||||
props.uiSchema["ui:options"]?.hideLabel ||
|
||||
props.options?.hideLabel ||
|
||||
props.hideLabel;
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && <Label label={props.label} required={props.required} id={props.id} />}
|
||||
<WrappedComponent {...props} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const widgets = {
|
||||
RadioWidget: RadioWidget,
|
||||
CheckboxWidget: WithLabel(CheckboxWidget),
|
||||
SelectWidget: WithLabel(SelectWidget, "select"),
|
||||
CheckboxesWidget: WithLabel(CheckboxesWidget),
|
||||
JsonWidget: WithLabel(JsonWidget)
|
||||
};
|
||||
107
app/src/ui/components/list/SortableList.tsx
Normal file
107
app/src/ui/components/list/SortableList.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
type DraggableProvided,
|
||||
type DraggableRubric,
|
||||
type DraggableStateSnapshot,
|
||||
Droppable,
|
||||
type DroppableProps
|
||||
} from "@hello-pangea/dnd";
|
||||
import type { ElementProps } from "@mantine/core";
|
||||
import { useListState } from "@mantine/hooks";
|
||||
import { IconGripVertical } from "@tabler/icons-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useId } from "react";
|
||||
|
||||
export type SortableItemProps = {
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
rubic: DraggableRubric;
|
||||
};
|
||||
|
||||
type SortableListProps<Item = any> = ElementProps<"div"> & {
|
||||
data: Item[];
|
||||
extractId?: (item: Item) => string;
|
||||
renderItem?: (props: Item & SortableItemProps, index: number) => React.ReactNode;
|
||||
dndProps?: Omit<DroppableProps, "children">;
|
||||
onReordered?: (from: number, to: number) => void;
|
||||
onChange?: (data: Item[]) => void;
|
||||
disableIndices?: number[];
|
||||
};
|
||||
|
||||
export function SortableList({
|
||||
data = [],
|
||||
extractId,
|
||||
renderItem,
|
||||
dndProps = { droppableId: "sortable-list", direction: "vertical" },
|
||||
onReordered,
|
||||
onChange,
|
||||
disableIndices = [],
|
||||
...props
|
||||
}: SortableListProps) {
|
||||
//const [state, handlers] = useListState(data);
|
||||
|
||||
function onDragEnd({ destination, source }) {
|
||||
if (disableIndices.includes(source.index) || disableIndices.includes(destination.index))
|
||||
return;
|
||||
|
||||
const change = { from: source.index, to: destination?.index || 0 };
|
||||
//handlers.reorder(change);
|
||||
onReordered?.(change.from, change.to);
|
||||
}
|
||||
|
||||
/*function onDragUpdate({ destination, source }) {
|
||||
if (disableIndices.includes(source.index) || disableIndices.includes(destination.index))
|
||||
return;
|
||||
|
||||
const change = { from: source.index, to: destination?.index || 0 };
|
||||
//handlers.reorder(change);
|
||||
onReordered?.(change.from, change.to);
|
||||
}*/
|
||||
|
||||
/*useEffect(() => {
|
||||
handlers.setState(data);
|
||||
}, [data]);*/
|
||||
|
||||
const items = data.map((item, index) => {
|
||||
const id = extractId ? extractId(item) : useId();
|
||||
return (
|
||||
<Draggable
|
||||
key={id}
|
||||
index={index}
|
||||
draggableId={id}
|
||||
isDragDisabled={disableIndices.includes(index)}
|
||||
>
|
||||
{(provided, snapshot, rubic) =>
|
||||
renderItem ? (
|
||||
renderItem({ ...item, dnd: { provided, snapshot, rubic } }, index)
|
||||
) : (
|
||||
<div
|
||||
className="flex flex-row gap-2 p-2 border border-gray-200 rounded-md mb-3 bg-white items-center"
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div {...provided.dragHandleProps}>
|
||||
<IconGripVertical className="size-5" stroke={1.5} />
|
||||
</div>
|
||||
<p>{JSON.stringify(item)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Draggable>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd} /*onDragUpdate={onDragUpdate}*/>
|
||||
<Droppable {...dndProps}>
|
||||
{(provided) => (
|
||||
<div {...props} {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{items}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
||||
3
app/src/ui/components/menu/Dropdown.tsx
Normal file
3
app/src/ui/components/menu/Dropdown.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const DropDown = () => {
|
||||
return null;
|
||||
};
|
||||
0
app/src/ui/components/modal/Modal.tsx
Normal file
0
app/src/ui/components/modal/Modal.tsx
Normal file
154
app/src/ui/components/modal/Modal2.tsx
Normal file
154
app/src/ui/components/modal/Modal2.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Modal, type ModalProps, Popover } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { IconBug } from "@tabler/icons-react";
|
||||
import { Fragment, forwardRef, useImperativeHandle } from "react";
|
||||
import { TbX } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "../buttons/Button";
|
||||
import { IconButton } from "../buttons/IconButton";
|
||||
import { JsonViewer } from "../code/JsonViewer";
|
||||
|
||||
export type Modal2Ref = {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
export type Modal2Props = Omit<ModalProps, "opened" | "onClose"> & {
|
||||
opened?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const Modal2 = forwardRef<Modal2Ref, Modal2Props>(
|
||||
(
|
||||
{ classNames, children, opened: initialOpened, closeOnClickOutside = false, ...props },
|
||||
ref
|
||||
) => {
|
||||
const [opened, { open, close }] = useDisclosure(initialOpened);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open,
|
||||
close
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
withCloseButton={false}
|
||||
padding={0}
|
||||
size="xl"
|
||||
opened={opened}
|
||||
{...props}
|
||||
closeOnClickOutside={closeOnClickOutside}
|
||||
onClose={close}
|
||||
classNames={{
|
||||
...classNames,
|
||||
root: "bknd-admin",
|
||||
content: "rounded-lg select-none"
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ModalTitle = ({ path, onClose }: { path: string[]; onClose: () => void }) => {
|
||||
return (
|
||||
<div className="py-3 px-5 font-bold bg-primary/5 flex flex-row justify-between items-center sticky top-0 left-0 right-0 z-10 border-b border-b-muted">
|
||||
<div className="flex flex-row gap-1">
|
||||
{path.map((p, i) => {
|
||||
const last = i + 1 === path.length;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<span key={i} className={twMerge("", !last && "opacity-70")}>
|
||||
{p}
|
||||
</span>
|
||||
{!last && <span className="opacity-40">/</span>}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<IconButton Icon={TbX} onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModalBody = ({ children, className }: { children?: any; className?: string }) => (
|
||||
<ScrollArea.Root
|
||||
className={twMerge("flex flex-col min-h-96", className)}
|
||||
style={{
|
||||
maxHeight: "calc(80vh)"
|
||||
}}
|
||||
>
|
||||
<ScrollArea.Viewport className="w-full h-full">
|
||||
<div className="py-3 px-5 gap-4 flex flex-col">{children}</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
forceMount
|
||||
className="flex select-none touch-none bg-transparent w-0.5"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-primary/50" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Scrollbar
|
||||
forceMount
|
||||
className="flex select-none touch-none bg-muted flex-col h-0.5"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-primary/50 " />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
|
||||
export const ModalFooter = ({
|
||||
next,
|
||||
prev,
|
||||
nextLabel = "Next",
|
||||
prevLabel = "Back",
|
||||
debug
|
||||
}: {
|
||||
next: any;
|
||||
prev: any;
|
||||
nextLabel?: string;
|
||||
prevLabel?: string;
|
||||
debug?: any;
|
||||
}) => {
|
||||
const [opened, handlers] = useDisclosure(false);
|
||||
return (
|
||||
<div className="flex flex-col border-t border-t-muted">
|
||||
<div className="flex flex-row justify-between items-center py-3 px-4">
|
||||
<div>
|
||||
{debug && (
|
||||
<Popover
|
||||
position="right-start"
|
||||
shadow="md"
|
||||
opened={opened}
|
||||
classNames={{
|
||||
dropdown: "!px-1 !pr-2.5 !py-2 text-sm"
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<IconButton
|
||||
onClick={handlers.toggle}
|
||||
Icon={IconBug}
|
||||
variant={opened ? "default" : "ghost"}
|
||||
/>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<JsonViewer json={debug} expand={6} className="p-0" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button className="w-24 justify-center" {...prev}>
|
||||
{prevLabel}
|
||||
</Button>
|
||||
<Button className="w-24 justify-center" variant="primary" {...next}>
|
||||
{nextLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
app/src/ui/components/overlay/Dropdown.tsx
Normal file
111
app/src/ui/components/overlay/Dropdown.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useClickOutside } from "@mantine/hooks";
|
||||
import { Fragment, type ReactElement, cloneElement, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
export type DropdownItem =
|
||||
| (() => JSX.Element)
|
||||
| {
|
||||
label: string | ReactElement;
|
||||
icon?: any;
|
||||
onClick?: () => void;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type DropdownProps = {
|
||||
className?: string;
|
||||
defaultOpen?: boolean;
|
||||
position?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
hideOnEmpty?: boolean;
|
||||
items: (DropdownItem | undefined | boolean)[];
|
||||
itemsClassName?: string;
|
||||
children: ReactElement<{ onClick: () => void }>;
|
||||
onClickItem?: (item: DropdownItem) => void;
|
||||
renderItem?: (
|
||||
item: DropdownItem,
|
||||
props: { key: number; onClick: () => void }
|
||||
) => ReactElement<{ onClick: () => void }>;
|
||||
};
|
||||
|
||||
export function Dropdown({
|
||||
children,
|
||||
defaultOpen = false,
|
||||
position = "bottom-start",
|
||||
items,
|
||||
hideOnEmpty = true,
|
||||
onClickItem,
|
||||
renderItem,
|
||||
itemsClassName,
|
||||
className
|
||||
}: DropdownProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const clickoutsideRef = useClickOutside(() => setOpen(false));
|
||||
const menuItems = items.filter(Boolean) as DropdownItem[];
|
||||
|
||||
const toggle = useEvent((delay: number = 50) =>
|
||||
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0)
|
||||
);
|
||||
|
||||
const offset = 4;
|
||||
const dropdownStyle = {
|
||||
"bottom-start": { top: "100%", left: 0, marginTop: offset },
|
||||
"bottom-end": { right: 0, top: "100%", marginTop: offset },
|
||||
"top-start": { bottom: "100%", marginBottom: offset },
|
||||
"top-end": { bottom: "100%", right: 0, marginBottom: offset }
|
||||
}[position];
|
||||
|
||||
const internalOnClickItem = useEvent((item) => {
|
||||
if (item.onClick) item.onClick();
|
||||
if (onClickItem) onClickItem(item);
|
||||
toggle(50);
|
||||
});
|
||||
|
||||
if (menuItems.length === 0 && hideOnEmpty) return null;
|
||||
const space_for_icon = menuItems.some((item) => "icon" in item && item.icon);
|
||||
|
||||
const itemRenderer =
|
||||
renderItem ||
|
||||
((item, { key, onClick }) =>
|
||||
typeof item === "function" ? (
|
||||
<Fragment key={key}>{item()}</Fragment>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={item.disabled}
|
||||
className={twMerge(
|
||||
"flex flex-row flex-nowrap text-nowrap items-center outline-none cursor-pointer px-2.5 rounded-md link leading-none h-8",
|
||||
itemsClassName,
|
||||
item.disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-primary/10",
|
||||
item.destructive && "text-red-500 hover:bg-red-600 hover:text-white"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{space_for_icon && (
|
||||
<div className="size-[16px] text-left mr-1.5 opacity-80">
|
||||
{item.icon && <item.icon className="size-[16px]" />}
|
||||
</div>
|
||||
)}
|
||||
{/*{item.icon && <item.icon className="size-4" />}*/}
|
||||
<div className="flex flex-grow truncate text-nowrap">{item.label}</div>
|
||||
</button>
|
||||
));
|
||||
|
||||
return (
|
||||
<div role="dropdown" className={twMerge("relative flex", className)} ref={clickoutsideRef}>
|
||||
{cloneElement(children as any, { onClick: toggle })}
|
||||
{open && (
|
||||
<div
|
||||
className="absolute z-30 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full"
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{menuItems.map((item, i) =>
|
||||
itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) })
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
app/src/ui/components/overlay/Modal.tsx
Normal file
48
app/src/ui/components/overlay/Modal.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useClickOutside } from "@mantine/hooks";
|
||||
import { type ReactElement, cloneElement, useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type ModalProps = {
|
||||
open?: boolean;
|
||||
stickToTop?: boolean;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
allowBackdropClose?: boolean;
|
||||
children: ReactElement<{ onClick: () => void }>;
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
open = false,
|
||||
children,
|
||||
onClose = () => null,
|
||||
allowBackdropClose = true,
|
||||
className,
|
||||
stickToTop
|
||||
}: ModalProps) {
|
||||
const clickoutsideRef = useClickOutside(() => {
|
||||
if (allowBackdropClose) onClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-full h-full fixed bottom-0 top-0 right-0 left-0 bg-background/60 flex justify-center backdrop-blur-sm z-10",
|
||||
stickToTop ? "items-start" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"z-20 flex flex-col bg-background rounded-lg shadow-lg",
|
||||
className
|
||||
)}
|
||||
ref={clickoutsideRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
app/src/ui/components/overlay/Popover.tsx
Normal file
57
app/src/ui/components/overlay/Popover.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useClickOutside } from "@mantine/hooks";
|
||||
import { type ReactElement, cloneElement, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
export type PopoverProps = {
|
||||
className?: string;
|
||||
defaultOpen?: boolean;
|
||||
position?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
backdrop?: boolean;
|
||||
target: (props: { toggle: () => void }) => ReactElement;
|
||||
children: ReactElement<{ onClick: () => void }>;
|
||||
};
|
||||
|
||||
export function Popover({
|
||||
children,
|
||||
target,
|
||||
defaultOpen = false,
|
||||
backdrop = false,
|
||||
position = "bottom-start",
|
||||
className,
|
||||
}: PopoverProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const clickoutsideRef = useClickOutside(() => setOpen(false));
|
||||
|
||||
const toggle = useEvent((delay: number = 50) =>
|
||||
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0),
|
||||
);
|
||||
|
||||
const pos = {
|
||||
"bottom-start": "mt-1 top-[100%]",
|
||||
"bottom-end": "right-0 top-[100%] mt-1",
|
||||
"top-start": "bottom-[100%] mb-1",
|
||||
"top-end": "bottom-[100%] right-0 mb-1",
|
||||
}[position];
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && backdrop && (
|
||||
<div className="animate-fade-in w-full h-full absolute top-0 bottom-0 right-0 left-0 bg-background/60" />
|
||||
)}
|
||||
<div role="dropdown" className={twMerge("relative flex", className)} ref={clickoutsideRef}>
|
||||
{cloneElement(children as any, { onClick: toggle })}
|
||||
{open && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"animate-fade-in absolute z-20 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full max-w-20 backdrop-blur-sm",
|
||||
pos,
|
||||
)}
|
||||
>
|
||||
{target({ toggle })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
app/src/ui/components/radix/ScrollArea.tsx
Normal file
22
app/src/ui/components/radix/ScrollArea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as ReactScrollArea from "@radix-ui/react-scroll-area";
|
||||
|
||||
export const ScrollArea = ({ children, className }: any) => (
|
||||
<ReactScrollArea.Root className={`${className} `}>
|
||||
<ReactScrollArea.Viewport className="w-full h-full ">
|
||||
{children}
|
||||
</ReactScrollArea.Viewport>
|
||||
<ReactScrollArea.Scrollbar
|
||||
className="ScrollAreaScrollbar"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
|
||||
</ReactScrollArea.Scrollbar>
|
||||
<ReactScrollArea.Scrollbar
|
||||
className="ScrollAreaScrollbar"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
|
||||
</ReactScrollArea.Scrollbar>
|
||||
<ReactScrollArea.Corner className="ScrollAreaCorner" />
|
||||
</ReactScrollArea.Root>
|
||||
);
|
||||
86
app/src/ui/components/radix/extend.tsx
Normal file
86
app/src/ui/components/radix/extend.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
type ComponentProps,
|
||||
type ComponentPropsWithRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
type ElementType,
|
||||
type ForwardedRef,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
forwardRef
|
||||
} from "react";
|
||||
|
||||
export function extend<ComponentType extends ElementType, AdditionalProps = {}>(
|
||||
Component: ComponentType,
|
||||
applyAdditionalProps?: (
|
||||
props: PropsWithChildren<ComponentPropsWithoutRef<ComponentType> & AdditionalProps> & {
|
||||
className?: string;
|
||||
}
|
||||
) => ComponentProps<ComponentType>
|
||||
) {
|
||||
return forwardRef<
|
||||
ElementRef<ComponentType>,
|
||||
ComponentPropsWithoutRef<ComponentType> & AdditionalProps
|
||||
>((props, ref) => {
|
||||
// Initialize newProps with a default empty object or the result of applyAdditionalProps
|
||||
let newProps: ComponentProps<ComponentType> & AdditionalProps = applyAdditionalProps
|
||||
? applyAdditionalProps(props as any)
|
||||
: (props as any);
|
||||
|
||||
// Append className if it exists in both props and newProps
|
||||
if (props.className && newProps.className) {
|
||||
newProps = {
|
||||
...newProps,
|
||||
className: `${props.className} ${newProps.className}`
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-expect-error haven't figured out the correct typing
|
||||
return <Component {...newProps} ref={ref} />;
|
||||
});
|
||||
}
|
||||
|
||||
type RenderFunction<ComponentType extends React.ElementType, AdditionalProps = {}> = (
|
||||
props: PropsWithChildren<ComponentPropsWithRef<ComponentType> & AdditionalProps> & {
|
||||
className?: string;
|
||||
},
|
||||
ref: ForwardedRef<ElementRef<ComponentType>>
|
||||
) => ReactElement;
|
||||
|
||||
export function extendComponent<ComponentType extends React.ElementType, AdditionalProps = {}>(
|
||||
renderFunction: RenderFunction<ComponentType, AdditionalProps>
|
||||
) {
|
||||
// The extended component using forwardRef to forward the ref to the custom component
|
||||
const ExtendedComponent = forwardRef<
|
||||
ElementRef<ComponentType>,
|
||||
ComponentPropsWithRef<ComponentType> & AdditionalProps
|
||||
>((props, ref) => {
|
||||
return renderFunction(props as any, ref);
|
||||
});
|
||||
|
||||
return ExtendedComponent;
|
||||
}
|
||||
|
||||
/*
|
||||
export const Content = forwardRef<
|
||||
ElementRef<typeof DropdownMenu.Content>,
|
||||
ComponentPropsWithoutRef<typeof DropdownMenu.Content>
|
||||
>(({ className, ...props }, forwardedRef) => (
|
||||
<DropdownMenu.Content
|
||||
className={`flex flex-col ${className}`}
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
/>
|
||||
));
|
||||
|
||||
export const Item = forwardRef<
|
||||
ElementRef<typeof DropdownMenu.Item>,
|
||||
ComponentPropsWithoutRef<typeof DropdownMenu.Item>
|
||||
>(({ className, ...props }, forwardedRef) => (
|
||||
<DropdownMenu.Item
|
||||
className={`flex flex-row flex-nowrap ${className}`}
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
/>
|
||||
));
|
||||
*/
|
||||
67
app/src/ui/components/steps/Steps.tsx
Normal file
67
app/src/ui/components/steps/Steps.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Children,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
createContext,
|
||||
useContext,
|
||||
useState
|
||||
} from "react";
|
||||
|
||||
export type TStepsProps = {
|
||||
children: any;
|
||||
initialPath?: string[];
|
||||
lastBack?: () => void;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type TStepContext<T = any> = {
|
||||
nextStep: (step: string) => () => void;
|
||||
stepBack: () => void;
|
||||
close: () => void;
|
||||
state: T;
|
||||
setState: Dispatch<SetStateAction<T>>;
|
||||
};
|
||||
|
||||
const StepContext = createContext<TStepContext>(undefined as any);
|
||||
|
||||
export function Steps({ children, initialPath = [], lastBack }: TStepsProps) {
|
||||
const [state, setState] = useState<any>({});
|
||||
const [path, setPath] = useState<string[]>(initialPath);
|
||||
const steps: any[] = Children.toArray(children).filter(
|
||||
(child: any) => child.props.disabled !== true
|
||||
);
|
||||
|
||||
function stepBack() {
|
||||
if (path.length === 0) {
|
||||
lastBack?.();
|
||||
} else {
|
||||
setPath((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
const nextStep = (step: string) => () => {
|
||||
setPath((prev) => [...prev, step]);
|
||||
};
|
||||
|
||||
const current = steps.find((step) => step.props.id === path[path.length - 1]) || steps[0];
|
||||
|
||||
return (
|
||||
<StepContext.Provider value={{ nextStep, stepBack, state, setState, close: lastBack! }}>
|
||||
{current}
|
||||
</StepContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useStepContext<T = any>(): TStepContext<T> {
|
||||
return useContext(StepContext);
|
||||
}
|
||||
|
||||
export function Step({
|
||||
children,
|
||||
disabled = false,
|
||||
path = [],
|
||||
id,
|
||||
...rest
|
||||
}: { children: React.ReactNode; disabled?: boolean; id: string; path?: string[] }) {
|
||||
return <div {...rest}>{children}</div>;
|
||||
}
|
||||
297
app/src/ui/components/table/DataTable.tsx
Normal file
297
app/src/ui/components/table/DataTable.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { Menu } from "@mantine/core";
|
||||
import { useToggle } from "@mantine/hooks";
|
||||
import { ucFirst } from "core/utils";
|
||||
import {
|
||||
TbArrowDown,
|
||||
TbArrowUp,
|
||||
TbChevronLeft,
|
||||
TbChevronRight,
|
||||
TbChevronsLeft,
|
||||
TbChevronsRight,
|
||||
TbDotsVertical,
|
||||
TbSelector,
|
||||
TbSquare,
|
||||
TbSquareCheckFilled
|
||||
} from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "../buttons/IconButton";
|
||||
import { Dropdown, type DropdownItem } from "../overlay/Dropdown";
|
||||
|
||||
export const Check = () => {
|
||||
const [checked, toggle] = useToggle([false, true]);
|
||||
const Icon = checked ? TbSquareCheckFilled : TbSquare;
|
||||
return (
|
||||
<button role="checkbox" type="button" className="flex px-3 py-3" onClick={() => toggle()}>
|
||||
<Icon size={18} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type DataTableProps<Data> = {
|
||||
data: Data[];
|
||||
columns?: string[];
|
||||
checkable?: boolean;
|
||||
onClickRow?: (row: Data) => void;
|
||||
onClickPage?: (page: number) => void;
|
||||
total?: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
rowActions?: (Omit<DropdownItem, "onClick"> & {
|
||||
onClick: (row: Data, key: number) => void;
|
||||
})[];
|
||||
perPageOptions?: number[];
|
||||
sort?: { by?: string; dir?: "asc" | "desc" };
|
||||
onClickSort?: (name: string) => void;
|
||||
onClickPerPage?: (perPage: number) => void;
|
||||
renderHeader?: (column: string) => React.ReactNode;
|
||||
renderValue?: ({ value, property }: { value: any; property: string }) => React.ReactNode;
|
||||
classNames?: {
|
||||
value?: string;
|
||||
};
|
||||
onClickNew?: () => void;
|
||||
};
|
||||
|
||||
export function DataTable<Data extends Record<string, any> = Record<string, any>>({
|
||||
data = [],
|
||||
columns,
|
||||
checkable,
|
||||
onClickRow,
|
||||
onClickPage,
|
||||
onClickSort,
|
||||
total,
|
||||
sort,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
perPageOptions,
|
||||
onClickPerPage,
|
||||
classNames,
|
||||
renderHeader,
|
||||
rowActions,
|
||||
renderValue,
|
||||
onClickNew
|
||||
}: DataTableProps<Data>) {
|
||||
total = total || data.length;
|
||||
page = page || 1;
|
||||
|
||||
const select = columns && columns.length > 0 ? columns : Object.keys(data[0] || {});
|
||||
const pages = Math.max(Math.ceil(total / perPage), 1);
|
||||
const CellRender = renderValue || CellValue;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{onClickNew && (
|
||||
<div className="flex flex-row space-between">
|
||||
{onClickNew && <Button onClick={onClickNew}>Create new</Button>}
|
||||
</div>
|
||||
)}
|
||||
<div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-x-scroll overflow-y-hidden">
|
||||
<table className="w-full">
|
||||
{select.length > 0 ? (
|
||||
<thead className="sticky top-0 bg-muted/10">
|
||||
<tr>
|
||||
{checkable && (
|
||||
<th align="center" className="w-[40px]">
|
||||
<Check />
|
||||
</th>
|
||||
)}
|
||||
{select.map((property, key) => {
|
||||
const label = renderHeader?.(property) ?? ucFirst(property);
|
||||
|
||||
return (
|
||||
<th key={key}>
|
||||
<div className="flex flex-row py-1 px-1 font-normal text-primary/55">
|
||||
<button
|
||||
type="button"
|
||||
className={twMerge(
|
||||
"link hover:bg-primary/5 py-1.5 rounded-md inline-flex flex-row justify-start items-center gap-1",
|
||||
onClickSort ? "pl-2.5 pr-1" : "px-2.5"
|
||||
)}
|
||||
onClick={() => onClickSort?.(property)}
|
||||
>
|
||||
<span className="text-left text-nowrap whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
{onClickSort && (
|
||||
<SortIndicator sort={sort} field={property} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
{rowActions && rowActions.length > 0 && <th className="w-10" />}
|
||||
</tr>
|
||||
</thead>
|
||||
) : null}
|
||||
<tbody>
|
||||
{!data || data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={select.length + (checkable ? 1 : 0)}>
|
||||
<div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted">
|
||||
<i className="opacity-50">No data to show</i>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, key) => {
|
||||
const rowClick = () => onClickRow?.(row);
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
data-border={key > 0}
|
||||
className="hover:bg-primary/5 active:bg-muted border-muted data-[border]:border-t cursor-pointer transition-colors"
|
||||
>
|
||||
{checkable && (
|
||||
<td align="center">
|
||||
<Check />
|
||||
</td>
|
||||
)}
|
||||
|
||||
{Object.entries(row).map(([key, value], index) => (
|
||||
<td key={index} onClick={rowClick}>
|
||||
<div className="flex flex-row items-start py-3 px-3.5 font-normal ">
|
||||
<CellRender property={key} value={value} />
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
|
||||
{rowActions && rowActions.length > 0 && (
|
||||
<td>
|
||||
{/* @todo: create new dropdown using popover */}
|
||||
<div className="flex flex-row justify-end pr-2">
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<IconButton Icon={TbDotsVertical} />
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{rowActions.map((a: any) => (
|
||||
<Menu.Item
|
||||
key={a.label}
|
||||
onClick={() => a.onClick(row, key)}
|
||||
leftSection={a.icon && <a.icon />}
|
||||
>
|
||||
{a.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="hidden md:flex text-primary/40">
|
||||
<TableDisplay perPage={perPage} page={page} items={data.length} total={total} />
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||
{perPageOptions && (
|
||||
<div className="hidden md:flex flex-row items-center gap-2 text-primary/40">
|
||||
Per Page{" "}
|
||||
<Dropdown
|
||||
items={perPageOptions.map((perPage) => ({
|
||||
label: String(perPage),
|
||||
perPage
|
||||
}))}
|
||||
position="top-end"
|
||||
onClickItem={(item: any) => onClickPerPage?.(item.perPage)}
|
||||
>
|
||||
<Button>{perPage}</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-primary/40">
|
||||
Page {page} of {pages}
|
||||
</div>
|
||||
{onClickPage && (
|
||||
<div className="flex flex-row gap-1.5">
|
||||
<TableNav current={page} total={pages} onClick={onClickPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CellValue = ({ value, property }) => {
|
||||
let value_mono = false;
|
||||
//console.log("value", property, value);
|
||||
if (value !== null && typeof value === "object") {
|
||||
value = JSON.stringify(value);
|
||||
value_mono = true;
|
||||
}
|
||||
|
||||
if (value !== null && typeof value !== "undefined") {
|
||||
return <span className={twMerge("line-clamp-2", value_mono && "font-mono")}>{value}</span>;
|
||||
}
|
||||
|
||||
return <span className="opacity-10 font-mono">null</span>;
|
||||
};
|
||||
|
||||
const SortIndicator = ({
|
||||
sort,
|
||||
field
|
||||
}: {
|
||||
sort: Pick<DataTableProps<any>, "sort">["sort"];
|
||||
field: string;
|
||||
}) => {
|
||||
if (!sort || sort.by !== field) return <TbSelector size={18} className="mt-[1px]" />;
|
||||
|
||||
if (sort.dir === "asc") return <TbArrowUp size={18} className="mt-[1px]" />;
|
||||
return <TbArrowDown size={18} className="mt-[1px]" />;
|
||||
};
|
||||
|
||||
const TableDisplay = ({ perPage, page, items, total }) => {
|
||||
if (total === 0) {
|
||||
return <>No rows to show</>;
|
||||
}
|
||||
|
||||
if (total === 1) {
|
||||
return <>Showing 1 row</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TableNavProps = {
|
||||
current: number;
|
||||
total: number;
|
||||
onClick?: (page: number) => void;
|
||||
};
|
||||
|
||||
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
|
||||
const navMap = [
|
||||
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
|
||||
{ value: total, Icon: TbChevronsRight, disabled: current === total }
|
||||
] as const;
|
||||
|
||||
return navMap.map((nav, key) => (
|
||||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={nav.disabled}
|
||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
const page = nav.value;
|
||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||
onClick?.(safePage);
|
||||
}}
|
||||
>
|
||||
<nav.Icon />
|
||||
</button>
|
||||
));
|
||||
};
|
||||
79
app/src/ui/components/wouter/Link.tsx
Normal file
79
app/src/ui/components/wouter/Link.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useInsertionEffect, useRef } from "react";
|
||||
import { type LinkProps, Link as WouterLink, useRoute, useRouter } from "wouter";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
/*
|
||||
* Transforms `path` into its relative `base` version
|
||||
* If base isn't part of the path provided returns absolute path e.g. `~/app`
|
||||
*/
|
||||
export const relativePath = (base = "", path = "") =>
|
||||
!path.toLowerCase().indexOf(base.toLowerCase()) ? path.slice(base.length) || "/" : "~" + path;
|
||||
|
||||
export const absolutePath = (to, base = "") => (to[0] === "~" ? to.slice(1) : base + to);
|
||||
|
||||
/*
|
||||
* Removes leading question mark
|
||||
*/
|
||||
export const stripQm = (str) => (str[0] === "?" ? str.slice(1) : str);
|
||||
|
||||
/*
|
||||
* decodes escape sequences such as %20
|
||||
*/
|
||||
|
||||
// biome-ignore lint/suspicious/noShadowRestrictedNames: <explanation>
|
||||
export const unescape = (str) => {
|
||||
try {
|
||||
return decodeURI(str);
|
||||
} catch (_e) {
|
||||
// fail-safe mode: if string can't be decoded do nothing
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
const useLocationFromRouter = (router) => {
|
||||
const [location, navigate] = router.hook(router);
|
||||
|
||||
// the function reference should stay the same between re-renders, so that
|
||||
// it can be passed down as an element prop without any performance concerns.
|
||||
// (This is achieved via `useEvent`.)
|
||||
return [
|
||||
unescape(relativePath(router.base, location)),
|
||||
useEvent((to, navOpts) => navigate(absolutePath(to, router.base), navOpts))
|
||||
];
|
||||
};
|
||||
|
||||
export function Link({ className, ...props }: { className?: string } & LinkProps) {
|
||||
const router = useRouter();
|
||||
const [path, navigate] = useLocationFromRouter(router);
|
||||
|
||||
function isActive(absPath: string, href: string) {
|
||||
if (absPath.startsWith(href)) {
|
||||
const l = absPath.replace(href, "");
|
||||
return l.length === 0 || l[0] === "/";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleClick(e) {}
|
||||
|
||||
const _href = props.href ?? props.to;
|
||||
const href = router
|
||||
.hrefs(
|
||||
_href[0] === "~" ? _href.slice(1) : router.base + _href,
|
||||
router // pass router as a second argument for convinience
|
||||
)
|
||||
.replace("//", "/");
|
||||
const absPath = absolutePath(path, router.base).replace("//", "/");
|
||||
const active =
|
||||
href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href);
|
||||
const a = useRoute(_href);
|
||||
|
||||
/*if (active) {
|
||||
console.log("link", { a, path, absPath, href, to, active, router });
|
||||
}*/
|
||||
return (
|
||||
// @ts-expect-error className is not typed on WouterLink
|
||||
<WouterLink className={`${active ? "active " : ""}${className}`} {...props} />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user