Merge pull request #3541 from fosrl/feat/test-alert-button

Feat: test alert rules
This commit is contained in:
Milo Schwartz
2026-08-19 10:33:16 -04:00
committed by GitHub
14 changed files with 574 additions and 74 deletions
@@ -45,7 +45,14 @@ import {
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react";
import {
Bell,
ChevronRightIcon,
ChevronsUpDown,
Globe,
Plus,
Trash2
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Control, UseFormReturn } from "react-hook-form";
@@ -53,6 +60,7 @@ import { useFormContext, useWatch } from "react-hook-form";
import { useDebounce } from "use-debounce";
import { RolesSelector } from "../roles-selector";
import { UsersSelector } from "../users-selector";
import { cn } from "@app/lib/cn";
export function AddActionPanel({
onAdd
@@ -95,6 +103,7 @@ export function AddActionPanel({
const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id);
const [selected, setSelected] = useState<string | null>("notify");
const [isPopoverOpen, setPopoverOpen] = useState(false);
const isPremiumSelected =
selected !== null && EXTERNAL_IDS.includes(selected as any);
@@ -131,27 +140,46 @@ export function AddActionPanel({
if (!isBuiltInSelected) return;
onAdd(selected as AlertRuleFormAction["type"]);
setSelected(null);
setPopoverOpen(false);
};
return (
<div className="space-y-3">
<StrategySelect
options={actionTypeOptions}
value={selected}
cols={2}
onChange={(v) => setSelected(v)}
/>
{isPremiumSelected && <ContactSalesBanner />}
{!isPremiumSelected && (
<Button
type="button"
disabled={!isBuiltInSelected}
onClick={handleAdd}
>
<Plus className="h-4 w-4 mr-1" />
{t("alertingAddAction")}
</Button>
)}
<div className="flex flex-col gap-3 items-start">
<h3 className="font-medium">{t("alertingAddActionHeading")}</h3>
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
{t("alertingSelectActionType")}
<ChevronRightIcon
className={cn(
"size-4 transition-transform duration-150",
isPopoverOpen && "rotate-90"
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="shadow-md flex flex-col gap-3 w-150">
<StrategySelect
options={actionTypeOptions}
value={selected}
cols={2}
onChange={(v) => setSelected(v)}
/>
{isPremiumSelected ? (
<ContactSalesBanner />
) : (
<Button
type="button"
disabled={!isBuiltInSelected}
onClick={handleAdd}
>
<Plus className="h-4 w-4 mr-1" />
{t("alertingAddAction")}
</Button>
)}
</PopoverContent>
</Popover>
</div>
);
}
@@ -6,7 +6,9 @@ import {
AlertRuleSourceFields,
AlertRuleTriggerFields
} from "@app/components/alert-rule-editor/AlertRuleFields";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SettingsContainer } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button";
import { Card, CardContent } from "@app/components/ui/card";
import {
@@ -19,6 +21,7 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import {
buildFormSchema,
@@ -27,19 +30,15 @@ import {
type AlertRuleFormValues
} from "@app/lib/alertRuleForm";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { CreateAlertRuleResponse } from "@server/routers/alertRule/types";
import type { AxiosResponse } from "axios";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeft, Cog, Flag, Zap } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, type ReactNode } from "react";
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
import { Cog, Flag, Zap, ZapIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SwitchInput } from "@app/components/SwitchInput";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useRouter } from "next/navigation";
import { useActionState, useMemo, useTransition, type ReactNode } from "react";
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
import { Badge } from "../ui/badge";
const FORM_ID = "alert-rule-form";
@@ -115,7 +114,6 @@ export default function AlertRuleGraphEditor({
const t = useTranslations();
const router = useRouter();
const api = createApiClient(useEnvContext());
const [isSaving, setIsSaving] = useState(false);
const schema = useMemo(() => buildFormSchema(t), [t]);
const form = useForm<AlertRuleFormValues>({
resolver: zodResolver(schema) as Resolver<AlertRuleFormValues>,
@@ -127,8 +125,22 @@ export default function AlertRuleGraphEditor({
name: "actions"
});
const onSubmit = form.handleSubmit(async (values) => {
setIsSaving(true);
const saveAlert = async () => {
const isValid = await form.trigger();
if (!isValid) {
const values = form.getValues();
if (values.actions.length === 0) {
toast({
variant: "warning",
title: t("alertingNoActionsTitle"),
description: t("alertingNoActionsSaveDescription")
});
}
return;
}
const values = form.getValues();
try {
const payload = formValuesToApiPayload(values);
if (isNew) {
@@ -158,14 +170,48 @@ export default function AlertRuleGraphEditor({
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setIsSaving(false);
}
});
};
const testAlert = async () => {
const isValid = await form.trigger("actions");
const values = form.getValues();
if (!isValid) {
if (values.actions.length === 0) {
toast({
variant: "warning",
title: t("alertingNoActionsTitle"),
description: t("alertingNoActionsTestDescription")
});
}
return;
}
try {
const payload = formValuesToApiPayload(values);
await api.post(`/org/${orgId}/test-alert-rule`, payload);
toast({
title: t("alertingTestAlertSent"),
description: t("alertingTestAlertSentDescription")
});
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const [, formAction, isSaving] = useActionState(saveAlert, null);
const [isTestingAlert, startTransition] = useTransition();
return (
<Form {...form}>
<form id={FORM_ID} onSubmit={onSubmit}>
<form id={FORM_ID} action={formAction}>
<SettingsContainer>
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
@@ -263,14 +309,29 @@ export default function AlertRuleGraphEditor({
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isSaving}
loading={isSaving}
>
{t("save")}
</Button>
<div className="flex flex-col items-center w-full gap-3">
<Button
type="submit"
className="w-full"
disabled={isSaving}
loading={isSaving}
>
{t("save")}
</Button>
<Button
type="button"
variant="outline"
className="w-full gap-1.5"
onClick={() =>
startTransition(testAlert)
}
loading={isTestingAlert}
>
{t("alertingTestRule")}
<ZapIcon className="size-3.5 flex-none" />
</Button>
</div>
</fieldset>
</CardContent>
</Card>
+5
View File
@@ -0,0 +1,5 @@
export function getRandomItemInArray<T>(array: T[]) {
// Source - https://stackoverflow.com/a/4550514
const randomElement = array[Math.floor(Math.random() * array.length)];
return randomElement;
}
+1 -1
View File
@@ -1757,7 +1757,7 @@ export const approvalQueries = {
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
return durationToMs(1.5, "minutes");
}
return false;
}