feat(web): add run inputs options on dashboard
This commit is contained in:
parent
cdbf4f3dd5
commit
592cc2abcd
BIN
web/bun.lockb
BIN
web/bun.lockb
Binary file not shown.
@ -25,15 +25,21 @@
|
|||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.1.3",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
|
"@radix-ui/react-toggle": "^1.0.3",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@tanstack/react-table": "^8.10.7",
|
"@tanstack/react-table": "^8.10.7",
|
||||||
"@types/jsonwebtoken": "^9.0.5",
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
"date-fns": "^3.0.5",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"drizzle-orm": "^0.29.1",
|
"drizzle-orm": "^0.29.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@ -43,6 +49,7 @@
|
|||||||
"next-plausible": "^3.12.0",
|
"next-plausible": "^3.12.0",
|
||||||
"next-usequerystate": "^1.13.2",
|
"next-usequerystate": "^1.13.2",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
|
"react-day-picker": "^8.9.1",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
"react-use-websocket": "^4.5.0",
|
"react-use-websocket": "^4.5.0",
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { OutputRender } from "./OutputRender";
|
|
||||||
import { CodeBlock } from "@/components/CodeBlock";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -8,8 +6,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { findAllRuns } from "@/server/findAllRuns";
|
import type { findAllRuns } from "@/server/findAllRuns";
|
||||||
import { getRunsOutput } from "@/server/getRunsOutput";
|
|
||||||
|
|
||||||
export async function RunInputs({
|
export async function RunInputs({
|
||||||
run,
|
run,
|
||||||
@ -30,16 +27,28 @@ export async function RunInputs({
|
|||||||
{Object.entries(run.workflow_inputs).map(([key, data]) => {
|
{Object.entries(run.workflow_inputs).map(([key, data]) => {
|
||||||
let imageUrl;
|
let imageUrl;
|
||||||
try {
|
try {
|
||||||
const url = new URL(data);
|
if (data.startsWith("data:image/")) {
|
||||||
if (url.pathname.endsWith('.png')) {
|
|
||||||
imageUrl = data;
|
imageUrl = data;
|
||||||
|
} else {
|
||||||
|
const url = new URL(data);
|
||||||
|
if (url.pathname.endsWith(".png")) {
|
||||||
|
imageUrl = data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={key}>
|
<TableRow key={key}>
|
||||||
<TableCell>{key}</TableCell>
|
<TableCell>{key}</TableCell>
|
||||||
{imageUrl ? <TableCell><img className="w-[200px] aspect-square object-contain" src={imageUrl}></img></TableCell> : <TableCell>{data}</TableCell>}
|
{imageUrl ? (
|
||||||
|
<TableCell>
|
||||||
|
<img
|
||||||
|
className="w-[200px] aspect-square object-contain"
|
||||||
|
src={imageUrl}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
) : (
|
||||||
|
<TableCell>{data}</TableCell>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
import { callServerPromise } from "./callServerPromise";
|
import { callServerPromise } from "./callServerPromise";
|
||||||
import { LoadingIcon } from "@/components/LoadingIcon";
|
import { LoadingIcon } from "@/components/LoadingIcon";
|
||||||
|
import AutoForm, { AutoFormSubmit } from "@/components/ui/auto-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -18,14 +27,16 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { getInputsFromWorkflow } from "@/lib/getInputsFromWorkflow";
|
||||||
import { createRun } from "@/server/createRun";
|
import { createRun } from "@/server/createRun";
|
||||||
import { createDeployments } from "@/server/curdDeploments";
|
import { createDeployments } from "@/server/curdDeploments";
|
||||||
import type { getMachines } from "@/server/curdMachine";
|
import type { getMachines } from "@/server/curdMachine";
|
||||||
import type { findFirstTableWithVersion } from "@/server/findFirstTableWithVersion";
|
import type { findFirstTableWithVersion } from "@/server/findFirstTableWithVersion";
|
||||||
import { Copy, MoreVertical, Play } from "lucide-react";
|
import { Copy, MoreVertical, Play } from "lucide-react";
|
||||||
import { parseAsInteger, useQueryState } from "next-usequerystate";
|
import { parseAsInteger, useQueryState } from "next-usequerystate";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export function VersionSelect({
|
export function VersionSelect({
|
||||||
workflow,
|
workflow,
|
||||||
@ -106,31 +117,95 @@ export function RunWorkflowButton({
|
|||||||
defaultValue: machines[0].id ?? "",
|
defaultValue: machines[0].id ?? "",
|
||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className="gap-2"
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={async () => {
|
|
||||||
const workflow_version_id = workflow?.versions.find(
|
|
||||||
(x) => x.version === version
|
|
||||||
)?.id;
|
|
||||||
if (!workflow_version_id) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
try {
|
const [open, setOpen] = useState(false);
|
||||||
const origin = window.location.origin;
|
|
||||||
await callServerPromise(
|
const schema = useMemo(() => {
|
||||||
createRun(origin, workflow_version_id, machine, undefined, true)
|
const workflow_version = getWorkflowVersionFromVersionIndex(
|
||||||
);
|
workflow,
|
||||||
// console.log(res.json());
|
version
|
||||||
setIsLoading(false);
|
);
|
||||||
} catch (error) {
|
const inputs = getInputsFromWorkflow(workflow_version);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
if (!inputs) return null;
|
||||||
}}
|
|
||||||
>
|
return z.object({
|
||||||
Run {isLoading ? <LoadingIcon /> : <Play size={14} />}
|
...Object.fromEntries(
|
||||||
</Button>
|
inputs?.map((x) => {
|
||||||
|
return [x?.input_id, z.string().optional()];
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [version]);
|
||||||
|
|
||||||
|
const runWorkflow = async () => {
|
||||||
|
console.log(values);
|
||||||
|
|
||||||
|
const val = Object.keys(values).length > 0 ? values : undefined;
|
||||||
|
|
||||||
|
const workflow_version_id = workflow?.versions.find(
|
||||||
|
(x) => x.version === version
|
||||||
|
)?.id;
|
||||||
|
console.log(workflow_version_id);
|
||||||
|
if (!workflow_version_id) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const origin = window.location.origin;
|
||||||
|
await callServerPromise(
|
||||||
|
createRun(origin, workflow_version_id, machine, val, true)
|
||||||
|
);
|
||||||
|
// console.log(res.json());
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild className="appearance-none hover:cursor-pointer">
|
||||||
|
<Button className="gap-2" disabled={isLoading}>
|
||||||
|
Run {isLoading ? <LoadingIcon /> : <Play size={14} />}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Run outputs</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
You can view your run's outputs here
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{/* <div className="max-h-96 overflow-y-scroll"> */}
|
||||||
|
{schema && (
|
||||||
|
<AutoForm
|
||||||
|
formSchema={schema}
|
||||||
|
values={values}
|
||||||
|
onValuesChange={setValues}
|
||||||
|
onSubmit={runWorkflow}
|
||||||
|
>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<AutoFormSubmit>
|
||||||
|
Run
|
||||||
|
<span className="ml-2">
|
||||||
|
{isLoading ? <LoadingIcon /> : <Play size={14} />}
|
||||||
|
</span>
|
||||||
|
</AutoFormSubmit>
|
||||||
|
</div>
|
||||||
|
</AutoForm>
|
||||||
|
)}
|
||||||
|
{!schema && (
|
||||||
|
<Button className="gap-2" disabled={isLoading} onClick={runWorkflow}>
|
||||||
|
Confirm {isLoading ? <LoadingIcon /> : <Play size={14} />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* </div> */}
|
||||||
|
{/* <div className="max-h-96 overflow-y-scroll">{view}</div> */}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
export const customInputNodes: Record<string, string> = {
|
export const customInputNodes: Record<string, string> = {
|
||||||
ComfyUIDeployExternalText: "string",
|
ComfyUIDeployExternalText: "string",
|
||||||
ComfyUIDeployExternalImage: "string - (public image url)",
|
ComfyUIDeployExternalImage: "string - (public image url)",
|
||||||
|
ComfyUIDeployExternalImageAlpha: "string - (public image url)",
|
||||||
};
|
};
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import { cn } from "@/lib/utils";
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
import { ChevronDown } from "lucide-react"
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
const Accordion = AccordionPrimitive.Root;
|
||||||
|
|
||||||
const Accordion = AccordionPrimitive.Root
|
|
||||||
|
|
||||||
const AccordionItem = React.forwardRef<
|
const AccordionItem = React.forwardRef<
|
||||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||||
@ -17,8 +16,8 @@ const AccordionItem = React.forwardRef<
|
|||||||
className={cn("border-b", className)}
|
className={cn("border-b", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AccordionItem.displayName = "AccordionItem"
|
AccordionItem.displayName = "AccordionItem";
|
||||||
|
|
||||||
const AccordionTrigger = React.forwardRef<
|
const AccordionTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||||
@ -37,8 +36,8 @@ const AccordionTrigger = React.forwardRef<
|
|||||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||||
</AccordionPrimitive.Trigger>
|
</AccordionPrimitive.Trigger>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionPrimitive.Header>
|
||||||
))
|
));
|
||||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const AccordionContent = React.forwardRef<
|
const AccordionContent = React.forwardRef<
|
||||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||||
@ -51,8 +50,8 @@ const AccordionContent = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||||
</AccordionPrimitive.Content>
|
</AccordionPrimitive.Content>
|
||||||
))
|
));
|
||||||
|
|
||||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
|
33
web/src/components/ui/auto-form/config.ts
Normal file
33
web/src/components/ui/auto-form/config.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import AutoFormCheckbox from "./fields/checkbox";
|
||||||
|
import AutoFormDate from "./fields/date";
|
||||||
|
import AutoFormEnum from "./fields/enum";
|
||||||
|
import AutoFormInput from "./fields/input";
|
||||||
|
import AutoFormNumber from "./fields/number";
|
||||||
|
import AutoFormRadioGroup from "./fields/radio-group";
|
||||||
|
import AutoFormSwitch from "./fields/switch";
|
||||||
|
import AutoFormTextarea from "./fields/textarea";
|
||||||
|
|
||||||
|
export const INPUT_COMPONENTS = {
|
||||||
|
checkbox: AutoFormCheckbox,
|
||||||
|
date: AutoFormDate,
|
||||||
|
select: AutoFormEnum,
|
||||||
|
radio: AutoFormRadioGroup,
|
||||||
|
switch: AutoFormSwitch,
|
||||||
|
textarea: AutoFormTextarea,
|
||||||
|
number: AutoFormNumber,
|
||||||
|
fallback: AutoFormInput,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define handlers for specific Zod types.
|
||||||
|
* You can expand this object to support more types.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ZOD_HANDLERS: {
|
||||||
|
[key: string]: keyof typeof INPUT_COMPONENTS;
|
||||||
|
} = {
|
||||||
|
ZodBoolean: "checkbox",
|
||||||
|
ZodDate: "date",
|
||||||
|
ZodEnum: "select",
|
||||||
|
ZodNativeEnum: "select",
|
||||||
|
ZodNumber: "number",
|
||||||
|
};
|
67
web/src/components/ui/auto-form/fields/array.tsx
Normal file
67
web/src/components/ui/auto-form/fields/array.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import type { useForm , useForm } from "react-hook-form";
|
||||||
|
import { useFieldArray } from "react-hook-form";
|
||||||
|
import { Button } from "../../button";
|
||||||
|
import { Separator } from "../../separator";
|
||||||
|
import { beautifyObjectName } from "../utils";
|
||||||
|
import AutoFormObject from "./object";
|
||||||
|
import { Plus, Trash } from "lucide-react";
|
||||||
|
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
export default function AutoFormArray({
|
||||||
|
name,
|
||||||
|
item,
|
||||||
|
form,
|
||||||
|
path = [],
|
||||||
|
fieldConfig,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
item: z.ZodArray<any>;
|
||||||
|
form: ReturnType<typeof useForm>;
|
||||||
|
path?: string[];
|
||||||
|
fieldConfig?: any;
|
||||||
|
}) {
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
const title = item._def.description ?? beautifyObjectName(name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionItem value={name}>
|
||||||
|
<AccordionTrigger>{title}</AccordionTrigger>
|
||||||
|
<AccordionContent className="border-l p-3 pl-6">
|
||||||
|
{fields.map((_field, index) => {
|
||||||
|
const key = [...path, index.toString()].join(".");
|
||||||
|
return (
|
||||||
|
<div className="mb-4 grid gap-6" key={`${key}`}>
|
||||||
|
<AutoFormObject
|
||||||
|
schema={item._def.type as z.ZodObject<any, any>}
|
||||||
|
form={form}
|
||||||
|
fieldConfig={fieldConfig}
|
||||||
|
path={[...path, index.toString()]}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => append({})}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2" size={16} />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
}
|
32
web/src/components/ui/auto-form/fields/checkbox.tsx
Normal file
32
web/src/components/ui/auto-form/fields/checkbox.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Checkbox } from "../../checkbox";
|
||||||
|
import { FormControl, FormDescription, FormItem, FormLabel } from "../../form";
|
||||||
|
import { AutoFormInputComponentProps } from "../types";
|
||||||
|
|
||||||
|
export default function AutoFormCheckbox({
|
||||||
|
label,
|
||||||
|
isRequired,
|
||||||
|
field,
|
||||||
|
fieldConfigItem,
|
||||||
|
fieldProps,
|
||||||
|
}: AutoFormInputComponentProps) {
|
||||||
|
return (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
{...fieldProps}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel>
|
||||||
|
{label}
|
||||||
|
{isRequired && <span className="text-destructive"> *</span>}
|
||||||
|
</FormLabel>
|
||||||
|
{fieldConfigItem.description && (
|
||||||
|
<FormDescription>{fieldConfigItem.description}</FormDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
37
web/src/components/ui/auto-form/fields/date.tsx
Normal file
37
web/src/components/ui/auto-form/fields/date.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { DatePicker } from "../../date-picker";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../form";
|
||||||
|
import type { AutoFormInputComponentProps } from "../types";
|
||||||
|
|
||||||
|
export default function AutoFormDate({
|
||||||
|
label,
|
||||||
|
isRequired,
|
||||||
|
field,
|
||||||
|
fieldConfigItem,
|
||||||
|
fieldProps,
|
||||||
|
}: AutoFormInputComponentProps) {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{label}
|
||||||
|
{isRequired && <span className="text-destructive"> *</span>}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<DatePicker
|
||||||
|
date={field.value}
|
||||||
|
setDate={field.onChange}
|
||||||
|
{...fieldProps}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{fieldConfigItem.description && (
|
||||||
|
<FormDescription>{fieldConfigItem.description}</FormDescription>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
71
web/src/components/ui/auto-form/fields/enum.tsx
Normal file
71
web/src/components/ui/auto-form/fields/enum.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../../select";
|
||||||
|
import { AutoFormInputComponentProps } from "../types";
|
||||||
|
import { getBaseSchema } from "../utils";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
export default function AutoFormEnum({
|
||||||
|
label,
|
||||||
|
isRequired,
|
||||||
|
field,
|
||||||
|
fieldConfigItem,
|
||||||
|
zodItem,
|
||||||
|
}: AutoFormInputComponentProps) {
|
||||||
|
const baseValues = (getBaseSchema(zodItem) as unknown as z.ZodEnum<any>)._def
|
||||||
|
.values;
|
||||||
|
|
||||||
|
let values: [string, string][] = [];
|
||||||
|
if (!Array.isArray(baseValues)) {
|
||||||
|
values = Object.entries(baseValues);
|
||||||
|
} else {
|
||||||
|
values = baseValues.map((value) => [value, value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findItem(value: any) {
|
||||||
|
return values.find((item) => item[0] === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{label}
|
||||||
|
{isRequired && <span className="text-destructive"> *</span>}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
className="w-full"
|
||||||
|
placeholder={fieldConfigItem.inputProps?.placeholder}
|
||||||
|
>
|
||||||
|
{field.value ? findItem(field.value)?.[1] : "Select an option"}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{values.map(([value, label]) => (
|
||||||
|
<SelectItem value={label} key={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
{fieldConfigItem.description && (
|
||||||
|
<FormDescription>{fieldConfigItem.description}</FormDescription>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
36
web/src/components/ui/auto-form/fields/input.tsx
Normal file
36
web/src/components/ui/auto-form/fields/input.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../form";
|
||||||
|
import { Input } from "../../input";
|
||||||
|
import { AutoFormInputComponentProps } from "../types";
|
||||||
|
|
||||||
|
export default function AutoFormInput({
|
||||||
|
label,
|
||||||
|
isRequired,
|
||||||
|
fieldConfigItem,
|
||||||
|
fieldProps,
|
||||||
|
}: AutoFormInputComponentProps) {
|
||||||
|
const { showLabel: _showLabel, ...fieldPropsWithoutShowLabel } = fieldProps;
|
||||||
|
const showLabel = _showLabel === undefined ? true : _showLabel;
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
{showLabel && (
|
||||||
|
<FormLabel>
|
||||||
|
{label}
|
||||||
|
{isRequired && <span className="text-destructive"> *</span>}
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" {...fieldPropsWithoutShowLabel} />
|
||||||
|
</FormControl>
|
||||||
|
{fieldConfigItem.description && (
|
||||||
|
<FormDescription>{fieldConfigItem.description}</FormDescription>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
17
web/src/components/ui/auto-form/fields/number.tsx
Normal file
17
web/src/components/ui/auto-form/fields/number.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { AutoFormInputComponentProps } from "../types";
|
||||||
|
import AutoFormInput from "./input";
|
||||||
|
|
||||||
|
export default function AutoFormNumber({
|
||||||
|
fieldProps,
|
||||||
|
...props
|
||||||
|
}: AutoFormInputComponentProps) {
|
||||||
|
return (
|
||||||
|
<AutoFormInput
|
||||||
|
fieldProps={{
|
||||||
|
type: "number",
|
||||||
|
...fieldProps,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
130
web/src/components/ui/auto-form/fields/object.tsx
Normal file
130
web/src/components/ui/auto-form/fields/object.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import * as z from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { FieldConfig, FieldConfigItem } from "../types";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "../../accordion";
|
||||||
|
import {
|
||||||
|
beautifyObjectName,
|
||||||
|
getBaseSchema,
|
||||||
|
getBaseType,
|
||||||
|
zodToHtmlInputProps,
|
||||||
|
} from "../utils";
|
||||||
|
import { FormField } from "../../form";
|
||||||
|
import { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from "../config";
|
||||||
|
import AutoFormArray from "./array";
|
||||||
|
|
||||||
|
function DefaultParent({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AutoFormObject<
|
||||||
|
SchemaType extends z.ZodObject<any, any>,
|
||||||
|
>({
|
||||||
|
schema,
|
||||||
|
form,
|
||||||
|
fieldConfig,
|
||||||
|
path = [],
|
||||||
|
}: {
|
||||||
|
schema: SchemaType | z.ZodEffects<SchemaType>;
|
||||||
|
form: ReturnType<typeof useForm>;
|
||||||
|
fieldConfig?: FieldConfig<z.infer<SchemaType>>;
|
||||||
|
path?: string[];
|
||||||
|
}) {
|
||||||
|
const { shape } = getBaseSchema<SchemaType>(schema);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion type="multiple" className="space-y-5">
|
||||||
|
{Object.keys(shape).map((name) => {
|
||||||
|
const item = shape[name] as z.ZodAny;
|
||||||
|
const zodBaseType = getBaseType(item);
|
||||||
|
const itemName = item._def.description ?? beautifyObjectName(name);
|
||||||
|
const key = [...path, name].join(".");
|
||||||
|
|
||||||
|
if (zodBaseType === "ZodObject") {
|
||||||
|
return (
|
||||||
|
<AccordionItem value={name} key={key}>
|
||||||
|
<AccordionTrigger>{itemName}</AccordionTrigger>
|
||||||
|
<AccordionContent className="p-2">
|
||||||
|
<AutoFormObject
|
||||||
|
schema={item as unknown as z.ZodObject<any, any>}
|
||||||
|
form={form}
|
||||||
|
fieldConfig={
|
||||||
|
(fieldConfig?.[name] ?? {}) as FieldConfig<
|
||||||
|
z.infer<typeof item>
|
||||||
|
>
|
||||||
|
}
|
||||||
|
path={[...path, name]}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (zodBaseType === "ZodArray") {
|
||||||
|
return (
|
||||||
|
<AutoFormArray
|
||||||
|
key={key}
|
||||||
|
name={name}
|
||||||
|
item={item as unknown as z.ZodArray<any>}
|
||||||
|
form={form}
|
||||||
|
fieldConfig={fieldConfig?.[name] ?? {}}
|
||||||
|
path={[...path, name]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldConfigItem: FieldConfigItem = fieldConfig?.[name] ?? {};
|
||||||
|
const zodInputProps = zodToHtmlInputProps(item);
|
||||||
|
const isRequired =
|
||||||
|
zodInputProps.required ||
|
||||||
|
fieldConfigItem.inputProps?.required ||
|
||||||
|
false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={key}
|
||||||
|
key={key}
|
||||||
|
render={({ field }) => {
|
||||||
|
const inputType =
|
||||||
|
fieldConfigItem.fieldType ??
|
||||||
|
DEFAULT_ZOD_HANDLERS[zodBaseType] ??
|
||||||
|
"fallback";
|
||||||
|
|
||||||
|
const InputComponent =
|
||||||
|
typeof inputType === "function"
|
||||||
|
? inputType
|
||||||
|
: INPUT_COMPONENTS[inputType];
|
||||||
|
const ParentElement =
|
||||||
|
fieldConfigItem.renderParent ?? DefaultParent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentElement key={`${key}.parent`}>
|
||||||
|
<InputComponent
|
||||||
|
zodInputProps={zodInputProps}
|
||||||
|
field={field}
|
||||||
|
fieldConfigItem={fieldConfigItem}
|
||||||
|
label={itemName}
|
||||||
|
isRequired={isRequired}
|
||||||
|
zodItem={item}
|
||||||
|
fieldProps={{
|
||||||
|
...zodInputProps,
|
||||||
|
...field,
|
||||||
|
...fieldConfigItem.inputProps,
|
||||||
|
value: !fieldConfigItem.inputProps?.defaultValue
|
||||||
|
? field.value ?? ""
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ParentElement>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
44
web/src/components/ui/auto-form/fields/radio-group.tsx
Normal file
44
web/src/components/ui/auto-form/fields/radio-group.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import * as z from "zod";
|
||||||
|
import { AutoFormInputComponentProps } from "../types";
|
||||||
|
import { FormControl, FormItem, FormLabel, FormMessage } from "../../form";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "../../radio-group";
|
||||||
|
|
||||||
|
export default function AutoFormRadioGroup({
|
||||||
|
label,
|
||||||
|
isRequired,
|
||||||
|
field,
|
||||||
|
zodItem,
|
||||||
|
fieldProps,
|
||||||
|
}: AutoFormInputComponentProps) {
|
||||||
|
const values = (zodItem as unknown as z.ZodEnum<any>)._def.values;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem className="space-y-3">
|
||||||
|
<FormLabel>
|
||||||
|
{label}
|
||||||
|
{isRequired && <span className="text-destructive"> *</span>}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
className="flex flex-col space-y-1"
|
||||||
|
{...fieldProps}
|
||||||
|
>
|
||||||
|
{values.map((value: any) => (
|
||||||
|
<FormItem
|
||||||
|
className="flex items-center space-x-3 space-y-0"
|
||||||
|
key={value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroupItem value={value} />
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="font-normal">{value}</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
32
web/src/components/ui/auto-form/fields/switch.tsx
Normal file
32
web/src/components/ui/auto-form/fields/switch.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { FormControl, FormDescription, FormItem, FormLabel } from "../../form";
|
||||||
|
import { Switch } from "../../switch";
|
||||||
|
import { AutoFormInputComponentProps } from "../types";
|
||||||
|
|
||||||
|
export default function AutoFormSwitch({
|
||||||
|
label,
|
||||||
|
isRequired,
|
||||||
|
field,
|
||||||
|
fieldConfigItem,
|
||||||
|
fieldProps,
|
||||||
|
}: AutoFormInputComponentProps) {
|
||||||
|
return (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
{...fieldProps}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel>
|
||||||
|
{label}
|
||||||
|
{isRequired && <span className="text-destructive"> *</span>}
|
||||||
|
</FormLabel>
|
||||||
|
{fieldConfigItem.description && (
|
||||||
|
<FormDescription>{fieldConfigItem.description}</FormDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
36
web/src/components/ui/auto-form/fields/textarea.tsx
Normal file
36
web/src/components/ui/auto-form/fields/textarea.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../../form";
|
||||||
|
import { Textarea } from "../../textarea";
|
||||||
|
import { AutoFormInputComponentProps } from "../types";
|
||||||
|
|
||||||
|
export default function AutoFormTextarea({
|
||||||
|
label,
|
||||||
|
isRequired,
|
||||||
|
fieldConfigItem,
|
||||||
|
fieldProps,
|
||||||
|
}: AutoFormInputComponentProps) {
|
||||||
|
const { showLabel: _showLabel, ...fieldPropsWithoutShowLabel } = fieldProps;
|
||||||
|
const showLabel = _showLabel === undefined ? true : _showLabel;
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
{showLabel && (
|
||||||
|
<FormLabel>
|
||||||
|
{label}
|
||||||
|
{isRequired && <span className="text-destructive"> *</span>}
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...fieldPropsWithoutShowLabel} />
|
||||||
|
</FormControl>
|
||||||
|
{fieldConfigItem.description && (
|
||||||
|
<FormDescription>{fieldConfigItem.description}</FormDescription>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
87
web/src/components/ui/auto-form/index.tsx
Normal file
87
web/src/components/ui/auto-form/index.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Form } from "../form";
|
||||||
|
import { DefaultValues, useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "../button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { FieldConfig } from "./types";
|
||||||
|
import {
|
||||||
|
ZodObjectOrWrapped,
|
||||||
|
getDefaultValues,
|
||||||
|
getObjectFormSchema,
|
||||||
|
} from "./utils";
|
||||||
|
import AutoFormObject from "./fields/object";
|
||||||
|
|
||||||
|
export function AutoFormSubmit({ children }: { children?: React.ReactNode }) {
|
||||||
|
return <Button type="submit">{children ?? "Submit"}</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutoForm<SchemaType extends ZodObjectOrWrapped>({
|
||||||
|
formSchema,
|
||||||
|
values: valuesProp,
|
||||||
|
onValuesChange: onValuesChangeProp,
|
||||||
|
onParsedValuesChange,
|
||||||
|
onSubmit: onSubmitProp,
|
||||||
|
fieldConfig,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
formSchema: SchemaType;
|
||||||
|
values?: Partial<z.infer<SchemaType>>;
|
||||||
|
onValuesChange?: (values: Partial<z.infer<SchemaType>>) => void;
|
||||||
|
onParsedValuesChange?: (values: Partial<z.infer<SchemaType>>) => void;
|
||||||
|
onSubmit?: (values: z.infer<SchemaType>) => void;
|
||||||
|
fieldConfig?: FieldConfig<z.infer<SchemaType>>;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const objectFormSchema = getObjectFormSchema(formSchema);
|
||||||
|
const defaultValues: DefaultValues<z.infer<typeof objectFormSchema>> =
|
||||||
|
getDefaultValues(objectFormSchema);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof objectFormSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues,
|
||||||
|
values: valuesProp,
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
const parsedValues = formSchema.safeParse(values);
|
||||||
|
if (parsedValues.success) {
|
||||||
|
onSubmitProp?.(parsedValues.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
form.handleSubmit(onSubmit)(e);
|
||||||
|
}}
|
||||||
|
onChange={() => {
|
||||||
|
const values = form.getValues();
|
||||||
|
onValuesChangeProp?.(values);
|
||||||
|
const parsedValues = formSchema.safeParse(values);
|
||||||
|
if (parsedValues.success) {
|
||||||
|
onParsedValuesChange?.(parsedValues.data);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn("space-y-5", className)}
|
||||||
|
>
|
||||||
|
<AutoFormObject
|
||||||
|
schema={objectFormSchema}
|
||||||
|
form={form}
|
||||||
|
fieldConfig={fieldConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AutoForm;
|
37
web/src/components/ui/auto-form/types.ts
Normal file
37
web/src/components/ui/auto-form/types.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { ControllerRenderProps, FieldValues } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { INPUT_COMPONENTS } from "./config";
|
||||||
|
|
||||||
|
export type FieldConfigItem = {
|
||||||
|
description?: React.ReactNode;
|
||||||
|
inputProps?: React.InputHTMLAttributes<HTMLInputElement> & {
|
||||||
|
showLabel?: boolean;
|
||||||
|
};
|
||||||
|
fieldType?:
|
||||||
|
| keyof typeof INPUT_COMPONENTS
|
||||||
|
| React.FC<AutoFormInputComponentProps>;
|
||||||
|
|
||||||
|
renderParent?: (props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => React.ReactElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FieldConfig<SchemaType extends z.infer<z.ZodObject<any, any>>> = {
|
||||||
|
// If SchemaType.key is an object, create a nested FieldConfig, otherwise FieldConfigItem
|
||||||
|
[Key in keyof SchemaType]?: SchemaType[Key] extends object
|
||||||
|
? FieldConfig<z.infer<SchemaType[Key]>>
|
||||||
|
: FieldConfigItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A FormInput component can handle a specific Zod type (e.g. "ZodBoolean")
|
||||||
|
*/
|
||||||
|
export type AutoFormInputComponentProps = {
|
||||||
|
zodInputProps: React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
field: ControllerRenderProps<FieldValues, any>;
|
||||||
|
fieldConfigItem: FieldConfigItem;
|
||||||
|
label: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
fieldProps: any;
|
||||||
|
zodItem: z.ZodAny;
|
||||||
|
};
|
159
web/src/components/ui/auto-form/utils.ts
Normal file
159
web/src/components/ui/auto-form/utils.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { DefaultValues } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// TODO: This should support recursive ZodEffects but TypeScript doesn't allow circular type definitions.
|
||||||
|
export type ZodObjectOrWrapped =
|
||||||
|
| z.ZodObject<any, any>
|
||||||
|
| z.ZodEffects<z.ZodObject<any, any>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beautify a camelCase string.
|
||||||
|
* e.g. "myString" -> "My String"
|
||||||
|
*/
|
||||||
|
export function beautifyObjectName(string: string) {
|
||||||
|
let output = string.replace(/([A-Z])/g, " $1");
|
||||||
|
output = output.charAt(0).toUpperCase() + output.slice(1);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the lowest level Zod type.
|
||||||
|
* This will unpack optionals, refinements, etc.
|
||||||
|
*/
|
||||||
|
export function getBaseSchema<
|
||||||
|
ChildType extends z.ZodAny | z.AnyZodObject = z.ZodAny,
|
||||||
|
>(schema: ChildType | z.ZodEffects<ChildType>): ChildType {
|
||||||
|
if ("innerType" in schema._def) {
|
||||||
|
return getBaseSchema(schema._def.innerType as ChildType);
|
||||||
|
}
|
||||||
|
if ("schema" in schema._def) {
|
||||||
|
return getBaseSchema(schema._def.schema as ChildType);
|
||||||
|
}
|
||||||
|
return schema as ChildType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the type name of the lowest level Zod type.
|
||||||
|
* This will unpack optionals, refinements, etc.
|
||||||
|
*/
|
||||||
|
export function getBaseType(schema: z.ZodAny): string {
|
||||||
|
return getBaseSchema(schema)._def.typeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a "ZodDefult" in the Zod stack and return its value.
|
||||||
|
*/
|
||||||
|
export function getDefaultValueInZodStack(schema: z.ZodAny): any {
|
||||||
|
const typedSchema = schema as unknown as z.ZodDefault<
|
||||||
|
z.ZodNumber | z.ZodString
|
||||||
|
>;
|
||||||
|
|
||||||
|
if (typedSchema._def.typeName === "ZodDefault") {
|
||||||
|
return typedSchema._def.defaultValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("innerType" in typedSchema._def) {
|
||||||
|
return getDefaultValueInZodStack(
|
||||||
|
typedSchema._def.innerType as unknown as z.ZodAny,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ("schema" in typedSchema._def) {
|
||||||
|
return getDefaultValueInZodStack(
|
||||||
|
(typedSchema._def as any).schema as z.ZodAny,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all default values from a Zod schema.
|
||||||
|
*/
|
||||||
|
export function getDefaultValues<Schema extends z.ZodObject<any, any>>(
|
||||||
|
schema: Schema,
|
||||||
|
) {
|
||||||
|
const { shape } = schema;
|
||||||
|
type DefaultValuesType = DefaultValues<Partial<z.infer<Schema>>>;
|
||||||
|
const defaultValues = {} as DefaultValuesType;
|
||||||
|
|
||||||
|
for (const key of Object.keys(shape)) {
|
||||||
|
const item = shape[key] as z.ZodAny;
|
||||||
|
|
||||||
|
if (getBaseType(item) === "ZodObject") {
|
||||||
|
const defaultItems = getDefaultValues(
|
||||||
|
getBaseSchema(item) as unknown as z.ZodObject<any, any>,
|
||||||
|
);
|
||||||
|
for (const defaultItemKey of Object.keys(defaultItems)) {
|
||||||
|
const pathKey = `${key}.${defaultItemKey}` as keyof DefaultValuesType;
|
||||||
|
defaultValues[pathKey] = defaultItems[defaultItemKey];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const defaultValue = getDefaultValueInZodStack(item);
|
||||||
|
if (defaultValue !== undefined) {
|
||||||
|
defaultValues[key as keyof DefaultValuesType] = defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObjectFormSchema(
|
||||||
|
schema: ZodObjectOrWrapped,
|
||||||
|
): z.ZodObject<any, any> {
|
||||||
|
if (schema._def.typeName === "ZodEffects") {
|
||||||
|
const typedSchema = schema as z.ZodEffects<z.ZodObject<any, any>>;
|
||||||
|
return getObjectFormSchema(typedSchema._def.schema);
|
||||||
|
}
|
||||||
|
return schema as z.ZodObject<any, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Zod schema to HTML input props to give direct feedback to the user.
|
||||||
|
* Once submitted, the schema will be validated completely.
|
||||||
|
*/
|
||||||
|
export function zodToHtmlInputProps(
|
||||||
|
schema:
|
||||||
|
| z.ZodNumber
|
||||||
|
| z.ZodString
|
||||||
|
| z.ZodOptional<z.ZodNumber | z.ZodString>
|
||||||
|
| any,
|
||||||
|
): React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
if (["ZodOptional", "ZodNullable"].includes(schema._def.typeName)) {
|
||||||
|
const typedSchema = schema as z.ZodOptional<z.ZodNumber | z.ZodString>;
|
||||||
|
return {
|
||||||
|
...zodToHtmlInputProps(typedSchema._def.innerType),
|
||||||
|
required: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedSchema = schema as z.ZodNumber | z.ZodString;
|
||||||
|
|
||||||
|
if (!("checks" in typedSchema._def)) return {
|
||||||
|
required: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const { checks } = typedSchema._def;
|
||||||
|
const inputProps: React.InputHTMLAttributes<HTMLInputElement> = {
|
||||||
|
required: true,
|
||||||
|
};
|
||||||
|
const type = getBaseType(schema);
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
if (check.kind === "min") {
|
||||||
|
if (type === "ZodString") {
|
||||||
|
inputProps.minLength = check.value;
|
||||||
|
} else {
|
||||||
|
inputProps.min = check.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (check.kind === "max") {
|
||||||
|
if (type === "ZodString") {
|
||||||
|
inputProps.maxLength = check.value;
|
||||||
|
} else {
|
||||||
|
inputProps.max = check.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputProps;
|
||||||
|
}
|
66
web/src/components/ui/calendar.tsx
Normal file
66
web/src/components/ui/calendar.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
import { DayPicker } from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||||
|
month: "space-y-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center",
|
||||||
|
caption_label: "text-sm font-medium",
|
||||||
|
nav: "space-x-1 flex items-center",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-y-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||||
|
),
|
||||||
|
day_range_end: "day-range-end",
|
||||||
|
day_selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||||
|
day_today: "bg-accent text-accent-foreground",
|
||||||
|
day_outside:
|
||||||
|
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||||
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
|
day_range_middle:
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
|
day_hidden: "invisible",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||||
|
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Calendar.displayName = "Calendar"
|
||||||
|
|
||||||
|
export { Calendar }
|
46
web/src/components/ui/date-picker.tsx
Normal file
46
web/src/components/ui/date-picker.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Calendar as CalendarIcon } from "lucide-react";
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
|
export const DatePicker = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{
|
||||||
|
date?: Date;
|
||||||
|
setDate: (date?: Date) => void;
|
||||||
|
}
|
||||||
|
>(function DatePickerCmp({ date, setDate }, ref) {
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start text-left font-normal",
|
||||||
|
!date && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{date ? format(date, "PPP") : <span>Pick a date</span>}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" ref={ref}>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={date}
|
||||||
|
onSelect={setDate}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
});
|
@ -1,30 +1,23 @@
|
|||||||
import * as React from "react"
|
import { Label } from "@/components/ui/label";
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import { cn } from "@/lib/utils";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import type * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
import {
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
Controller,
|
import * as React from "react";
|
||||||
ControllerProps,
|
import type { ControllerProps, FieldPath, FieldValues } from "react-hook-form";
|
||||||
FieldPath,
|
import { Controller, FormProvider, useFormContext } from "react-hook-form";
|
||||||
FieldValues,
|
|
||||||
FormProvider,
|
|
||||||
useFormContext,
|
|
||||||
} from "react-hook-form"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
const Form = FormProvider;
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
|
|
||||||
const Form = FormProvider
|
|
||||||
|
|
||||||
type FormFieldContextValue<
|
type FormFieldContextValue<
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
> = {
|
> = {
|
||||||
name: TName
|
name: TName;
|
||||||
}
|
};
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
{} as FormFieldContextValue
|
{} as FormFieldContextValue
|
||||||
)
|
);
|
||||||
|
|
||||||
const FormField = <
|
const FormField = <
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
@ -36,21 +29,21 @@ const FormField = <
|
|||||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
<Controller {...props} />
|
<Controller {...props} />
|
||||||
</FormFieldContext.Provider>
|
</FormFieldContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const useFormField = () => {
|
const useFormField = () => {
|
||||||
const fieldContext = React.useContext(FormFieldContext)
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
const itemContext = React.useContext(FormItemContext)
|
const itemContext = React.useContext(FormItemContext);
|
||||||
const { getFieldState, formState } = useFormContext()
|
const { getFieldState, formState } = useFormContext();
|
||||||
|
|
||||||
const fieldState = getFieldState(fieldContext.name, formState)
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
if (!fieldContext) {
|
if (!fieldContext) {
|
||||||
throw new Error("useFormField should be used within <FormField>")
|
throw new Error("useFormField should be used within <FormField>");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = itemContext
|
const { id } = itemContext;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@ -59,36 +52,36 @@ const useFormField = () => {
|
|||||||
formDescriptionId: `${id}-form-item-description`,
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
formMessageId: `${id}-form-item-message`,
|
formMessageId: `${id}-form-item-message`,
|
||||||
...fieldState,
|
...fieldState,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
type FormItemContextValue = {
|
type FormItemContextValue = {
|
||||||
id: string
|
id: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
{} as FormItemContextValue
|
{} as FormItemContextValue
|
||||||
)
|
);
|
||||||
|
|
||||||
const FormItem = React.forwardRef<
|
const FormItem = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
const id = React.useId()
|
const id = React.useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItemContext.Provider value={{ id }}>
|
<FormItemContext.Provider value={{ id }}>
|
||||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
</FormItemContext.Provider>
|
</FormItemContext.Provider>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
FormItem.displayName = "FormItem"
|
FormItem.displayName = "FormItem";
|
||||||
|
|
||||||
const FormLabel = React.forwardRef<
|
const FormLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
const { error, formItemId } = useFormField()
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Label
|
<Label
|
||||||
@ -97,15 +90,16 @@ const FormLabel = React.forwardRef<
|
|||||||
htmlFor={formItemId}
|
htmlFor={formItemId}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
FormLabel.displayName = "FormLabel"
|
FormLabel.displayName = "FormLabel";
|
||||||
|
|
||||||
const FormControl = React.forwardRef<
|
const FormControl = React.forwardRef<
|
||||||
React.ElementRef<typeof Slot>,
|
React.ElementRef<typeof Slot>,
|
||||||
React.ComponentPropsWithoutRef<typeof Slot>
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
>(({ ...props }, ref) => {
|
>(({ ...props }, ref) => {
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
|
useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slot
|
<Slot
|
||||||
@ -119,15 +113,15 @@ const FormControl = React.forwardRef<
|
|||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
FormControl.displayName = "FormControl"
|
FormControl.displayName = "FormControl";
|
||||||
|
|
||||||
const FormDescription = React.forwardRef<
|
const FormDescription = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
const { formDescriptionId } = useFormField()
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
@ -136,19 +130,19 @@ const FormDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
FormDescription.displayName = "FormDescription"
|
FormDescription.displayName = "FormDescription";
|
||||||
|
|
||||||
const FormMessage = React.forwardRef<
|
const FormMessage = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, children, ...props }, ref) => {
|
>(({ className, children, ...props }, ref) => {
|
||||||
const { error, formMessageId } = useFormField()
|
const { error, formMessageId } = useFormField();
|
||||||
const body = error ? String(error?.message) : children
|
const body = error ? String(error?.message) : children;
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -160,9 +154,9 @@ const FormMessage = React.forwardRef<
|
|||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
</p>
|
</p>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
FormMessage.displayName = "FormMessage"
|
FormMessage.displayName = "FormMessage";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFormField,
|
useFormField,
|
||||||
@ -173,4 +167,4 @@ export {
|
|||||||
FormDescription,
|
FormDescription,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
FormField,
|
FormField,
|
||||||
}
|
};
|
||||||
|
31
web/src/components/ui/popover.tsx
Normal file
31
web/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
44
web/src/components/ui/radio-group.tsx
Normal file
44
web/src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
31
web/src/components/ui/separator.tsx
Normal file
31
web/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
29
web/src/components/ui/switch.tsx
Normal file
29
web/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
24
web/src/components/ui/textarea.tsx
Normal file
24
web/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
45
web/src/components/ui/toggle.tsx
Normal file
45
web/src/components/ui/toggle.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-3",
|
||||||
|
sm: "h-9 px-2.5",
|
||||||
|
lg: "h-11 px-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toggle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, variant, size, ...props }, ref) => (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
Loading…
x
Reference in New Issue
Block a user