Skip to content

Commit f4b70b0

Browse files
committed
feat(settings+vercel): route & UX fixes
Redirect project settings to the general settings route - Replace v3ProjectSettingsPath with v3ProjectSettingsGeneralPath in org project settings loader so users land on the correct general settings page after selecting an environment. Add integrations settings route for Vercel onboarding flows - Swap v3ProjectSettingsPath for v3ProjectSettingsIntegrationsPath in VercelOnboardingModal imports to point onboarding actions at the integrations-specific settings route. Improve VercelOnboardingModal behavior and telemetry - Introduce origin variable and use it to derive fromMarketplaceContext for clearer intent. - Add optional vercelManageAccessUrl prop to allow rendering a manage access link when present. - Track a "vercel onboarding github step viewed" event with extra context (origin, step, org/project slugs, GH app installation state) and include capture and identifiers in the effect dependencies. - Prevent dialog from closing when interacting outside by handling onInteractOutside to stop accidental dismissals. - Start introducing rendering for a "Manage access" link next to the Connect Project button when vercelManageAccessUrl exists and no origin query param is present. Misc - Update effect dependency array to include newly referenced values (capture, organizationSlug, projectSlug, gitHubAppInstallations.length) to satisfy hooks correctness.
1 parent 6765252 commit f4b70b0

File tree

20 files changed

+1081
-781
lines changed

20 files changed

+1081
-781
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { VercelLogo } from "./VercelLogo";
2+
import { LinkButton } from "~/components/primitives/Buttons";
3+
import { SimpleTooltip } from "~/components/primitives/Tooltip";
4+
5+
export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) {
6+
return (
7+
<SimpleTooltip
8+
button={
9+
<LinkButton
10+
variant="minimal/small"
11+
LeadingIcon={<VercelLogo className="size-3.5" />}
12+
iconSpacing="gap-x-1"
13+
to={vercelDeploymentUrl}
14+
className="pl-1"
15+
>
16+
Vercel
17+
</LinkButton>
18+
}
19+
content="View on Vercel"
20+
/>
21+
);
22+
}

