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.
47 kB ยท 1306 lines
TSX
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307"use client";
import type { UniqueIdentifier } from "@dnd-kit/core";import { zodResolver } from "@hookform/resolvers/zod";import type { RouterOutputs } from "@openstatus/api";import { Check, Show, Hide, DragHandle, Linked, Unlinked, Plug, Add, Delete,} from "@openstatus/icons";import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,} from "@openstatus/ui/components/ui/alert-dialog";import { Button } from "@openstatus/ui/components/ui/button";import { Checkbox } from "@openstatus/ui/components/ui/checkbox";import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from "@openstatus/ui/components/ui/command";import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,} from "@openstatus/ui/components/ui/dropdown-menu";import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage,} from "@openstatus/ui/components/ui/form";import { Input } from "@openstatus/ui/components/ui/input";import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from "@openstatus/ui/components/ui/tooltip";import { isTRPCClientError } from "@trpc/client";import { useCallback, useEffect, useState, useTransition } from "react";import { type UseFormReturn, useForm } from "react-hook-form";import { toast } from "sonner";import { z } from "zod";
import { Link } from "@/components/common/link";import { EmptyStateContainer, EmptyStateTitle,} from "@/components/content/empty-state";import { UpgradeDialog } from "@/components/dialogs/upgrade";import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardSeparator, FormCardTitle,} from "@/components/forms/form-card";import { STATUS } from "@/components/nav/nav-monitors";import { Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay,} from "@/components/ui/sortable";import { cn } from "@/lib/utils";
type PageComponent = RouterOutputs["pageComponent"]["list"][number];type Monitor = RouterOutputs["monitor"]["list"][number];type Workspace = RouterOutputs["workspace"]["get"];
type ComponentGroup = { id: number; name: string; components: PageComponent[];};
const componentSchema = z.object({ id: z.number(), monitorId: z.number().nullish(), order: z.number(), name: z.string().trim().min(1, { message: "Name is required" }), description: z.string().optional(), type: z.enum(["monitor", "static"]),});
const schema = z.object({ components: z.array(componentSchema), groups: z.array( z.object({ id: z.number(), order: z.number(), name: z.string().trim().min(1, { message: "Name is required" }), defaultOpen: z.boolean(), components: z.array(componentSchema).min(1, { message: "At least one component is required", }), }), ),});
const getSortedComponents = ( components: PageComponent[], componentData: { id: number; order: number; name?: string; type?: "monitor" | "static"; monitorId?: number | null; }[], monitors: Monitor[],) => { const orderMap = new Map(componentData?.map((c) => [c.id, c.order]) ?? []);
// Create a map of existing components const componentMap = new Map(components.map((c) => [c.id, c]));
// Create a map of monitors for lookup const monitorMap = new Map(monitors.map((m) => [m.id, m]));
// Create synthetic components for any in componentData that don't exist in components componentData.forEach((c) => { if (!componentMap.has(c.id)) { // Look up monitor data if this is a monitor component const monitor = c.monitorId ? monitorMap.get(c.monitorId) : null;
// Create synthetic PageComponent componentMap.set(c.id, { id: c.id, name: c.name ?? "", type: c.type ?? "static", monitorId: c.monitorId ?? null, monitor: monitor ?? null, groupId: null, groupOrder: null, order: c.order, } as PageComponent); } });
return Array.from(componentMap.values()) .filter((component) => orderMap.has(component.id)) .sort((a, b) => { const aOrder = orderMap.get(a.id) ?? 0; const bOrder = orderMap.get(b.id) ?? 0; return aOrder - bOrder; });};
const getSortedItems = ( components: PageComponent[], componentData: { id: number; order: number; name?: string; type?: "monitor" | "static"; monitorId?: number | null; }[], groups: Array<{ id: number; order: number; name: string; defaultOpen: boolean; components: Array<{ id: number; order: number; name?: string; type?: "monitor" | "static"; monitorId?: number | null; }>; }>, monitors: Monitor[],): (PageComponent | ComponentGroup)[] => { // Create map of component orders const componentOrderMap = new Map(componentData.map((c) => [c.id, c.order]));
// Create a map of existing components const componentMap = new Map(components.map((c) => [c.id, c]));
// Create a map of monitors for lookup const monitorMap = new Map(monitors.map((m) => [m.id, m]));
// Create synthetic components for any in componentData that don't exist in components componentData.forEach((c) => { if (!componentMap.has(c.id)) { // Look up monitor data if this is a monitor component const monitor = c.monitorId ? monitorMap.get(c.monitorId) : null;
// Create synthetic PageComponent componentMap.set(c.id, { id: c.id, name: c.name ?? "", type: c.type ?? "static", monitorId: c.monitorId ?? null, monitor: monitor ?? null, groupId: null, groupOrder: null, order: c.order, } as PageComponent); } });
// Get all enhanced components (including synthetic ones) const enhancedComponents = Array.from(componentMap.values());
// Create array of components with their orders const componentsWithOrder = enhancedComponents .filter((component) => componentOrderMap.has(component.id)) .map((component) => ({ item: component, order: componentOrderMap.get(component.id) ?? 0, }));
// Create array of groups with their orders const groupsWithOrder = groups.map((group) => ({ item: { id: group.id, name: group.name, components: getSortedComponents( enhancedComponents, group.components, monitors, ), } as ComponentGroup, order: group.order, }));
// Combine and sort by order return [...componentsWithOrder, ...groupsWithOrder] .sort((a, b) => a.order - b.order) .map((entry) => entry.item);};
type FormValues = z.infer<typeof schema>;
export function FormComponents({ defaultValues, onSubmit, pageComponents, allPageComponents, monitors, workspace, ...props}: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; /** Page components available for selection (standalone, not in groups) */ pageComponents: PageComponent[]; /** All page components for the page (including those in groups) */ allPageComponents: PageComponent[]; /** Monitors available for selection */ monitors: Monitor[]; /** * The workspace the page belongs to */ workspace: Workspace; onSubmit: (values: FormValues) => Promise<void>;}) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { components: [], groups: [] }, }); const [isPending, startTransition] = useTransition(); const watchComponents = form.watch("components"); const watchGroups = form.watch("groups"); const [openUpgradeDialog, setOpenUpgradeDialog] = useState(false); const [data, setData] = useState<(PageComponent | ComponentGroup)[]>( getSortedItems( allPageComponents, defaultValues?.components ?? [], defaultValues?.groups ?? [], monitors, ), );
// Get all monitor IDs that are already used (in standalone components or groups) const usedMonitorIds = new Set([ ...(watchComponents ?? []) .filter((c) => c.monitorId) .map((c) => c.monitorId), ...(watchGroups ?? []) .flatMap((g) => g.components) .filter((c) => c.monitorId) .map((c) => c.monitorId), ]);
useEffect(() => { const sortedItems = getSortedItems( allPageComponents, watchComponents, watchGroups ?? [], monitors, ); setData(sortedItems); }, [watchComponents, watchGroups, allPageComponents, monitors]);
const validateLimit = useCallback(() => { const limitReached = workspace.limits["page-components"] <= data.length; if (limitReached) { setOpenUpgradeDialog(true); return false; } return true; }, [workspace, data.length]);
const onValueChange = useCallback( (newItems: (PageComponent | ComponentGroup)[]) => { setData(newItems);
// Update components with their position in the overall list const existingComponents = form.getValues("components") ?? []; const components = newItems .map((item, index) => ({ item, index })) .filter( (entry): entry is { item: PageComponent; index: number } => "type" in entry.item, ) .map(({ item, index }) => { const existingComponent = existingComponents.find( (c) => c.id === item.id, ); return { id: item.id, monitorId: item.monitorId, order: index, name: existingComponent?.name ?? item.name, description: existingComponent?.description ?? "", type: item.type, }; }); form.setValue("components", components);
// Update groups with their position in the overall list const existingGroups = form.getValues("groups") ?? []; const groups = newItems .map((item, index) => ({ item, index })) .filter( (entry): entry is { item: ComponentGroup; index: number } => "components" in entry.item && !("type" in entry.item), ) .map(({ item, index }) => { const existingGroup = existingGroups.find((g) => g.id === item.id); return existingGroup ? { ...existingGroup, order: index, } : { id: item.id, order: index, name: item.name, defaultOpen: false, components: [], }; }); form.setValue("groups", groups); }, [form], );
const getItemValue = useCallback( (item: PageComponent | ComponentGroup) => item.id, [], );
const handleAddGroup = useCallback(() => { if (!validateLimit()) return;
const newGroupId = Date.now(); const existingGroups = form.getValues("groups") ?? []; const existingComponents = form.getValues("components") ?? []; const order = existingGroups.length + existingComponents.length; const newGroups = [ ...existingGroups, { id: newGroupId, order, name: "", defaultOpen: false, components: [] }, ]; form.setValue("groups", newGroups); setData((prev) => [...prev, { id: newGroupId, name: "", components: [] }]); }, [form, validateLimit]);
const handleDeleteGroup = useCallback( (groupId: number) => { const existingGroups = form.getValues("groups") ?? []; form.setValue( "groups", existingGroups.filter((g) => g.id !== groupId), ); setData((prev) => prev.filter((item) => item.id !== groupId)); }, [form], );
const handleDeleteComponent = useCallback( (componentId: number) => { const existingComponents = form.getValues("components") ?? []; form.setValue( "components", existingComponents.filter((c) => c.id !== componentId), ); setData((prev) => prev.filter((item) => item.id !== componentId)); }, [form], );
const renderOverlay = useCallback( ({ value }: { value: UniqueIdentifier }) => { const index = data.findIndex((item) => item.id === value); if (index === -1) return null; const item = data[index];
if ("type" in item) { return ( <ComponentRow component={item} form={form} className="border-x border-transparent px-2" onDelete={handleDeleteComponent} // FIXME: this is used to show an input instead of the name when dragging a component // fieldNamePrefix={`components.${index}`} /> ); }
const groups = form.getValues("groups") ?? []; const groupIndex = groups.findIndex((g) => g.id === item.id); return ( <ComponentGroupRow group={item} groupIndex={groupIndex} onDeleteGroup={handleDeleteGroup} form={form} allPageComponents={allPageComponents} monitors={monitors} validateLimit={validateLimit} /> ); }, [ data, handleDeleteGroup, form, allPageComponents, monitors, handleDeleteComponent, validateLimit, ], );
function submitAction(values: FormValues) { if (isPending) return;
startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); }
return ( <> <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Components</FormCardTitle> <FormCardDescription> Manage your page components </FormCardDescription> </FormCardHeader> <FormCardContent className="flex flex-row gap-2"> <Button variant="outline" type="button" onClick={handleAddGroup}> <Add /> Add Component Group </Button> <FormField control={form.control} name="components" render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel className="sr-only">Components</FormLabel> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" className="w-full"> <Add /> Add Component </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuGroup> <DropdownMenuItem onClick={() => { if (!validateLimit()) return; form.setValue("components", [ ...form.getValues("components"), { id: Date.now(), monitorId: null, order: watchComponents.length, name: "", description: "", type: "static" as const, }, ]); }} > <Unlinked className="text-muted-foreground" /> Add Static Component </DropdownMenuItem> <DropdownMenuSub> <DropdownMenuSubTrigger className="[&_svg:not([class*='text-'])]:text-muted-foreground gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> <Linked className="text-muted-foreground" /> Add Monitor Component </DropdownMenuSubTrigger> <DropdownMenuSubContent className="p-0"> <Command> <CommandInput placeholder="Search monitors..." className="h-9" /> <CommandList> <CommandEmpty> No monitors found. </CommandEmpty> <CommandGroup> {monitors.map((monitor) => { const isUsed = usedMonitorIds.has( monitor.id, ); const isSelected = field.value.some( (c) => c.monitorId === monitor.id, ); return ( <CommandItem value={monitor.name} key={monitor.id} disabled={isUsed} onSelect={() => { if (isSelected) { form.setValue( "components", form .getValues("components") .filter( (c) => c.monitorId !== monitor.id, ), ); } else { if (!validateLimit()) return; form.setValue("components", [ ...form.getValues("components"), { id: Date.now(), monitorId: monitor.id, order: watchComponents.length, name: monitor.name, description: monitor.description, type: "monitor" as const, }, ]); } }} > {monitor.name} <Check className={cn( "ml-auto", isSelected ? "opacity-100" : "opacity-0", )} /> </CommandItem> ); })} </CommandGroup> </CommandList> </Command> </DropdownMenuSubContent> </DropdownMenuSub> <DropdownMenuItem disabled> <Plug className="text-muted-foreground" /> Add Third-Party Component </DropdownMenuItem> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <Sortable value={data} onValueChange={onValueChange} getItemValue={getItemValue} orientation="vertical" > {data.length ? ( <SortableContent className="grid gap-2"> {data.map((item) => { if ("type" in item) { const components = form.getValues("components") ?? []; const componentIndex = components.findIndex( (c) => c.id === item.id, ); return ( <ComponentRow key={`${item.id}-component`} className="border-x border-transparent px-2" component={item} form={form} onDelete={handleDeleteComponent} fieldNamePrefix={ componentIndex >= 0 ? `components.${componentIndex}` : undefined } /> ); } const groups = form.getValues("groups") ?? []; const groupIndex = groups.findIndex( (g) => g.id === item.id, ); return ( <ComponentGroupRow key={`${item.id}-group`} group={item} groupIndex={groupIndex} onDeleteGroup={handleDeleteGroup} form={form} allPageComponents={allPageComponents} monitors={monitors} validateLimit={validateLimit} /> ); })} <SortableOverlay>{renderOverlay}</SortableOverlay> </SortableContent> ) : ( <EmptyStateContainer> <EmptyStateTitle>No components selected</EmptyStateTitle> </EmptyStateContainer> )} </Sortable> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://www.openstatus.dev/docs/reference/page-components"> page components </Link> . </FormCardFooterInfo> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> <UpgradeDialog limit="page-components" open={openUpgradeDialog} onOpenChange={setOpenUpgradeDialog} /> </> );}
interface ComponentRowProps extends Omit< React.ComponentPropsWithoutRef<typeof SortableItem>, "value"> { component: PageComponent; form: UseFormReturn<FormValues>; onDelete: (componentId: number) => void; /** The form field name prefix, e.g. "components.0" or "groups.0.components.1" */ fieldNamePrefix?: string;}
function ComponentRow({ component, className, onDelete, form, fieldNamePrefix, ...props}: ComponentRowProps) { return ( <SortableItem value={component.id} asChild className={cn("rounded-md", className)} {...props} > <div className="grid min-h-9 grid-cols-4 gap-2"> <div className="flex flex-row items-center gap-1 self-center"> <SortableItemHandle> <DragHandle size={16} aria-hidden="true" className="text-muted-foreground" /> </SortableItemHandle> {fieldNamePrefix ? ( <FormField key={`${component.id}-name-${fieldNamePrefix}`} control={form.control} name={`${fieldNamePrefix}.name` as "components.0.name"} render={({ field }) => ( <FormItem className="w-full"> <FormLabel className="sr-only">Component name</FormLabel> <FormControl> <Input placeholder="Name" className="bg-background w-full" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> ) : ( <span className="truncate rounded-md border border-transparent px-3 py-1 text-sm"> {component.name} </span> )} </div> <div className="flex flex-row items-center gap-1 self-center"> {fieldNamePrefix ? ( <FormField key={`${component.id}-description-${fieldNamePrefix}`} control={form.control} name={ `${fieldNamePrefix}.description` as "components.0.description" } render={({ field }) => ( <FormItem className="w-full"> <FormLabel className="sr-only"> Component description </FormLabel> <FormControl> <Input placeholder="Description" className="bg-background w-full" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> ) : ( <span className="truncate rounded-md border border-transparent px-3 py-1 text-sm"> {component.description} </span> )} </div> <div className="text-muted-foreground flex items-center gap-2 self-center text-sm"> {component.monitor && component.type === "monitor" ? ( <Link href={`/monitors/${component.monitorId}/overview`} onClick={(e) => e.stopPropagation()} className="flex w-full items-center gap-2 truncate py-1.5 text-sm" > <Linked className="size-4 shrink-0" />{" "} <span className="truncate">{component.monitor.name}</span> </Link> ) : ( <span className="text-muted-foreground flex items-center gap-2 text-sm"> <Unlinked className="size-4 shrink-0" />{" "} <span className="truncate">Static Component</span> </span> )} </div> <div className="flex justify-between"> <div className="flex flex-1 items-center gap-2.5"> {component.monitor && component.type === "monitor" ? ( <div className="flex items-center gap-2"> <TooltipProvider delayDuration={0}> {component.monitor.public ? ( <Tooltip> <TooltipTrigger> <Show className="text-muted-foreground size-4" /> </TooltipTrigger> <TooltipContent>Public</TooltipContent> </Tooltip> ) : ( <Tooltip> <TooltipTrigger> <Hide className="text-muted-foreground size-4" /> </TooltipTrigger> <TooltipContent>Private</TooltipContent> </Tooltip> )} </TooltipProvider> </div> ) : null} {component.monitor && component.type === "monitor" ? ( <div className={cn( "size-2 rounded-full", STATUS[ component.monitor.active ? component.monitor.status : "inactive" ], )} /> ) : null} </div> <AlertDialog> <AlertDialogTrigger asChild> <Button type="button" variant="ghost" size="icon" className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20 [&_svg]:text-destructive [&_svg]:size-4" > <Delete /> </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Are you sure?</AlertDialogTitle> <AlertDialogDescription> Once saved, this will unlink the component from attached status reports and maintenances. </AlertDialogDescription> <ComponentAttachments statusReports={component.statusReports ?? []} maintenances={component.maintenances ?? []} /> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction className="bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 text-white shadow-xs" onClick={() => onDelete(component.id)} > Remove </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </div> </div> </SortableItem> );}
interface ComponentGroupRowProps extends Omit< React.ComponentPropsWithoutRef<typeof SortableItem>, "value"> { group: ComponentGroup; groupIndex: number; onDeleteGroup: (groupId: number) => void; form: UseFormReturn<FormValues>; allPageComponents: PageComponent[]; monitors: Monitor[]; validateLimit: () => boolean;}
function ComponentGroupRow({ group, groupIndex, onDeleteGroup, form, allPageComponents, monitors, validateLimit,}: ComponentGroupRowProps) { const watchGroup = form.watch(`groups.${groupIndex}`); const watchComponents = form.watch("components"); const watchGroups = form.watch("groups"); const [data, setData] = useState<PageComponent[]>(group.components);
// Calculate taken monitor IDs (in main list or other groups) const takenMonitorIds = new Set([ ...watchComponents.filter((c) => c.monitorId).map((c) => c.monitorId), ...watchGroups .filter((g) => g.id !== group.id) .flatMap((g) => g.components.filter((c) => c.monitorId).map((c) => c.monitorId), ), ]);
// FIXME: order is not being updated in the form const onValueChange = useCallback( (newComponents: PageComponent[]) => { setData(newComponents); // Update the form with the new component order const existingComponents = form.getValues(`groups.${groupIndex}.components`) ?? []; form.setValue( `groups.${groupIndex}.components`, newComponents.map((c, index) => { const existingComponent = existingComponents.find( (ec) => ec.id === c.id, ); return { id: c.id, monitorId: c.monitorId, order: index, name: existingComponent?.name ?? c.name, description: existingComponent?.description ?? "", type: c.type, }; }), ); }, [form, groupIndex], );
useEffect(() => { setData( getSortedComponents(allPageComponents, watchGroup.components, monitors), ); }, [watchGroup.components, allPageComponents, monitors]);
const getItemValue = useCallback((item: PageComponent) => item.id, []);
const handleDeleteComponent = useCallback( (componentId: number) => { const existingComponents = form.getValues(`groups.${groupIndex}.components`) ?? []; form.setValue( `groups.${groupIndex}.components`, existingComponents.filter((c) => c.id !== componentId), ); setData((prev) => prev.filter((item) => item.id !== componentId)); }, [form, groupIndex], );
const renderOverlay = useCallback( ({ value }: { value: UniqueIdentifier }) => { const component = data.find((item) => item.id === value); if (!component) return null;
return ( <ComponentRow component={component} form={form} onDelete={handleDeleteComponent} /> ); }, [data, form, handleDeleteComponent], );
return ( <SortableItem value={group.id} className="bg-muted rounded-md border"> <div className="grid grid-cols-4 gap-2 px-2 pt-2"> <div className="flex flex-row items-center gap-1 self-center"> <SortableItemHandle> <DragHandle size={16} aria-hidden="true" className="text-muted-foreground" /> </SortableItemHandle> <FormField key={`${group.id}-name-${groupIndex}`} control={form.control} name={`groups.${groupIndex}.name` as const} render={({ field }) => ( <FormItem className="w-full"> <FormLabel className="sr-only">Group name</FormLabel> <FormControl> <Input placeholder="Group Name" className="bg-background w-full" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div> <FormField key={`${group.id}-components-${groupIndex}`} control={form.control} name={`groups.${groupIndex}.components` as const} render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel className="sr-only">Components</FormLabel> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" className="w-full"> <Add /> Add Component </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuGroup> <DropdownMenuItem onClick={() => { if (!validateLimit()) return; const current = form.getValues(`groups.${groupIndex}.components`) ?? []; form.setValue(`groups.${groupIndex}.components`, [ ...current, { id: Date.now(), monitorId: null, order: current.length, name: "", description: "", type: "static" as const, }, ]); }} > <Unlinked className="text-muted-foreground" /> Add Static Component </DropdownMenuItem> <DropdownMenuSub> <DropdownMenuSubTrigger className="[&_svg:not([class*='text-'])]:text-muted-foreground gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> <Linked className="text-muted-foreground" /> Add Monitor Component </DropdownMenuSubTrigger> <DropdownMenuSubContent className="p-0"> <Command> <CommandInput placeholder="Search monitors..." className="h-9" /> <CommandList> <CommandEmpty>No monitors found.</CommandEmpty> <CommandGroup> {monitors.map((monitor) => { const current = field.value ?? []; const isTaken = takenMonitorIds.has(monitor.id); const isSelected = current.some( (c) => c.monitorId === monitor.id, ); return ( <CommandItem value={monitor.name} key={monitor.id} disabled={isTaken || isSelected} onSelect={() => { const live = form.getValues( `groups.${groupIndex}.components`, ) ?? []; if (isSelected) { form.setValue( `groups.${groupIndex}.components`, live.filter( (c) => c.monitorId !== monitor.id, ), ); } else { if (!validateLimit()) return; form.setValue( `groups.${groupIndex}.components`, [ ...live, { id: Date.now(), monitorId: monitor.id, order: live.length, name: monitor.name, description: monitor.description, type: "monitor" as const, }, ], ); } }} > {monitor.name} <Check className={cn( "ml-auto", isSelected ? "opacity-100" : "opacity-0", )} /> </CommandItem> ); })} </CommandGroup> </CommandList> </Command> </DropdownMenuSubContent> </DropdownMenuSub> <DropdownMenuItem disabled> <Plug className="text-muted-foreground" /> Add Third-Party Component </DropdownMenuItem> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> <FormMessage /> </FormItem> )} /> <FormField key={`${group.id}-defaultOpen-${groupIndex}`} control={form.control} name={`groups.${groupIndex}.defaultOpen` as const} render={({ field }) => ( <FormItem className="flex items-center gap-2 self-center"> <FormControl> <Checkbox className="bg-background" checked={field.value} onCheckedChange={field.onChange} /> </FormControl> <FormLabel className="text-muted-foreground !mt-0 text-sm font-normal"> Open by default </FormLabel> </FormItem> )} /> <div className="flex justify-end"> <AlertDialog> <AlertDialogTrigger asChild> <Button type="button" variant="ghost" size="icon" className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20 [&_svg]:text-destructive [&_svg]:size-4" // NOTE: delete directly if no components are in the group {...(data.length === 0 ? { onClick: () => onDeleteGroup(group.id) } : {})} > <Delete /> </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Are you sure?</AlertDialogTitle> <AlertDialogDescription> Once saved, this will delete all components in the group and unlink them from attached status reports and maintenances. </AlertDialogDescription> <ComponentAttachments statusReports={Array.from( new Map( group.components .flatMap((c) => c.statusReports ?? []) .map((sr) => [sr.id, sr]), ).values(), )} maintenances={Array.from( new Map( group.components .flatMap((c) => c.maintenances ?? []) .map((m) => [m.id, m]), ).values(), )} /> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction className="bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 text-white shadow-xs" onClick={() => onDeleteGroup(group.id)} > Remove </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </div> </div> <div className="mt-2 border-t px-2 pt-2 pb-2"> <Sortable value={data} onValueChange={onValueChange} getItemValue={getItemValue} orientation="vertical" > {data.length ? ( <SortableContent className="grid gap-2"> {data.map((item, _index) => { const groupComponents = form.getValues(`groups.${groupIndex}.components`) ?? []; const componentIndex = groupComponents.findIndex( (c) => c.id === item.id, ); return ( <ComponentRow key={`${item.id}-component`} component={item} form={form} onDelete={handleDeleteComponent} fieldNamePrefix={ componentIndex >= 0 ? `groups.${groupIndex}.components.${componentIndex}` : undefined } /> ); })} <SortableOverlay>{renderOverlay}</SortableOverlay> </SortableContent> ) : ( <EmptyStateContainer> <EmptyStateTitle>No components selected</EmptyStateTitle> </EmptyStateContainer> )} </Sortable> </div> </SortableItem> );}
function ComponentAttachments({ statusReports, maintenances,}: { statusReports: Array<{ id: number; title: string }>; maintenances: Array<{ id: number; title: string }>;}) { if (statusReports.length === 0 && maintenances.length === 0) { return null; }
const allItems = [ ...statusReports.map((report) => ({ id: `report-${report.id}`, title: report.title, type: "report" as const, })), ...maintenances.map((maintenance) => ({ id: `maintenance-${maintenance.id}`, title: maintenance.title, type: "maintenance" as const, })), ];
const displayLimit = 3; const displayedItems = allItems.slice(0, displayLimit); const remainingCount = allItems.length - displayLimit;
return ( <ul className="text-foreground list-inside list-disc space-y-1 text-sm"> {displayedItems.map((item) => ( <li key={item.id}> {item.title}{" "} <span className="text-muted-foreground">({item.type})</span> </li> ))} {remainingCount > 0 && ( <li className="text-muted-foreground">+ {remainingCount} more</li> )} </ul> );}