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:
@@ -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,
|
||||
|
||||
+174
-161
@@ -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>;
|
||||
}
|
||||
|
||||
+14
-2
@@ -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>
|
||||
|
||||
+345
-186
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+314
-304
@@ -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) },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user