import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; import { HashtagIcon, LockClosedIcon } from "@heroicons/react/20/solid"; import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/router"; import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { SlackIcon } from "@trigger.dev/companyicons"; import { useEffect, useRef, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { InlineCode } from "~/components/code/InlineCode"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout, variantClasses } from "~/components/primitives/Callout"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import SegmentedControl from "~/components/primitives/SegmentedControl"; import { Select, SelectItem } from "~/components/primitives/Select"; import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { env } from "~/env.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, ProjectParamSchema, v3ProjectAlertsPath, } from "~/utils/pathBuilder"; import { type CreateAlertChannelOptions, CreateAlertChannelService, } from "~/v3/services/alerts/createAlertChannel.server"; import { assertSafeWebhookUrl, UnsafeWebhookUrlError, } from "~/v3/services/alerts/safeWebhookUrl.server"; const FormSchema = z .object({ alertTypes: z .array(z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])) .min(1) .or(z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])), environmentTypes: z .array(z.enum(["STAGING", "PRODUCTION", "PREVIEW"])) .min(1) .or(z.enum(["STAGING", "PRODUCTION", "PREVIEW"])), type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"), channelValue: z.string().nonempty(), integrationId: z.string().optional(), }) .refine( (value) => value.type === "EMAIL" ? z.string().email().safeParse(value.channelValue).success : true, { message: "Must be a valid email address", path: ["channelValue"], } ) .refine( (value) => value.type === "WEBHOOK" ? z.string().url().safeParse(value.channelValue).success : true, { message: "Must be a valid URL", path: ["channelValue"], } ) .refine( (value) => value.type === "SLACK" ? typeof value.channelValue === "string" && value.channelValue.startsWith("C") : true, { message: "Must select a Slack channel", path: ["channelValue"], } ); function formDataToCreateAlertChannelOptions( formData: z.infer ): CreateAlertChannelOptions { switch (formData.type) { case "WEBHOOK": { return { name: `Webhook to ${new URL(formData.channelValue).hostname}`, alertTypes: Array.isArray(formData.alertTypes) ? formData.alertTypes : [formData.alertTypes], environmentTypes: Array.isArray(formData.environmentTypes) ? formData.environmentTypes : [formData.environmentTypes], channel: { type: "WEBHOOK", url: formData.channelValue, }, }; } case "EMAIL": { return { name: `Email to ${formData.channelValue}`, alertTypes: Array.isArray(formData.alertTypes) ? formData.alertTypes : [formData.alertTypes], environmentTypes: Array.isArray(formData.environmentTypes) ? formData.environmentTypes : [formData.environmentTypes], channel: { type: "EMAIL", email: formData.channelValue, }, }; } case "SLACK": { const [channelId, channelName] = formData.channelValue.split("/"); return { name: `Slack message to ${channelName}`, alertTypes: Array.isArray(formData.alertTypes) ? formData.alertTypes : [formData.alertTypes], environmentTypes: Array.isArray(formData.environmentTypes) ? formData.environmentTypes : [formData.environmentTypes], channel: { type: "SLACK", channelId, channelName, integrationId: formData.integrationId, }, }; } } } export async function loader({ request, params }: LoaderFunctionArgs) { const userId = await requireUserId(request); const { organizationSlug, projectParam } = ProjectParamSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Response("Project not found", { status: 404 }); } const presenter = new NewAlertChannelPresenter(); const results = await presenter.call(project.id); const url = new URL(request.url); const option = url.searchParams.get("option"); const emailAlertsEnabled = env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined; return typedjson({ ...results, option: option === "slack" ? ("SLACK" as const) : undefined, emailAlertsEnabled, }); } export const action = async ({ request, params }: ActionFunctionArgs) => { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); if (request.method.toUpperCase() !== "POST") { return { status: 405, body: "Method Not Allowed" }; } const formData = await request.formData(); const submission = parseWithZod(formData, { schema: FormSchema }); if (submission.status !== "success") { return json(submission.reply()); } const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { return json(submission.reply({ formErrors: ["Project not found"] })); } // Validate the webhook URL before storing it, for an inline field error. if (submission.value.type === "WEBHOOK") { try { await assertSafeWebhookUrl(submission.value.channelValue); } catch (error) { if (error instanceof UnsafeWebhookUrlError) { return json(submission.reply({ fieldErrors: { channelValue: [error.message] } })); } throw error; } } const service = new CreateAlertChannelService(); const alertChannel = await service.call( project.externalRef, userId, formDataToCreateAlertChannelOptions(submission.value) ); if (!alertChannel) { return json(submission.reply({ formErrors: ["Failed to create alert channel"] })); } return redirectWithSuccessMessage( v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, `Created ${alertChannel.name} alert` ); }; export default function Page() { const [isOpen, setIsOpen] = useState(false); const { slack, option, emailAlertsEnabled } = useTypedLoaderData(); const lastSubmission = useActionData(); const navigation = useNavigation(); const navigate = useNavigate(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const [currentAlertChannel, setCurrentAlertChannel] = useState(option ?? "EMAIL"); const formRef = useRef(null); const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState(); const selectedSlackChannel = slack.channels?.find( (s) => selectedSlackChannelValue === `${s.id}/${s.name}` ); const isLoading = navigation.state !== "idle" && navigation.formMethod === "post" && navigation.formData?.get("action") === "create"; const [ form, { channelValue, alertTypes, environmentTypes, type, integrationId: _integrationId }, ] = useForm({ id: "create-alert", // TODO: type this lastResult: lastSubmission as any, onValidate({ formData }) { return parseWithZod(formData, { schema: FormSchema }); }, shouldRevalidate: "onSubmit", }); useEffect(() => { setIsOpen(true); }, []); useEffect(() => { if (navigation.state !== "idle") return; if (lastSubmission !== undefined) return; formRef.current?.reset(); }, [navigation.state, lastSubmission]); return ( { if (!o) { navigate(v3ProjectAlertsPath(organization, project, environment)); } }} > New alert
{ setCurrentAlertChannel(value); }} fullWidth defaultValue={currentAlertChannel ?? undefined} /> {currentAlertChannel === "EMAIL" ? ( emailAlertsEnabled ? ( {channelValue.errors} ) : ( Email integration is not available. Please contact your organization administrator. ) ) : currentAlertChannel === "SLACK" ? ( {slack.status === "READY" ? ( <> {selectedSlackChannel && selectedSlackChannel.is_private && ( To receive alerts in the{" "} {selectedSlackChannel.name}{" "} channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in Slack and type:{" "} /invite @Trigger.dev. )} {channelValue.errors} ) : slack.status === "NOT_CONFIGURED" ? ( Connect to Slack ) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? (
The Slack integration in your workspace has been revoked or has expired. Please re-connect your Slack workspace. Connect to Slack
) : slack.status === "FAILED_FETCHING_CHANNELS" ? (
Failed loading channels from Slack. Please try again later.
) : ( Slack integration is not available. Please contact your organization administrator. )}
) : ( {channelValue.errors} We'll issue POST requests to this URL with a JSON payload. )}
{alertTypes.errors}
{environmentTypes.errors} {form.errors} {isLoading ? "Saving…" : "Save"} } cancelButton={ Cancel } />
); } function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) { return (
{is_private ? : } {name}
); }