apps/webapp/app/components/integrations/VercelOnboardingModal.tsx

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {
4444
} from "~/v3/vercel/vercelProjectIntegrationSchema";
4545
import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server";
4646
import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server";
47-
import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
47+
import { vercelAppInstallPath, v3ProjectSettingsIntegrationsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
4848
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
4949
import { useEffect, useState, useCallback, useRef } from "react";
5050
import { usePostHogTracking } from "~/hooks/usePostHog";
@@ -102,6 +102,7 @@ export function VercelOnboardingModal({
102102
hasOrgIntegration,
103103
nextUrl,
104104
onDataReload,
105+
vercelManageAccessUrl,
105106
}: {
106107
isOpen: boolean;
107108
onClose: () => void;
@@ -114,6 +115,7 @@ export function VercelOnboardingModal({
114115
hasOrgIntegration: boolean;
115116
nextUrl?: string;
116117
onDataReload?: (vercelStagingEnvironment?: string) => void;
118+
vercelManageAccessUrl?: string;
117119
}) {
118120
const { capture, startSessionRecording } = usePostHogTracking();
119121
const navigation = useNavigation();
@@ -122,7 +124,8 @@ export function VercelOnboardingModal({
122124
const completeOnboardingFetcher = useFetcher();
123125
const { Form: CompleteOnboardingForm } = completeOnboardingFetcher;
124126
const [searchParams] = useSearchParams();
125-
const fromMarketplaceContext = searchParams.get("origin") === "marketplace";
127+
const origin = searchParams.get("origin");
128+
const fromMarketplaceContext = origin === "marketplace";
126129

127130
const availableProjects = onboardingData?.availableProjects || [];
128131
const hasProjectSelected = onboardingData?.hasProjectSelected ?? false;
@@ -543,8 +546,15 @@ export function VercelOnboardingModal({
543546

544547
if (!isGitHubConnectedForOnboarding) {
545548
setState("github-connection");
549+
capture("vercel onboarding github step viewed", {
550+
origin: fromMarketplaceContext ? "marketplace" : "dashboard",
551+
step: "github-connection",
552+
organization_slug: organizationSlug,
553+
project_slug: projectSlug,
554+
github_app_installed: gitHubAppInstallations.length > 0,
555+
});
546556
}
547-
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding]);
557+
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding, capture, organizationSlug, projectSlug, gitHubAppInstallations.length]);
548558

549559
const handleFinishOnboarding = useCallback((e: React.FormEvent<HTMLFormElement>) => {
550560
e.preventDefault();
@@ -639,7 +649,7 @@ export function VercelOnboardingModal({
639649
onClose();
640650
}
641651
}}>
642-
<DialogContent className="max-w-lg">
652+
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
643653
<DialogHeader>
644654
<div className="flex items-center gap-2">
645655
<VercelLogo className="size-5" />
@@ -727,14 +737,25 @@ export function VercelOnboardingModal({
727737

728738
<FormButtons
729739
confirmButton={
730-
<Button
731-
variant="primary/medium"
732-
onClick={handleProjectSelection}
733-
disabled={!selectedVercelProject || fetcher.state !== "idle"}
734-
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
735-
>
736-
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
737-
</Button>
740+
<div className="flex items-center gap-2">
741+
{vercelManageAccessUrl && !origin && (
742+
<LinkButton
743+
to={vercelManageAccessUrl}
744+
variant="tertiary/medium"
745+
target="_self"
746+
>
747+
Manage access
748+
</LinkButton>
749+
)}
750+
<Button
751+
variant="primary/medium"
752+
onClick={handleProjectSelection}
753+
disabled={!selectedVercelProject || fetcher.state !== "idle"}
754+
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
755+
>
756+
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
757+
</Button>
758+
</div>
738759
}
739760
cancelButton={
740761
<Button
@@ -813,6 +834,7 @@ export function VercelOnboardingModal({
813834
<Header3>Pull Environment Variables</Header3>
814835
<Paragraph className="text-sm">
815836
Select which environment variables to pull from Vercel now. This is a one-time pull.
837+
Later on environment variables can be pulled before each build.
816838
</Paragraph>
817839

818840
<div className="flex gap-4 text-sm">
@@ -1057,7 +1079,7 @@ export function VercelOnboardingModal({
10571079
</Callout>
10581080

10591081
{(() => {
1060-
const baseSettingsPath = v3ProjectSettingsPath(
1082+
const baseSettingsPath = v3ProjectSettingsIntegrationsPath(
10611083
{ slug: organizationSlug },
10621084
{ slug: projectSlug },
10631085
{ slug: environmentSlug }
@@ -1081,6 +1103,7 @@ export function VercelOnboardingModal({
10811103
)}
10821104
variant="secondary/medium"
10831105
LeadingIcon={OctoKitty}
1106+
onClick={() => trackOnboarding("vercel onboarding github app install clicked")}
10841107
>
10851108
Install GitHub app
10861109
</LinkButton>
@@ -1110,6 +1133,7 @@ export function VercelOnboardingModal({
11101133
<Button
11111134
variant="primary/medium"
11121135
onClick={() => {
1136+
trackOnboarding("vercel onboarding github completed");
11131137
setState("completed");
11141138
const validUrl = safeRedirectUrl(nextUrl);
11151139
if (validUrl) {
@@ -1123,6 +1147,7 @@ export function VercelOnboardingModal({
11231147
<Button
11241148
variant="tertiary/medium"
11251149
onClick={() => {
1150+
trackOnboarding("vercel onboarding github skipped");
11261151
setState("completed");
11271152
if (fromMarketplaceContext && nextUrl) {
11281153
const validUrl = safeRedirectUrl(nextUrl);
@@ -1141,6 +1166,7 @@ export function VercelOnboardingModal({
11411166
<Button
11421167
variant="tertiary/medium"
11431168
onClick={() => {
1169+
trackOnboarding("vercel onboarding github skipped");
11441170
setState("completed");
11451171
}}
11461172
>

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Cog8ToothIcon,
1212
CogIcon,
1313
ExclamationTriangleIcon,
14+
PuzzlePieceIcon,
1415
FolderIcon,
1516
FolderOpenIcon,
1617
GlobeAmericasIcon,
@@ -74,7 +75,8 @@ import {
7475
v3LogsPath,
7576
v3ProjectAlertsPath,
7677
v3ProjectPath,
77-
v3ProjectSettingsPath,
78+
v3ProjectSettingsGeneralPath,
79+
v3ProjectSettingsIntegrationsPath,
7880
v3QueuesPath,
7981
v3RunsPath,
8082
v3SchedulesPath,
@@ -589,13 +591,34 @@ export function SideMenu({
589591
data-action="limits"
590592
isCollapsed={isCollapsed}
591593
/>
594+
</SideMenuSection>
595+
596+
<SideMenuSection
597+
title="Project settings"
598+
isSideMenuCollapsed={isCollapsed}
599+
itemSpacingClassName="space-y-0"
600+
initialCollapsed={getSectionCollapsed(
601+
user.dashboardPreferences.sideMenu,
602+
"project-settings"
603+
)}
604+
onCollapseToggle={handleSectionToggle("project-settings")}
605+
>
592606
<SideMenuItem
593-
name="Project settings"
607+
name="General"
594608
icon={Cog8ToothIcon}
595609
activeIconColor="text-text-bright"
596610
inactiveIconColor="text-text-dimmed"
597-
to={v3ProjectSettingsPath(organization, project, environment)}
598-
data-action="project-settings"
611+
to={v3ProjectSettingsGeneralPath(organization, project, environment)}
612+
data-action="project-settings-general"
613+
isCollapsed={isCollapsed}
614+
/>
615+
<SideMenuItem
616+
name="Integrations"
617+
icon={PuzzlePieceIcon}
618+
activeIconColor="text-text-bright"
619+
inactiveIconColor="text-text-dimmed"
620+
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
621+
data-action="project-settings-integrations"
599622
isCollapsed={isCollapsed}
600623
/>
601624
</SideMenuSection>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from "zod";
22

33
// Valid section IDs that can have their collapsed state toggled
4-
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
4+
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics", "project-settings"]);
55

66
// Inferred type from the schema
77
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;

apps/webapp/app/models/vercelIntegration.server.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,13 @@ export class VercelIntegrationRepository {
975975
return { created: 0, updated: 0, errors: [] as string[] };
976976
}
977977

978+
await this.removeAllVercelEnvVarsByKey({
979+
client,
980+
vercelProjectId: params.vercelProjectId,
981+
teamId: params.teamId,
982+
key: "TRIGGER_SECRET_KEY",
983+
});
984+
978985
const result = await this.batchUpsertVercelEnvVars({
979986
client,
980987
vercelProjectId: params.vercelProjectId,
@@ -1526,6 +1533,35 @@ export class VercelIntegrationRepository {
15261533
return { created, updated, errors };
15271534
}
15281535

1536+
private static async removeAllVercelEnvVarsByKey(params: {
1537+
client: Vercel;
1538+
vercelProjectId: string;
1539+
teamId: string | null;
1540+
key: string;
1541+
}): Promise<void> {
1542+
const { client, vercelProjectId, teamId, key } = params;
1543+
1544+
const existingEnvs = await client.projects.filterProjectEnvs({
1545+
idOrName: vercelProjectId,
1546+
...(teamId && { teamId }),
1547+
});
1548+
1549+
const envs = extractVercelEnvs(existingEnvs);
1550+
const idsToRemove = envs
1551+
.filter((env) => env.key === key && env.id)
1552+
.map((env) => env.id!);
1553+
1554+
if (idsToRemove.length === 0) {
1555+
return;
1556+
}
1557+
1558+
await client.projects.batchRemoveProjectEnv({
1559+
idOrName: vercelProjectId,
1560+
...(teamId && { teamId }),
1561+
requestBody: { ids: idsToRemove },
1562+
});
1563+
}
1564+
15291565
private static async upsertVercelEnvVar(params: {
15301566
client: Vercel;
15311567
vercelProjectId: string;

apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
1010
import { type User } from "~/models/user.server";
1111
import { processGitMetadata } from "./BranchesPresenter.server";
1212
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
13-
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
13+
import {
14+
VercelProjectIntegrationDataSchema,
15+
buildVercelDeploymentUrl,
16+
} from "~/v3/vercel/vercelProjectIntegrationSchema";
1417

1518
const pageSize = 20;
1619

@@ -232,8 +235,11 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
232235

233236
let vercelDeploymentUrl: string | null = null;
234237
if (hasVercelIntegration && deployment.integrationDeploymentId && vercelTeamSlug && vercelProjectName) {
235-
const vercelId = deployment.integrationDeploymentId.replace(/^dpl_/, "");
236-
vercelDeploymentUrl = `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`;
238+
vercelDeploymentUrl = buildVercelDeploymentUrl(
239+
vercelTeamSlug,
240+
vercelProjectName,
241+
deployment.integrationDeploymentId
242+
);
237243
}
238244

239245
return {

apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
1212
import { type User } from "~/models/user.server";
1313
import { getUsername } from "~/utils/username";
1414
import { processGitMetadata } from "./BranchesPresenter.server";
15+
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
1516
import { S2 } from "@s2-dev/streamstore";
1617
import { env } from "~/env.server";
1718
import { createRedisClient } from "~/redis.server";
@@ -161,6 +162,51 @@ export class DeploymentPresenter {
161162
});
162163

163164
const gitMetadata = processGitMetadata(deployment.git);
165+
166+
// Look up Vercel integration data to construct a deployment URL
167+
let vercelDeploymentUrl: string | undefined;
168+
const vercelProjectIntegration =
169+
await this.#prismaClient.organizationProjectIntegration.findFirst({
170+
where: {
171+
projectId: project.id,
172+
deletedAt: null,
173+
organizationIntegration: {
174+
service: "VERCEL",
175+
deletedAt: null,
176+
},
177+
},
178+
select: {
179+
integrationData: true,
180+
},
181+
});
182+
183+
if (vercelProjectIntegration) {
184+
const parsed = VercelProjectIntegrationDataSchema.safeParse(
185+
vercelProjectIntegration.integrationData
186+
);
187+
188+
if (parsed.success && parsed.data.vercelTeamSlug) {
189+
const integrationDeployment =
190+
await this.#prismaClient.integrationDeployment.findFirst({
191+
where: {
192+
deploymentId: deployment.id,
193+
integrationName: "vercel",
194+
},
195+
select: {
196+
integrationDeploymentId: true,
197+
},
198+
orderBy: {
199+
createdAt: "desc",
200+
},
201+
});
202+
203+
if (integrationDeployment) {
204+
const vercelId = integrationDeployment.integrationDeploymentId.replace(/^dpl_/, "");
205+
vercelDeploymentUrl = `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`;
206+
}
207+
}
208+
}
209+
164210
const externalBuildData = deployment.externalBuildData
165211
? ExternalBuildData.safeParse(deployment.externalBuildData)
166212
: undefined;
@@ -227,6 +273,7 @@ export class DeploymentPresenter {
227273
type: deployment.type,
228274
git: gitMetadata,
229275
triggeredVia: deployment.triggeredVia,
276+
vercelDeploymentUrl,
230277
},
231278
};
232279
}

0 commit comments

Comments
 (0)