feat(webapp): Vercel / Slack integrations improvements (#3108)
## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Testing Slack + GitHub + Vercel + Builds + Deployments --- ## Changelog Settings changes: - Split general from integrations - Add new Slack section to org level integrations Vercel improvements: - bugfix for TRIGGER_SECRET_KEY collision - onboarding improvements for connecting to projects - new loops event Slack improvements: - nicer alerts Webhook/Email alerts: - rich events with Github & Vercel integration data --- ## Screenshots <img width="2550" height="652" alt="Screenshot 2026-02-20 at 21 53 34" src="https://github.com/user-attachments/assets/8d7c9f1d-5fe9-4516-8fb3-885460b4207f" /> <img width="843" height="710" alt="Screenshot 2026-02-23 at 10 55 54" src="https://github.com/user-attachments/assets/8ea72c1f-431b-493c-b9a9-8076cce12262" /> <img width="765" height="466" alt="Screenshot 2026-02-20 at 21 52 46" src="https://github.com/user-attachments/assets/157fafb8-b7bf-499d-8953-c2aed5e44ce0" /> <img width="691" height="261" alt="Screenshot 2026-02-20 at 22 04 24" src="https://github.com/user-attachments/assets/3aea7369-2008-4af8-a9c0-5fbfa2cc381d" /> <img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 48 49" src="https://github.com/user-attachments/assets/dc10c14e-cd15-445a-b5be-d694d29d20e5" /> <img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 49 04" src="https://github.com/user-attachments/assets/1ef591fd-fd00-430a-9649-8b18cff9586d" /> <img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 32 56" src="https://github.com/user-attachments/assets/c5c8f318-d193-4dd4-86f7-1cc4bbcc4e0c" /> <img width="422" height="187" alt="Screenshot 2026-02-20 at 21 57 41" src="https://github.com/user-attachments/assets/37865cb6-4c0d-40ef-9c60-7b057d546c61" /> <img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 33 06" src="https://github.com/user-attachments/assets/e9180e8e-e611-4734-9232-80c62ff863ad" /> 💯
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { VercelLogo } from "./VercelLogo";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
|
||||
export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={<VercelLogo className="size-3.5" />}
|
||||
iconSpacing="gap-x-1"
|
||||
to={vercelDeploymentUrl}
|
||||
className="pl-1"
|
||||
>
|
||||
Vercel
|
||||
</LinkButton>
|
||||
}
|
||||
content="View on Vercel"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server";
|
||||
import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server";
|
||||
import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import { vercelAppInstallPath, v3ProjectSettingsIntegrationsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { usePostHogTracking } from "~/hooks/usePostHog";
|
||||
@@ -102,6 +102,7 @@ export function VercelOnboardingModal({
|
||||
hasOrgIntegration,
|
||||
nextUrl,
|
||||
onDataReload,
|
||||
vercelManageAccessUrl,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -114,6 +115,7 @@ export function VercelOnboardingModal({
|
||||
hasOrgIntegration: boolean;
|
||||
nextUrl?: string;
|
||||
onDataReload?: (vercelStagingEnvironment?: string) => void;
|
||||
vercelManageAccessUrl?: string;
|
||||
}) {
|
||||
const { capture, startSessionRecording } = usePostHogTracking();
|
||||
const navigation = useNavigation();
|
||||
@@ -122,7 +124,8 @@ export function VercelOnboardingModal({
|
||||
const completeOnboardingFetcher = useFetcher();
|
||||
const { Form: CompleteOnboardingForm } = completeOnboardingFetcher;
|
||||
const [searchParams] = useSearchParams();
|
||||
const fromMarketplaceContext = searchParams.get("origin") === "marketplace";
|
||||
const origin = searchParams.get("origin");
|
||||
const fromMarketplaceContext = origin === "marketplace";
|
||||
|
||||
const availableProjects = onboardingData?.availableProjects || [];
|
||||
const hasProjectSelected = onboardingData?.hasProjectSelected ?? false;
|
||||
@@ -543,8 +546,15 @@ export function VercelOnboardingModal({
|
||||
|
||||
if (!isGitHubConnectedForOnboarding) {
|
||||
setState("github-connection");
|
||||
capture("vercel onboarding github step viewed", {
|
||||
origin: fromMarketplaceContext ? "marketplace" : "dashboard",
|
||||
step: "github-connection",
|
||||
organization_slug: organizationSlug,
|
||||
project_slug: projectSlug,
|
||||
github_app_installed: gitHubAppInstallations.length > 0,
|
||||
});
|
||||
}
|
||||
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding]);
|
||||
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding, capture, organizationSlug, projectSlug, gitHubAppInstallations.length]);
|
||||
|
||||
const handleFinishOnboarding = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -639,7 +649,7 @@ export function VercelOnboardingModal({
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<VercelLogo className="size-5" />
|
||||
@@ -727,14 +737,25 @@ export function VercelOnboardingModal({
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={handleProjectSelection}
|
||||
disabled={!selectedVercelProject || fetcher.state !== "idle"}
|
||||
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
|
||||
>
|
||||
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{vercelManageAccessUrl && !origin && (
|
||||
<LinkButton
|
||||
to={vercelManageAccessUrl}
|
||||
variant="tertiary/medium"
|
||||
target="_self"
|
||||
>
|
||||
Manage access
|
||||
</LinkButton>
|
||||
)}
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={handleProjectSelection}
|
||||
disabled={!selectedVercelProject || fetcher.state !== "idle"}
|
||||
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
|
||||
>
|
||||
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
cancelButton={
|
||||
<Button
|
||||
@@ -813,6 +834,7 @@ export function VercelOnboardingModal({
|
||||
<Header3>Pull Environment Variables</Header3>
|
||||
<Paragraph className="text-sm">
|
||||
Select which environment variables to pull from Vercel now. This is a one-time pull.
|
||||
Later on environment variables can be pulled before each build.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex gap-4 text-sm">
|
||||
@@ -1057,7 +1079,7 @@ export function VercelOnboardingModal({
|
||||
</Callout>
|
||||
|
||||
{(() => {
|
||||
const baseSettingsPath = v3ProjectSettingsPath(
|
||||
const baseSettingsPath = v3ProjectSettingsIntegrationsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
@@ -1081,6 +1103,7 @@ export function VercelOnboardingModal({
|
||||
)}
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={OctoKitty}
|
||||
onClick={() => trackOnboarding("vercel onboarding github app install clicked")}
|
||||
>
|
||||
Install GitHub app
|
||||
</LinkButton>
|
||||
@@ -1110,6 +1133,7 @@ export function VercelOnboardingModal({
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={() => {
|
||||
trackOnboarding("vercel onboarding github completed");
|
||||
setState("completed");
|
||||
const validUrl = safeRedirectUrl(nextUrl);
|
||||
if (validUrl) {
|
||||
@@ -1123,6 +1147,7 @@ export function VercelOnboardingModal({
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
onClick={() => {
|
||||
trackOnboarding("vercel onboarding github skipped");
|
||||
setState("completed");
|
||||
if (fromMarketplaceContext && nextUrl) {
|
||||
const validUrl = safeRedirectUrl(nextUrl);
|
||||
@@ -1141,6 +1166,7 @@ export function VercelOnboardingModal({
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
onClick={() => {
|
||||
trackOnboarding("vercel onboarding github skipped");
|
||||
setState("completed");
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -3,15 +3,17 @@ import {
|
||||
ChartBarIcon,
|
||||
Cog8ToothIcon,
|
||||
CreditCardIcon,
|
||||
PuzzlePieceIcon,
|
||||
UserGroupIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { VercelLogo } from "~/components/integrations/VercelLogo";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
organizationSlackIntegrationPath,
|
||||
organizationTeamPath,
|
||||
organizationVercelIntegrationPath,
|
||||
rootPath,
|
||||
@@ -115,13 +117,25 @@ export function OrganizationSettingsSideMenu({
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="settings"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-1">
|
||||
<SideMenuHeader title="Integrations" />
|
||||
</div>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon={PuzzlePieceIcon}
|
||||
activeIconColor="text-blue-500"
|
||||
name="Vercel"
|
||||
icon={VercelLogo}
|
||||
activeIconColor="text-white"
|
||||
to={organizationVercelIntegrationPath(organization)}
|
||||
data-action="integrations"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Slack"
|
||||
icon={SlackIcon}
|
||||
activeIconColor="text-white"
|
||||
to={organizationSlackIntegrationPath(organization)}
|
||||
data-action="integrations"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="App version" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Cog8ToothIcon,
|
||||
CogIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PuzzlePieceIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeAmericasIcon,
|
||||
@@ -74,7 +75,8 @@ import {
|
||||
v3LogsPath,
|
||||
v3ProjectAlertsPath,
|
||||
v3ProjectPath,
|
||||
v3ProjectSettingsPath,
|
||||
v3ProjectSettingsGeneralPath,
|
||||
v3ProjectSettingsIntegrationsPath,
|
||||
v3QueuesPath,
|
||||
v3RunsPath,
|
||||
v3SchedulesPath,
|
||||
@@ -589,13 +591,34 @@ export function SideMenu({
|
||||
data-action="limits"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
<SideMenuSection
|
||||
title="Project settings"
|
||||
isSideMenuCollapsed={isCollapsed}
|
||||
itemSpacingClassName="space-y-0"
|
||||
initialCollapsed={getSectionCollapsed(
|
||||
user.dashboardPreferences.sideMenu,
|
||||
"project-settings"
|
||||
)}
|
||||
onCollapseToggle={handleSectionToggle("project-settings")}
|
||||
>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
name="General"
|
||||
icon={Cog8ToothIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectSettingsPath(organization, project, environment)}
|
||||
data-action="project-settings"
|
||||
to={v3ProjectSettingsGeneralPath(organization, project, environment)}
|
||||
data-action="project-settings-general"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon={PuzzlePieceIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
|
||||
data-action="project-settings-integrations"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Valid section IDs that can have their collapsed state toggled
|
||||
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
|
||||
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics", "project-settings"]);
|
||||
|
||||
// Inferred type from the schema
|
||||
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
|
||||
|
||||
@@ -975,6 +975,13 @@ export class VercelIntegrationRepository {
|
||||
return { created: 0, updated: 0, errors: [] as string[] };
|
||||
}
|
||||
|
||||
await this.removeAllVercelEnvVarsByKey({
|
||||
client,
|
||||
vercelProjectId: params.vercelProjectId,
|
||||
teamId: params.teamId,
|
||||
key: "TRIGGER_SECRET_KEY",
|
||||
});
|
||||
|
||||
const result = await this.batchUpsertVercelEnvVars({
|
||||
client,
|
||||
vercelProjectId: params.vercelProjectId,
|
||||
@@ -1526,6 +1533,35 @@ export class VercelIntegrationRepository {
|
||||
return { created, updated, errors };
|
||||
}
|
||||
|
||||
private static async removeAllVercelEnvVarsByKey(params: {
|
||||
client: Vercel;
|
||||
vercelProjectId: string;
|
||||
teamId: string | null;
|
||||
key: string;
|
||||
}): Promise<void> {
|
||||
const { client, vercelProjectId, teamId, key } = params;
|
||||
|
||||
const existingEnvs = await client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
});
|
||||
|
||||
const envs = extractVercelEnvs(existingEnvs);
|
||||
const idsToRemove = envs
|
||||
.filter((env) => env.key === key && env.id)
|
||||
.map((env) => env.id!);
|
||||
|
||||
if (idsToRemove.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.projects.batchRemoveProjectEnv({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
requestBody: { ids: idsToRemove },
|
||||
});
|
||||
}
|
||||
|
||||
private static async upsertVercelEnvVar(params: {
|
||||
client: Vercel;
|
||||
vercelProjectId: string;
|
||||
|
||||
@@ -10,7 +10,10 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { processGitMetadata } from "./BranchesPresenter.server";
|
||||
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
|
||||
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import {
|
||||
VercelProjectIntegrationDataSchema,
|
||||
buildVercelDeploymentUrl,
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
@@ -232,8 +235,11 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
|
||||
|
||||
let vercelDeploymentUrl: string | null = null;
|
||||
if (hasVercelIntegration && deployment.integrationDeploymentId && vercelTeamSlug && vercelProjectName) {
|
||||
const vercelId = deployment.integrationDeploymentId.replace(/^dpl_/, "");
|
||||
vercelDeploymentUrl = `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`;
|
||||
vercelDeploymentUrl = buildVercelDeploymentUrl(
|
||||
vercelTeamSlug,
|
||||
vercelProjectName,
|
||||
deployment.integrationDeploymentId
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { processGitMetadata } from "./BranchesPresenter.server";
|
||||
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { S2 } from "@s2-dev/streamstore";
|
||||
import { env } from "~/env.server";
|
||||
import { createRedisClient } from "~/redis.server";
|
||||
@@ -161,6 +162,51 @@ export class DeploymentPresenter {
|
||||
});
|
||||
|
||||
const gitMetadata = processGitMetadata(deployment.git);
|
||||
|
||||
// Look up Vercel integration data to construct a deployment URL
|
||||
let vercelDeploymentUrl: string | undefined;
|
||||
const vercelProjectIntegration =
|
||||
await this.#prismaClient.organizationProjectIntegration.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
organizationIntegration: {
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
integrationData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (vercelProjectIntegration) {
|
||||
const parsed = VercelProjectIntegrationDataSchema.safeParse(
|
||||
vercelProjectIntegration.integrationData
|
||||
);
|
||||
|
||||
if (parsed.success && parsed.data.vercelTeamSlug) {
|
||||
const integrationDeployment =
|
||||
await this.#prismaClient.integrationDeployment.findFirst({
|
||||
where: {
|
||||
deploymentId: deployment.id,
|
||||
integrationName: "vercel",
|
||||
},
|
||||
select: {
|
||||
integrationDeploymentId: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (integrationDeployment) {
|
||||
const vercelId = integrationDeployment.integrationDeploymentId;
|
||||
vercelDeploymentUrl = `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const externalBuildData = deployment.externalBuildData
|
||||
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
||||
: undefined;
|
||||
@@ -227,6 +273,7 @@ export class DeploymentPresenter {
|
||||
type: deployment.type,
|
||||
git: gitMetadata,
|
||||
triggeredVia: deployment.triggeredVia,
|
||||
vercelDeploymentUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export type VercelSettingsResult = {
|
||||
customEnvironments: VercelCustomEnvironment[];
|
||||
/** Whether autoAssignCustomDomains is enabled on the Vercel project. null if unknown. */
|
||||
autoAssignCustomDomains?: boolean | null;
|
||||
/** URL to manage Vercel integration access (project sharing) on vercel.com */
|
||||
vercelManageAccessUrl?: string;
|
||||
};
|
||||
|
||||
export type VercelAvailableProject = {
|
||||
@@ -242,11 +244,12 @@ export class VercelSettingsPresenter extends BasePresenter {
|
||||
checkPreviewEnvironment(),
|
||||
getVercelProjectIntegration(),
|
||||
]).andThen(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, hasPreviewEnvironment, connectedProject]) => {
|
||||
const fetchCustomEnvsAndProjectSettings = async (): Promise<{
|
||||
const fetchVercelData = async (): Promise<{
|
||||
customEnvironments: VercelCustomEnvironment[];
|
||||
autoAssignCustomDomains: boolean | null;
|
||||
vercelManageAccessUrl?: string;
|
||||
}> => {
|
||||
if (!connectedProject || !orgIntegration) {
|
||||
if (!orgIntegration) {
|
||||
return { customEnvironments: [], autoAssignCustomDomains: null };
|
||||
}
|
||||
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
|
||||
@@ -255,6 +258,26 @@ export class VercelSettingsPresenter extends BasePresenter {
|
||||
}
|
||||
const client = clientResult.value;
|
||||
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
||||
|
||||
// Build manage access URL
|
||||
let vercelManageAccessUrl: string | undefined;
|
||||
const appSlug = env.VERCEL_INTEGRATION_APP_SLUG;
|
||||
const integrationData = orgIntegration.integrationData as Record<string, unknown> | null;
|
||||
const installationId =
|
||||
typeof integrationData?.installationId === "string"
|
||||
? integrationData.installationId
|
||||
: undefined;
|
||||
if (appSlug && installationId && teamId) {
|
||||
const teamSlugResult = await VercelIntegrationRepository.getTeamSlug(client, teamId);
|
||||
if (teamSlugResult.isOk()) {
|
||||
vercelManageAccessUrl = `https://vercel.com/${teamSlugResult.value}/~/integrations/${appSlug}/${installationId}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!connectedProject) {
|
||||
return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl };
|
||||
}
|
||||
|
||||
const [customEnvsResult, autoAssignResult] = await Promise.all([
|
||||
VercelIntegrationRepository.getVercelCustomEnvironments(
|
||||
client,
|
||||
@@ -270,13 +293,14 @@ export class VercelSettingsPresenter extends BasePresenter {
|
||||
return {
|
||||
customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [],
|
||||
autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null,
|
||||
vercelManageAccessUrl,
|
||||
};
|
||||
};
|
||||
|
||||
return fromPromise(
|
||||
fetchCustomEnvsAndProjectSettings(),
|
||||
fetchVercelData(),
|
||||
(error) => ({ type: "other" as const, cause: error })
|
||||
).map(({ customEnvironments, autoAssignCustomDomains }) => ({
|
||||
).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl }) => ({
|
||||
enabled: true,
|
||||
hasOrgIntegration,
|
||||
authInvalid: false,
|
||||
@@ -286,6 +310,7 @@ export class VercelSettingsPresenter extends BasePresenter {
|
||||
hasPreviewEnvironment,
|
||||
customEnvironments,
|
||||
autoAssignCustomDomains,
|
||||
vercelManageAccessUrl,
|
||||
} as VercelSettingsResult));
|
||||
}).mapErr((error) => {
|
||||
// Log the error and return a safe fallback
|
||||
|
||||
+11
@@ -15,6 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
import { VercelLink } from "~/components/integrations/VercelLink";
|
||||
import { RuntimeIcon } from "~/components/RuntimeIcon";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
@@ -516,6 +517,16 @@ export default function Page() {
|
||||
})()}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{deployment.vercelDeploymentUrl && (
|
||||
<Property.Item>
|
||||
<Property.Label>Linked</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="-ml-1 mt-0.5 flex flex-col">
|
||||
<VercelLink vercelDeploymentUrl={deployment.vercelDeploymentUrl} />
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</Property.Table>
|
||||
</div>
|
||||
|
||||
|
||||
+11
-18
@@ -19,7 +19,7 @@ import { useEffect } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
|
||||
import { VercelLogo } from "~/components/integrations/VercelLogo";
|
||||
import { VercelLink } from "~/components/integrations/VercelLink";
|
||||
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import {
|
||||
DeploymentStatus,
|
||||
deploymentStatusDescription,
|
||||
@@ -76,7 +75,7 @@ import {
|
||||
EnvironmentParamSchema,
|
||||
docsPath,
|
||||
v3DeploymentPath,
|
||||
v3ProjectSettingsPath,
|
||||
v3ProjectSettingsIntegrationsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
|
||||
@@ -314,20 +313,14 @@ export default function Page() {
|
||||
{hasVercelIntegration && (
|
||||
<TableCell isSelected={isSelected}>
|
||||
{deployment.vercelDeploymentUrl ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<a
|
||||
href={deployment.vercelDeploymentUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-center text-text-dimmed transition-colors hover:text-text-bright"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VercelLogo className="size-3.5" />
|
||||
</a>
|
||||
}
|
||||
content="View on Vercel"
|
||||
/>
|
||||
<div
|
||||
className="-ml-1 flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VercelLink
|
||||
vercelDeploymentUrl={deployment.vercelDeploymentUrl}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
@@ -377,7 +370,7 @@ export default function Page() {
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={CogIcon}
|
||||
to={v3ProjectSettingsPath(organization, project, environment)}
|
||||
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunction, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
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 { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import {
|
||||
redirectWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder";
|
||||
import { useState } from "react";
|
||||
|
||||
function createSchema(
|
||||
constraints: {
|
||||
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
|
||||
} = {}
|
||||
) {
|
||||
return z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("rename"),
|
||||
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
projectSlug: z.string().superRefine((slug, ctx) => {
|
||||
if (constraints.getSlugMatch === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conform.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
|
||||
if (isMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The slug must match ${projectSlug}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = params;
|
||||
if (!organizationSlug || !projectParam) {
|
||||
return json({ errors: { body: "organizationSlug and projectParam are required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const schema = createSchema({
|
||||
getSlugMatch: (slug) => {
|
||||
return { isMatch: slug === projectParam, projectSlug: projectParam };
|
||||
},
|
||||
});
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const projectSettingsService = new ProjectSettingsService();
|
||||
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
|
||||
if (membershipResultOrFail.isErr()) {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId } = membershipResultOrFail.value;
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
const resultOrFail = await projectSettingsService.renameProject(
|
||||
projectId,
|
||||
submission.value.projectName
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to rename project", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project renamed to ${submission.value.projectName}`
|
||||
);
|
||||
}
|
||||
case "delete": {
|
||||
const resultOrFail = await projectSettingsService.deleteProject(projectId, userId);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to delete project", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project ${projectParam} could not be deleted`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Project deleted"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const project = useProject();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false);
|
||||
|
||||
const [renameForm, { projectName }] = useForm({
|
||||
id: "rename-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isRenameLoading =
|
||||
navigation.formData?.get("action") === "rename" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [deleteForm, { projectSlug }] = useForm({
|
||||
id: "delete-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldValidate: "onInput",
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema({
|
||||
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isDeleteLoading =
|
||||
navigation.formData?.get("action") === "delete" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [deleteInputValue, setDeleteInputValue] = useState("");
|
||||
|
||||
return (
|
||||
<MainHorizontallyCenteredContainer className="md:mt-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Header2 spacing>General</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<Fieldset className="mb-5">
|
||||
<InputGroup fullWidth>
|
||||
<Label>Project ref</Label>
|
||||
<ClipboardField value={project.externalRef} variant={"secondary/medium"} />
|
||||
<Hint>
|
||||
This goes in your{" "}
|
||||
<InlineCode variant="extra-extra-small">trigger.config</InlineCode> file.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
<Form method="post" {...renameForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={projectName.id}>Project name</Label>
|
||||
<Input
|
||||
{...conform.input(projectName, { type: "text" })}
|
||||
defaultValue={project.name}
|
||||
placeholder="Project name"
|
||||
icon={FolderIcon}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setHasRenameFormChanges(e.target.value !== project.name);
|
||||
}}
|
||||
/>
|
||||
<FormError id={projectName.errorId}>{projectName.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="rename"
|
||||
variant={"secondary/small"}
|
||||
disabled={isRenameLoading || !hasRenameFormChanges}
|
||||
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Danger zone</Header2>
|
||||
<div className="w-full rounded-sm border border-rose-500/40 p-4">
|
||||
<Form method="post" {...deleteForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={projectSlug.id}>Delete project</Label>
|
||||
<Input
|
||||
{...conform.input(projectSlug, { type: "text" })}
|
||||
placeholder="Your project slug"
|
||||
icon={ExclamationTriangleIcon}
|
||||
onChange={(e) => setDeleteInputValue(e.target.value)}
|
||||
/>
|
||||
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
|
||||
<FormError>{deleteForm.error}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Project slug
|
||||
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
|
||||
Delete.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="delete"
|
||||
variant={"danger/small"}
|
||||
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isDeleteLoading || deleteInputValue !== project.slug}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
);
|
||||
}
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData, useTypedFetcher } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
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 { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectBackWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { type BuildSettings } from "~/v3/buildSettings";
|
||||
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
import {
|
||||
VercelSettingsPanel,
|
||||
VercelOnboardingModal,
|
||||
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const projectSettingsPresenter = new ProjectSettingsPresenter();
|
||||
const resultOrFail = await projectSettingsPresenter.getProjectSettings(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "project_not_found": {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed loading project settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, please try again!",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { gitHubApp, buildSettings } = resultOrFail.value;
|
||||
|
||||
return typedjson({
|
||||
githubAppEnabled: gitHubApp.enabled,
|
||||
buildSettings,
|
||||
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
|
||||
});
|
||||
};
|
||||
|
||||
const UpdateBuildSettingsFormSchema = z.object({
|
||||
action: z.literal("update-build-settings"),
|
||||
triggerConfigFilePath: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
|
||||
.refine((val) => !val || val.length <= 255, {
|
||||
message: "Config file path must not exceed 255 characters",
|
||||
}),
|
||||
installCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((val) => !val || !val.includes("\n"), {
|
||||
message: "Install command must be a single line",
|
||||
})
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Install command must not exceed 500 characters",
|
||||
}),
|
||||
preBuildCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((val) => !val || !val.includes("\n"), {
|
||||
message: "Pre-build command must be a single line",
|
||||
})
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Pre-build command must not exceed 500 characters",
|
||||
}),
|
||||
useNativeBuildServer: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = params;
|
||||
if (!organizationSlug || !projectParam) {
|
||||
return json({ errors: { body: "organizationSlug and projectParam are required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: UpdateBuildSettingsFormSchema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const projectSettingsService = new ProjectSettingsService();
|
||||
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
|
||||
if (membershipResultOrFail.isErr()) {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId } = membershipResultOrFail.value;
|
||||
|
||||
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
|
||||
submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
|
||||
installCommand: installCommand || undefined,
|
||||
preBuildCommand: preBuildCommand || undefined,
|
||||
triggerConfigFilePath: triggerConfigFilePath || undefined,
|
||||
useNativeBuildServer: useNativeBuildServer,
|
||||
});
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to update build settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to update build settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "Build settings updated successfully");
|
||||
};
|
||||
|
||||
export default function IntegrationsSettingsPage() {
|
||||
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const environment = useEnvironment();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Vercel onboarding modal state
|
||||
const hasQueryParam = searchParams.get("vercelOnboarding") === "true";
|
||||
const nextUrl = searchParams.get("next");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
|
||||
|
||||
// Helper to open modal and ensure query param is present
|
||||
const openVercelOnboarding = useCallback(() => {
|
||||
setIsModalOpen(true);
|
||||
// Ensure query param is present to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
const closeVercelOnboarding = useCallback(() => {
|
||||
// Remove query param if present
|
||||
if (hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.delete("vercelOnboarding");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
// Close modal
|
||||
setIsModalOpen(false);
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
// When query param is present, handle modal opening
|
||||
// Note: We don't close the modal based on data state during onboarding - only when explicitly closed
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelIntegrationEnabled) {
|
||||
// Ensure query param is present and modal is open
|
||||
if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data is loaded, ensure modal is open (query param takes precedence)
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
|
||||
// Load onboarding data
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
} else if (!hasQueryParam && isModalOpen) {
|
||||
// Query param removed but modal is open, close modal
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// Ensure modal stays open when query param is present (even after data reloads)
|
||||
// This is a safeguard to prevent the modal from closing during form submissions
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && !isModalOpen) {
|
||||
// Query param is present but modal is closed, open it
|
||||
// This ensures the modal stays open during the onboarding flow
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [hasQueryParam, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// When data finishes loading (from query param), ensure modal is open
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data loaded and query param is present, ensure modal is open
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}
|
||||
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// Track if we're waiting for data from button click (not query param)
|
||||
const waitingForButtonClickRef = useRef(false);
|
||||
|
||||
// Handle opening modal from button click (without query param)
|
||||
const handleOpenVercelModal = useCallback(() => {
|
||||
// Add query param to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
|
||||
// Data already loaded, open modal immediately
|
||||
openVercelOnboarding();
|
||||
} else {
|
||||
// Need to load data first, mark that we're waiting for button click
|
||||
waitingForButtonClickRef.current = true;
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
}, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]);
|
||||
|
||||
// When data loads from button click, open modal
|
||||
useEffect(() => {
|
||||
if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data loaded from button click, open modal and ensure query param is present
|
||||
waitingForButtonClickRef.current = false;
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainHorizontallyCenteredContainer className="md:mt-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{githubAppEnabled && (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<Header2 spacing>Git settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vercelIntegrationEnabled && (
|
||||
<div>
|
||||
<Header2 spacing>Vercel integration</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<VercelSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
onOpenVercelModal={handleOpenVercelModal}
|
||||
isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Build settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
|
||||
{/* Vercel Onboarding Modal */}
|
||||
{vercelIntegrationEnabled && (
|
||||
<VercelOnboardingModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeVercelOnboarding}
|
||||
onboardingData={vercelFetcher.data?.onboardingData ?? null}
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
|
||||
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
|
||||
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
|
||||
nextUrl={nextUrl ?? undefined}
|
||||
vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl}
|
||||
onDataReload={(vercelEnvironmentId) => {
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${
|
||||
vercelEnvironmentId ? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}` : ""
|
||||
}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
|
||||
const [buildSettingsValues, setBuildSettingsValues] = useState({
|
||||
preBuildCommand: buildSettings?.preBuildCommand || "",
|
||||
installCommand: buildSettings?.installCommand || "",
|
||||
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
|
||||
useNativeBuildServer: buildSettings?.useNativeBuildServer || false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") ||
|
||||
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
|
||||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") ||
|
||||
buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
|
||||
setHasBuildSettingsChanges(hasChanges);
|
||||
}, [buildSettingsValues, buildSettings]);
|
||||
|
||||
const [buildSettingsForm, fields] = useForm({
|
||||
id: "update-build-settings",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: UpdateBuildSettingsFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isBuildSettingsLoading =
|
||||
navigation.formData?.get("action") === "update-build-settings" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<Form method="post" {...buildSettingsForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
|
||||
<Input
|
||||
{...conform.input(fields.triggerConfigFilePath, { type: "text" })}
|
||||
defaultValue={buildSettings?.triggerConfigFilePath || ""}
|
||||
placeholder="trigger.config.ts"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
triggerConfigFilePath: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Path to your Trigger configuration file, relative to the root directory of your repo.
|
||||
</Hint>
|
||||
<FormError id={fields.triggerConfigFilePath.errorId}>
|
||||
{fields.triggerConfigFilePath.error}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.installCommand.id}>Install command</Label>
|
||||
<Input
|
||||
{...conform.input(fields.installCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.installCommand || ""}
|
||||
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
installCommand: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Command to install your project dependencies. This will be run from the root directory
|
||||
of your repo. Auto-detected by default.
|
||||
</Hint>
|
||||
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
|
||||
<Input
|
||||
{...conform.input(fields.preBuildCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.preBuildCommand || ""}
|
||||
placeholder="e.g., `npm run prisma:generate`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
preBuildCommand: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Any command that needs to run before we build and deploy your project. This will be run
|
||||
from the root directory of your repo.
|
||||
</Hint>
|
||||
<FormError id={fields.preBuildCommand.errorId}>{fields.preBuildCommand.error}</FormError>
|
||||
</InputGroup>
|
||||
<div className="border-t border-grid-dimmed pt-4">
|
||||
<InputGroup>
|
||||
<CheckboxWithLabel
|
||||
id={fields.useNativeBuildServer.id}
|
||||
{...conform.input(fields.useNativeBuildServer, { type: "checkbox" })}
|
||||
label="Use native build server"
|
||||
variant="simple/small"
|
||||
defaultChecked={buildSettings?.useNativeBuildServer || false}
|
||||
onChange={(isChecked) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
useNativeBuildServer: isChecked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Native build server builds do not rely on external build providers and will become the
|
||||
default in the future. Version 4.2.0 or newer is required.
|
||||
</Hint>
|
||||
<FormError id={fields.useNativeBuildServer.errorId}>
|
||||
{fields.useNativeBuildServer.error}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<FormError>{buildSettingsForm.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-build-settings"
|
||||
variant="secondary/small"
|
||||
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
|
||||
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
+20
-727
@@ -1,57 +1,13 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
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 { Outlet, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectBackWithSuccessMessage,
|
||||
redirectWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
|
||||
import { type BuildSettings } from "~/v3/buildSettings";
|
||||
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
import {
|
||||
VercelSettingsPanel,
|
||||
VercelOnboardingModal,
|
||||
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { EnvironmentParamSchema, v3ProjectSettingsGeneralPath, v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -62,397 +18,28 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params);
|
||||
await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const projectSettingsPresenter = new ProjectSettingsPresenter();
|
||||
const resultOrFail = await projectSettingsPresenter.getProjectSettings(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
// Redirect /settings to /settings/general (or /settings/integrations for Vercel onboarding)
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.endsWith("/settings") || url.pathname.endsWith("/settings/")) {
|
||||
const org = { slug: organizationSlug };
|
||||
const project = { slug: projectParam };
|
||||
const env = { slug: envParam };
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "project_not_found": {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
const basePath = url.searchParams.has("vercelOnboarding")
|
||||
? v3ProjectSettingsIntegrationsPath(org, project, env)
|
||||
: v3ProjectSettingsGeneralPath(org, project, env);
|
||||
|
||||
logger.error("Failed loading project settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, please try again!",
|
||||
});
|
||||
}
|
||||
}
|
||||
return redirect(`${basePath}${url.search}`);
|
||||
}
|
||||
|
||||
const { gitHubApp, buildSettings } = resultOrFail.value;
|
||||
|
||||
return typedjson({
|
||||
githubAppEnabled: gitHubApp.enabled,
|
||||
buildSettings,
|
||||
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
const UpdateBuildSettingsFormSchema = z.object({
|
||||
action: z.literal("update-build-settings"),
|
||||
triggerConfigFilePath: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
|
||||
.refine((val) => !val || val.length <= 255, {
|
||||
message: "Config file path must not exceed 255 characters",
|
||||
}),
|
||||
installCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((val) => !val || !val.includes("\n"), {
|
||||
message: "Install command must be a single line",
|
||||
})
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Install command must not exceed 500 characters",
|
||||
}),
|
||||
preBuildCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((val) => !val || !val.includes("\n"), {
|
||||
message: "Pre-build command must be a single line",
|
||||
})
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Pre-build command must not exceed 500 characters",
|
||||
}),
|
||||
useNativeBuildServer: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
});
|
||||
|
||||
type UpdateBuildSettingsFormSchema = z.infer<typeof UpdateBuildSettingsFormSchema>;
|
||||
|
||||
export function createSchema(
|
||||
constraints: {
|
||||
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
|
||||
} = {}
|
||||
) {
|
||||
return z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("rename"),
|
||||
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
projectSlug: z.string().superRefine((slug, ctx) => {
|
||||
if (constraints.getSlugMatch === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conform.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
|
||||
if (isMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The slug must match ${projectSlug}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
UpdateBuildSettingsFormSchema,
|
||||
]);
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = params;
|
||||
if (!organizationSlug || !projectParam) {
|
||||
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const schema = createSchema({
|
||||
getSlugMatch: (slug) => {
|
||||
return { isMatch: slug === projectParam, projectSlug: projectParam };
|
||||
},
|
||||
});
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const projectSettingsService = new ProjectSettingsService();
|
||||
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
|
||||
if (membershipResultOrFail.isErr()) {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId } = membershipResultOrFail.value;
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
const resultOrFail = await projectSettingsService.renameProject(
|
||||
projectId,
|
||||
submission.value.projectName
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to rename project", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project renamed to ${submission.value.projectName}`
|
||||
);
|
||||
}
|
||||
case "delete": {
|
||||
const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to delete project", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project ${projectParam} could not be deleted`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Project deleted"
|
||||
);
|
||||
}
|
||||
case "update-build-settings": {
|
||||
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
|
||||
submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
|
||||
installCommand: installCommand || undefined,
|
||||
preBuildCommand: preBuildCommand || undefined,
|
||||
triggerConfigFilePath: triggerConfigFilePath || undefined,
|
||||
useNativeBuildServer: useNativeBuildServer,
|
||||
});
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to update build settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to update build settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "Build settings updated successfully");
|
||||
}
|
||||
default: {
|
||||
submission.value satisfies never;
|
||||
return redirectBackWithErrorMessage(request, "Failed to process request");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
export default function SettingsLayout() {
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const environment = useEnvironment();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Vercel onboarding modal state
|
||||
const hasQueryParam = searchParams.get("vercelOnboarding") === "true";
|
||||
const nextUrl = searchParams.get("next");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
|
||||
|
||||
// Helper to open modal and ensure query param is present
|
||||
const openVercelOnboarding = useCallback(() => {
|
||||
setIsModalOpen(true);
|
||||
// Ensure query param is present to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
const closeVercelOnboarding = useCallback(() => {
|
||||
// Remove query param if present
|
||||
if (hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.delete("vercelOnboarding");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
// Close modal
|
||||
setIsModalOpen(false);
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
// When query param is present, handle modal opening
|
||||
// Note: We don't close the modal based on data state during onboarding - only when explicitly closed
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelIntegrationEnabled) {
|
||||
// Ensure query param is present and modal is open
|
||||
if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data is loaded, ensure modal is open (query param takes precedence)
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
|
||||
// Load onboarding data
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
} else if (!hasQueryParam && isModalOpen) {
|
||||
// Query param removed but modal is open, close modal
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// Ensure modal stays open when query param is present (even after data reloads)
|
||||
// This is a safeguard to prevent the modal from closing during form submissions
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && !isModalOpen) {
|
||||
// Query param is present but modal is closed, open it
|
||||
// This ensures the modal stays open during the onboarding flow
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [hasQueryParam, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// When data finishes loading (from query param), ensure modal is open
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data loaded and query param is present, ensure modal is open
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}
|
||||
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
|
||||
// Track if we're waiting for data from button click (not query param)
|
||||
const waitingForButtonClickRef = useRef(false);
|
||||
|
||||
// Handle opening modal from button click (without query param)
|
||||
const handleOpenVercelModal = useCallback(() => {
|
||||
// Add query param to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
|
||||
// Data already loaded, open modal immediately
|
||||
openVercelOnboarding();
|
||||
} else {
|
||||
// Need to load data first, mark that we're waiting for button click
|
||||
waitingForButtonClickRef.current = true;
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
}, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]);
|
||||
|
||||
// When data loads from button click, open modal
|
||||
useEffect(() => {
|
||||
if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data loaded from button click, open modal and ensure query param is present
|
||||
waitingForButtonClickRef.current = false;
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]);
|
||||
|
||||
const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false);
|
||||
|
||||
const [renameForm, { projectName }] = useForm({
|
||||
id: "rename-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isRenameLoading =
|
||||
navigation.formData?.get("action") === "rename" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [deleteForm, { projectSlug }] = useForm({
|
||||
id: "delete-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldValidate: "onInput",
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema({
|
||||
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isDeleteLoading =
|
||||
navigation.formData?.get("action") === "delete" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [deleteInputValue, setDeleteInputValue] = useState("");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -479,302 +66,8 @@ export default function Page() {
|
||||
</NavBar>
|
||||
|
||||
<PageBody>
|
||||
<MainHorizontallyCenteredContainer className="md:mt-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Header2 spacing>General</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<Fieldset className="mb-5">
|
||||
<InputGroup fullWidth>
|
||||
<Label>Project ref</Label>
|
||||
<ClipboardField value={project.externalRef} variant={"secondary/medium"} />
|
||||
<Hint>
|
||||
This goes in your{" "}
|
||||
<InlineCode variant="extra-extra-small">trigger.config</InlineCode> file.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
<Form method="post" {...renameForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={projectName.id}>Project name</Label>
|
||||
<Input
|
||||
{...conform.input(projectName, { type: "text" })}
|
||||
defaultValue={project.name}
|
||||
placeholder="Project name"
|
||||
icon={FolderIcon}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setHasRenameFormChanges(e.target.value !== project.name);
|
||||
}}
|
||||
/>
|
||||
<FormError id={projectName.errorId}>{projectName.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="rename"
|
||||
variant={"secondary/small"}
|
||||
disabled={isRenameLoading || !hasRenameFormChanges}
|
||||
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{githubAppEnabled && (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<Header2 spacing>Git settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vercelIntegrationEnabled && (
|
||||
<div>
|
||||
<Header2 spacing>Vercel integration</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<VercelSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
onOpenVercelModal={handleOpenVercelModal}
|
||||
isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Build settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Danger zone</Header2>
|
||||
<div className="w-full rounded-sm border border-rose-500/40 p-4">
|
||||
<Form method="post" {...deleteForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={projectSlug.id}>Delete project</Label>
|
||||
<Input
|
||||
{...conform.input(projectSlug, { type: "text" })}
|
||||
placeholder="Your project slug"
|
||||
icon={ExclamationTriangleIcon}
|
||||
onChange={(e) => setDeleteInputValue(e.target.value)}
|
||||
/>
|
||||
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
|
||||
<FormError>{deleteForm.error}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Project slug
|
||||
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
|
||||
Delete.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="delete"
|
||||
variant={"danger/small"}
|
||||
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isDeleteLoading || deleteInputValue !== project.slug}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
<Outlet />
|
||||
</PageBody>
|
||||
|
||||
{/* Vercel Onboarding Modal */}
|
||||
{vercelIntegrationEnabled && (
|
||||
<VercelOnboardingModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeVercelOnboarding}
|
||||
onboardingData={vercelFetcher.data?.onboardingData ?? null}
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
|
||||
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
|
||||
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
|
||||
nextUrl={nextUrl ?? undefined}
|
||||
onDataReload={(vercelEnvironmentId) => {
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${
|
||||
vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : ""
|
||||
}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
|
||||
const [buildSettingsValues, setBuildSettingsValues] = useState({
|
||||
preBuildCommand: buildSettings?.preBuildCommand || "",
|
||||
installCommand: buildSettings?.installCommand || "",
|
||||
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
|
||||
useNativeBuildServer: buildSettings?.useNativeBuildServer || false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") ||
|
||||
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
|
||||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") ||
|
||||
buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
|
||||
setHasBuildSettingsChanges(hasChanges);
|
||||
}, [buildSettingsValues, buildSettings]);
|
||||
|
||||
const [buildSettingsForm, fields] = useForm({
|
||||
id: "update-build-settings",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: UpdateBuildSettingsFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isBuildSettingsLoading =
|
||||
navigation.formData?.get("action") === "update-build-settings" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<Form method="post" {...buildSettingsForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
|
||||
<Input
|
||||
{...conform.input(fields.triggerConfigFilePath, { type: "text" })}
|
||||
defaultValue={buildSettings?.triggerConfigFilePath || ""}
|
||||
placeholder="trigger.config.ts"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
triggerConfigFilePath: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Path to your Trigger configuration file, relative to the root directory of your repo.
|
||||
</Hint>
|
||||
<FormError id={fields.triggerConfigFilePath.errorId}>
|
||||
{fields.triggerConfigFilePath.error}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.installCommand.id}>Install command</Label>
|
||||
<Input
|
||||
{...conform.input(fields.installCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.installCommand || ""}
|
||||
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
installCommand: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Command to install your project dependencies. This will be run from the root directory
|
||||
of your repo. Auto-detected by default.
|
||||
</Hint>
|
||||
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
|
||||
<Input
|
||||
{...conform.input(fields.preBuildCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.preBuildCommand || ""}
|
||||
placeholder="e.g., `npm run prisma:generate`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
preBuildCommand: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Any command that needs to run before we build and deploy your project. This will be run
|
||||
from the root directory of your repo.
|
||||
</Hint>
|
||||
<FormError id={fields.preBuildCommand.errorId}>{fields.preBuildCommand.error}</FormError>
|
||||
</InputGroup>
|
||||
<div className="border-t border-grid-dimmed pt-4">
|
||||
<InputGroup>
|
||||
<CheckboxWithLabel
|
||||
id={fields.useNativeBuildServer.id}
|
||||
{...conform.input(fields.useNativeBuildServer, { type: "checkbox" })}
|
||||
label="Use native build server"
|
||||
variant="simple/small"
|
||||
defaultChecked={buildSettings?.useNativeBuildServer || false}
|
||||
onChange={(isChecked) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
useNativeBuildServer: isChecked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Native build server builds do not rely on external build providers and will become the
|
||||
default in the future. Version 4.2.0 or newer is required.
|
||||
</Hint>
|
||||
<FormError id={fields.useNativeBuildServer.errorId}>
|
||||
{fields.useNativeBuildServer.error}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<FormError>{buildSettingsForm.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-build-settings"
|
||||
variant="secondary/small"
|
||||
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
|
||||
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { fromPromise } from "neverthrow";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
import { requireOrganization } from "~/services/org.server";
|
||||
import { OrganizationParamsSchema, organizationSettingsPath } from "~/utils/pathBuilder";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
const { organization } = await requireOrganization(request, organizationSlug);
|
||||
|
||||
const slackIntegration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
service: "SLACK",
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!slackIntegration) {
|
||||
return typedjson({
|
||||
organization,
|
||||
slackIntegration: null,
|
||||
alertChannels: [],
|
||||
teamName: null,
|
||||
});
|
||||
}
|
||||
|
||||
const integrationData = slackIntegration.integrationData as any;
|
||||
const teamName = integrationData?.team?.name ?? null;
|
||||
|
||||
const alertChannels = await prisma.projectAlertChannel.findMany({
|
||||
where: {
|
||||
type: "SLACK",
|
||||
project: { organizationId: organization.id },
|
||||
OR: [
|
||||
{ integrationId: slackIntegration.id },
|
||||
{
|
||||
properties: {
|
||||
path: ["integrationId"],
|
||||
equals: slackIntegration.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
organization,
|
||||
slackIntegration,
|
||||
alertChannels,
|
||||
teamName,
|
||||
});
|
||||
};
|
||||
|
||||
const ActionSchema = z.object({
|
||||
intent: z.literal("uninstall"),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
const { organization, userId } = await requireOrganization(request, organizationSlug);
|
||||
|
||||
const formData = await request.formData();
|
||||
const result = ActionSchema.safeParse({ intent: formData.get("intent") });
|
||||
if (!result.success) {
|
||||
return json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
const slackIntegration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
service: "SLACK",
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!slackIntegration) {
|
||||
return json({ error: "Slack integration not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const txResult = await fromPromise(
|
||||
$transaction(prisma, async (tx) => {
|
||||
await tx.projectAlertChannel.updateMany({
|
||||
where: {
|
||||
type: "SLACK",
|
||||
OR: [
|
||||
{ integrationId: slackIntegration.id },
|
||||
{
|
||||
properties: {
|
||||
path: ["integrationId"],
|
||||
equals: slackIntegration.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
enabled: false,
|
||||
integrationId: null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organizationIntegration.update({
|
||||
where: { id: slackIntegration.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (txResult.isErr()) {
|
||||
logger.error("Failed to remove Slack integration", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: slackIntegration.id,
|
||||
error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error),
|
||||
});
|
||||
|
||||
return json(
|
||||
{ error: "Failed to remove Slack integration. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Slack integration removed successfully", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: slackIntegration.id,
|
||||
});
|
||||
|
||||
return redirect(organizationSettingsPath({ slug: organizationSlug }));
|
||||
};
|
||||
|
||||
export default function SlackIntegrationPage() {
|
||||
const { slackIntegration, alertChannels, teamName } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const isUninstalling =
|
||||
navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall";
|
||||
|
||||
if (!slackIntegration) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageBody>
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Header1>No Slack Integration Found</Header1>
|
||||
<Paragraph className="mt-2 text-center text-text-dimmed">
|
||||
This organization doesn't have a Slack integration configured. You can connect Slack
|
||||
when setting up alert channels in your project settings.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageBody>
|
||||
<div className="mb-8">
|
||||
<Header1>Slack Integration</Header1>
|
||||
<Paragraph className="mt-2 text-text-dimmed">
|
||||
Manage your organization's Slack integration and connected alert channels.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Integration Info Section */}
|
||||
<div className="mb-8 rounded-lg border border-grid-bright bg-background-bright p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-text-bright">Integration Details</h2>
|
||||
<div className="mt-2 space-y-1 text-sm text-text-dimmed">
|
||||
{teamName && (
|
||||
<div>
|
||||
<span className="font-medium">Slack Workspace:</span> {teamName}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">Installed:</span>{" "}
|
||||
{formatDate(new Date(slackIntegration.createdAt))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="danger/medium" LeadingIcon={TrashIcon} disabled={isUninstalling}>
|
||||
Remove Integration
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Slack Integration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
This will remove the Slack integration and disable all connected alert channels.
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="uninstall" />
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
type="submit"
|
||||
disabled={isUninstalling}
|
||||
>
|
||||
{isUninstalling ? "Removing..." : "Remove Integration"}
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{actionData?.error && (
|
||||
<Paragraph variant="small" className="text-error">
|
||||
{actionData.error}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connected Alert Channels Section */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-medium text-text-bright">
|
||||
Connected Alert Channels ({alertChannels.length})
|
||||
</h2>
|
||||
|
||||
{alertChannels.length === 0 ? (
|
||||
<div className="rounded-lg border border-grid-bright bg-background-bright p-6 text-center">
|
||||
<Paragraph className="text-text-dimmed">
|
||||
No alert channels are currently connected to this Slack integration.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Channel Name</TableHeaderCell>
|
||||
<TableHeaderCell>Project</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alertChannels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>{channel.name}</TableCell>
|
||||
<TableCell>{channel.project.name}</TableCell>
|
||||
<TableCell>
|
||||
<EnabledStatus enabled={channel.enabled} />
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(new Date(channel.createdAt))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import { requireOrganization } from "~/services/org.server";
|
||||
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { v3ProjectSettingsPath } from "~/utils/pathBuilder";
|
||||
import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
@@ -354,7 +354,7 @@ export default function VercelIntegrationPage() {
|
||||
<TableCell>
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
to={v3ProjectSettingsPath(
|
||||
to={v3ProjectSettingsIntegrationsPath(
|
||||
organization,
|
||||
projectIntegration.project,
|
||||
{ slug: "prod" } // Default to production environment
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { ProjectParamSchema, v3ProjectSettingsPath } from "~/utils/pathBuilder";
|
||||
import { ProjectParamSchema, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
@@ -39,5 +39,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const selector = new SelectBestEnvironmentPresenter();
|
||||
const environment = await selector.selectBestEnvironment(project.id, user, project.environments);
|
||||
|
||||
return redirect(v3ProjectSettingsPath({ slug: organizationSlug }, project, environment));
|
||||
return redirect(v3ProjectSettingsGeneralPath({ slug: organizationSlug }, project, environment));
|
||||
};
|
||||
|
||||
+3
-3
@@ -43,7 +43,7 @@ import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
githubAppInstallPath,
|
||||
EnvironmentParamSchema,
|
||||
v3ProjectSettingsPath,
|
||||
v3ProjectSettingsIntegrationsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type BranchTrackingConfig } from "~/v3/github";
|
||||
@@ -459,7 +459,7 @@ export function ConnectGitHubRepoModal({
|
||||
navigate(
|
||||
githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${v3ProjectSettingsPath(
|
||||
`${v3ProjectSettingsIntegrationsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
@@ -567,7 +567,7 @@ export function GitHubConnectionPrompt({
|
||||
redirectUrl?: string;
|
||||
}) {
|
||||
|
||||
const githubInstallationRedirect = redirectUrl || v3ProjectSettingsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug });
|
||||
const githubInstallationRedirect = redirectUrl || v3ProjectSettingsIntegrationsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug });
|
||||
return (
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
|
||||
+2
-2
@@ -44,7 +44,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server";
|
||||
import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import { EnvironmentParamSchema, v3ProjectSettingsIntegrationsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
VercelSettingsPresenter,
|
||||
type VercelOnboardingData,
|
||||
@@ -224,7 +224,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const settingsPath = v3ProjectSettingsPath(
|
||||
const settingsPath = v3ProjectSettingsIntegrationsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
|
||||
@@ -7,7 +7,7 @@ import { VercelIntegrationRepository, type TokenResponse } from "~/models/vercel
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { v3ProjectSettingsPath } from "~/utils/pathBuilder";
|
||||
import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder";
|
||||
import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
|
||||
|
||||
const VercelConnectSchema = z.object({
|
||||
@@ -139,7 +139,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const settingsPath = v3ProjectSettingsPath(
|
||||
const settingsPath = v3ProjectSettingsIntegrationsPath(
|
||||
{ slug: stateData.organizationSlug },
|
||||
{ slug: stateData.projectSlug },
|
||||
{ slug: environment.slug }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { loopsClient } from "~/services/loops.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
@@ -65,6 +66,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
projectSlug: project_slug,
|
||||
});
|
||||
|
||||
// Send Loops.so event (fire-and-forget, don't block the redirect)
|
||||
loopsClient
|
||||
?.vercelIntegrationStarted({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Generate Vercel install URL
|
||||
const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken);
|
||||
|
||||
|
||||
@@ -22,6 +22,24 @@ class LoopsClient {
|
||||
});
|
||||
}
|
||||
|
||||
async vercelIntegrationStarted({
|
||||
userId,
|
||||
email,
|
||||
name,
|
||||
}: {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
}) {
|
||||
logger.info(`Loops send "vercel-integration" event`, { userId, email, name });
|
||||
return this.#sendEvent({
|
||||
email,
|
||||
userId,
|
||||
firstName: name?.split(" ").at(0),
|
||||
eventName: "vercel-integration",
|
||||
});
|
||||
}
|
||||
|
||||
async #sendEvent({
|
||||
email,
|
||||
userId,
|
||||
|
||||
@@ -30,10 +30,10 @@ export class ProjectSettingsService {
|
||||
);
|
||||
}
|
||||
|
||||
deleteProject(projectSlug: string, userId: string) {
|
||||
deleteProject(projectId: string, userId: string) {
|
||||
const deleteProjectService = new DeleteProjectService(this.#prismaClient);
|
||||
|
||||
return fromPromise(deleteProjectService.call({ projectSlug, userId }), (error) => ({
|
||||
return fromPromise(deleteProjectService.call({ projectId, userId }), (error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
}));
|
||||
|
||||
@@ -129,6 +129,10 @@ export function organizationVercelIntegrationPath(organization: OrgForPath) {
|
||||
return `${organizationIntegrationsPath(organization)}/vercel`;
|
||||
}
|
||||
|
||||
export function organizationSlackIntegrationPath(organization: OrgForPath) {
|
||||
return `${organizationIntegrationsPath(organization)}/slack`;
|
||||
}
|
||||
|
||||
function organizationParam(organization: OrgForPath) {
|
||||
return organization.slug;
|
||||
}
|
||||
@@ -499,6 +503,22 @@ export function v3ProjectSettingsPath(
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/settings`;
|
||||
}
|
||||
|
||||
export function v3ProjectSettingsGeneralPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3ProjectSettingsPath(organization, project, environment)}/general`;
|
||||
}
|
||||
|
||||
export function v3ProjectSettingsIntegrationsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3ProjectSettingsPath(organization, project, environment)}/integrations`;
|
||||
}
|
||||
|
||||
export function v3LogsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
|
||||
@@ -33,14 +33,20 @@ import {
|
||||
ProjectAlertWebhookProperties,
|
||||
} from "~/models/projectAlert.server";
|
||||
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
|
||||
import {
|
||||
processGitMetadata,
|
||||
type GitMetaLinks,
|
||||
} from "~/presenters/v3/BranchesPresenter.server";
|
||||
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
|
||||
import { sendAlertEmail } from "~/services/email.server";
|
||||
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { decryptSecret } from "~/services/secrets/secretStore.server";
|
||||
import { v3RunPath } from "~/utils/pathBuilder";
|
||||
import { alertsRateLimiter } from "~/v3/alertsRateLimiter.server";
|
||||
import { alertsWorker } from "~/v3/alertsWorker.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
import { fromPromise } from "neverthrow";
|
||||
import { BaseService } from "../baseService.server";
|
||||
import { CURRENT_API_VERSION } from "~/api/versions";
|
||||
|
||||
@@ -89,6 +95,11 @@ type FoundAlert = Prisma.Result<
|
||||
|
||||
class SkipRetryError extends Error {}
|
||||
|
||||
type DeploymentIntegrationMetadata = {
|
||||
git: GitMetaLinks | null;
|
||||
vercelDeploymentUrl: string | undefined;
|
||||
};
|
||||
|
||||
export class DeliverAlertService extends BaseService {
|
||||
public async call(alertId: string) {
|
||||
const alert: FoundAlert | null = await this._prisma.projectAlert.findFirst({
|
||||
@@ -139,18 +150,27 @@ export class DeliverAlertService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyMeta: DeploymentIntegrationMetadata = { git: null, vercelDeploymentUrl: undefined };
|
||||
|
||||
const deploymentMeta =
|
||||
alert.type === "DEPLOYMENT_SUCCESS" || alert.type === "DEPLOYMENT_FAILURE"
|
||||
? (
|
||||
await fromPromise(this.#resolveDeploymentMetadata(alert), (e) => e)
|
||||
).unwrapOr(emptyMeta)
|
||||
: emptyMeta;
|
||||
|
||||
try {
|
||||
switch (alert.channel.type) {
|
||||
case "EMAIL": {
|
||||
await this.#sendEmail(alert);
|
||||
await this.#sendEmail(alert, deploymentMeta);
|
||||
break;
|
||||
}
|
||||
case "SLACK": {
|
||||
await this.#sendSlack(alert);
|
||||
await this.#sendSlack(alert, deploymentMeta);
|
||||
break;
|
||||
}
|
||||
case "WEBHOOK": {
|
||||
await this.#sendWebhook(alert);
|
||||
await this.#sendWebhook(alert, deploymentMeta);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -177,7 +197,7 @@ export class DeliverAlertService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
async #sendEmail(alert: FoundAlert) {
|
||||
async #sendEmail(alert: FoundAlert, deploymentMeta: DeploymentIntegrationMetadata) {
|
||||
const emailProperties = ProjectAlertEmailProperties.safeParse(alert.channel.properties);
|
||||
|
||||
if (!emailProperties.success) {
|
||||
@@ -243,6 +263,19 @@ export class DeliverAlertService extends BaseService {
|
||||
error: preparedError,
|
||||
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
organization: alert.project.organization.title,
|
||||
git: deploymentMeta.git
|
||||
? {
|
||||
branchName: deploymentMeta.git.branchName,
|
||||
shortSha: deploymentMeta.git.shortSha,
|
||||
commitMessage: deploymentMeta.git.commitMessage,
|
||||
commitUrl: deploymentMeta.git.commitUrl,
|
||||
branchUrl: deploymentMeta.git.branchUrl,
|
||||
pullRequestNumber: deploymentMeta.git.pullRequestNumber,
|
||||
pullRequestTitle: deploymentMeta.git.pullRequestTitle,
|
||||
pullRequestUrl: deploymentMeta.git.pullRequestUrl,
|
||||
}
|
||||
: undefined,
|
||||
vercelDeploymentUrl: deploymentMeta.vercelDeploymentUrl,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
@@ -264,6 +297,19 @@ export class DeliverAlertService extends BaseService {
|
||||
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
taskCount: alert.workerDeployment.worker?.tasks.length ?? 0,
|
||||
organization: alert.project.organization.title,
|
||||
git: deploymentMeta.git
|
||||
? {
|
||||
branchName: deploymentMeta.git.branchName,
|
||||
shortSha: deploymentMeta.git.shortSha,
|
||||
commitMessage: deploymentMeta.git.commitMessage,
|
||||
commitUrl: deploymentMeta.git.commitUrl,
|
||||
branchUrl: deploymentMeta.git.branchUrl,
|
||||
pullRequestNumber: deploymentMeta.git.pullRequestNumber,
|
||||
pullRequestTitle: deploymentMeta.git.pullRequestTitle,
|
||||
pullRequestUrl: deploymentMeta.git.pullRequestUrl,
|
||||
}
|
||||
: undefined,
|
||||
vercelDeploymentUrl: deploymentMeta.vercelDeploymentUrl,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
@@ -279,7 +325,7 @@ export class DeliverAlertService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async #sendWebhook(alert: FoundAlert) {
|
||||
async #sendWebhook(alert: FoundAlert, deploymentMeta: DeploymentIntegrationMetadata) {
|
||||
const webhookProperties = ProjectAlertWebhookProperties.safeParse(alert.channel.properties);
|
||||
|
||||
if (!webhookProperties.success) {
|
||||
@@ -452,6 +498,8 @@ export class DeliverAlertService extends BaseService {
|
||||
name: alert.project.name,
|
||||
},
|
||||
error: preparedError,
|
||||
git: this.#buildWebhookGitObject(deploymentMeta.git),
|
||||
vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl),
|
||||
};
|
||||
|
||||
await this.#deliverWebhook(payload, webhookProperties.data);
|
||||
@@ -488,6 +536,8 @@ export class DeliverAlertService extends BaseService {
|
||||
name: alert.project.name,
|
||||
},
|
||||
error: preparedError,
|
||||
git: this.#buildWebhookGitObject(deploymentMeta.git),
|
||||
vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -542,6 +592,8 @@ export class DeliverAlertService extends BaseService {
|
||||
slug: alert.project.slug,
|
||||
name: alert.project.name,
|
||||
},
|
||||
git: this.#buildWebhookGitObject(deploymentMeta.git),
|
||||
vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl),
|
||||
};
|
||||
|
||||
await this.#deliverWebhook(payload, webhookProperties.data);
|
||||
@@ -584,6 +636,8 @@ export class DeliverAlertService extends BaseService {
|
||||
slug: alert.project.slug,
|
||||
name: alert.project.name,
|
||||
},
|
||||
git: this.#buildWebhookGitObject(deploymentMeta.git),
|
||||
vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -609,7 +663,7 @@ export class DeliverAlertService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async #sendSlack(alert: FoundAlert) {
|
||||
async #sendSlack(alert: FoundAlert, deploymentMeta: DeploymentIntegrationMetadata) {
|
||||
const slackProperties = ProjectAlertSlackProperties.safeParse(alert.channel.properties);
|
||||
|
||||
if (!slackProperties.success) {
|
||||
@@ -694,9 +748,7 @@ export class DeliverAlertService extends BaseService {
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `:rotating_light: Error in *${taskIdentifier}* _<!date^${Math.round(
|
||||
timestamp.getTime() / 1000
|
||||
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
|
||||
text: `:rotating_light: Error in *${taskIdentifier}*`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -706,18 +758,7 @@ export class DeliverAlertService extends BaseService {
|
||||
text: this.#wrapInCodeBlock(error.stackTrace ?? error.message),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `${runId} | ${taskIdentifier} | ${version}.${environment} | ${alert.project.name}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
},
|
||||
this.#buildRunQuoteBlock(taskIdentifier, version, environment, runId, alert.project.name, timestamp),
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
@@ -789,14 +830,13 @@ export class DeliverAlertService extends BaseService {
|
||||
|
||||
await this.#postSlackMessage(integration, {
|
||||
channel: slackProperties.data.channelId,
|
||||
text: `:rotating_light: Deployment failed *${version}.${environment}*`,
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `:rotating_light: Deployment failed *${version}.${environment}* _<!date^${Math.round(
|
||||
timestamp.getTime() / 1000
|
||||
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
|
||||
text: `:rotating_light: Deployment failed *${version}.${environment}*`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -806,15 +846,7 @@ export class DeliverAlertService extends BaseService {
|
||||
text: this.#wrapInCodeBlock(preparedError.stack ?? preparedError.message),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
this.#buildDeploymentQuoteBlock(alert, deploymentMeta, version, environment, timestamp),
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
@@ -842,7 +874,6 @@ export class DeliverAlertService extends BaseService {
|
||||
if (alert.workerDeployment) {
|
||||
const version = alert.workerDeployment.version;
|
||||
const environment = alert.environment.slug;
|
||||
const numberOfTasks = alert.workerDeployment.worker?.tasks.length ?? 0;
|
||||
const timestamp = alert.workerDeployment.deployedAt ?? new Date();
|
||||
|
||||
await this.#postSlackMessage(integration, {
|
||||
@@ -853,20 +884,10 @@ export class DeliverAlertService extends BaseService {
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `:rocket: Deployed *${version}.${environment}* successfully _<!date^${Math.round(
|
||||
timestamp.getTime() / 1000
|
||||
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
|
||||
text: `:rocket: Deployed *${version}.${environment}* successfully`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `${numberOfTasks} tasks | ${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
this.#buildDeploymentQuoteBlock(alert, deploymentMeta, version, environment, timestamp),
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
@@ -949,7 +970,11 @@ export class DeliverAlertService extends BaseService {
|
||||
);
|
||||
|
||||
try {
|
||||
return await client.chat.postMessage(message);
|
||||
return await client.chat.postMessage({
|
||||
...message,
|
||||
unfurl_links: false,
|
||||
unfurl_media: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isWebAPIRateLimitedError(error)) {
|
||||
logger.warn("[DeliverAlert] Slack rate limited", {
|
||||
@@ -1013,6 +1038,174 @@ export class DeliverAlertService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async #resolveDeploymentMetadata(
|
||||
alert: FoundAlert
|
||||
): Promise<DeploymentIntegrationMetadata> {
|
||||
const deployment = alert.workerDeployment;
|
||||
if (!deployment) {
|
||||
return { git: null, vercelDeploymentUrl: undefined };
|
||||
}
|
||||
|
||||
const git = processGitMetadata(deployment.git);
|
||||
const vercelDeploymentUrl = await this.#resolveVercelDeploymentUrl(
|
||||
deployment.projectId,
|
||||
deployment.id
|
||||
);
|
||||
|
||||
return { git, vercelDeploymentUrl };
|
||||
}
|
||||
|
||||
async #resolveVercelDeploymentUrl(
|
||||
projectId: string,
|
||||
deploymentId: string
|
||||
): Promise<string | undefined> {
|
||||
const vercelProjectIntegration =
|
||||
await this._prisma.organizationProjectIntegration.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
deletedAt: null,
|
||||
organizationIntegration: {
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
integrationData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!vercelProjectIntegration) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = VercelProjectIntegrationDataSchema.safeParse(
|
||||
vercelProjectIntegration.integrationData
|
||||
);
|
||||
|
||||
if (!parsed.success || !parsed.data.vercelTeamSlug) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const integrationDeployment =
|
||||
await this._prisma.integrationDeployment.findFirst({
|
||||
where: {
|
||||
deploymentId,
|
||||
integrationName: "vercel",
|
||||
},
|
||||
select: {
|
||||
integrationDeploymentId: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (!integrationDeployment) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const vercelId = integrationDeployment.integrationDeploymentId.replace(/^dpl_/, "");
|
||||
return `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`;
|
||||
}
|
||||
|
||||
#buildDeploymentQuoteBlock(
|
||||
alert: FoundAlert,
|
||||
deploymentMeta: DeploymentIntegrationMetadata,
|
||||
version: string,
|
||||
environment: string,
|
||||
timestamp: Date
|
||||
) {
|
||||
const git = deploymentMeta.git;
|
||||
const shortCode = alert.workerDeployment!.shortCode;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Line 1: git author + branch (if available)
|
||||
if (git) {
|
||||
lines.push(`> By *${git.commitAuthor}* on <${git.branchUrl}|\`${git.branchName}\`>`);
|
||||
}
|
||||
|
||||
// Line 2: deployment info
|
||||
lines.push(`> ${shortCode} | ${version}.${environment} | ${alert.project.name} `);
|
||||
|
||||
// Line 3: provider + commit link + vercel link (conditional parts)
|
||||
const integrationParts: string[] = [];
|
||||
if (git?.provider === "github") {
|
||||
integrationParts.push(`via GitHub | <${git.commitUrl}|${git.shortSha}>`);
|
||||
}
|
||||
if (deploymentMeta.vercelDeploymentUrl) {
|
||||
integrationParts.push(`with <${deploymentMeta.vercelDeploymentUrl}|Vercel>`);
|
||||
}
|
||||
if (integrationParts.length > 0) {
|
||||
lines.push(`> ${integrationParts.join(" | ")} `);
|
||||
}
|
||||
|
||||
// Line 4: timestamp
|
||||
lines.push(`> ${this.#formatTimestamp(timestamp)}`);
|
||||
|
||||
return {
|
||||
type: "context" as const,
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn" as const,
|
||||
text: lines.join("\n"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
#buildRunQuoteBlock(
|
||||
taskIdentifier: string,
|
||||
version: string,
|
||||
environment: string,
|
||||
runId: string,
|
||||
projectName: string,
|
||||
timestamp: Date
|
||||
) {
|
||||
return {
|
||||
type: "context" as const,
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn" as const,
|
||||
text: `> *${taskIdentifier}* | ${version}.${environment}\n> ${runId} | ${projectName}\n> ${this.#formatTimestamp(timestamp)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
#formatTimestamp(date: Date): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
#buildWebhookGitObject(git: GitMetaLinks | null) {
|
||||
if (!git) return undefined;
|
||||
|
||||
return {
|
||||
branch: git.branchName,
|
||||
commitSha: git.shortSha,
|
||||
commitMessage: git.commitMessage,
|
||||
commitUrl: git.commitUrl,
|
||||
branchUrl: git.branchUrl,
|
||||
pullRequestNumber: git.pullRequestNumber,
|
||||
pullRequestTitle: git.pullRequestTitle,
|
||||
pullRequestUrl: git.pullRequestUrl,
|
||||
provider: git.provider,
|
||||
};
|
||||
}
|
||||
|
||||
#buildWebhookVercelObject(url: string | undefined) {
|
||||
if (!url) return undefined;
|
||||
|
||||
return { deploymentUrl: url };
|
||||
}
|
||||
|
||||
#getRunError(alert: FoundAlert): TaskRunError {
|
||||
if (alert.taskRun) {
|
||||
const res = TaskRunError.safeParse(alert.taskRun.error);
|
||||
|
||||
@@ -202,6 +202,15 @@ export function shouldSyncEnvVarForAnyEnvironment(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildVercelDeploymentUrl(
|
||||
vercelTeamSlug: string,
|
||||
vercelProjectName: string,
|
||||
integrationDeploymentId: string
|
||||
): string {
|
||||
const vercelId = integrationDeploymentId.replace(/^dpl_/, "");
|
||||
return `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`;
|
||||
}
|
||||
|
||||
export function isPullEnvVarsEnabledForEnvironment(
|
||||
pullEnvVarsBeforeBuild: EnvSlug[] | null | undefined,
|
||||
environmentType: TriggerEnvironmentType
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import {
|
||||
Body,
|
||||
CodeBlock,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Text,
|
||||
dracula,
|
||||
} from "@react-email/components";
|
||||
import { z } from "zod";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { anchor, container, h1, main, paragraphLight } from "./components/styles";
|
||||
import { anchor, bullets, container, grey, h1, main, paragraphLight } from "./components/styles";
|
||||
|
||||
export const AlertDeploymentFailureEmailSchema = z.object({
|
||||
email: z.literal("alert-deployment-failure"),
|
||||
@@ -27,6 +29,19 @@ export const AlertDeploymentFailureEmailSchema = z.object({
|
||||
stack: z.string().optional(),
|
||||
}),
|
||||
deploymentLink: z.string().url(),
|
||||
git: z
|
||||
.object({
|
||||
branchName: z.string(),
|
||||
shortSha: z.string(),
|
||||
commitMessage: z.string(),
|
||||
commitUrl: z.string(),
|
||||
branchUrl: z.string(),
|
||||
pullRequestNumber: z.number().optional(),
|
||||
pullRequestTitle: z.string().optional(),
|
||||
pullRequestUrl: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
vercelDeploymentUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
const previewDefaults = {
|
||||
@@ -40,10 +55,31 @@ const previewDefaults = {
|
||||
stack: "Error: Something went wrong\n at main.ts:12:34",
|
||||
},
|
||||
deploymentLink: "https://trigger.dev",
|
||||
git: {
|
||||
branchName: "feat/new-feature",
|
||||
shortSha: "abc1234",
|
||||
commitMessage: "Add new background task for processing uploads",
|
||||
commitUrl: "https://github.com/acme/app/commit/abc1234",
|
||||
branchUrl: "https://github.com/acme/app/tree/feat/new-feature",
|
||||
pullRequestNumber: 42,
|
||||
pullRequestTitle: "Add upload processing",
|
||||
pullRequestUrl: "https://github.com/acme/app/pull/42",
|
||||
},
|
||||
vercelDeploymentUrl: "https://vercel.com/acme/app/abc1234",
|
||||
};
|
||||
|
||||
export default function Email(props: z.infer<typeof AlertDeploymentFailureEmailSchema>) {
|
||||
const { version, environment, organization, shortCode, failedAt, error, deploymentLink } = {
|
||||
const {
|
||||
version,
|
||||
environment,
|
||||
organization,
|
||||
shortCode,
|
||||
failedAt,
|
||||
error,
|
||||
deploymentLink,
|
||||
git,
|
||||
vercelDeploymentUrl,
|
||||
} = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
@@ -63,6 +99,7 @@ export default function Email(props: z.infer<typeof AlertDeploymentFailureEmailS
|
||||
{error.stack && (
|
||||
<CodeBlock code={error.stack} theme={dracula} lineNumbers language="log" />
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={deploymentLink}
|
||||
target="_blank"
|
||||
@@ -75,6 +112,49 @@ export default function Email(props: z.infer<typeof AlertDeploymentFailureEmailS
|
||||
Investigate this error
|
||||
</Link>
|
||||
|
||||
{git && (
|
||||
<>
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Branch</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.branchUrl} style={anchor}>
|
||||
{git.branchName}
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Commit</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.commitUrl} style={anchor}>
|
||||
{git.shortSha}
|
||||
</Link>{" "}
|
||||
{git.commitMessage}
|
||||
</Column>
|
||||
</Row>
|
||||
{git.pullRequestNumber && git.pullRequestUrl && (
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Pull Request</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.pullRequestUrl} style={anchor}>
|
||||
#{git.pullRequestNumber}
|
||||
</Link>
|
||||
{git.pullRequestTitle ? ` ${git.pullRequestTitle}` : ""}
|
||||
</Column>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{vercelDeploymentUrl && (
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Vercel</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={vercelDeploymentUrl} style={anchor}>
|
||||
View Vercel Deployment
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Body, Container, Head, Html, Link, Preview, Text } from "@react-email/components";
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { z } from "zod";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { anchor, container, h1, main } from "./components/styles";
|
||||
import { anchor, bullets, container, grey, h1, main } from "./components/styles";
|
||||
|
||||
export const AlertDeploymentSuccessEmailSchema = z.object({
|
||||
email: z.literal("alert-deployment-success"),
|
||||
@@ -13,6 +23,19 @@ export const AlertDeploymentSuccessEmailSchema = z.object({
|
||||
deployedAt: z.date(),
|
||||
taskCount: z.number(),
|
||||
deploymentLink: z.string().url(),
|
||||
git: z
|
||||
.object({
|
||||
branchName: z.string(),
|
||||
shortSha: z.string(),
|
||||
commitMessage: z.string(),
|
||||
commitUrl: z.string(),
|
||||
branchUrl: z.string(),
|
||||
pullRequestNumber: z.number().optional(),
|
||||
pullRequestTitle: z.string().optional(),
|
||||
pullRequestUrl: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
vercelDeploymentUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
const previewDefaults = {
|
||||
@@ -23,10 +46,31 @@ const previewDefaults = {
|
||||
deployedAt: new Date().toISOString(),
|
||||
taskCount: 3,
|
||||
deploymentLink: "https://trigger.dev",
|
||||
git: {
|
||||
branchName: "feat/new-feature",
|
||||
shortSha: "abc1234",
|
||||
commitMessage: "Add new background task for processing uploads",
|
||||
commitUrl: "https://github.com/acme/app/commit/abc1234",
|
||||
branchUrl: "https://github.com/acme/app/tree/feat/new-feature",
|
||||
pullRequestNumber: 42,
|
||||
pullRequestTitle: "Add upload processing",
|
||||
pullRequestUrl: "https://github.com/acme/app/pull/42",
|
||||
},
|
||||
vercelDeploymentUrl: "https://vercel.com/acme/app/abc1234",
|
||||
};
|
||||
|
||||
export default function Email(props: z.infer<typeof AlertDeploymentSuccessEmailSchema>) {
|
||||
const { version, environment, organization, shortCode, deployedAt, taskCount, deploymentLink } = {
|
||||
const {
|
||||
version,
|
||||
environment,
|
||||
organization,
|
||||
shortCode,
|
||||
deployedAt,
|
||||
taskCount,
|
||||
deploymentLink,
|
||||
git,
|
||||
vercelDeploymentUrl,
|
||||
} = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
@@ -53,6 +97,49 @@ export default function Email(props: z.infer<typeof AlertDeploymentSuccessEmailS
|
||||
View Deployment
|
||||
</Link>
|
||||
|
||||
{git && (
|
||||
<>
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Branch</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.branchUrl} style={anchor}>
|
||||
{git.branchName}
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Commit</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.commitUrl} style={anchor}>
|
||||
{git.shortSha}
|
||||
</Link>{" "}
|
||||
{git.commitMessage}
|
||||
</Column>
|
||||
</Row>
|
||||
{git.pullRequestNumber && git.pullRequestUrl && (
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Pull Request</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.pullRequestUrl} style={anchor}>
|
||||
#{git.pullRequestNumber}
|
||||
</Link>
|
||||
{git.pullRequestTitle ? ` ${git.pullRequestTitle}` : ""}
|
||||
</Column>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{vercelDeploymentUrl && (
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Vercel</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={vercelDeploymentUrl} style={anchor}>
|
||||
View Vercel Deployment
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
|
||||
@@ -117,6 +117,26 @@ const deploymentCommonProperties = {
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
/** Git metadata for the deployment source code */
|
||||
git: z
|
||||
.object({
|
||||
branch: z.string(),
|
||||
commitSha: z.string(),
|
||||
commitMessage: z.string(),
|
||||
commitUrl: z.string(),
|
||||
branchUrl: z.string(),
|
||||
pullRequestNumber: z.number().optional(),
|
||||
pullRequestTitle: z.string().optional(),
|
||||
pullRequestUrl: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
/** Vercel integration data */
|
||||
vercel: z
|
||||
.object({
|
||||
deploymentUrl: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
};
|
||||
|
||||
const deploymentDeploymentCommonProperties = {
|
||||
|
||||
Reference in New Issue
Block a user