Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
9.5 kB ยท 297 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298"use client";
import { zodResolver } from "@hookform/resolvers/zod";import { System, Dark, Add, Light, Close } from "@openstatus/icons";import { THEME_KEYS, type ThemeKey } from "@openstatus/theme-store";import { Button } from "@openstatus/ui/components/ui/button";import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from "@openstatus/ui/components/ui/form";import { Input } from "@openstatus/ui/components/ui/input";import { InputGroup, InputGroupAddon, InputGroupInput,} from "@openstatus/ui/components/ui/input-group";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@openstatus/ui/components/ui/select";import { useDebounce } from "@openstatus/ui/hooks/use-debounce";import { useQuery } from "@tanstack/react-query";import { isTRPCClientError } from "@trpc/client";import { useTheme } from "next-themes";import { useEffect, useTransition } from "react";import { useFieldArray, useForm } from "react-hook-form";import { toast } from "sonner";import { z } from "zod";
import { ThemePickerPopover } from "@/components/forms/status-page/theme-picker";import { useTRPC } from "@/lib/trpc/client";
const SLUG_UNIQUE_ERROR_MESSAGE = "This slug is already taken. Please choose another one.";
// Keep in sync with `slugSchema` in// `packages/db/src/schema/pages/validation.ts`. We can't import that on the// client because `@openstatus/db` is server-only. Slugs are stored lowercase// (subdomains are case-insensitive), so we restrict input client-side too.const SLUG_PATTERN = /^[a-z0-9-]+$/;const SLUG_PATTERN_MESSAGE = "Only use digits (0-9), hyphen (-) or lowercase characters (a-z).";
const FORCE_THEME_OPTIONS = [ { value: "light", label: "Light", icon: Light }, { value: "dark", label: "Dark", icon: Dark }, { value: "system", label: "System", icon: System },] as const;
const schema = z.object({ slug: z.string().min(3).regex(SLUG_PATTERN, SLUG_PATTERN_MESSAGE), theme: z.enum(THEME_KEYS as [ThemeKey, ...ThemeKey[]]), forceTheme: z.enum(["light", "dark", "system"]), components: z .array( z.object({ name: z.string().trim().min(1, "Component name is required"), }), ) .optional(),});
export type FormValues = z.infer<typeof schema>;
export function CreatePageForm({ defaultValues, onSubmit, onValuesChange, showComponents = false, ...props}: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: Partial<FormValues>; onSubmit: (values: FormValues) => Promise<void>; /** Mirror live form values to a parent that needs them (e.g. a preview). */ onValuesChange?: (values: FormValues) => void; showComponents?: boolean;}) { const trpc = useTRPC(); const { theme: dashboardTheme, setTheme: setDashboardTheme } = useTheme();
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { slug: "", theme: "default-rounded", forceTheme: dashboardTheme === "dark" || dashboardTheme === "light" ? dashboardTheme : "system", components: showComponents ? [{ name: "Website" }] : undefined, ...defaultValues, }, }); const [isPending, startTransition] = useTransition(); const watchSlug = form.watch("slug"); const debouncedSlug = useDebounce(watchSlug, 500); const { data: isUnique } = useQuery( trpc.page.getSlugUniqueness.queryOptions( { slug: debouncedSlug }, { enabled: debouncedSlug.length > 0 }, ), );
const { fields, append, remove } = useFieldArray({ control: form.control, name: "components", });
useEffect(() => { if (isUnique === false) { form.setError("slug", { message: SLUG_UNIQUE_ERROR_MESSAGE }); } else { form.clearErrors("slug"); } }, [isUnique, form]);
useEffect(() => { if (!onValuesChange) return; onValuesChange(form.getValues()); const sub = form.watch((values) => { onValuesChange(values as FormValues); }); return () => sub.unsubscribe(); }, [form, onValuesChange]);
function submitAction(values: FormValues) { if (isPending) return;
startTransition(async () => { try { if (isUnique === false) { toast.error(SLUG_UNIQUE_ERROR_MESSAGE); form.setError("slug", { message: SLUG_UNIQUE_ERROR_MESSAGE }); return; }
const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } console.error(error); return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); }
return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} className="space-y-4" {...props} > <FormField control={form.control} name="slug" render={({ field }) => ( <FormItem> <FormLabel>Slug</FormLabel> <InputGroup> <FormControl> <InputGroupInput placeholder="status" {...field} /> </FormControl> <InputGroupAddon align="inline-end"> .openstatus.dev </InputGroupAddon> </InputGroup> <FormMessage /> <FormDescription> Choose a unique subdomain for your status page (minimum 3 characters). </FormDescription> </FormItem> )} /> <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <FormField control={form.control} name="theme" render={({ field }) => ( <FormItem className="min-w-0"> <FormLabel>Style</FormLabel> <ThemePickerPopover value={field.value} onChange={field.onChange} /> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="forceTheme" render={({ field }) => ( <FormItem className="min-w-0"> <FormLabel>Mode</FormLabel> <Select value={field.value} onValueChange={(v) => { field.onChange(v); // Mirror to the dashboard so the user previews exactly // what they'll publish. The whole shell flips with them. setDashboardTheme(v); }} > <FormControl> <SelectTrigger className="w-full"> <SelectValue placeholder="Mode" /> </SelectTrigger> </FormControl> <SelectContent> {FORCE_THEME_OPTIONS.map(({ value, label, icon: Icon }) => ( <SelectItem key={value} value={value}> <div className="flex items-center gap-2"> <Icon className="size-4" /> <span>{label}</span> </div> </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> </div> {showComponents && ( <div className="space-y-3"> <FormLabel>Components</FormLabel> <FormDescription> Add the services your users care about. You can attach monitors later. </FormDescription> <div className="space-y-2"> {fields.map((field, index) => ( <FormField key={field.id} control={form.control} name={`components.${index}.name`} render={({ field }) => ( <FormItem> <div className="flex items-center gap-2"> <FormControl> <Input placeholder="e.g. API, Dashboard" {...field} /> </FormControl> {fields.length > 1 && ( <Button type="button" variant="ghost" size="icon" className="shrink-0" onClick={() => remove(index)} > <Close className="size-4" /> </Button> )} </div> <FormMessage /> </FormItem> )} /> ))} </div> {fields.length < 3 && ( <Button type="button" variant="outline" size="sm" onClick={() => append({ name: "" })} > <Add className="mr-1 size-4" /> Add another </Button> )} </div> )} </form> </Form> );}