From 4a3239ac3e807448368f9500bc3c69dcb8666a78 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 26 Jul 2026 16:04:02 +0100 Subject: [PATCH 01/17] feat(webapp): move Integrations settings onto the shared settings layout Rebuilds the project Integrations page with the SettingsContainer, SettingsSection and SettingsRow primitives already used by the org SSO page, so GitHub, Vercel and build settings read as one consistent list of rows instead of headings over bordered boxes. The page now titles itself "Integrations" rather than inheriting "Project settings", via a pageTitle handle that the parent settings layout reads. Also fixes a nested form in the Vercel panel. Browsers drop a nested
when parsing server-rendered HTML, so any project with atomic production builds and auto-assign custom domains still enabled failed hydration and re-rendered the whole document on the client. Carries temporary development-only code (devRevealIntegrations.tsx plus the devReveal props) that forces every conditionally hidden state to render at once. That must be removed before this merges. --- .../app/assets/icons/PadlockRoundedIcon.tsx | 34 + .../integrations/devRevealIntegrations.tsx | 104 +++ .../route.tsx | 330 +++++----- .../route.tsx | 17 +- ...cts.$projectParam.env.$envParam.github.tsx | 590 ++++++++++++------ ...cts.$projectParam.env.$envParam.vercel.tsx | 454 ++++++++------ .../app/routes/storybook.icons/route.tsx | 2 + 7 files changed, 1018 insertions(+), 513 deletions(-) create mode 100644 apps/webapp/app/assets/icons/PadlockRoundedIcon.tsx create mode 100644 apps/webapp/app/components/integrations/devRevealIntegrations.tsx diff --git a/apps/webapp/app/assets/icons/PadlockRoundedIcon.tsx b/apps/webapp/app/assets/icons/PadlockRoundedIcon.tsx new file mode 100644 index 00000000000..56f2543fc3f --- /dev/null +++ b/apps/webapp/app/assets/icons/PadlockRoundedIcon.tsx @@ -0,0 +1,34 @@ +export function PadlockRoundedIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/components/integrations/devRevealIntegrations.tsx b/apps/webapp/app/components/integrations/devRevealIntegrations.tsx new file mode 100644 index 00000000000..57342ba7b92 --- /dev/null +++ b/apps/webapp/app/components/integrations/devRevealIntegrations.tsx @@ -0,0 +1,104 @@ +/** + * TEMPORARY — DEVELOPMENT ONLY. DELETE THIS FILE BEFORE MERGING. + * + * The project Integrations settings page hides most of its UI behind + * environment config (a configured GitHub App, a configured Vercel + * integration) and behind connection state (connected vs. not connected). + * Locally that leaves almost nothing on screen, which makes it impossible to + * style the page. + * + * When `DEV_REVEAL_ALL` is true, the page and its two panels render every + * state at once using the placeholder data below. Nothing here is wired to a + * real integration — the forms still post to their real actions and will fail. + * + * To revert: set `DEV_REVEAL_ALL` to false, delete this file, and remove every + * `devReveal` prop and `DEV_REVEAL_ALL` reference. `rg "DEV_REVEAL_ALL|devReveal"` + * finds all of them. + */ + +import { type ReactNode } from "react"; +import { type VercelProjectIntegrationData } from "~/v3/vercel/vercelProjectIntegrationSchema"; + +export const DEV_REVEAL_ALL = true; + +/** Labels a placeholder state so it is obvious it isn't real data. */ +export function DevRevealLabel({ children }: { children: ReactNode }) { + if (!DEV_REVEAL_ALL) return null; + + return ( +
+ + dev preview + + {children} +
+ ); +} + +export const devRevealGitHubInstallations = [ + { + id: "dev-installation", + appInstallationId: BigInt(1), + targetType: "Organization", + accountHandle: "acme-inc", + repositories: [ + { + id: "dev-repo", + name: "acme-app", + fullName: "acme-inc/acme-app", + private: true, + htmlUrl: "https://github.com/acme-inc/acme-app", + }, + ], + }, +]; + +/** Same repo, but public — so the globe/"This repo is public" state is visible too. */ +export const devRevealConnectedGitHubRepoPublic = { + branchTracking: { + prod: { branch: "main" }, + staging: { branch: "staging" }, + }, + previewDeploymentsEnabled: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + repository: { + ...devRevealGitHubInstallations[0].repositories[0], + private: false, + }, +}; + +export const devRevealConnectedGitHubRepo = { + branchTracking: { + prod: { branch: "main" }, + staging: { branch: "staging" }, + }, + previewDeploymentsEnabled: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + repository: devRevealGitHubInstallations[0].repositories[0], +}; + +const devRevealVercelIntegrationData: VercelProjectIntegrationData = { + vercelProjectId: "prj_dev", + vercelProjectName: "acme-app", + vercelTeamId: "team_dev", + syncEnvVarsMapping: {}, + onboardingCompleted: true, + config: { + atomicBuilds: ["prod"], + pullEnvVarsBeforeBuild: ["prod", "preview"], + discoverEnvVars: ["prod"], + vercelStagingEnvironment: { environmentId: "env_dev", displayName: "staging" }, + autoPromote: true, + }, +}; + +export const devRevealConnectedVercelProject = { + id: "dev-vercel-integration", + vercelProjectId: "prj_dev", + vercelProjectName: "acme-app", + vercelTeamId: "team_dev" as string | null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + integrationData: devRevealVercelIntegrationData, +}; + +export const devRevealVercelCustomEnvironments = [{ id: "env_dev", slug: "staging" }]; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx index a164d4082da..ab93bc04c9d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx @@ -6,18 +6,19 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { InlineCode } from "~/components/code/InlineCode"; -import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; +import { DEV_REVEAL_ALL } from "~/components/integrations/devRevealIntegrations"; 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 { + SettingsActions, + SettingsContainer, + SettingsHeader, + SettingsRow, + SettingsSection, +} from "~/components/primitives/SettingsLayout"; import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { Switch } from "~/components/primitives/Switch"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; @@ -41,6 +42,9 @@ import { VercelSettingsPanel, } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +/** Read by the parent settings layout to title the page. */ +export const handle = { pageTitle: "Integrations" }; + export const loader = dashboardLoader( { params: EnvironmentParamSchema, @@ -346,56 +350,59 @@ export default function IntegrationsSettingsPage() { } }, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]); + // DEV_REVEAL_ALL: force both integrations on locally. Remove before merging. + const showGitHubSection = githubAppEnabled || DEV_REVEAL_ALL; + const showVercelSection = vercelIntegrationEnabled || DEV_REVEAL_ALL; + return ( <> - -
- {githubAppEnabled && ( - -
- Git settings -
- -
-
- - {vercelIntegrationEnabled && ( -
- Vercel integration -
- -
-
- )} - -
- Build settings - - These settings apply to deployments triggered from GitHub and to CLI deployments - run with the --native-build-server{" "} - flag. - -
- -
-
-
- )} -
-
+ + {showGitHubSection && ( + + + + + + + {showVercelSection && ( + + + + + )} + + + + These settings apply to deployments triggered from GitHub and to CLI deployments + run with the{" "} + --native-build-server flag. + + } + /> + + + + )} + {/* Vercel Onboarding Modal */} {vercelIntegrationEnabled && ( @@ -471,109 +478,126 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) return ( -
- - - { - setBuildSettingsValues((prev) => ({ - ...prev, - triggerConfigFilePath: e.target.value, - })); - }} - /> - - Path to your Trigger configuration file, relative to the root directory of your repo. - - - {fields.triggerConfigFilePath.errors} - - - - - - { - setBuildSettingsValues((prev) => ({ - ...prev, - installCommand: e.target.value, - })); - }} - /> - - Command to install your project dependencies. This will be run from the root directory - of your repo. Auto-detected by default. - - - {fields.installCommand.errors?.join(", ")} - - - - - { - setBuildSettingsValues((prev) => ({ - ...prev, - preBuildCommand: e.target.value, - })); - }} - /> - - Any command that needs to run before we build and deploy your project. This will be run - from the root directory of your repo. - - - {fields.preBuildCommand.errors?.join(", ")} - - -
- - { + + { setBuildSettingsValues((prev) => ({ ...prev, - useNativeBuildServer: isChecked, + triggerConfigFilePath: e.target.value, })); }} /> - - Native build server builds don't rely on external build providers and are used by - default. Requires version 4.2.0 or newer. - - - {fields.useNativeBuildServer.errors} + + {fields.triggerConfigFilePath.errors} - -
- {buildSettingsForm.errors} - - Save - - } - /> -
+ + } + /> + + + { + setBuildSettingsValues((prev) => ({ + ...prev, + installCommand: e.target.value, + })); + }} + /> + + {fields.installCommand.errors?.join(", ")} + + + } + /> + + { + setBuildSettingsValues((prev) => ({ + ...prev, + preBuildCommand: e.target.value, + })); + }} + /> + + {fields.preBuildCommand.errors?.join(", ")} + + + } + /> + + { + setBuildSettingsValues((prev) => ({ + ...prev, + useNativeBuildServer: isChecked, + })); + }} + /> + } + /> + + + {fields.useNativeBuildServer.errors} + + {buildSettingsForm.errors} + + + + ); } + +/** + * Right-hand control column for a settings row whose action is a text input: + * fixed width so the inputs line up, with room for a validation message below. + */ +function SettingsControl({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index f99a14f336c..7d08cf679fb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -1,4 +1,4 @@ -import { Outlet, type MetaFunction } from "@remix-run/react"; +import { Outlet, useMatches, 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"; @@ -42,13 +42,26 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return null; }; +const DEFAULT_PAGE_TITLE = "Project settings"; + +/** Child settings routes can name themselves via `export const handle = { pageTitle }`. */ +function usePageTitle() { + const matches = useMatches(); + for (let i = matches.length - 1; i >= 0; i--) { + const pageTitle = (matches[i].handle as { pageTitle?: string } | undefined)?.pageTitle; + if (pageTitle) return pageTitle; + } + return DEFAULT_PAGE_TITLE; +} + export default function SettingsLayout() { const project = useProject(); + const pageTitle = usePageTitle(); return ( - + diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index 1842edf347c..602eef53353 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -1,6 +1,12 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; -import { CheckCircleIcon, LockClosedIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { + ArrowUpCircleIcon, + ArrowUpRightIcon, + CheckCircleIcon, + LockClosedIcon, + PlusIcon, +} from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { Form, @@ -21,7 +27,13 @@ import { environmentTextClassName, } from "~/components/environments/EnvironmentLabel"; import { OctoKitty } from "~/components/GitHubLoginButton"; -import { Button } from "~/components/primitives/Buttons"; +import { + DevRevealLabel, + devRevealConnectedGitHubRepo, + devRevealConnectedGitHubRepoPublic, + devRevealGitHubInstallations, +} from "~/components/integrations/devRevealIntegrations"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -34,10 +46,15 @@ import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { PermissionLink } from "~/components/primitives/PermissionLink"; import { Select, SelectItem } from "~/components/primitives/Select"; +import { + SettingsActions, + SettingsBlock, + SettingsRow, + SettingsRowDescription, +} from "~/components/primitives/SettingsLayout"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { Switch } from "~/components/primitives/Switch"; import { TextLink } from "~/components/primitives/TextLink"; -import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { redirectBackWithErrorMessage, redirectBackWithSuccessMessage, @@ -386,6 +403,7 @@ export function ConnectGitHubRepoModal({ redirectUrl, preventDismiss, canManageGithub = true, + buttonVariant = "secondary/medium", }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; @@ -395,6 +413,8 @@ export function ConnectGitHubRepoModal({ /** When true, prevents closing the modal via Escape key or clicking outside */ preventDismiss?: boolean; canManageGithub?: boolean; + /** Settings rows use the small button; onboarding/blank states use medium. */ + buttonVariant?: "secondary/small" | "secondary/medium"; }) { const [isModalOpen, setIsModalOpen] = useState(false); const lastSubmission = useActionData() as any; @@ -457,7 +477,7 @@ export function ConnectGitHubRepoModal({ - - - Disconnect GitHub repository -
- - Are you sure you want to disconnect{" "} - {connectedGitHubRepo.repository.fullName}? - This will stop automatic deployments from GitHub. - - - - {redirectUrl && } - - - } - cancelButton={ - - - + . + + } + action={ + + +
-
- - + > + Disconnect + + + + Disconnect GitHub repository +
+ + Are you sure you want to disconnect{" "} + {connectedGitHubRepo.repository.fullName}? + This will stop automatic deployments from GitHub. + + + + {redirectUrl && ( + + )} + + + } + cancelButton={ + + + + } + /> +
+
+ + } + />
{redirectUrl && } -
- - - Every push to the selected tracking branch creates a deployment in the corresponding - environment. - -
-
- - - {environmentFullTitle({ type: "PRODUCTION" })} - -
- { - setGitSettingsValues((prev) => ({ - ...prev, - productionBranch: e.target.value, - })); - }} - /> -
- - - {environmentFullTitle({ type: "STAGING" })} - -
- { + + + + Every push to the selected tracking branch creates a deployment in the corresponding + environment. + + + + { + setGitSettingsValues((prev) => ({ + ...prev, + productionBranch: e.target.value, + })); + }} + /> + } + > + + + + { + setGitSettingsValues((prev) => ({ + ...prev, + stagingBranch: e.target.value, + })); + }} + /> + } + > + + + + { setGitSettingsValues((prev) => ({ ...prev, - stagingBranch: e.target.value, + previewDeploymentsEnabled: checked, })); }} /> - -
- - - {environmentFullTitle({ type: "PREVIEW" })} - -
-
- { - setGitSettingsValues((prev) => ({ - ...prev, - previewDeploymentsEnabled: checked, - })); - }} - /> - {!previewEnvironmentEnabled && ( - - Upgrade your plan to enable preview - branches - - } - /> - )} -
-
- {fields.productionBranch?.errors} - {fields.stagingBranch?.errors} - {fields.previewDeploymentsEnabled?.errors} - {gitSettingsForm.errors} -
- - - Save - + Upgrade + + ) + } + > + -
+ + + {fields.productionBranch?.errors} + {fields.stagingBranch?.errors} + {fields.previewDeploymentsEnabled?.errors} + {gitSettingsForm.errors} + + + +
); } +/** + * Environment name + icon, used as the left-hand label of a branch-tracking row. + * `description` renders beneath it, matching the subtitle of a plain settings row. + */ +function EnvironmentRowLabel({ + type, + description, +}: { + type: "PRODUCTION" | "STAGING" | "PREVIEW"; + description?: React.ReactNode; +}) { + return ( +
+
+ + + {environmentFullTitle({ type })} + +
+ {description ? {description} : null} +
+ ); +} + // ============================================================================ // Main GitHub Settings Panel Component // ============================================================================ @@ -928,11 +1085,21 @@ export function GitHubSettingsPanel({ projectSlug, environmentSlug, billingPath, + layout = "compact", + devReveal = false, }: { organizationSlug: string; projectSlug: string; environmentSlug: string; billingPath: string; + /** + * "settings" renders the not-yet-connected states as settings rows (project + * Integrations page); "compact" keeps the button-only treatment used by the + * deployments blank state. + */ + layout?: "settings" | "compact"; + /** TEMPORARY dev-only: render every state at once. Remove before merging. */ + devReveal?: boolean; }) { const fetcher = useTypedFetcher(); const location = useLocation(); @@ -951,8 +1118,15 @@ export function GitHubSettingsPanel({ const data = fetcher.data; + const connectedRepo = + data?.connectedRepository ?? (devReveal ? devRevealConnectedGitHubRepo : undefined); + const installations = data?.installations?.length + ? data.installations + : devRevealGitHubInstallations; + const canManageGithub = data?.canManageGithub ?? true; + // Loading state - if (fetcher.state === "loading" && !data) { + if (fetcher.state === "loading" && !data && !devReveal) { return (
@@ -962,40 +1136,104 @@ export function GitHubSettingsPanel({ } // GitHub app not enabled - if (!data || !data.enabled) { + if ((!data || !data.enabled) && !devReveal) { return null; } + // DEV_REVEAL_ALL: stack the mutually exclusive states so all of them are visible. + if (devReveal) { + return ( + <> + 1. App not installed + + 2. App installed, no repository connected + + 3. Connected repository — Preview locked (needs upgrade) + + + 4. Connected repository — Preview unlocked, public repo + + + + ); + } + // Connected repository exists - show form - if (data.connectedRepository) { + if (data?.connectedRepository) { + return ( + <> + {layout === "settings" && } + + + ); + } + + // No connected repository - show connection prompt + if (layout === "settings") { return ( - ); } - // No connected repository - show connection prompt return (
- {!data.connectedRepository && ( - Connect your GitHub repository to automatically deploy your changes. - )} + Connect a GitHub repo to automatically deploy changes.
); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 7a5f61a48dc..66ccc275da1 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -2,12 +2,17 @@ import { getFormProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; import { CheckCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react"; +import { Form, useActionData, useFetcher, useLocation, useNavigation } from "@remix-run/react"; import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { Result, fromPromise } from "neverthrow"; import { useEffect, useRef, useState } from "react"; import { typedjson, useTypedFetcher } from "remix-typedjson"; import { z } from "zod"; +import { + DevRevealLabel, + devRevealConnectedVercelProject, + devRevealVercelCustomEnvironments, +} from "~/components/integrations/devRevealIntegrations"; import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings"; import { VercelLogo } from "~/components/integrations/VercelLogo"; import { Button } from "~/components/primitives/Buttons"; @@ -19,10 +24,14 @@ import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Hint } from "~/components/primitives/Hint"; import { InputGroup } from "~/components/primitives/InputGroup"; -import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { PermissionLink } from "~/components/primitives/PermissionLink"; import { Select, SelectItem } from "~/components/primitives/Select"; +import { + SettingsActions, + SettingsBlock, + SettingsRow, +} from "~/components/primitives/SettingsLayout"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { redirectBackWithErrorMessage, @@ -776,6 +785,9 @@ function ConnectedVercelProjectForm({ navigation.formData?.get("action") === "update-config" && (navigation.state === "submitting" || navigation.state === "loading"); + const disableAutoAssignFetcher = useFetcher(); + const isDisablingAutoAssign = disableAutoAssignFetcher.state !== "idle"; + const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); const availableEnvSlugs = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment); @@ -800,8 +812,51 @@ function ConnectedVercelProjectForm({ return ( <> -
-
+ + + + + + Disconnect Vercel project +
+ + Are you sure you want to disconnect{" "} + {connectedProject.vercelProjectName}? This + will stop pulling environment variables and disable atomic deployments. + + + + + + } + cancelButton={ + + + + } + /> +
+
+ + } + > +
{connectedProject.vercelProjectName} @@ -816,47 +871,7 @@ function ConnectedVercelProjectForm({ />
- - - - - - Disconnect Vercel project -
- - Are you sure you want to disconnect{" "} - {connectedProject.vercelProjectName}? This - will stop pulling environment variables and disable atomic deployments. - - - - - - } - cancelButton={ - - - - } - /> -
-
-
-
+ {/* Configuration form */}
@@ -893,148 +908,141 @@ function ConnectedVercelProjectForm({ ref={clearTriggerVersionInputRef} /> -
- -
- {/* Staging environment mapping */} - {hasStagingEnvironment && customEnvironments && customEnvironments.length > 0 && ( -
- - - Select which custom Vercel environment should map to Trigger.dev's Staging - environment. - - -
- )} - - - setConfigValues((prev) => ({ ...prev, pullEnvVarsBeforeBuild: slugs })) - } - discoverEnvVars={configValues.discoverEnvVars} - onDiscoverEnvVarsChange={(slugs) => - setConfigValues((prev) => ({ ...prev, discoverEnvVars: slugs })) - } - atomicBuilds={configValues.atomicBuilds} - onAtomicBuildsChange={(slugs) => - setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs })) - } - envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/environment-variables`} - disabledEnvSlugs={disabledEnvSlugsForBuildSettings} - autoPromote={configValues.autoPromote} - onAutoPromoteChange={(value) => - setConfigValues((prev) => ({ ...prev, autoPromote: value })) - } - currentTriggerVersion={currentTriggerVersion} - currentTriggerVersionFetchFailed={currentTriggerVersionFetchFailed} - hideSectionToggles - /> - - {/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */} - {autoAssignCustomDomains !== false && configValues.atomicBuilds.includes("prod") && ( - -
-

- Atomic deployments require the "Auto-assign Custom Domains" setting to be - disabled on your Vercel project. Without this, Vercel will promote deployments - before Trigger.dev is ready. -

- - - - -
-
- )} -
- - {configForm.errors} -
- - { - if (shouldPromptClearOnSave) { - event.preventDefault(); - setShowClearDialog(true); + return next; + }); } }} + items={[{ id: "", slug: "None" }, ...customEnvironments]} + variant="tertiary/small" + placeholder="Select environment" + dropdownIcon + text={configValues.vercelStagingEnvironment?.displayName || "None"} > - Save - + {[ + + None + , + ...customEnvironments.map((env) => ( + + {env.slug} + + )), + ]} + } /> -
+ )} + + + + setConfigValues((prev) => ({ ...prev, pullEnvVarsBeforeBuild: slugs })) + } + discoverEnvVars={configValues.discoverEnvVars} + onDiscoverEnvVarsChange={(slugs) => + setConfigValues((prev) => ({ ...prev, discoverEnvVars: slugs })) + } + atomicBuilds={configValues.atomicBuilds} + onAtomicBuildsChange={(slugs) => + setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs })) + } + envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/environment-variables`} + disabledEnvSlugs={disabledEnvSlugsForBuildSettings} + autoPromote={configValues.autoPromote} + onAutoPromoteChange={(value) => + setConfigValues((prev) => ({ ...prev, autoPromote: value })) + } + currentTriggerVersion={currentTriggerVersion} + currentTriggerVersionFetchFailed={currentTriggerVersionFetchFailed} + hideSectionToggles + /> + + + {/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */} + {autoAssignCustomDomains !== false && configValues.atomicBuilds.includes("prod") && ( + + +
+

+ Atomic deployments require the "Auto-assign Custom Domains" setting to be disabled + on your Vercel project. Without this, Vercel will promote deployments before + Trigger.dev is ready. +

+ {/* Submitted via fetcher rather than a nested
, which the + browser drops during SSR and which breaks hydration. */} + +
+
+
+ )} + + {configForm.errors} + + + + @@ -1090,12 +1098,15 @@ function VercelSettingsPanel({ environmentSlug, onOpenVercelModal, isLoadingVercelData, + devReveal = false, }: { organizationSlug: string; projectSlug: string; environmentSlug: string; onOpenVercelModal?: () => void; isLoadingVercelData?: boolean; + /** TEMPORARY dev-only: render every state at once. Remove before merging. */ + devReveal?: boolean; }) { const fetcher = useTypedFetcher(); const _location = useLocation(); @@ -1135,7 +1146,7 @@ function VercelSettingsPanel({ ); } - if (fetcher.state === "loading" && !data) { + if (fetcher.state === "loading" && !data && !devReveal) { return (
@@ -1144,6 +1155,85 @@ function VercelSettingsPanel({ ); } + // DEV_REVEAL_ALL: stack the mutually exclusive states so all of them are visible. + if (devReveal) { + return ( + <> + Connected project + + + Connection expired + + + + + GitHub not connected + + + + + App installed, no project connected + +
+ + + Connect your Vercel project to pull environment variables and trigger builds + automatically. + +
+
+ + App not installed + +
+ + + Install the Vercel app to connect your projects and pull environment variables. + + + GitHub integration is not connected. Vercel integration cannot sync environment + variables and link deployments without a properly installed GitHub integration. + +
+
+ + ); + } + if (!data || !data.enabled) { return null; } diff --git a/apps/webapp/app/routes/storybook.icons/route.tsx b/apps/webapp/app/routes/storybook.icons/route.tsx index a465e05fb5a..36acd29b234 100644 --- a/apps/webapp/app/routes/storybook.icons/route.tsx +++ b/apps/webapp/app/routes/storybook.icons/route.tsx @@ -93,6 +93,7 @@ import { MoveUpIcon } from "~/assets/icons/MoveUpIcon"; import { NodejsLogoIcon } from "~/assets/icons/NodejsLogoIcon"; import { OneTreeIcon } from "~/assets/icons/OneTreeIcon"; import { PadlockIcon } from "~/assets/icons/PadlockIcon"; +import { PadlockRoundedIcon } from "~/assets/icons/PadlockRoundedIcon"; import { PauseIcon } from "~/assets/icons/PauseIcon"; import { PlaygroundIcon } from "~/assets/icons/PlaygroundIcon"; import { PlusIcon } from "~/assets/icons/PlusIcon"; @@ -233,6 +234,7 @@ const icons: IconEntry[] = [ { name: "OneTreeIcon", render: simple(OneTreeIcon) }, { name: "OpenAIIcon", render: simple(OpenAIIcon) }, { name: "PadlockIcon", render: simple(PadlockIcon) }, + { name: "PadlockRoundedIcon", render: simple(PadlockRoundedIcon) }, { name: "PauseIcon", render: simple(PauseIcon) }, { name: "PerplexityIcon", render: simple(PerplexityIcon) }, { name: "PlaygroundIcon", render: simple(PlaygroundIcon) }, From 9f412ccaf9a8de724c609f28c1665a4af5c013e3 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 26 Jul 2026 16:16:46 +0100 Subject: [PATCH 02/17] chore(webapp): tighten build settings copy Shortens the five descriptions in the project build settings section by about 60%. Most of the cut is descriptions restating their own label: "Command to install your project dependencies" under a field called "Install command" carries no information, so only the non obvious part remains (it runs from the repo root, and we auto detect it). Also names the version requirement on the native build server row. It previously read "version 4.2.0 or newer" without saying version of what. The section sentence keeps its trailing "flag." rather than ending on the code chip, whose horizontal padding left the full stop looking detached, and the flag no longer breaks across two lines mid token. --- .../route.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx index ab93bc04c9d..1ceafc8616f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx @@ -392,9 +392,11 @@ export default function IntegrationsSettingsPage() { title="Build settings" description={ <> - These settings apply to deployments triggered from GitHub and to CLI deployments - run with the{" "} - --native-build-server flag. + Applies to deployments triggered from GitHub, and CLI deployments run with the{" "} + + --native-build-server + {" "} + flag. } /> @@ -482,7 +484,7 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) align="start" htmlFor={fields.triggerConfigFilePath.id} title="Trigger config file" - description="Path to your Trigger configuration file, relative to the root directory of your repo." + description="Path relative to your repo root." action={ Date: Sun, 26 Jul 2026 18:26:06 +0100 Subject: [PATCH 03/17] feat(webapp): add a tinted warning button variant Adds warning/small through warning/extra-large for the recovery action on a warning panel: amber text on a translucent amber fill, so the button reads as part of the warning rather than as something destructive. The solid danger variant is unchanged and stays the right choice for genuinely destructive buttons. Both are shown side by side in the buttons storybook. --- apps/webapp/app/components/primitives/Buttons.tsx | 11 +++++++++++ apps/webapp/app/routes/storybook.buttons/route.tsx | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 323f3743826..6075d52f1bf 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -89,6 +89,13 @@ const theme = { shortcut: "border-text-bright text-text-bright group-hover/button:border-text-bright/60", icon: "text-text-bright", }, + warning: { + textColor: "text-warning transition group-disabled/button:text-warning/60", + button: + "bg-warning/10 border border-warning/20 group-hover/button:bg-warning/20 group-hover/button:border-warning/40 group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", + shortcut: "border-warning/40 text-warning group-hover/button:border-warning/60", + icon: "text-warning", + }, docs: { textColor: "text-blue-200/70 transition group-disabled/button:text-text-dimmed/80", button: @@ -133,6 +140,10 @@ const variant = { "danger/medium": createVariant("medium", "danger"), "danger/large": createVariant("large", "danger"), "danger/extra-large": createVariant("extra-large", "danger"), + "warning/small": createVariant("small", "warning"), + "warning/medium": createVariant("medium", "warning"), + "warning/large": createVariant("large", "warning"), + "warning/extra-large": createVariant("extra-large", "warning"), "docs/small": createVariant("small", "docs"), "docs/medium": createVariant("medium", "docs"), "docs/large": createVariant("large", "docs"), diff --git a/apps/webapp/app/routes/storybook.buttons/route.tsx b/apps/webapp/app/routes/storybook.buttons/route.tsx index 29f4a4b078a..702bcbfa976 100644 --- a/apps/webapp/app/routes/storybook.buttons/route.tsx +++ b/apps/webapp/app/routes/storybook.buttons/route.tsx @@ -36,6 +36,7 @@ export default function Story() { +
Icon left @@ -165,6 +166,7 @@ export default function Story() { +
Icon left @@ -310,6 +312,10 @@ export default function Story() { /> This is a delete button +
@@ -335,6 +341,12 @@ export default function Story() { /> This is a delete button + From 628e483fd219b1cfc18355d929c139ab4534863d Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 26 Jul 2026 18:26:12 +0100 Subject: [PATCH 04/17] feat(webapp): end-aligned settings rows, and rows that point at their unlock control Two additions to the settings layout shared by the org SSO and project settings pages. SettingsRow gains align="end", so a row whose action belongs beside the last line of a long description can sit there instead of centring against the whole block. A CSS rule lets a disabled row highlight the control elsewhere on the page that unlocks it. The row carries .unlock-hint-, the control carries data-unlock-target="", and a :has() selector scoped to the surrounding form draws a dashed ring while the row is hovered. It has to be CSS rather than a sibling selector because the control sits before the hovered row in the DOM. --- .../app/components/primitives/SettingsLayout.tsx | 4 ++-- apps/webapp/app/tailwind.css | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/primitives/SettingsLayout.tsx b/apps/webapp/app/components/primitives/SettingsLayout.tsx index 7d6484fb467..10781cd7bb1 100644 --- a/apps/webapp/app/components/primitives/SettingsLayout.tsx +++ b/apps/webapp/app/components/primitives/SettingsLayout.tsx @@ -152,14 +152,14 @@ export function SettingsRow({ className?: string; titleClassName?: string; size?: RowSize; - align?: "center" | "start"; + align?: "center" | "start" | "end"; bordered?: boolean; }) { return (
` and + * the control carries `data-unlock-target=""`, both inside the same form. + */ +form:has(.unlock-hint-staging-env:hover) [data-unlock-target="staging-env"], +form:has(.unlock-hint-pull-prod:hover) [data-unlock-target="pull-prod"], +form:has(.unlock-hint-pull-stg:hover) [data-unlock-target="pull-stg"], +form:has(.unlock-hint-pull-preview:hover) [data-unlock-target="pull-preview"], +form:has(.unlock-hint-pull-dev:hover) [data-unlock-target="pull-dev"] { + border-radius: 0.25rem; + outline: 2px dashed var(--color-warning); + outline-offset: 2px; +} From d640593d45fe918b25efafb1c553a658f6d72816 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 26 Jul 2026 18:26:24 +0100 Subject: [PATCH 05/17] feat(webapp): move Vercel integration settings onto the settings layout Rebuilds the Vercel section of the project Integrations page as settings rows, matching the GitHub section above it. The states now read as a progression: install the app, connect a project, then configure it. The "Vercel app: Installed" and "Vercel project: Connected" rows persist once each step is done, so the confirmation no longer disappears the moment you move past it. Environment variable settings become one row per environment. A disabled toggle explains why in place rather than hiding the reason in a hover tooltip, and hovering it ring-highlights the control that unlocks it. The three warning panels become rows with a hazard icon, a title, and their recovery action on the right. Copy throughout leads with the outcome instead of restating the field label. Atomic deployments now says what it buys you, that your app never runs against a mismatched task version, rather than only describing the mechanism. Still carries the temporary development-only preview states added earlier on this branch. --- .../integrations/VercelBuildSettings.tsx | 518 +++++++++---- .../integrations/devRevealIntegrations.tsx | 13 + ...cts.$projectParam.env.$envParam.vercel.tsx | 703 ++++++++++-------- 3 files changed, 780 insertions(+), 454 deletions(-) diff --git a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx index 1abd54b0121..43cfac2b7c3 100644 --- a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx +++ b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx @@ -1,5 +1,12 @@ import { Switch } from "~/components/primitives/Switch"; +import { LinkButton } from "~/components/primitives/Buttons"; import { Label } from "~/components/primitives/Label"; +import { + SettingsRow, + SettingsRowDescription, + SettingsRowTitle, +} from "~/components/primitives/SettingsLayout"; +import { cn } from "~/utils/cn"; import { Hint } from "~/components/primitives/Hint"; import { TextLink } from "~/components/primitives/TextLink"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; @@ -31,6 +38,11 @@ type BuildSettingsFieldsProps = { currentTriggerVersionFetchFailed?: boolean; /** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */ hideSectionToggles?: boolean; + /** + * "settings" renders the env var sections as settings rows (project Integrations + * page); "card" keeps the bordered card used by the onboarding modal. + */ + layout?: "settings" | "card"; }; export function BuildSettingsFields({ @@ -48,187 +60,326 @@ export function BuildSettingsFields({ currentTriggerVersion, currentTriggerVersionFetchFailed, hideSectionToggles, + layout = "card", }: BuildSettingsFieldsProps) { const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug]; const enabledSlugs = availableEnvSlugs.filter((s) => !isSlugDisabled(s)); + const envVarSections = + layout === "settings" ? ( + <> + + Configure env vars + + ) : undefined + } + /> + {availableEnvSlugs.map((slug) => ( + { + onPullEnvVarsChange( + checked + ? [...pullEnvVarsBeforeBuild, slug] + : pullEnvVarsBeforeBuild.filter((s) => s !== slug) + ); + }} + /> + ))} + + + {availableEnvSlugs.map((slug) => { + const pullOff = !pullEnvVarsBeforeBuild.includes(slug); + return ( + { + onDiscoverEnvVarsChange( + checked ? [...discoverEnvVars, slug] : discoverEnvVars.filter((s) => s !== slug) + ); + }} + /> + ); + })} + + ) : null; + + const atomicSections = + layout === "settings" ? ( + <> + { + onAtomicBuildsChange(checked ? ["prod"] : []); + }} + /> + } + > +
+ Atomic deployments + + Promotes your Vercel deployment and your tasks together in Production, so your app + never runs against a mismatched task version. Requires turning off "Auto-assign Custom + Production Domains" on your Vercel project, which Trigger.dev does for you.{" "} + + Learn more + + . + + {currentTriggerVersion && ( + + Currently pinned to{" "} + {currentTriggerVersion} in + Vercel production. + + )} + {!currentTriggerVersion && currentTriggerVersionFetchFailed && ( + + Couldn't read TRIGGER_VERSION{" "} + from Vercel. Check the Vercel dashboard to confirm the production pin. + + )} +
+
+ + {atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && ( + + } + /> + )} + + ) : null; + return ( <> + {envVarSections} {/* Pull env vars before build */} -
-
-
- - {!hideSectionToggles && availableEnvSlugs.length > 1 && ( - 0 && - enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s)) - } - onCheckedChange={(checked) => { - onPullEnvVarsChange(checked ? [...enabledSlugs] : []); - }} - /> - )} -
- - Select which environments should pull environment variables from Vercel before each - build.{" "} - {envVarsConfigLink && ( - <> - Configure which variables to pull. - - )} - -
-
- {availableEnvSlugs.map((slug) => { - const envType = envSlugToType(slug); - const disabled = isSlugDisabled(slug); - const disabledReason = disabledEnvSlugs?.[slug]; - const row = ( -
-
- - - {environmentFullTitle({ type: envType })} - -
+ {layout === "card" && ( +
+
+
+ + {!hideSectionToggles && availableEnvSlugs.length > 1 && ( 0 && + enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s)) + } onCheckedChange={(checked) => { - onPullEnvVarsChange( - checked - ? [...pullEnvVarsBeforeBuild, slug] - : pullEnvVarsBeforeBuild.filter((s) => s !== slug) - ); + onPullEnvVarsChange(checked ? [...enabledSlugs] : []); }} /> -
- ); - if (disabled && disabledReason) { - return ; - } - return row; - })} + )} +
+ + Select which environments should pull environment variables from Vercel before each + build.{" "} + {envVarsConfigLink && ( + <> + Configure which variables to pull. + + )} + +
+
+ {availableEnvSlugs.map((slug) => { + const envType = envSlugToType(slug); + const disabled = isSlugDisabled(slug); + const disabledReason = disabledEnvSlugs?.[slug]; + const row = ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + onPullEnvVarsChange( + checked + ? [...pullEnvVarsBeforeBuild, slug] + : pullEnvVarsBeforeBuild.filter((s) => s !== slug) + ); + }} + /> +
+ ); + if (disabled && disabledReason) { + return ( + + ); + } + return row; + })} +
-
+ )} {/* Discover new env vars */} -
-
-
- - {!hideSectionToggles && availableEnvSlugs.length > 1 && ( - 0 && - enabledSlugs.every( - (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s) - ) && - enabledSlugs.some((s) => discoverEnvVars.includes(s)) - } - disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))} - onCheckedChange={(checked) => { - onDiscoverEnvVarsChange( - checked ? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s)) : [] - ); - }} - /> - )} -
- - Select which environments should automatically discover and create new environment - variables from Vercel during builds. - -
-
- {availableEnvSlugs.map((slug) => { - const envType = envSlugToType(slug); - const disabled = isSlugDisabled(slug); - const disabledReason = disabledEnvSlugs?.[slug]; - const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug); - const row = ( -
-
- - - {environmentFullTitle({ type: envType })} - -
+ {layout === "card" && ( +
+
+
+ + {!hideSectionToggles && availableEnvSlugs.length > 1 && ( 0 && + enabledSlugs.every( + (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s) + ) && + enabledSlugs.some((s) => discoverEnvVars.includes(s)) + } + disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))} onCheckedChange={(checked) => { onDiscoverEnvVarsChange( - checked - ? [...discoverEnvVars, slug] - : discoverEnvVars.filter((s) => s !== slug) + checked ? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s)) : [] ); }} /> -
- ); - if (disabled && disabledReason) { - return ; - } - return row; - })} + )} +
+ + Select which environments should automatically discover and create new environment + variables from Vercel during builds. + +
+
+ {availableEnvSlugs.map((slug) => { + const envType = envSlugToType(slug); + const disabled = isSlugDisabled(slug); + const disabledReason = disabledEnvSlugs?.[slug]; + const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug); + const row = ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + onDiscoverEnvVarsChange( + checked + ? [...discoverEnvVars, slug] + : discoverEnvVars.filter((s) => s !== slug) + ); + }} + /> +
+ ); + if (disabled && disabledReason) { + return ( + + ); + } + return row; + })} +
-
+ )} + + {atomicSections} {/* Atomic deployments */} -
-
- - { - onAtomicBuildsChange(checked ? ["prod"] : []); - }} - /> -
- - When enabled, production deployments wait for Vercel deployment to complete before - promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom Production - Domains" option in your Vercel project settings to perform staged deployments.{" "} - - Learn more - - . - - {currentTriggerVersion && ( + {layout === "card" && ( +
+
+ + { + onAtomicBuildsChange(checked ? ["prod"] : []); + }} + /> +
- Currently pinned to{" "} - {currentTriggerVersion} in Vercel - production. + When enabled, production deployments wait for Vercel deployment to complete before + promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom + Production Domains" option in your Vercel project settings to perform staged + deployments.{" "} + + Learn more + + . - )} - {!currentTriggerVersion && currentTriggerVersionFetchFailed && ( - - Couldn't read TRIGGER_VERSION from - Vercel — check the Vercel dashboard to confirm the production pin. - - )} -
+ {currentTriggerVersion && ( + + Currently pinned to{" "} + {currentTriggerVersion} in Vercel + production. + + )} + {!currentTriggerVersion && currentTriggerVersionFetchFailed && ( + + Couldn't read TRIGGER_VERSION from + Vercel — check the Vercel dashboard to confirm the production pin. + + )} +
+ )} {/* Auto promotion — only visible when atomic deployments are on */} - {atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && ( + {layout === "card" && atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && (
@@ -248,3 +399,66 @@ export function BuildSettingsFields({ ); } + +/** + * One environment's toggle as a settings row. The reason a toggle is disabled + * is shown as a permanent description rather than a hover tooltip, and the + * environment badge dims to match. + */ +function EnvToggleRow({ + slug, + checked, + disabled, + disabledReason, + unlockHint, + unlockTarget, + onCheckedChange, +}: { + slug: EnvSlug; + checked: boolean; + disabled: boolean; + disabledReason?: string; + /** Names the control that unlocks this row; see tailwind.css for the rules. */ + unlockHint?: string; + /** Name other rows can point at to highlight this row's toggle. */ + unlockTarget?: string; + onCheckedChange: (checked: boolean) => void; +}) { + const envType = envSlugToType(slug); + + return ( + + + + } + > +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ {disabled && disabledReason ? ( + {disabledReason} + ) : null} +
+
+ ); +} diff --git a/apps/webapp/app/components/integrations/devRevealIntegrations.tsx b/apps/webapp/app/components/integrations/devRevealIntegrations.tsx index 57342ba7b92..d5086c39a91 100644 --- a/apps/webapp/app/components/integrations/devRevealIntegrations.tsx +++ b/apps/webapp/app/components/integrations/devRevealIntegrations.tsx @@ -101,4 +101,17 @@ export const devRevealConnectedVercelProject = { integrationData: devRevealVercelIntegrationData, }; +/** Staging environment exists but no Vercel environment is mapped to it yet. */ +export const devRevealConnectedVercelProjectUnmappedStaging = { + ...devRevealConnectedVercelProject, + integrationData: { + ...devRevealVercelIntegrationData, + config: { + ...devRevealVercelIntegrationData.config, + atomicBuilds: [], + vercelStagingEnvironment: null, + }, + }, +}; + export const devRevealVercelCustomEnvironments = [{ id: "env_dev", slug: "staging" }]; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 66ccc275da1..ea791de98ee 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -1,6 +1,10 @@ import { getFormProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; -import { CheckCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid"; +import { + CheckCircleIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { Form, useActionData, useFetcher, useLocation, useNavigation } from "@remix-run/react"; import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; @@ -11,26 +15,28 @@ import { z } from "zod"; import { DevRevealLabel, devRevealConnectedVercelProject, + devRevealConnectedVercelProjectUnmappedStaging, devRevealVercelCustomEnvironments, } from "~/components/integrations/devRevealIntegrations"; +import { + EnvironmentIcon, + environmentTextClassName, +} from "~/components/environments/EnvironmentLabel"; import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings"; import { VercelLogo } from "~/components/integrations/VercelLogo"; import { Button } from "~/components/primitives/Buttons"; -import { Callout } from "~/components/primitives/Callout"; import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; -import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; -import { Hint } from "~/components/primitives/Hint"; -import { InputGroup } from "~/components/primitives/InputGroup"; import { Paragraph } from "~/components/primitives/Paragraph"; import { PermissionLink } from "~/components/primitives/PermissionLink"; import { Select, SelectItem } from "~/components/primitives/Select"; import { SettingsActions, - SettingsBlock, SettingsRow, + SettingsRowDescription, + SettingsRowTitle, } from "~/components/primitives/SettingsLayout"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { @@ -66,6 +72,7 @@ import { getAvailableEnvSlugsForBuildSettings, } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server"; +import { cn } from "~/utils/cn"; export type ConnectedVercelProject = { id: string; @@ -523,10 +530,74 @@ export const action = dashboardAction( } ); -function VercelConnectionPrompt({ +/** + * A warning or error rendered as a settings row: hazard icon and title on the + * left with the explanation beneath, both in the severity colour, and the + * recovery action on the right. + */ +function SettingsAlertRow({ + variant, + title, + description, + action, +}: { + variant: "warning" | "error"; + title: string; + description: React.ReactNode; + action?: React.ReactNode; +}) { + const Icon = variant === "error" ? ExclamationCircleIcon : ExclamationTriangleIcon; + const color = variant === "error" ? "text-error" : "text-warning"; + + return ( + +
+
+ + {title} +
+ {description} +
+
+ ); +} + +/** + * A selectable Vercel environment, shown with the Staging environment badge it + * will map onto so the mapping reads as "this Vercel env becomes Staging". + */ +function StagingEnvOption({ name }: { name: string }) { + return ( + + + {name} + + ); +} + +/** "Vercel app - Installed" status row, mirroring the GitHub app row. */ +function VercelAppInstalledRow() { + return ( + + + Installed + + } + /> + ); +} + +/** + * The "not yet connected" Vercel states as settings rows: install the app, + * then connect a project. Mirrors GitHubSettingsRows. + */ +function VercelSettingsRows({ organizationSlug, projectSlug, - environmentSlug, + environmentSlug: _environmentSlug, hasOrgIntegration, isGitHubConnected, onOpenModal, @@ -542,67 +613,55 @@ function VercelConnectionPrompt({ isLoading?: boolean; canManageVercel?: boolean; }) { - const installPath = vercelAppInstallPath(organizationSlug, projectSlug); - - const handleConnectProject = () => { - if (onOpenModal) { - onOpenModal(); - } - }; - + const noPermissionTooltip = "You don't have permission to manage the Vercel integration"; const isLoadingProjects = isLoading ?? false; - const isDisabled = isLoadingProjects || !onOpenModal; return ( -
- -
-
- {hasOrgIntegration ? ( - <> - - - Vercel app is installed - - {!onOpenModal && ( - - Please reconnect Vercel to continue - - )} - - ) : ( - <> - } - > - Install Vercel app - - - )} -
-
-
-
+ <> + {hasOrgIntegration ? ( + + ) : ( + } + > + Install Vercel app + + } + /> + )} + + {hasOrgIntegration && ( + onOpenModal?.()} + disabled={isLoadingProjects || !onOpenModal || !canManageVercel} + tooltip={canManageVercel ? undefined : noPermissionTooltip} + LeadingIcon={ + isLoadingProjects + ? () => + : () => + } + > + {isLoadingProjects ? "Loading projects…" : "Connect Vercel project"} + + } + /> + )} + + {!isGitHubConnected && } + ); } @@ -615,42 +674,32 @@ function VercelAuthInvalidBanner({ projectSlug: string; canManageVercel?: boolean; }) { - const installUrl = vercelAppInstallPath(organizationSlug, projectSlug); - return ( - -
-
-

- Vercel connection expired -

-

- Your Vercel access token has expired or been revoked. Please reconnect to restore - functionality. -

- - Reconnect Vercel - -
-
-
+ + Reconnect Vercel + + } + /> ); } function VercelGitHubWarning() { return ( - -

- GitHub integration is not connected. Vercel integration cannot sync environment variables - and link deployments without a properly installed GitHub integration. -

-
+ ); } @@ -798,7 +847,7 @@ function ConnectedVercelProjectForm({ const disabledEnvSlugsForBuildSettings: Partial> | undefined = hasStagingEnvironment && !configValues.vercelStagingEnvironment - ? { stg: "Map a custom Vercel environment to Staging to enable this" } + ? { stg: "Set a Vercel environment for Staging first." } : undefined; const _formatSelectedEnvs = ( @@ -813,11 +862,37 @@ function ConnectedVercelProjectForm({ return ( <> + + Connected + + } + /> + + + + Vercel project + + {connectedProject.vercelProjectName} connected on{" "} + + . + + } action={ } - > -
- - - {connectedProject.vercelProjectName} - - - - -
-
+ /> {/* Configuration form */}
@@ -911,110 +970,110 @@ function ConnectedVercelProjectForm({ {/* Staging environment mapping */} {hasStagingEnvironment && customEnvironments && customEnvironments.length > 0 && ( { - if (!Array.isArray(value)) { - const env = customEnvironments?.find((e) => e.id === value); - setConfigValues((prev) => { - const next = { - ...prev, - vercelStagingEnvironment: env - ? { environmentId: env.id, displayName: env.slug } - : null, - }; - // When clearing the staging mapping, strip "stg" from build settings - if (!env) { - next.pullEnvVarsBeforeBuild = prev.pullEnvVarsBeforeBuild.filter( - (s) => s !== "stg" - ); - next.discoverEnvVars = prev.discoverEnvVars.filter((s) => s !== "stg"); - } - return next; - }); +
+ + > + {[ + + None + , + ...customEnvironments.map((env) => ( + + + + )), + ]} + +
} /> )} - - - setConfigValues((prev) => ({ ...prev, pullEnvVarsBeforeBuild: slugs })) - } - discoverEnvVars={configValues.discoverEnvVars} - onDiscoverEnvVarsChange={(slugs) => - setConfigValues((prev) => ({ ...prev, discoverEnvVars: slugs })) - } - atomicBuilds={configValues.atomicBuilds} - onAtomicBuildsChange={(slugs) => - setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs })) - } - envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/environment-variables`} - disabledEnvSlugs={disabledEnvSlugsForBuildSettings} - autoPromote={configValues.autoPromote} - onAutoPromoteChange={(value) => - setConfigValues((prev) => ({ ...prev, autoPromote: value })) - } - currentTriggerVersion={currentTriggerVersion} - currentTriggerVersionFetchFailed={currentTriggerVersionFetchFailed} - hideSectionToggles - /> - + + setConfigValues((prev) => ({ ...prev, pullEnvVarsBeforeBuild: slugs })) + } + discoverEnvVars={configValues.discoverEnvVars} + onDiscoverEnvVarsChange={(slugs) => + setConfigValues((prev) => ({ ...prev, discoverEnvVars: slugs })) + } + atomicBuilds={configValues.atomicBuilds} + onAtomicBuildsChange={(slugs) => + setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs })) + } + envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/environment-variables`} + disabledEnvSlugs={disabledEnvSlugsForBuildSettings} + autoPromote={configValues.autoPromote} + onAutoPromoteChange={(value) => + setConfigValues((prev) => ({ ...prev, autoPromote: value })) + } + currentTriggerVersion={currentTriggerVersion} + currentTriggerVersionFetchFailed={currentTriggerVersionFetchFailed} + hideSectionToggles + layout="settings" + /> {/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */} {autoAssignCustomDomains !== false && configValues.atomicBuilds.includes("prod") && ( - - -
-

- Atomic deployments require the "Auto-assign Custom Domains" setting to be disabled - on your Vercel project. Without this, Vercel will promote deployments before - Trigger.dev is ready. -

- {/* Submitted via fetcher rather than a nested , which the - browser drops during SSR and which breaks hydration. */} - -
-
-
+ + disableAutoAssignFetcher.submit( + { action: "disable-auto-assign" }, + { method: "post", action: actionUrl } + ) + } + > + Disable auto-assign + + } + /> )} {configForm.errors} @@ -1155,81 +1214,130 @@ function VercelSettingsPanel({ ); } - // DEV_REVEAL_ALL: stack the mutually exclusive states so all of them are visible. + // DEV_REVEAL_ALL: stack the mutually exclusive states so all of them are + // visible, ordered the way a user unlocks them. if (devReveal) { + const canManage = data?.canManageVercel ?? true; + const connectedProject = data?.connectedProject ?? devRevealConnectedVercelProject; + // `?? ` is not enough here: the loader returns an empty array when Vercel + // isn't configured, which would hide the staging mapping row. + const customEnvironments = data?.customEnvironments?.length + ? data.customEnvironments + : devRevealVercelCustomEnvironments; + return ( <> - Connected project + 1. GitHub not connected (Vercel can't sync anything yet) + + + 2. App not installed + + + 3. App installed, no project connected + + + + 4. Connected, no Vercel environment mapped to Staging (Staging rows disabled) + + + + + + 5. Connected, atomic deployments need auto-assign custom domains off + + - Connection expired - - - - - GitHub not connected - - - - - App installed, no project connected - -
- - - Connect your Vercel project to pull environment variables and trigger builds - automatically. - -
-
- - App not installed - -
- - - Install the Vercel app to connect your projects and pull environment variables. - - - GitHub integration is not connected. Vercel integration cannot sync environment - variables and link deployments without a properly installed GitHub integration. - -
-
+ 6. Connected and fully configured + + + + 7. Connected, but GitHub was disconnected afterwards + + + + + 8. Connection expired (settings hidden until reconnected) + ); } @@ -1252,6 +1360,7 @@ function VercelSettingsPanel({ /> )} {showGitHubWarning && } + {!showAuthInvalid && } {!showAuthInvalid && ( + ); + } + return ( -
- {showAuthInvalid && ( - - )} - {!showAuthInvalid && ( - <> - - - {data.hasOrgIntegration - ? "Connect your Vercel project to pull environment variables and trigger builds automatically." - : "Install the Vercel app to connect your projects and pull environment variables."} - - {!data.isGitHubConnected && ( - - GitHub integration is not connected. Vercel integration cannot sync environment - variables and link deployments without a properly installed GitHub integration. - - )} - - )} -
+ ); } From 688de8b72f7d89f6ad9cecfd8ebeaf6b9ad098c7 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 26 Jul 2026 18:51:12 +0100 Subject: [PATCH 06/17] fix(webapp): even out the vertical padding on settings rows Every settings row carried about 4px more space above its title than below its description, despite a symmetric py-4. SettingsRowTitle renders an inline element, so inside the block-level row wrapper it sat in an anonymous line box sized by the inherited line-height (24px) rather than by its own leading-tight (17.5px). The surplus split above and below the title, but only the top half read as whitespace because the description absorbed the bottom half. Making the title block-level sizes its box to its own line-height. Applies anywhere the row is used, so the org SSO page gets the same correction as the project settings pages. --- apps/webapp/app/components/primitives/SettingsLayout.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/SettingsLayout.tsx b/apps/webapp/app/components/primitives/SettingsLayout.tsx index 10781cd7bb1..4304790e76e 100644 --- a/apps/webapp/app/components/primitives/SettingsLayout.tsx +++ b/apps/webapp/app/components/primitives/SettingsLayout.tsx @@ -100,7 +100,12 @@ export function SettingsRowTitle({ htmlFor?: string; className?: string; }) { - const classes = cn("font-sans text-sm font-semibold leading-tight text-text-bright", className); + // `block` matters: as an inline element this would sit in an anonymous line + // box sized by the inherited line-height, padding the top of every row. + const classes = cn( + "block font-sans text-sm font-semibold leading-tight text-text-bright", + className + ); return htmlFor ? (