chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import {
|
||||
EnvelopeIcon,
|
||||
GlobeAltIcon,
|
||||
HashtagIcon,
|
||||
LockClosedIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { BellAlertIcon } from "@heroicons/react/24/solid";
|
||||
import { useFetcher, useNavigate } from "@remix-run/react";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout, variantClasses } from "~/components/primitives/Callout";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { useToast } from "~/components/primitives/Toast";
|
||||
import { UnorderedList } from "~/components/primitives/UnorderedList";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import type { ErrorAlertChannelData } from "~/presenters/v3/ErrorAlertChannelPresenter.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { organizationSlackIntegrationPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const ErrorAlertsFormSchema = z.object({
|
||||
emails: z.preprocess((i) => {
|
||||
if (typeof i === "string") return i === "" ? [] : [i];
|
||||
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
|
||||
return [];
|
||||
}, z.string().email().array()),
|
||||
slackChannel: z.string().optional(),
|
||||
slackIntegrationId: z.string().optional(),
|
||||
webhooks: z.preprocess((i) => {
|
||||
if (typeof i === "string") return i === "" ? [] : [i];
|
||||
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
|
||||
return [];
|
||||
}, z.string().url().array()),
|
||||
});
|
||||
|
||||
type ConfigureErrorAlertsProps = ErrorAlertChannelData & {
|
||||
connectToSlackHref?: string;
|
||||
formAction: string;
|
||||
};
|
||||
|
||||
export function ConfigureErrorAlerts({
|
||||
emails: existingEmails,
|
||||
webhooks: existingWebhooks,
|
||||
slackChannel: existingSlackChannel,
|
||||
slack,
|
||||
emailAlertsEnabled,
|
||||
connectToSlackHref,
|
||||
formAction,
|
||||
}: ConfigureErrorAlertsProps) {
|
||||
const organization = useOrganization();
|
||||
const fetcher = useFetcher<{ ok?: boolean }>();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const location = useOptimisticLocation();
|
||||
const isSubmitting = fetcher.state !== "idle";
|
||||
|
||||
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>(
|
||||
existingSlackChannel
|
||||
? `${existingSlackChannel.channelId}/${existingSlackChannel.channelName}`
|
||||
: undefined
|
||||
);
|
||||
|
||||
const selectedSlackChannel =
|
||||
slack.status === "READY"
|
||||
? slack.channels?.find((s) => selectedSlackChannelValue === `${s.id}/${s.name}`)
|
||||
: undefined;
|
||||
|
||||
const closeHref = (() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("alerts");
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : location.pathname;
|
||||
})();
|
||||
|
||||
const hasHandledSuccess = useRef(false);
|
||||
useEffect(() => {
|
||||
if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) {
|
||||
hasHandledSuccess.current = true;
|
||||
toast.success("Alert settings saved");
|
||||
navigate(closeHref, { replace: true });
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, closeHref, navigate, toast]);
|
||||
|
||||
const emailFieldValues = useRef<string[]>(
|
||||
existingEmails.length > 0 ? [...existingEmails.map((e) => e.email), ""] : [""]
|
||||
);
|
||||
|
||||
const webhookFieldValues = useRef<string[]>(
|
||||
existingWebhooks.length > 0 ? [...existingWebhooks.map((w) => w.url), ""] : [""]
|
||||
);
|
||||
|
||||
const [form, fields] = useForm<z.infer<typeof ErrorAlertsFormSchema>>({
|
||||
id: "configure-error-alerts",
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: ErrorAlertsFormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
defaultValue: {
|
||||
emails: emailFieldValues.current,
|
||||
webhooks: webhookFieldValues.current,
|
||||
},
|
||||
});
|
||||
const { emails, webhooks, slackChannel, slackIntegrationId } = fields;
|
||||
|
||||
const emailFields = emails.getFieldList();
|
||||
const webhookFields = webhooks.getFieldList();
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
|
||||
<Header2 className="flex items-center gap-2">
|
||||
<BellAlertIcon className="size-5 text-alerts" /> Configure alerts
|
||||
</Header2>
|
||||
<LinkButton
|
||||
to={closeHref}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fetcher.Form method="post" action={formAction} {...getFormProps(form)} className="contents">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<Fieldset className="flex flex-col gap-4 p-4">
|
||||
<div className="flex flex-col">
|
||||
<Header3>Receive alerts when</Header3>
|
||||
<UnorderedList variant="small/dimmed" className="mt-1">
|
||||
<li>An error is seen for the first time</li>
|
||||
<li>A resolved error re-occurs</li>
|
||||
<li>An ignored error re-occurs based on settings you configured</li>
|
||||
</UnorderedList>
|
||||
</div>
|
||||
|
||||
{/* Email section */}
|
||||
<div>
|
||||
<Header3 className="mb-1">Email</Header3>
|
||||
{emailAlertsEnabled ? (
|
||||
<InputGroup>
|
||||
{emailFields.map((emailField, index) => (
|
||||
<Fragment key={emailField.key}>
|
||||
<Input
|
||||
{...getInputProps(emailField, { type: "email" })}
|
||||
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
|
||||
icon={EnvelopeIcon}
|
||||
onChange={(e) => {
|
||||
emailFieldValues.current[index] = e.target.value;
|
||||
if (
|
||||
emailFields.length === emailFieldValues.current.length &&
|
||||
emailFieldValues.current.every((v) => v !== "")
|
||||
) {
|
||||
form.insert({ name: emails.name });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormError id={emailField.errorId}>{emailField.errors}</FormError>
|
||||
</Fragment>
|
||||
))}
|
||||
</InputGroup>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
Email integration is not available. Please contact your organization
|
||||
administrator.
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Slack section */}
|
||||
<div>
|
||||
<Header3 className="mb-1">Slack</Header3>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
{slack.status === "READY" ? (
|
||||
<>
|
||||
<Select
|
||||
name={slackChannel.name}
|
||||
placeholder={<span className="text-text-dimmed">Select a Slack channel</span>}
|
||||
heading="Filter channels…"
|
||||
value={selectedSlackChannelValue ?? ""}
|
||||
dropdownIcon
|
||||
variant="tertiary/medium"
|
||||
items={slack.channels}
|
||||
setValue={(value) => {
|
||||
typeof value === "string" && setSelectedSlackChannelValue(value);
|
||||
}}
|
||||
filter={(channel, search) =>
|
||||
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
|
||||
}
|
||||
text={(value) => {
|
||||
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
|
||||
if (!channel) return;
|
||||
return (
|
||||
<span className="text-text-bright">
|
||||
<SlackChannelTitle {...channel} />
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(matches) => (
|
||||
<>
|
||||
<SelectItem
|
||||
value=""
|
||||
className="border-b border-grid-bright text-text-dimmed"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<XMarkIcon className="size-4" />
|
||||
<span>No channel</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{matches?.map((channel) => (
|
||||
<SelectItem
|
||||
key={channel.id}
|
||||
value={`${channel.id}/${channel.name}`}
|
||||
className="text-text-bright"
|
||||
>
|
||||
<SlackChannelTitle {...channel} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
{selectedSlackChannel && selectedSlackChannel.is_private && (
|
||||
<Callout
|
||||
variant="warning"
|
||||
className={cn("text-sm", variantClasses.warning.textColor)}
|
||||
>
|
||||
To receive alerts in the{" "}
|
||||
<InlineCode variant="extra-small">{selectedSlackChannel.name}</InlineCode>{" "}
|
||||
channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in
|
||||
Slack and type:{" "}
|
||||
<InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>.
|
||||
</Callout>
|
||||
)}
|
||||
<Hint>
|
||||
<TextLink to={organizationSlackIntegrationPath(organization)}>
|
||||
Manage Slack connection
|
||||
</TextLink>
|
||||
</Hint>
|
||||
<input
|
||||
type="hidden"
|
||||
name={slackIntegrationId.name}
|
||||
value={slack.integrationId}
|
||||
/>
|
||||
</>
|
||||
) : slack.status === "NOT_CONFIGURED" ? (
|
||||
connectToSlackHref ? (
|
||||
<LinkButton variant="tertiary/medium" to={connectToSlackHref} fullWidth>
|
||||
<span className="flex items-center gap-2 text-text-bright">
|
||||
<SlackIcon className="size-5" /> Connect to Slack
|
||||
</span>
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Callout variant="info">
|
||||
Slack is not connected. Connect Slack from the{" "}
|
||||
<span className="font-medium text-text-bright">Alerts</span> page to enable
|
||||
Slack notifications.
|
||||
</Callout>
|
||||
)
|
||||
) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? (
|
||||
connectToSlackHref ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Callout variant="info">
|
||||
The Slack integration in your workspace has been revoked or has expired.
|
||||
Please re-connect your Slack workspace.
|
||||
</Callout>
|
||||
<LinkButton
|
||||
variant="tertiary/large"
|
||||
to={`${connectToSlackHref}?reinstall=true`}
|
||||
fullWidth
|
||||
>
|
||||
<span className="flex items-center gap-2 text-text-bright">
|
||||
<SlackIcon className="size-5" /> Connect to Slack
|
||||
</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : (
|
||||
<Callout variant="info">
|
||||
The Slack integration in your workspace has been revoked or expired. Please
|
||||
re-connect from the{" "}
|
||||
<span className="font-medium text-text-bright">Alerts</span> page.
|
||||
</Callout>
|
||||
)
|
||||
) : slack.status === "FAILED_FETCHING_CHANNELS" ? (
|
||||
<Callout variant="warning">
|
||||
Failed loading channels from Slack. Please try again later.
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
Slack integration is not available. Please contact your organization
|
||||
administrator.
|
||||
</Callout>
|
||||
)}
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
{/* Webhook section */}
|
||||
<div>
|
||||
<Header3 className="mb-1">Webhook</Header3>
|
||||
<InputGroup>
|
||||
{webhookFields.map((webhookField, index) => (
|
||||
<Fragment key={webhookField.key}>
|
||||
<Input
|
||||
{...getInputProps(webhookField, { type: "url" })}
|
||||
placeholder={
|
||||
index === 0 ? "https://example.com/webhook" : "Add another webhook URL"
|
||||
}
|
||||
icon={GlobeAltIcon}
|
||||
onChange={(e) => {
|
||||
webhookFieldValues.current[index] = e.target.value;
|
||||
if (
|
||||
webhookFields.length === webhookFieldValues.current.length &&
|
||||
webhookFieldValues.current.every((v) => v !== "")
|
||||
) {
|
||||
form.insert({ name: webhooks.name });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormError id={webhookField.errorId}>{webhookField.errors}</FormError>
|
||||
</Fragment>
|
||||
))}
|
||||
<Hint>We'll issue POST requests to these URLs with a JSON payload.</Hint>
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
<FormError>{form.errors}</FormError>
|
||||
</Fieldset>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-grid-bright px-3 py-3">
|
||||
<LinkButton variant="secondary/medium" to={closeHref}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{is_private ? <LockClosedIcon className="size-4" /> : <HashtagIcon className="size-4" />}
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type ErrorGroupStatus } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const styles: Record<ErrorGroupStatus, string> = {
|
||||
UNRESOLVED: "bg-error/10 text-error",
|
||||
RESOLVED: "bg-success/10 text-success",
|
||||
IGNORED: "bg-blue-500/10 text-blue-400",
|
||||
};
|
||||
|
||||
const labels: Record<ErrorGroupStatus, string> = {
|
||||
UNRESOLVED: "Unresolved",
|
||||
RESOLVED: "Resolved",
|
||||
IGNORED: "Ignored",
|
||||
};
|
||||
|
||||
export function ErrorStatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: ErrorGroupStatus;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium",
|
||||
styles[status],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{labels[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { CheckIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
IconAlarmSnooze as IconAlarmSnoozeBase,
|
||||
IconArrowBackUp as IconArrowBackUpBase,
|
||||
IconBugOff as IconBugOffBase,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { type ErrorGroupStatus } from "@trigger.dev/database";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { useToast } from "~/components/primitives/Toast";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/primitives/Dialog";
|
||||
|
||||
const AlarmSnoozeIcon = ({ className }: { className?: string }) => (
|
||||
<IconAlarmSnoozeBase className={className} size={18} />
|
||||
);
|
||||
const ArrowBackUpIcon = ({ className }: { className?: string }) => (
|
||||
<IconArrowBackUpBase className={className} size={18} />
|
||||
);
|
||||
const BugOffIcon = ({ className }: { className?: string }) => (
|
||||
<IconBugOffBase className={className} size={18} />
|
||||
);
|
||||
|
||||
export function statusActionToastMessage(data: Record<string, string>): string {
|
||||
switch (data.action) {
|
||||
case "resolve":
|
||||
return "Error marked as resolved";
|
||||
case "unresolve":
|
||||
return "Error marked as unresolved";
|
||||
case "ignore": {
|
||||
const duration = data.duration ? Number(data.duration) : undefined;
|
||||
if (!duration) return "Error ignored indefinitely";
|
||||
const hours = duration / (60 * 60 * 1000);
|
||||
if (hours < 24) return `Error ignored for ${hours} ${hours === 1 ? "hour" : "hours"}`;
|
||||
const days = hours / 24;
|
||||
return `Error ignored for ${days} ${days === 1 ? "day" : "days"}`;
|
||||
}
|
||||
default:
|
||||
return "Error status updated";
|
||||
}
|
||||
}
|
||||
|
||||
export function ErrorStatusMenuItems({
|
||||
status,
|
||||
taskIdentifier,
|
||||
onAction,
|
||||
onCustomIgnore,
|
||||
}: {
|
||||
status: ErrorGroupStatus;
|
||||
taskIdentifier: string;
|
||||
onAction: (data: Record<string, string>) => void;
|
||||
onCustomIgnore: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{status === "UNRESOLVED" && (
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
icon={CheckIcon}
|
||||
leadingIconClassName="text-success"
|
||||
title="Resolved"
|
||||
onClick={() => onAction({ taskIdentifier, action: "resolve" })}
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={AlarmSnoozeIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="Ignored for 1 hour"
|
||||
onClick={() =>
|
||||
onAction({
|
||||
taskIdentifier,
|
||||
action: "ignore",
|
||||
duration: String(60 * 60 * 1000),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={AlarmSnoozeIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="Ignored for 24 hours"
|
||||
onClick={() =>
|
||||
onAction({
|
||||
taskIdentifier,
|
||||
action: "ignore",
|
||||
duration: String(24 * 60 * 60 * 1000),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={BugOffIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="Ignored forever"
|
||||
onClick={() => onAction({ taskIdentifier, action: "ignore" })}
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={AlarmSnoozeIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="Ignored with custom condition…"
|
||||
onClick={onCustomIgnore}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "IGNORED" && (
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
icon={CheckIcon}
|
||||
leadingIconClassName="text-success"
|
||||
title="Resolved"
|
||||
onClick={() => onAction({ taskIdentifier, action: "resolve" })}
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={ArrowBackUpIcon}
|
||||
leadingIconClassName="text-error"
|
||||
title="Unresolved"
|
||||
onClick={() => onAction({ taskIdentifier, action: "unresolve" })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "RESOLVED" && (
|
||||
<PopoverMenuItem
|
||||
icon={ArrowBackUpIcon}
|
||||
leadingIconClassName="text-error"
|
||||
title="Unresolved"
|
||||
onClick={() => onAction({ taskIdentifier, action: "unresolve" })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomIgnoreDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
taskIdentifier,
|
||||
formAction,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
taskIdentifier: string;
|
||||
formAction?: string;
|
||||
}) {
|
||||
const fetcher = useFetcher<{ ok?: boolean }>();
|
||||
const isSubmitting = fetcher.state !== "idle";
|
||||
const [conditionError, setConditionError] = useState<string | null>(null);
|
||||
const toast = useToast();
|
||||
const hasHandledSuccess = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) {
|
||||
hasHandledSuccess.current = true;
|
||||
toast.success("Error ignored with custom condition");
|
||||
onOpenChange(false);
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, onOpenChange, toast]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-1.5">
|
||||
<IconAlarmSnoozeBase className="-ml-1.5 size-6 text-blue-500" />
|
||||
Custom ignore condition
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
action={formAction}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const rate = formData.get("occurrenceRate")?.toString().trim();
|
||||
const total = formData.get("totalOccurrences")?.toString().trim();
|
||||
|
||||
if (!rate && !total) {
|
||||
setConditionError("At least one unignore condition is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setConditionError(null);
|
||||
hasHandledSuccess.current = false;
|
||||
fetcher.submit(e.currentTarget, { method: "post", action: formAction });
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="action" value="ignore" />
|
||||
<input type="hidden" name="taskIdentifier" value={taskIdentifier} />
|
||||
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor="occurrenceRate" variant="small">
|
||||
Unignore when occurrence rate exceeds (per minute)
|
||||
</Label>
|
||||
<Input
|
||||
id="occurrenceRate"
|
||||
name="occurrenceRate"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="e.g. 10"
|
||||
onChange={() => conditionError && setConditionError(null)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor="totalOccurrences" variant="small">
|
||||
Unignore when total occurrences exceed
|
||||
</Label>
|
||||
<Input
|
||||
id="totalOccurrences"
|
||||
name="totalOccurrences"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="e.g. 100"
|
||||
onChange={() => conditionError && setConditionError(null)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{conditionError && <FormError>{conditionError}</FormError>}
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor="reason" variant="small" required={false}>
|
||||
Reason
|
||||
</Label>
|
||||
<Input id="reason" name="reason" type="text" placeholder="e.g. Known flaky test" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="tertiary/medium" type="button" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary/medium" type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Ignoring…" : "Ignore error"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fetcher.Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user