feat(webapp): Improve the Integrations page layout (#4379)

## Summary

The project Integrations page now uses the same settings layout as the
org SSO page: a centered column of titled rows with dividers, instead of
headings over bordered boxes. GitHub, Vercel and build settings read as
one consistent list, and the page titles itself "Integrations".

Confirmations persist rather than vanishing once you move past them
(`GitHub app: Installed`, `Vercel project: Connected`), plan-gated rows
offer an Upgrade button instead of a dead toggle, a disabled toggle
explains why in place and highlights the control that unlocks it, and
warnings are rows with a hazard icon and their recovery action on the
right. Copy throughout leads with the outcome instead of restating the
field label.

Two fixes along the way: a nested `<form>` in the Vercel panel that
failed hydration and silently truncated the page, and every settings row
carrying a few pixels more space above its title than below its
description.

### Before
<img width="1160" height="1972" alt="CleanShot 2026-07-26 at 21 56
42@2x"
src="https://github.com/user-attachments/assets/ed0fd676-36d8-4eb7-a16e-827a24f007d9"
/>


### After
<img width="1358" height="4455" alt="CleanShot 2026-07-26 at 19 14
28@2x"
src="https://github.com/user-attachments/assets/6a635e6a-c0eb-4a4c-a68f-fcde4d25e8a6"
/>
This commit is contained in:
James Ritchie
2026-07-27 16:34:44 +01:00
committed by GitHub
parent d30ee6e570
commit 73eb4c5c16
14 changed files with 1374 additions and 871 deletions
@@ -0,0 +1,34 @@
export function PadlockRoundedIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12C5 10.8954 5.89543 10 7 10H17C18.1046 10 19 10.8954 19 12V19C19 20.1046 18.1046 21 17 21H7C5.89543 21 5 20.1046 5 19V12Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 9.5V7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7V9.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 14V17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -701,14 +701,12 @@ function DeploymentOnboardingSteps() {
Deploy automatically with every push. Read the{" "}
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
</Paragraph>
<div className="w-fit">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"cli"}>
@@ -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,7 @@ type BuildSettingsFieldsProps = {
currentTriggerVersionFetchFailed?: boolean;
/** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */
hideSectionToggles?: boolean;
layout?: "settings" | "card";
};
export function BuildSettingsFields({
@@ -48,187 +56,326 @@ export function BuildSettingsFields({
currentTriggerVersion,
currentTriggerVersionFetchFailed,
hideSectionToggles,
layout = "card",
}: BuildSettingsFieldsProps) {
const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug];
const enabledSlugs = availableEnvSlugs.filter((s) => !isSlugDisabled(s));
return (
<>
{/* Pull env vars before build */}
<div>
<div className="mb-2">
<div className="flex items-center justify-between">
<Label>Pull env vars before build</Label>
{!hideSectionToggles && availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
enabledSlugs.length > 0 &&
enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
}
onCheckedChange={(checked) => {
onPullEnvVarsChange(checked ? [...enabledSlugs] : []);
}}
/>
)}
</div>
<Hint className="pr-6">
Select which environments should pull environment variables from Vercel before each
build.{" "}
{envVarsConfigLink && (
<>
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
</>
)}
</Hint>
</div>
<div className="flex flex-col gap-2 rounded border bg-background-bright p-3">
{availableEnvSlugs.map((slug) => {
const envType = envSlugToType(slug);
const disabled = isSlugDisabled(slug);
const disabledReason = disabledEnvSlugs?.[slug];
const row = (
<div
key={slug}
className={`flex items-center justify-between ${disabled ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
{environmentFullTitle({ type: envType })}
</span>
</div>
<Switch
variant="small"
checked={disabled ? false : pullEnvVarsBeforeBuild.includes(slug)}
disabled={disabled}
onCheckedChange={(checked) => {
onPullEnvVarsChange(
checked
? [...pullEnvVarsBeforeBuild, slug]
: pullEnvVarsBeforeBuild.filter((s) => s !== slug)
);
}}
/>
</div>
);
if (disabled && disabledReason) {
return <SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />;
}
return row;
})}
</div>
</div>
{/* Discover new env vars */}
<div>
<div className="mb-2">
<div className="flex items-center justify-between">
<Label>Discover new env vars</Label>
{!hideSectionToggles && availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
enabledSlugs.length > 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)) : []
);
}}
/>
)}
</div>
<Hint className="pr-6">
Select which environments should automatically discover and create new environment
variables from Vercel during builds.
</Hint>
</div>
<div className="flex flex-col gap-2 rounded border bg-background-bright p-3">
{availableEnvSlugs.map((slug) => {
const envType = envSlugToType(slug);
const disabled = isSlugDisabled(slug);
const disabledReason = disabledEnvSlugs?.[slug];
const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
const row = (
<div
key={slug}
className={`flex items-center justify-between ${disabled || isPullDisabled ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
{environmentFullTitle({ type: envType })}
</span>
</div>
<Switch
variant="small"
checked={disabled ? false : discoverEnvVars.includes(slug)}
disabled={disabled || isPullDisabled}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked
? [...discoverEnvVars, slug]
: discoverEnvVars.filter((s) => s !== slug)
);
}}
/>
</div>
);
if (disabled && disabledReason) {
return <SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />;
}
return row;
})}
</div>
</div>
{/* Atomic deployments */}
<div>
<div className="flex items-center justify-between">
<Label>Atomic deployments</Label>
<Switch
variant="small"
checked={atomicBuilds.includes("prod")}
const envVarSections =
layout === "settings" ? (
<>
<SettingsRow
align="end"
title="Pull env vars before build"
description="Pulled from Vercel on every build."
action={
envVarsConfigLink ? (
<LinkButton to={envVarsConfigLink} variant="secondary/small">
Configure env vars
</LinkButton>
) : undefined
}
/>
{availableEnvSlugs.map((slug) => (
<EnvToggleRow
key={`pull-${slug}`}
slug={slug}
checked={isSlugDisabled(slug) ? false : pullEnvVarsBeforeBuild.includes(slug)}
disabled={isSlugDisabled(slug)}
disabledReason={disabledEnvSlugs?.[slug]}
unlockHint={isSlugDisabled(slug) ? "staging-env" : undefined}
unlockTarget={`pull-${slug}`}
onCheckedChange={(checked) => {
onAtomicBuildsChange(checked ? ["prod"] : []);
onPullEnvVarsChange(
checked
? [...pullEnvVarsBeforeBuild, slug]
: pullEnvVarsBeforeBuild.filter((s) => s !== slug)
);
}}
/>
))}
<SettingsRow
title="Discover new env vars"
description="New variables on Vercel are created automatically during builds."
/>
{availableEnvSlugs.map((slug) => {
const pullOff = !pullEnvVarsBeforeBuild.includes(slug);
return (
<EnvToggleRow
key={`discover-${slug}`}
slug={slug}
checked={isSlugDisabled(slug) ? false : discoverEnvVars.includes(slug)}
disabled={isSlugDisabled(slug) || pullOff}
disabledReason={
disabledEnvSlugs?.[slug] ??
(pullOff ? "Pull env vars for this environment first." : undefined)
}
unlockHint={
isSlugDisabled(slug) ? "staging-env" : pullOff ? `pull-${slug}` : undefined
}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked ? [...discoverEnvVars, slug] : discoverEnvVars.filter((s) => s !== slug)
);
}}
/>
);
})}
</>
) : null;
const atomicSections =
layout === "settings" ? (
<>
<SettingsRow
action={
<Switch
variant="medium"
checked={atomicBuilds.includes("prod")}
onCheckedChange={(checked) => {
onAtomicBuildsChange(checked ? ["prod"] : []);
}}
/>
}
>
<div className="flex-1 space-y-1">
<SettingsRowTitle>Atomic deployments</SettingsRowTitle>
<SettingsRowDescription>
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.{" "}
<TextLink
href="https://trigger.dev/docs/vercel-integration#atomic-deployments"
target="_blank"
>
Learn more
</TextLink>
.
</SettingsRowDescription>
{currentTriggerVersion && (
<Hint>
Currently pinned to{" "}
<span className="font-mono text-text-bright">{currentTriggerVersion}</span> in
Vercel production.
</Hint>
)}
{!currentTriggerVersion && currentTriggerVersionFetchFailed && (
<Hint className="text-warning">
Couldn't read <span className="font-mono text-text-bright">TRIGGER_VERSION</span>{" "}
from Vercel. Check the Vercel dashboard to confirm the production pin.
</Hint>
)}
</div>
</SettingsRow>
{atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && (
<SettingsRow
title="Auto promotion"
description="Once your tasks finish deploying, Trigger.dev promotes the Vercel deployment for you. Turn this off to promote from the Vercel dashboard yourself, and Trigger.dev will follow as soon as you do."
action={
<Switch
variant="medium"
checked={autoPromote ?? true}
onCheckedChange={onAutoPromoteChange}
/>
}
/>
)}
</>
) : null;
return (
<>
{envVarSections}
{/* Pull env vars before build */}
{layout === "card" && (
<div>
<div className="mb-2">
<div className="flex items-center justify-between">
<Label>Pull env vars before build</Label>
{!hideSectionToggles && availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
enabledSlugs.length > 0 &&
enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
}
onCheckedChange={(checked) => {
onPullEnvVarsChange(checked ? [...enabledSlugs] : []);
}}
/>
)}
</div>
<Hint className="pr-6">
Select which environments should pull environment variables from Vercel before each
build.{" "}
{envVarsConfigLink && (
<>
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
</>
)}
</Hint>
</div>
<div className="flex flex-col gap-2 rounded border bg-background-bright p-3">
{availableEnvSlugs.map((slug) => {
const envType = envSlugToType(slug);
const disabled = isSlugDisabled(slug);
const disabledReason = disabledEnvSlugs?.[slug];
const row = (
<div
key={slug}
className={`flex items-center justify-between ${disabled ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
{environmentFullTitle({ type: envType })}
</span>
</div>
<Switch
variant="small"
checked={disabled ? false : pullEnvVarsBeforeBuild.includes(slug)}
disabled={disabled}
onCheckedChange={(checked) => {
onPullEnvVarsChange(
checked
? [...pullEnvVarsBeforeBuild, slug]
: pullEnvVarsBeforeBuild.filter((s) => s !== slug)
);
}}
/>
</div>
);
if (disabled && disabledReason) {
return (
<SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />
);
}
return row;
})}
</div>
</div>
<Hint className="pr-6">
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.{" "}
<TextLink
href="https://trigger.dev/docs/vercel-integration#atomic-deployments"
target="_blank"
>
Learn more
</TextLink>
.
</Hint>
{currentTriggerVersion && (
)}
{/* Discover new env vars */}
{layout === "card" && (
<div>
<div className="mb-2">
<div className="flex items-center justify-between">
<Label>Discover new env vars</Label>
{!hideSectionToggles && availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
enabledSlugs.length > 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)) : []
);
}}
/>
)}
</div>
<Hint className="pr-6">
Select which environments should automatically discover and create new environment
variables from Vercel during builds.
</Hint>
</div>
<div className="flex flex-col gap-2 rounded border bg-background-bright p-3">
{availableEnvSlugs.map((slug) => {
const envType = envSlugToType(slug);
const disabled = isSlugDisabled(slug);
const disabledReason = disabledEnvSlugs?.[slug];
const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
const row = (
<div
key={slug}
className={`flex items-center justify-between ${disabled || isPullDisabled ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
{environmentFullTitle({ type: envType })}
</span>
</div>
<Switch
variant="small"
checked={disabled ? false : discoverEnvVars.includes(slug)}
disabled={disabled || isPullDisabled}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked
? [...discoverEnvVars, slug]
: discoverEnvVars.filter((s) => s !== slug)
);
}}
/>
</div>
);
if (disabled && disabledReason) {
return (
<SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />
);
}
return row;
})}
</div>
</div>
)}
{atomicSections}
{/* Atomic deployments */}
{layout === "card" && (
<div>
<div className="flex items-center justify-between">
<Label>Atomic deployments</Label>
<Switch
variant="small"
checked={atomicBuilds.includes("prod")}
onCheckedChange={(checked) => {
onAtomicBuildsChange(checked ? ["prod"] : []);
}}
/>
</div>
<Hint className="pr-6">
Currently pinned to{" "}
<span className="font-mono text-text-bright">{currentTriggerVersion}</span> 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.{" "}
<TextLink
href="https://trigger.dev/docs/vercel-integration#atomic-deployments"
target="_blank"
>
Learn more
</TextLink>
.
</Hint>
)}
{!currentTriggerVersion && currentTriggerVersionFetchFailed && (
<Hint className="pr-6 text-warning">
Couldn't read <span className="font-mono text-text-bright">TRIGGER_VERSION</span> from
Vercel check the Vercel dashboard to confirm the production pin.
</Hint>
)}
</div>
{currentTriggerVersion && (
<Hint className="pr-6">
Currently pinned to{" "}
<span className="font-mono text-text-bright">{currentTriggerVersion}</span> in Vercel
production.
</Hint>
)}
{!currentTriggerVersion && currentTriggerVersionFetchFailed && (
<Hint className="pr-6 text-warning">
Couldn't read <span className="font-mono text-text-bright">TRIGGER_VERSION</span> from
Vercel check the Vercel dashboard to confirm the production pin.
</Hint>
)}
</div>
)}
{/* Auto promotion — only visible when atomic deployments are on */}
{atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && (
{layout === "card" && atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && (
<div>
<div className="flex items-center justify-between">
<Label>Auto promotion</Label>
@@ -248,3 +395,59 @@ export function BuildSettingsFields({
</>
);
}
function EnvToggleRow({
slug,
checked,
disabled,
disabledReason,
unlockHint,
unlockTarget,
onCheckedChange,
}: {
slug: EnvSlug;
checked: boolean;
disabled: boolean;
disabledReason?: string;
unlockHint?: string;
unlockTarget?: string;
onCheckedChange: (checked: boolean) => void;
}) {
const envType = envSlugToType(slug);
return (
<SettingsRow
className={disabled && unlockHint ? `unlock-hint-${unlockHint}` : undefined}
action={
<span data-unlock-target={unlockTarget}>
<Switch
variant="medium"
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</span>
}
>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-1.5">
<EnvironmentIcon
environment={{ type: envType }}
className={cn("size-4", disabled && "text-text-dimmed/50")}
/>
<span
className={cn(
"text-sm",
disabled ? "text-text-dimmed/50" : environmentTextClassName({ type: envType })
)}
>
{environmentFullTitle({ type: envType })}
</span>
</div>
{disabled && disabledReason ? (
<SettingsRowDescription>{disabledReason}</SettingsRowDescription>
) : null}
</div>
</SettingsRow>
);
}
@@ -13,6 +13,7 @@ import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header3 } from "~/components/primitives/Headers";
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 { Select, SelectItem } from "~/components/primitives/Select";
@@ -797,10 +798,8 @@ export function VercelOnboardingModal({
<div className="mt-4">
{showProjectSelection && (
<div className="flex flex-col gap-4">
<Header3>Select Vercel Project</Header3>
<Paragraph className="text-sm">
Choose which Vercel project to connect with this Trigger.dev project. Your API keys
will be automatically synced to Vercel.
<Paragraph>
Choose the Vercel project to pair with this Trigger.dev project.
</Paragraph>
{availableProjects.length === 0 ? (
@@ -808,41 +807,42 @@ export function VercelOnboardingModal({
No Vercel projects found. Please create a project in Vercel first.
</Callout>
) : (
<Select
disabled={availableProjects.length === 1}
value={selectedVercelProject?.id || ""}
setValue={(value) => {
if (!Array.isArray(value)) {
const project = availableProjects.find((p) => p.id === value);
setSelectedVercelProject(project || null);
setProjectSelectionError(null);
}
}}
items={availableProjects}
filter={availableProjects.length > 5 ? { keys: ["name"] } : undefined}
variant="tertiary/medium"
placeholder="Select a Vercel project"
dropdownIcon
text={selectedVercelProject?.name || "Select a project"}
>
{availableProjects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</Select>
<InputGroup fullWidth>
<Select
disabled={availableProjects.length === 1}
value={selectedVercelProject?.id || ""}
setValue={(value) => {
if (!Array.isArray(value)) {
const project = availableProjects.find((p) => p.id === value);
setSelectedVercelProject(project || null);
setProjectSelectionError(null);
}
}}
items={availableProjects}
filter={availableProjects.length > 5 ? { keys: ["name"] } : undefined}
variant="secondary/medium"
placeholder="Select a Vercel project"
dropdownIcon
text={selectedVercelProject?.name || "Select a project"}
>
{availableProjects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</Select>
<Hint>
Your{" "}
<code className="rounded bg-background-raised px-1 py-0.5 text-xs text-text-bright">
TRIGGER_SECRET_KEY
</code>{" "}
is synced to Vercel for each environment once connected.
</Hint>
</InputGroup>
)}
{projectSelectionError && <FormError>{projectSelectionError}</FormError>}
<Hint>
Once connected, your{" "}
<code className="text-xs rounded bg-background-raised px-1 py-0.5 text-text-bright">
TRIGGER_SECRET_KEY
</code>{" "}
will be automatically synced to Vercel for each environment.
</Hint>
<FormButtons
confirmButton={
<div className="flex items-center gap-2">
@@ -866,7 +866,7 @@ export function VercelOnboardingModal({
</div>
}
cancelButton={
<Button variant="tertiary/medium" onClick={handleSkipOnboarding}>
<Button variant="secondary/medium" onClick={handleSkipOnboarding}>
Cancel
</Button>
}
@@ -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"),
@@ -38,6 +38,9 @@ const variants = {
"secondary/small": {
button: cn(sizes.small.button, style.secondary.button),
},
"secondary/medium": {
button: cn(sizes.medium.button, style.secondary.button),
},
"tertiary/small": {
button: cn(sizes.small.button, style.tertiary.button),
},
@@ -692,11 +695,11 @@ export function ComboBox({
...props
}: ComboBoxProps) {
return (
<div className="flex h-9 w-full flex-none items-center border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-hidden">
<div className="flex h-9 w-full flex-none items-center border-b border-grid-dimmed bg-transparent pl-0 pr-3 text-xs text-text-dimmed outline-hidden">
<Ariakit.Combobox
autoSelect={autoSelect}
render={<input placeholder={placeholder} />}
className="flex-1 bg-transparent text-xs text-text-dimmed outline-hidden"
className="flex-1 border-0 bg-transparent text-xs text-text-dimmed outline-hidden focus:border-0 focus:ring-0"
{...props}
/>
{shortcut && (
@@ -1,3 +1,4 @@
import { ExclamationCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { type ReactNode } from "react";
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
import { cn } from "~/utils/cn";
@@ -100,7 +101,10 @@ export function SettingsRowTitle({
htmlFor?: string;
className?: string;
}) {
const classes = cn("font-sans text-sm font-semibold leading-tight text-text-bright", className);
const classes = cn(
"block font-sans text-sm font-semibold leading-tight text-text-bright",
className
);
return htmlFor ? (
<label htmlFor={htmlFor} className={classes}>
{children}
@@ -152,14 +156,14 @@ export function SettingsRow({
className?: string;
titleClassName?: string;
size?: RowSize;
align?: "center" | "start";
align?: "center" | "start" | "end";
bordered?: boolean;
}) {
return (
<div
className={cn(
"flex w-full justify-between gap-8",
align === "center" ? "items-center" : "items-start",
align === "center" ? "items-center" : align === "end" ? "items-end" : "items-start",
rowSize[size],
bordered && "border-b border-grid-dimmed",
className
@@ -204,6 +208,38 @@ export function SettingsBlock({
);
}
/**
* A warning or error as a settings row: hazard icon and title on the left in the
* severity colour, the explanation beneath in the usual dimmed body text, and
* the recovery action on the right.
*/
export function SettingsAlertRow({
variant,
title,
description,
action,
}: {
variant: "warning" | "error";
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
}) {
const Icon = variant === "error" ? ExclamationCircleIcon : ExclamationTriangleIcon;
const color = variant === "error" ? "text-error" : "text-warning";
return (
<SettingsRow action={action}>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-1.5">
<Icon className={cn("size-4 shrink-0", color)} />
<SettingsRowTitle className={color}>{title}</SettingsRowTitle>
</div>
{description ? <SettingsRowDescription>{description}</SettingsRowDescription> : null}
</div>
</SettingsRow>
);
}
/** Right-aligned action bar, typically for a section's Save button. */
export function SettingsActions({
children,
@@ -6,18 +6,18 @@ 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 { 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 +41,8 @@ import {
VercelSettingsPanel,
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
export const handle = { pageTitle: "Integrations" };
export const loader = dashboardLoader(
{
params: EnvironmentParamSchema,
@@ -215,6 +217,7 @@ export default function IntegrationsSettingsPage() {
const nextUrl = searchParams.get("next");
const [isModalOpen, setIsModalOpen] = useState(false);
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
const onboardingData = vercelFetcher.data?.onboardingData ?? null;
// Helper to open modal and ensure query param is present
const openVercelOnboarding = useCallback(() => {
@@ -245,7 +248,7 @@ export default function IntegrationsSettingsPage() {
useEffect(() => {
if (hasQueryParam && vercelIntegrationEnabled) {
// Ensure query param is present and modal is open
if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
if (onboardingData && vercelFetcher.state === "idle") {
// Data is loaded, ensure modal is open (query param takes precedence)
if (!isModalOpen) {
openVercelOnboarding();
@@ -270,6 +273,7 @@ export default function IntegrationsSettingsPage() {
organization.slug,
project.slug,
environment.slug,
onboardingData,
vercelFetcher.data,
vercelFetcher.state,
isModalOpen,
@@ -288,7 +292,7 @@ export default function IntegrationsSettingsPage() {
// When data finishes loading (from query param), ensure modal is open
useEffect(() => {
if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
if (hasQueryParam && onboardingData && vercelFetcher.state === "idle") {
// Data loaded and query param is present, ensure modal is open
if (!isModalOpen) {
openVercelOnboarding();
@@ -309,7 +313,7 @@ export default function IntegrationsSettingsPage() {
});
}
if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
if (onboardingData) {
// Data already loaded, open modal immediately
openVercelOnboarding();
} else {
@@ -328,6 +332,7 @@ export default function IntegrationsSettingsPage() {
project.slug,
environment.slug,
vercelFetcher,
onboardingData,
setSearchParams,
hasQueryParam,
openVercelOnboarding,
@@ -335,74 +340,69 @@ export default function IntegrationsSettingsPage() {
// When data loads from button click, open modal
useEffect(() => {
if (
waitingForButtonClickRef.current &&
vercelFetcher.data?.onboardingData &&
vercelFetcher.state === "idle"
) {
if (waitingForButtonClickRef.current && 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]);
}, [onboardingData, 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>
<SettingsContainer className="md:mt-6">
{githubAppEnabled && (
<React.Fragment>
<SettingsSection>
<SettingsHeader title="Git settings" />
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
layout="settings"
/>
</SettingsSection>
{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>
)}
{vercelIntegrationEnabled && (
<SettingsSection>
<SettingsHeader title="Vercel integration" />
<VercelSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
onOpenVercelModal={handleOpenVercelModal}
isLoadingVercelData={
vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"
}
/>
</SettingsSection>
)}
<div>
<Header2 spacing>Build settings</Header2>
<Hint className="mb-2">
These settings apply to deployments triggered from GitHub and to CLI deployments
run with the <InlineCode variant="extra-small">--native-build-server</InlineCode>{" "}
flag.
</Hint>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
</div>
</div>
</React.Fragment>
)}
</div>
</MainHorizontallyCenteredContainer>
<SettingsSection>
<SettingsHeader
title="Build settings"
description={
<>
Applies to deployments triggered from GitHub, and CLI deployments run with the{" "}
<InlineCode variant="extra-small" className="whitespace-nowrap">
--native-build-server
</InlineCode>{" "}
flag.
</>
}
/>
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
</SettingsSection>
</React.Fragment>
)}
</SettingsContainer>
{/* Vercel Onboarding Modal */}
{vercelIntegrationEnabled && (
<VercelOnboardingModal
isOpen={isModalOpen}
onClose={closeVercelOnboarding}
onboardingData={vercelFetcher.data?.onboardingData ?? null}
onboardingData={onboardingData}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
@@ -471,109 +471,122 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
return (
<Form method="post" {...getFormProps(buildSettingsForm)}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
<Input
{...getInputProps(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.errors}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.installCommand.id}>Install command</Label>
<Input
{...getInputProps(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.errors?.join(", ")}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
<Input
{...getInputProps(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.errors?.join(", ")}
</FormError>
</InputGroup>
<div className="border-t border-grid-dimmed pt-4">
<InputGroup>
<CheckboxWithLabel
{...getInputProps(fields.useNativeBuildServer, { type: "checkbox" })}
label="Use native build server"
variant="simple/small"
defaultChecked={nativeBuildServerEnabled}
onChange={(isChecked) => {
<SettingsRow
align="start"
htmlFor={fields.triggerConfigFilePath.id}
title="Trigger config file"
description="Path relative to your repo root."
action={
<SettingsControl>
<Input
{...getInputProps(fields.triggerConfigFilePath, { type: "text" })}
variant="medium"
defaultValue={buildSettings?.triggerConfigFilePath || ""}
placeholder="trigger.config.ts"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
useNativeBuildServer: isChecked,
triggerConfigFilePath: e.target.value,
}));
}}
/>
<Hint>
Native build server builds don't rely on external build providers and are used by
default. Requires version 4.2.0 or newer.
</Hint>
<FormError id={fields.useNativeBuildServer.errorId}>
{fields.useNativeBuildServer.errors}
<FormError id={fields.triggerConfigFilePath.errorId}>
{fields.triggerConfigFilePath.errors}
</FormError>
</InputGroup>
</div>
<FormError>{buildSettingsForm.errors}</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>
</SettingsControl>
}
/>
<SettingsRow
align="start"
htmlFor={fields.installCommand.id}
title="Install command"
description="Runs from your repo root. Auto-detected by default."
action={
<SettingsControl>
<Input
{...getInputProps(fields.installCommand, { type: "text" })}
variant="medium"
defaultValue={buildSettings?.installCommand || ""}
placeholder="pnpm install"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
installCommand: e.target.value,
}));
}}
/>
<FormError id={fields.installCommand.errorId}>
{fields.installCommand.errors?.join(", ")}
</FormError>
</SettingsControl>
}
/>
<SettingsRow
align="start"
htmlFor={fields.preBuildCommand.id}
title="Pre-build command"
description="Runs from your repo root, before the build."
action={
<SettingsControl>
<Input
{...getInputProps(fields.preBuildCommand, { type: "text" })}
variant="medium"
defaultValue={buildSettings?.preBuildCommand || ""}
placeholder="npm run prisma:generate"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
preBuildCommand: e.target.value,
}));
}}
/>
<FormError id={fields.preBuildCommand.errorId}>
{fields.preBuildCommand.errors?.join(", ")}
</FormError>
</SettingsControl>
}
/>
<SettingsRow
title="Use native build server"
description="Builds without an external build provider. Requires trigger.dev v4.2.0 or newer."
action={
<Switch
variant="medium"
name={fields.useNativeBuildServer.name}
defaultChecked={nativeBuildServerEnabled}
onCheckedChange={(isChecked) => {
setBuildSettingsValues((prev) => ({
...prev,
useNativeBuildServer: isChecked,
}));
}}
/>
}
/>
<FormError id={fields.useNativeBuildServer.errorId}>
{fields.useNativeBuildServer.errors}
</FormError>
<FormError>{buildSettingsForm.errors}</FormError>
<SettingsActions>
<Button
type="submit"
name="action"
value="update-build-settings"
variant="secondary/small"
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
</SettingsActions>
</Form>
);
}
function SettingsControl({ children }: { children: React.ReactNode }) {
return <div className="flex w-64 flex-col gap-1">{children}</div>;
}
@@ -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,25 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return null;
};
const DEFAULT_PAGE_TITLE = "Project settings";
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 (
<PageContainer>
<NavBar>
<PageTitle title="Project settings" />
<PageTitle title={pageTitle} />
<PageAccessories>
<AdminDebugTooltip>
@@ -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,7 @@ import {
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { Button } from "~/components/primitives/Buttons";
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 +40,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 +397,7 @@ export function ConnectGitHubRepoModal({
redirectUrl,
preventDismiss,
canManageGithub = true,
buttonVariant = "secondary/medium",
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
@@ -395,6 +407,7 @@ export function ConnectGitHubRepoModal({
/** When true, prevents closing the modal via Escape key or clicking outside */
preventDismiss?: boolean;
canManageGithub?: boolean;
buttonVariant?: "secondary/small" | "secondary/medium";
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const lastSubmission = useActionData() as any;
@@ -457,7 +470,7 @@ export function ConnectGitHubRepoModal({
<DialogTrigger asChild>
<Button
type="button"
variant={"secondary/medium"}
variant={buttonVariant}
LeadingIcon={OctoKitty}
disabled={!canManageGithub}
tooltip={
@@ -504,7 +517,7 @@ export function ConnectGitHubRepoModal({
setSelectedRepository(undefined);
}}
items={gitHubAppInstallations}
variant="tertiary/small"
variant="secondary/medium"
placeholder="Select account"
dropdownIcon
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
@@ -555,7 +568,7 @@ export function ConnectGitHubRepoModal({
);
setSelectedRepository(repository);
}}
variant="tertiary/small"
variant="secondary/medium"
placeholder="Select repository"
heading="Filter repositories"
dropdownIcon
@@ -606,7 +619,7 @@ export function ConnectGitHubRepoModal({
cancelButton={
preventDismiss ? undefined : (
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
)
}
@@ -678,6 +691,90 @@ export function GitHubConnectionPrompt({
);
}
function GitHubAppInstalledRow() {
return (
<SettingsRow
title="GitHub app"
action={
<span className="flex items-center gap-1.5 text-sm text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" />
Installed
</span>
}
/>
);
}
function GitHubSettingsRows({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
redirectUrl,
canManageGithub = true,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
redirectUrl?: string;
canManageGithub?: boolean;
}) {
const appInstalled = gitHubAppInstallations.length > 0;
const githubInstallationRedirect =
redirectUrl ||
v3ProjectSettingsIntegrationsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
);
return (
<>
{appInstalled ? (
<GitHubAppInstalledRow />
) : (
<SettingsRow
title="GitHub app"
description="Give Trigger.dev access to the repo you want to deploy from."
action={
<PermissionLink
hasPermission={canManageGithub}
noPermissionTooltip="You don't have permission to manage the GitHub integration"
to={githubAppInstallPath(
organizationSlug,
`${githubInstallationRedirect}?openGithubRepoModal=1`
)}
variant="secondary/small"
LeadingIcon={OctoKitty}
>
Install GitHub app
</PermissionLink>
}
/>
)}
{appInstalled && (
<SettingsRow
title="GitHub repo"
description="Connect a GitHub repo to automatically deploy changes."
action={
<ConnectGitHubRepoModal
gitHubAppInstallations={gitHubAppInstallations}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
redirectUrl={redirectUrl}
canManageGithub={canManageGithub}
buttonVariant="secondary/small"
/>
}
/>
)}
</>
);
}
export function ConnectedGitHubRepoForm({
connectedGitHubRepo,
previewEnvironmentEnabled,
@@ -736,21 +833,36 @@ export function ConnectedGitHubRepoForm({
return (
<>
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
<div className="flex items-center gap-2">
<OctoKitty className="size-4" />
<a
href={connectedGitHubRepo.repository.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="max-w-52 truncate text-sm text-text-bright hover:underline"
>
{connectedGitHubRepo.repository.fullName}
</a>
{connectedGitHubRepo.repository.private && (
<LockClosedIcon className="size-3 text-text-dimmed" />
)}
<span className="text-xs text-text-dimmed">
<SettingsRow
title="GitHub repo"
action={
<span className="flex items-center gap-1.5 text-sm text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" />
Connected
</span>
}
/>
<SettingsRow
description={
<>
<span className="mr-2 inline-block size-1.5 rounded-full bg-success align-[0.15em]" />
{connectedGitHubRepo.repository.private ? "Private" : "Public"} repo
<OctoKitty className="ml-2 mr-1.5 inline size-3.5 align-text-bottom text-text-bright" />
<TextLink
href={connectedGitHubRepo.repository.htmlUrl}
target="_blank"
rel="noreferrer noopener"
tooltip={
<span className="flex items-center gap-1 text-text-bright">
View repo
<ArrowUpRightIcon className="size-3.5" />
</span>
}
>
{connectedGitHubRepo.repository.fullName}
</TextLink>{" "}
connected on{" "}
<DateTime
date={connectedGitHubRepo.createdAt}
includeTime={false}
@@ -758,167 +870,196 @@ export function ConnectedGitHubRepoForm({
showTimezone={false}
showTooltip={false}
/>
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Button
variant="minimal/small"
disabled={!canManageGithub}
tooltip={
canManageGithub
? undefined
: "You don't have permission to manage the GitHub integration"
}
>
Disconnect
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Disconnect GitHub repository</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph className="mb-1">
Are you sure you want to disconnect{" "}
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
This will stop automatic deployments from GitHub.
</Paragraph>
<FormButtons
confirmButton={
<Form method="post" action={actionUrl}>
<input type="hidden" name="action" value="disconnect-repo" />
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
<Button type="submit" variant="danger/medium">
Disconnect repository
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
</div>
<Form method="post" action={actionUrl} {...getFormProps(gitSettingsForm)}>
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
<Fieldset>
<InputGroup fullWidth>
<Hint>
Every push to the selected tracking branch creates a deployment in the corresponding
environment.
</Hint>
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
{environmentFullTitle({ type: "PRODUCTION" })}
</span>
</div>
<Input
{...getInputProps(fields.productionBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
productionBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
{environmentFullTitle({ type: "STAGING" })}
</span>
</div>
<Input
{...getInputProps(fields.stagingBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
stagingBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
{environmentFullTitle({ type: "PREVIEW" })}
</span>
</div>
<div className="flex items-center gap-1.5">
<Switch
name="previewDeploymentsEnabled"
disabled={!previewEnvironmentEnabled}
defaultChecked={
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
}
variant="small"
label="Create preview deployments for pull requests"
labelPosition="right"
onCheckedChange={(checked) => {
setGitSettingsValues((prev) => ({
...prev,
previewDeploymentsEnabled: checked,
}));
}}
/>
{!previewEnvironmentEnabled && (
<InfoIconTooltip
content={
<span className="text-xs">
<TextLink to={billingPath}>Upgrade</TextLink> your plan to enable preview
branches
</span>
}
/>
)}
</div>
</div>
<FormError>{fields.productionBranch?.errors}</FormError>
<FormError>{fields.stagingBranch?.errors}</FormError>
<FormError>{fields.previewDeploymentsEnabled?.errors}</FormError>
<FormError>{gitSettingsForm.errors}</FormError>
</InputGroup>
<FormButtons
confirmButton={
.
</>
}
action={
<Dialog>
<DialogTrigger asChild>
<Button
type="submit"
name="action"
value="update-git-settings"
variant="secondary/small"
disabled={isGitSettingsLoading || !hasGitSettingsChanges || !canManageGithub}
disabled={!canManageGithub}
tooltip={
canManageGithub
? undefined
: "You don't have permission to manage the GitHub integration"
}
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
>
Save
Disconnect
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Disconnect GitHub repository</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph className="mb-1">
Are you sure you want to disconnect{" "}
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
This will stop automatic deployments from GitHub.
</Paragraph>
<FormButtons
confirmButton={
<Form method="post" action={actionUrl}>
<input type="hidden" name="action" value="disconnect-repo" />
{redirectUrl && (
<input type="hidden" name="redirectUrl" value={redirectUrl} />
)}
<Button type="submit" variant="danger/medium">
Disconnect repository
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
}
/>
<Form method="post" action={actionUrl} {...getFormProps(gitSettingsForm)}>
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
<SettingsBlock size="sm">
<Hint>
Every push to the selected tracking branch creates a deployment in the corresponding
environment.
</Hint>
</SettingsBlock>
<SettingsRow
action={
<Input
{...getInputProps(fields.productionBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
placeholder="none"
variant="medium"
className="font-mono"
containerClassName="w-64"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
productionBranch: e.target.value,
}));
}}
/>
}
>
<EnvironmentRowLabel type="PRODUCTION" />
</SettingsRow>
<SettingsRow
action={
<Input
{...getInputProps(fields.stagingBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
placeholder="none"
variant="medium"
className="font-mono"
containerClassName="w-64"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
stagingBranch: e.target.value,
}));
}}
/>
}
>
<EnvironmentRowLabel type="STAGING" />
</SettingsRow>
<SettingsRow
action={
previewEnvironmentEnabled ? (
<Switch
name="previewDeploymentsEnabled"
defaultChecked={connectedGitHubRepo.previewDeploymentsEnabled}
variant="medium"
onCheckedChange={(checked) => {
setGitSettingsValues((prev) => ({
...prev,
previewDeploymentsEnabled: checked,
}));
}}
/>
) : (
<>
{connectedGitHubRepo.previewDeploymentsEnabled && (
<input type="hidden" name="previewDeploymentsEnabled" value="on" />
)}
<LinkButton
to={billingPath}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Upgrade
</LinkButton>
</>
)
}
>
<EnvironmentRowLabel
type="PREVIEW"
description={
previewEnvironmentEnabled ? undefined : "Upgrade your plan to enable preview branches"
}
/>
</Fieldset>
</SettingsRow>
<FormError>{fields.productionBranch?.errors}</FormError>
<FormError>{fields.stagingBranch?.errors}</FormError>
<FormError>{fields.previewDeploymentsEnabled?.errors}</FormError>
<FormError>{gitSettingsForm.errors}</FormError>
<SettingsActions>
<Button
type="submit"
name="action"
value="update-git-settings"
variant="secondary/small"
disabled={isGitSettingsLoading || !hasGitSettingsChanges || !canManageGithub}
tooltip={
canManageGithub
? undefined
: "You don't have permission to manage the GitHub integration"
}
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
</SettingsActions>
</Form>
</>
);
}
function EnvironmentRowLabel({
type,
description,
}: {
type: "PRODUCTION" | "STAGING" | "PREVIEW";
description?: React.ReactNode;
}) {
return (
<div className="flex-1 space-y-1">
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type }} className="size-4" />
<span className={cn("text-sm", environmentTextClassName({ type }))}>
{environmentFullTitle({ type })}
</span>
</div>
{description ? <SettingsRowDescription>{description}</SettingsRowDescription> : null}
</div>
);
}
// ============================================================================
// Main GitHub Settings Panel Component
// ============================================================================
@@ -928,11 +1069,13 @@ export function GitHubSettingsPanel({
projectSlug,
environmentSlug,
billingPath,
layout = "compact",
}: {
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
billingPath: string;
layout?: "settings" | "compact";
}) {
const fetcher = useTypedFetcher<typeof loader>();
const location = useLocation();
@@ -951,6 +1094,8 @@ export function GitHubSettingsPanel({
const data = fetcher.data;
const canManageGithub = data?.canManageGithub ?? true;
// Loading state
if (fetcher.state === "loading" && !data) {
return (
@@ -967,35 +1112,49 @@ export function GitHubSettingsPanel({
}
// Connected repository exists - show form
if (data.connectedRepository) {
if (data?.connectedRepository) {
return (
<ConnectedGitHubRepoForm
connectedGitHubRepo={data.connectedRepository}
previewEnvironmentEnabled={data.isPreviewEnvironmentEnabled}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
billingPath={billingPath}
redirectUrl={effectiveRedirectUrl}
canManageGithub={data.canManageGithub}
/>
<>
{layout === "settings" && <GitHubAppInstalledRow />}
<ConnectedGitHubRepoForm
connectedGitHubRepo={data.connectedRepository}
previewEnvironmentEnabled={data.isPreviewEnvironmentEnabled}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
billingPath={billingPath}
redirectUrl={effectiveRedirectUrl}
canManageGithub={data.canManageGithub}
/>
</>
);
}
// No connected repository - show connection prompt
return (
<div className="flex flex-col gap-2">
<GitHubConnectionPrompt
gitHubAppInstallations={data.installations ?? []}
if (layout === "settings") {
return (
<GitHubSettingsRows
gitHubAppInstallations={data?.installations ?? []}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
redirectUrl={effectiveRedirectUrl}
canManageGithub={data.canManageGithub}
canManageGithub={canManageGithub}
/>
{!data.connectedRepository && (
<Hint>Connect your GitHub repository to automatically deploy your changes.</Hint>
)}
);
}
return (
<div className="flex flex-col gap-2">
<GitHubConnectionPrompt
gitHubAppInstallations={data?.installations ?? []}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
redirectUrl={effectiveRedirectUrl}
canManageGithub={canManageGithub}
/>
<Hint>Connect a GitHub repo to automatically deploy changes.</Hint>
</div>
);
}
@@ -2,27 +2,31 @@ 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 {
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 { 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,
SettingsAlertRow,
SettingsRow,
} from "~/components/primitives/SettingsLayout";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import {
redirectBackWithErrorMessage,
@@ -514,10 +518,33 @@ export const action = dashboardAction(
}
);
function VercelConnectionPrompt({
function StagingEnvOption({ name }: { name: string }) {
return (
<span className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
<span className={environmentTextClassName({ type: "STAGING" })}>{name}</span>
</span>
);
}
function VercelAppInstalledRow() {
return (
<SettingsRow
title="Vercel app"
action={
<span className="flex items-center gap-1.5 text-sm text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" />
Installed
</span>
}
/>
);
}
function VercelSettingsRows({
organizationSlug,
projectSlug,
environmentSlug,
environmentSlug: _environmentSlug,
hasOrgIntegration,
isGitHubConnected,
onOpenModal,
@@ -533,67 +560,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 (
<Fieldset>
<InputGroup fullWidth>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
{hasOrgIntegration ? (
<>
<Button
variant="secondary/medium"
onClick={handleConnectProject}
disabled={isDisabled || !canManageVercel}
tooltip={
canManageVercel
? undefined
: "You don't have permission to manage the Vercel integration"
}
LeadingIcon={
isLoadingProjects
? () => <SpinnerWhite className="size-4" />
: () => <VercelLogo className="-mx-1 size-4" />
}
>
{isLoadingProjects ? "Loading projects..." : "Connect Vercel project"}
</Button>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> Vercel app is installed
</span>
{!onOpenModal && (
<span className="text-xs text-amber-400">
Please reconnect Vercel to continue
</span>
)}
</>
) : (
<>
<PermissionLink
hasPermission={canManageVercel}
noPermissionTooltip="You don't have permission to manage the Vercel integration"
to={installPath}
variant="secondary/medium"
LeadingIcon={() => <VercelLogo className="-mx-1 size-4" />}
>
Install Vercel app
</PermissionLink>
</>
)}
</div>
</div>
</InputGroup>
</Fieldset>
<>
{hasOrgIntegration ? (
<VercelAppInstalledRow />
) : (
<SettingsRow
title="Vercel app"
description="Give Trigger.dev access to your Vercel projects and environment variables."
action={
<PermissionLink
hasPermission={canManageVercel}
noPermissionTooltip={noPermissionTooltip}
to={vercelAppInstallPath(organizationSlug, projectSlug)}
variant="secondary/small"
LeadingIcon={() => <VercelLogo className="-mx-1 size-3.5 text-text-bright" />}
>
Install Vercel app
</PermissionLink>
}
/>
)}
{hasOrgIntegration && (
<SettingsRow
title="Vercel project"
description="Connect a Vercel project to pull environment variables and trigger builds."
action={
<Button
variant="secondary/small"
onClick={() => onOpenModal?.()}
disabled={isLoadingProjects || !onOpenModal || !canManageVercel}
tooltip={canManageVercel ? undefined : noPermissionTooltip}
LeadingIcon={
isLoadingProjects
? () => <SpinnerWhite className="size-4" />
: () => <VercelLogo className="-mx-1 size-3.5 text-text-bright" />
}
>
{isLoadingProjects ? "Loading projects…" : "Connect Vercel project"}
</Button>
}
/>
)}
{!isGitHubConnected && <VercelGitHubWarning />}
</>
);
}
@@ -606,42 +621,32 @@ function VercelAuthInvalidBanner({
projectSlug: string;
canManageVercel?: boolean;
}) {
const installUrl = vercelAppInstallPath(organizationSlug, projectSlug);
return (
<Callout variant="error" className="mb-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<p className="mb-2 font-sans text-sm font-medium text-text-bright">
Vercel connection expired
</p>
<p className="mb-3 font-sans text-xs text-text-dimmed">
Your Vercel access token has expired or been revoked. Please reconnect to restore
functionality.
</p>
<PermissionLink
hasPermission={canManageVercel}
noPermissionTooltip="You don't have permission to manage the Vercel integration"
to={installUrl}
variant="minimal/small"
className="border-error/20 bg-error/10 text-error hover:bg-error/20"
>
Reconnect Vercel
</PermissionLink>
</div>
</div>
</Callout>
<SettingsAlertRow
variant="warning"
title="Vercel connection expired"
description="Your access token has expired or been revoked. Reconnect to restore the integration."
action={
<PermissionLink
hasPermission={canManageVercel}
noPermissionTooltip="You don't have permission to manage the Vercel integration"
to={vercelAppInstallPath(organizationSlug, projectSlug)}
variant="warning/small"
>
Reconnect Vercel
</PermissionLink>
}
/>
);
}
function VercelGitHubWarning() {
return (
<Callout variant="warning" className="mb-4">
<p className="font-sans text-xs font-normal text-text-dimmed">
GitHub integration is not connected. Vercel integration cannot sync environment variables
and link deployments without a properly installed GitHub integration.
</p>
</Callout>
<SettingsAlertRow
variant="warning"
title="GitHub isn't connected"
description="Vercel can't sync environment variables or link deployments until you connect a GitHub repo."
/>
);
}
@@ -776,6 +781,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);
@@ -786,7 +794,7 @@ function ConnectedVercelProjectForm({
const disabledEnvSlugsForBuildSettings: Partial<Record<EnvSlug, string>> | 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 = (
@@ -800,13 +808,23 @@ function ConnectedVercelProjectForm({
return (
<>
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
<div className="flex items-center gap-2">
<VercelLogo className="size-4" />
<span className="max-w-52 truncate text-sm text-text-bright">
{connectedProject.vercelProjectName}
<SettingsRow
title="Vercel project"
action={
<span className="flex items-center gap-1.5 text-sm text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" />
Connected
</span>
<span className="text-xs text-text-dimmed">
}
/>
<SettingsRow
description={
<>
<span className="mr-2 inline-block size-1.5 rounded-full bg-success align-[0.15em]" />
Vercel project
<VercelLogo className="relative -top-px mx-1.5 inline size-3.5 align-text-bottom text-text-bright" />
{connectedProject.vercelProjectName} connected on{" "}
<DateTime
date={connectedProject.createdAt}
includeTime={false}
@@ -814,49 +832,52 @@ function ConnectedVercelProjectForm({
showTimezone={false}
showTooltip={false}
/>
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Button
variant="minimal/small"
disabled={!canManageVercel}
tooltip={
canManageVercel
? undefined
: "You don't have permission to manage the Vercel integration"
}
>
Disconnect
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Disconnect Vercel project</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph className="mb-1">
Are you sure you want to disconnect{" "}
<span className="font-semibold">{connectedProject.vercelProjectName}</span>? This
will stop pulling environment variables and disable atomic deployments.
</Paragraph>
<FormButtons
confirmButton={
<Form method="post" action={actionUrl}>
<input type="hidden" name="action" value="disconnect" />
<Button type="submit" variant="danger/medium">
Disconnect project
</Button>
</Form>
.
</>
}
action={
<Dialog>
<DialogTrigger asChild>
<Button
variant="secondary/small"
disabled={!canManageVercel}
tooltip={
canManageVercel
? undefined
: "You don't have permission to manage the Vercel integration"
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
</div>
>
Disconnect
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Disconnect Vercel project</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph className="mb-1">
Are you sure you want to disconnect{" "}
<span className="font-semibold">{connectedProject.vercelProjectName}</span>? This
will stop pulling environment variables and disable atomic deployments.
</Paragraph>
<FormButtons
confirmButton={
<Form method="post" action={actionUrl}>
<input type="hidden" name="action" value="disconnect" />
<Button type="submit" variant="danger/medium">
Disconnect project
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
}
/>
{/* Configuration form */}
<Form method="post" action={actionUrl} {...getFormProps(configForm)}>
@@ -893,148 +914,146 @@ function ConnectedVercelProjectForm({
ref={clearTriggerVersionInputRef}
/>
<Fieldset>
<InputGroup fullWidth>
<div className="flex flex-col gap-4">
{/* Staging environment mapping */}
{hasStagingEnvironment && customEnvironments && customEnvironments.length > 0 && (
<div>
<Label>Map Vercel environment to Staging</Label>
<Hint className="mb-2">
Select which custom Vercel environment should map to Trigger.dev's Staging
environment.
</Hint>
<Select
value={configValues.vercelStagingEnvironment?.environmentId || ""}
setValue={(value) => {
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;
});
}
}}
items={[{ id: "", slug: "None" }, ...customEnvironments]}
variant="tertiary/small"
placeholder="Select environment"
dropdownIcon
text={configValues.vercelStagingEnvironment?.displayName || "None"}
>
{[
<SelectItem key="" value="">
None
</SelectItem>,
...customEnvironments.map((env) => (
<SelectItem key={env.id} value={env.id}>
{env.slug}
</SelectItem>
)),
]}
</Select>
</div>
)}
<BuildSettingsFields
availableEnvSlugs={availableEnvSlugsForBuildSettings}
pullEnvVarsBeforeBuild={configValues.pullEnvVarsBeforeBuild}
onPullEnvVarsChange={(slugs) =>
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") && (
<Callout variant="warning">
<div className="flex flex-col gap-2">
<p className="font-sans text-xs font-normal text-text-dimmed">
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.
</p>
<Form method="post" action={actionUrl}>
<input type="hidden" name="action" value="disable-auto-assign" />
<Button
type="submit"
variant="tertiary/small"
disabled={
navigation.formData?.get("action") === "disable-auto-assign" &&
(navigation.state === "submitting" || navigation.state === "loading")
{/* Staging environment mapping */}
{hasStagingEnvironment && customEnvironments && customEnvironments.length > 0 && (
<SettingsRow
title="Vercel environment for Staging"
description="Required to enable the Staging options below."
action={
<div data-unlock-target="staging-env">
<Select
value={configValues.vercelStagingEnvironment?.environmentId || ""}
setValue={(value) => {
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");
}
LeadingIcon={
navigation.formData?.get("action") === "disable-auto-assign" &&
(navigation.state === "submitting" || navigation.state === "loading")
? SpinnerWhite
: undefined
}
>
Disable auto-assign custom domains
</Button>
</Form>
</div>
</Callout>
)}
</div>
return next;
});
}
}}
items={[{ id: "", slug: "None" }, ...customEnvironments]}
variant="secondary/small"
placeholder="Select environment"
dropdownIcon
text={
configValues.vercelStagingEnvironment ? (
<StagingEnvOption name={configValues.vercelStagingEnvironment.displayName} />
) : (
"None"
)
}
>
{[
<SelectItem key="" value="">
<span className="text-text-bright">None</span>
</SelectItem>,
...customEnvironments.map((env) => (
<SelectItem key={env.id} value={env.id}>
<StagingEnvOption name={env.slug} />
</SelectItem>
)),
]}
</Select>
</div>
}
/>
)}
<FormError>{configForm.errors}</FormError>
</InputGroup>
<BuildSettingsFields
availableEnvSlugs={availableEnvSlugsForBuildSettings}
pullEnvVarsBeforeBuild={configValues.pullEnvVarsBeforeBuild}
onPullEnvVarsChange={(slugs) =>
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"
/>
<FormButtons
confirmButton={
{/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */}
{autoAssignCustomDomains !== false && configValues.atomicBuilds.includes("prod") && (
<SettingsAlertRow
variant="warning"
title="Auto-assign Custom Domains is still on"
description="Vercel will promote deployments before Trigger.dev is ready. Turn it off so atomic deployments can stage the switch."
action={
<Button
ref={saveButtonRef}
type="submit"
name="action"
value="update-config"
variant="secondary/small"
disabled={isConfigLoading || !hasConfigChanges || !canManageVercel}
type="button"
variant="warning/small"
disabled={isDisablingAutoAssign || !canManageVercel}
tooltip={
canManageVercel
? undefined
: "You don't have permission to manage the Vercel integration"
}
LeadingIcon={isConfigLoading ? SpinnerWhite : undefined}
onClick={(event) => {
if (shouldPromptClearOnSave) {
event.preventDefault();
setShowClearDialog(true);
}
}}
LeadingIcon={isDisablingAutoAssign ? SpinnerWhite : undefined}
onClick={() =>
disableAutoAssignFetcher.submit(
{ action: "disable-auto-assign" },
{ method: "post", action: actionUrl }
)
}
>
Save
Disable auto-assign
</Button>
}
/>
</Fieldset>
)}
<FormError>{configForm.errors}</FormError>
<SettingsActions>
<Button
ref={saveButtonRef}
type="submit"
name="action"
value="update-config"
variant="secondary/small"
disabled={isConfigLoading || !hasConfigChanges || !canManageVercel}
tooltip={
canManageVercel
? undefined
: "You don't have permission to manage the Vercel integration"
}
LeadingIcon={isConfigLoading ? SpinnerWhite : undefined}
onClick={(event) => {
if (shouldPromptClearOnSave) {
event.preventDefault();
setShowClearDialog(true);
}
}}
>
Save
</Button>
</SettingsActions>
</Form>
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
@@ -1162,6 +1181,7 @@ function VercelSettingsPanel({
/>
)}
{showGitHubWarning && <VercelGitHubWarning />}
{!showAuthInvalid && <VercelAppInstalledRow />}
{!showAuthInvalid && (
<ConnectedVercelProjectForm
connectedProject={data.connectedProject}
@@ -1181,37 +1201,27 @@ function VercelSettingsPanel({
);
}
if (showAuthInvalid) {
return (
<VercelAuthInvalidBanner
organizationSlug={organizationSlug}
projectSlug={projectSlug}
canManageVercel={data.canManageVercel}
/>
);
}
return (
<div className="flex flex-col gap-2">
{showAuthInvalid && (
<VercelAuthInvalidBanner organizationSlug={organizationSlug} projectSlug={projectSlug} />
)}
{!showAuthInvalid && (
<>
<VercelConnectionPrompt
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
hasOrgIntegration={data.hasOrgIntegration}
isGitHubConnected={data.isGitHubConnected}
onOpenModal={showAuthInvalid ? undefined : onOpenVercelModal}
isLoading={isLoadingVercelData}
canManageVercel={data.canManageVercel}
/>
<Hint>
{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."}
</Hint>
{!data.isGitHubConnected && (
<Hint>
GitHub integration is not connected. Vercel integration cannot sync environment
variables and link deployments without a properly installed GitHub integration.
</Hint>
)}
</>
)}
</div>
<VercelSettingsRows
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
hasOrgIntegration={data.hasOrgIntegration}
isGitHubConnected={data.isGitHubConnected}
onOpenModal={onOpenVercelModal}
isLoading={isLoadingVercelData}
canManageVercel={data.canManageVercel}
/>
);
}
@@ -36,6 +36,7 @@ export default function Story() {
<Button variant="tertiary/small">Tertiary button</Button>
<Button variant="minimal/small">Minimal button</Button>
<Button variant="danger/small">Danger button</Button>
<Button variant="warning/small">Warning button</Button>
</div>
<div className="flex flex-col items-start gap-2">
<Header3 className="mb-1 uppercase">Icon left</Header3>
@@ -165,6 +166,7 @@ export default function Story() {
<Button variant="secondary/medium">Secondary button</Button>
<Button variant="tertiary/medium">Tertiary button</Button>
<Button variant="danger/medium">Danger button</Button>
<Button variant="warning/medium">Warning button</Button>
</div>
<div className="flex flex-col items-start gap-2">
<Header3 className="mb-1 uppercase">Icon left</Header3>
@@ -310,6 +312,10 @@ export default function Story() {
/>
<span className="text-text-bright">This is a delete button</span>
</Button>
<Button variant="warning/large" fullWidth>
<TrashIcon className="mr-1.5 h-4 w-4" />
<span>This is a warning button</span>
</Button>
</div>
</div>
</div>
@@ -335,6 +341,12 @@ export default function Story() {
/>
<span className="text-text-bright">This is a delete button</span>
</Button>
<Button variant="warning/extra-large" fullWidth>
<TrashIcon
className={"mr-1.5 size-5 text-text-bright transition group-hover:text-text-bright"}
/>
<span>This is a warning button</span>
</Button>
</div>
</div>
</div>
@@ -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) },
+10
View File
@@ -639,3 +639,13 @@
@apply leading-relaxed;
}
}
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;
}