407 lines
13 KiB
TypeScript
407 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, type ReactNode } from "react";
|
|
import * as z from "zod";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { HiOutlineChevronDown, HiXMark } from "react-icons/hi2";
|
|
import Image from "next/image";
|
|
import {
|
|
FieldErrors,
|
|
useForm,
|
|
FieldValues,
|
|
DefaultValues,
|
|
Path,
|
|
PathValue,
|
|
} from "react-hook-form";
|
|
import { cn } from "@/lib/utils";
|
|
import Template from "./Template";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@/components/ui/form";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import Spinner from "../spinner";
|
|
import useImageUpload from "@/lib/hooks/useImageUpload";
|
|
|
|
type FieldOptions =
|
|
| "toggle"
|
|
| "horizontal-tabs"
|
|
| "input"
|
|
| "number"
|
|
| "text-area"
|
|
| "upload"
|
|
| "date-time"
|
|
| "custom";
|
|
|
|
type DefaultFieldType<TSchema> = {
|
|
label: string;
|
|
slug: keyof z.infer<z.Schema<TSchema>> & string;
|
|
placeholder?: string;
|
|
subtitle?: string;
|
|
description?: string;
|
|
lines?: number;
|
|
styles?: string;
|
|
value?: string | number | boolean;
|
|
custom?: ReactNode;
|
|
condition?: keyof z.infer<z.Schema<TSchema>> & string;
|
|
options?: {
|
|
label: string;
|
|
value: string;
|
|
icon?: ReactNode;
|
|
description?: string;
|
|
}[];
|
|
};
|
|
type FieldType<TSchema> = DefaultFieldType<TSchema> &
|
|
(
|
|
| {
|
|
type: "select-search";
|
|
options: {
|
|
label: string;
|
|
description?: string;
|
|
value: string;
|
|
icon?: ReactNode;
|
|
}[];
|
|
}
|
|
| {
|
|
type: "select";
|
|
description?: string;
|
|
options: {
|
|
label: string;
|
|
value: string;
|
|
icon?: ReactNode;
|
|
description?: string;
|
|
}[];
|
|
}
|
|
| {
|
|
type: FieldOptions;
|
|
}
|
|
);
|
|
|
|
type FormModalProps<TSchema> = {
|
|
title: string;
|
|
fields: FieldType<TSchema>[];
|
|
errors?: FieldErrors;
|
|
isSubmitting?: boolean;
|
|
cta: {
|
|
text: string;
|
|
};
|
|
defaultValues?: Partial<z.infer<z.Schema<TSchema>>>;
|
|
onSubmit: (props: z.infer<z.Schema<TSchema>>) => any;
|
|
formSchema: z.Schema<TSchema>;
|
|
};
|
|
|
|
export default function FormModal<TSchema extends FieldValues>({
|
|
title,
|
|
fields,
|
|
cta,
|
|
errors,
|
|
isSubmitting,
|
|
formSchema,
|
|
defaultValues,
|
|
onSubmit,
|
|
}: FormModalProps<TSchema>) {
|
|
type FormDataType = z.infer<typeof formSchema>;
|
|
const form = useForm<FormDataType>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: defaultValues as DefaultValues<TSchema>,
|
|
mode: "onChange",
|
|
});
|
|
const { watch, setValue } = form;
|
|
return (
|
|
<Template title={title} className="md:max-w-[450px]">
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
{fields.map(
|
|
({
|
|
label,
|
|
slug,
|
|
type,
|
|
placeholder,
|
|
description,
|
|
subtitle,
|
|
condition,
|
|
...fieldProps
|
|
}) => {
|
|
if (condition) {
|
|
const state = watch(condition as Path<TSchema>);
|
|
if (!state) return;
|
|
}
|
|
return (
|
|
<FormField
|
|
key={slug}
|
|
control={form.control}
|
|
name={slug as Path<TSchema>}
|
|
render={({ field }) => (
|
|
<FormItem
|
|
className={cn(
|
|
type === "toggle" &&
|
|
"flex items-center gap-x-3 space-y-0",
|
|
)}
|
|
>
|
|
<FormLabel>
|
|
{label}
|
|
{!!subtitle && (
|
|
<span className="ml-1.5 text-[0.8rem] font-normal text-muted-foreground ">
|
|
{subtitle}
|
|
</span>
|
|
)}
|
|
</FormLabel>
|
|
{type === "input" ? (
|
|
<FormControl>
|
|
<Input placeholder={placeholder} {...field} />
|
|
</FormControl>
|
|
) : type === "text-area" ? (
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={placeholder}
|
|
{...field}
|
|
className="auto-sizing"
|
|
/>
|
|
</FormControl>
|
|
) : type === "select" ? (
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
defaultValue={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={placeholder} />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent className="z-modal+">
|
|
{fieldProps.options?.map((o) => (
|
|
<SelectItem key={o.value} value={o.value}>
|
|
{o.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : type === "select-search" ? (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" className="flex">
|
|
{(() => {
|
|
const val = watch(slug as Path<TSchema>);
|
|
const selectedOption = fieldProps.options?.find(
|
|
(o) => o.value === val,
|
|
);
|
|
if (selectedOption) {
|
|
return selectedOption.label;
|
|
}
|
|
return "no list selected";
|
|
})()}{" "}
|
|
<HiOutlineChevronDown className="ml-2 h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="z-modal+ p-0"
|
|
align="start"
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder={placeholder} />
|
|
<CommandList>
|
|
<CommandEmpty>No options found.</CommandEmpty>
|
|
<CommandGroup className="p-1.5">
|
|
{fieldProps.options?.map((o) => (
|
|
<CommandItem
|
|
key={o.value}
|
|
onSelect={() => {
|
|
setValue(
|
|
field.name,
|
|
o.value as PathValue<
|
|
TSchema,
|
|
Path<TSchema>
|
|
>,
|
|
);
|
|
}}
|
|
className="teamaspace-y-1 flex flex-col items-start px-4 py-2"
|
|
>
|
|
<p>{o.label}</p>
|
|
{!!o.description && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{o.description}
|
|
</p>
|
|
)}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : // <Select
|
|
// onValueChange={field.onChange}
|
|
// defaultValue={field.value}
|
|
// >
|
|
// <FormControl>
|
|
// <SelectTrigger>
|
|
// <SelectValue placeholder={placeholder} />
|
|
// </SelectTrigger>
|
|
// </FormControl>
|
|
// <SelectContent className="z-modal+">
|
|
// {fieldProps.options?.map((o) => (
|
|
// <SelectItem key={o.value} value={o.value}>
|
|
// {o.label}
|
|
// </SelectItem>
|
|
// ))}
|
|
// </SelectContent>
|
|
// </Select>
|
|
type === "toggle" ? (
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
) : type === "upload" ? (
|
|
<FormControl>
|
|
<ImageUpload
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
) : type === "number" ? (
|
|
<FormControl>
|
|
<Input
|
|
placeholder={placeholder}
|
|
type="number"
|
|
{...field}
|
|
onChange={(e) => {
|
|
setValue(
|
|
field.name,
|
|
parseInt(e.target.value) as PathValue<
|
|
TSchema,
|
|
Path<TSchema>
|
|
>,
|
|
);
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
) : type === "date-time" ? (
|
|
<FormControl>
|
|
<Input
|
|
placeholder={placeholder}
|
|
{...field}
|
|
type="datetime-local"
|
|
/>
|
|
</FormControl>
|
|
) : (
|
|
<FormControl>
|
|
<Input placeholder={placeholder} {...field} />
|
|
</FormControl>
|
|
)}
|
|
{!!description && (
|
|
<FormDescription>{description}</FormDescription>
|
|
)}
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
},
|
|
)}
|
|
<Button type="submit" className="w-full" loading={isSubmitting}>
|
|
{cta.text}
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
</Template>
|
|
);
|
|
}
|
|
|
|
function ImageUpload({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value?: string;
|
|
onChange: (imageUrl: string) => void;
|
|
}) {
|
|
const { status, imageUrl, clear, ImageUploadButton } =
|
|
useImageUpload("event");
|
|
const handleClear = () => {
|
|
clear();
|
|
onChange("");
|
|
};
|
|
useEffect(() => {
|
|
if (imageUrl && value !== imageUrl) {
|
|
onChange(imageUrl);
|
|
}
|
|
}, [imageUrl]);
|
|
return (
|
|
<div className="">
|
|
{value || imageUrl ? (
|
|
<div className="relative overflow-hidden rounded-xl">
|
|
<div className="">
|
|
<Image
|
|
alt="Image"
|
|
height="288"
|
|
width="288"
|
|
unoptimized
|
|
src={(value || imageUrl) as string}
|
|
className={cn(
|
|
"bg-bckground h-full rounded-xl object-cover object-center max-sm:max-h-[100px]",
|
|
status === "uploading" && "grayscale",
|
|
status === "error" && "blur-xl",
|
|
)}
|
|
/>
|
|
</div>
|
|
{status === "uploading" && (
|
|
<button className="center absolute left-1 top-1 rounded-full bg-foreground bg-opacity-70 p-1 text-background hover:bg-opacity-100">
|
|
<Spinner />
|
|
</button>
|
|
)}
|
|
{(status === "success" || value) && (
|
|
<button
|
|
onClick={handleClear}
|
|
className="center absolute left-1 top-1 rounded-full bg-foreground bg-opacity-70 p-1 hover:bg-opacity-100"
|
|
>
|
|
<HiXMark
|
|
className="block h-4 w-4 text-background"
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<ImageUploadButton>
|
|
<Button
|
|
className=""
|
|
variant={"outline"}
|
|
loading={status === "uploading"}
|
|
>
|
|
{imageUrl ? "Uploaded!" : "Upload"}
|
|
</Button>
|
|
</ImageUploadButton>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|