"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().min(1, "Component name is required"), }), ) .optional(), }); export type FormValues = z.infer; export function CreatePageForm({ defaultValues, onSubmit, onValuesChange, showComponents = false, ...props }: Omit, "onSubmit"> & { defaultValues?: Partial; onSubmit: (values: FormValues) => Promise; /** 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({ 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 (
( Slug .openstatus.dev Choose a unique subdomain for your status page (minimum 3 characters). )} />
( Style )} /> ( Mode )} />
{showComponents && (
Components Add the services your users care about. You can attach monitors later.
{fields.map((field, index) => ( (
{fields.length > 1 && ( )}
)} /> ))}
{fields.length < 3 && ( )}
)} ); }