Files
triggerdotdev--trigger.dev/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx
Katia Bulatova fbd6df33b4 feat(webapp): Themes + contrast settings update (#4206)
Adds System Preferences, Dark and Light themes, gated by the
`hasThemeSwitcher` feature flag (off by default — dark stays the default
theme for everyone).

Old theme is now "Classic"and set as default. 
"System preferences" theme has both Light and Dark modes and uses your
laptop settings to use a correct one.
It has less color accents (specifically less colored text), and they are
the same for both modes, only grayscale values change between them. And
Light/Dark themes can be used separately.

New Contrast setting is available for System Preferences, Dark and Light
themes - it changes the contrast for the whole app. All new visual
Settings live in Account.
2026-08-03 19:29:33 +02:00

365 lines
15 KiB
TypeScript

import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon";
import { parseWithZod } from "@conform-to/zod";
import { EnvelopeIcon, 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={GlobeLinesIcon}
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>
);
}