fbd6df33b4
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.
365 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|