feat(webapp): add GitHub onboarding flow to empty Tasks and Deployments pages (#2775)

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Set up the local github application, and tested its connection with
trigger.dev

Checked:
- Change is backwards compatible
- Actions/ux are uniform across the pages (empty Tasks, Deployments,
project settings)
- Connecting GH, Connecting Repo, disconnecting Repo, modifying settings

---

## Changelog

- Create new resource route for GitHub settings management with loader
and actions
- Add GitHubSettingsPresenter to fetch connected repos and installations
- Implement GitHubSettingsPanel component for reusable GitHub
configuration UI
- Refactor project settings page to use shared GitHubSettingsPanel
component
- Integrate GitHub connection flow into empty state onboarding for Tasks
and Deployments
- Add support for GitHub repo connection, disconnection, and branch
tracking settings
- Include redirect URL support for seamless navigation after GitHub
actions
- Remove duplicate GitHub connection code from project settings route

---

## Screenshots


https://github.com/user-attachments/assets/8fc24699-640b-4f9e-afd8-b26edc945218

🐐

---------

Co-authored-by: James Ritchie <james@trigger.dev>
This commit is contained in:
Oskar Otwinowski
2025-12-10 17:03:16 +00:00
committed by GitHub
parent 8b00198f99
commit d28707826c
9 changed files with 1639 additions and 885 deletions
+148 -132
View File
@@ -52,6 +52,13 @@ import {
} from "./SetupCommands";
import { StepContentContainer } from "./StepContentContainer";
import { V4Badge } from "./V4Badge";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "./primitives/ClientTabs";
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
export function HasNoTasksDev() {
return (
@@ -93,62 +100,7 @@ export function HasNoTasksDev() {
}
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
return (
<PackageManagerProvider>
<div>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</div>
</PackageManagerProvider>
);
return <DeploymentOnboardingSteps />;
}
export function SchedulesNoPossibleTaskPanel() {
@@ -266,45 +218,7 @@ export function TestHasNoTasks() {
}
export function DeploymentsNone() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploy for the first time"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
);
return <DeploymentOnboardingSteps />;
}
export function DeploymentsNoneDev() {
@@ -313,46 +227,52 @@ export function DeploymentsNoneDev() {
const environment = useEnvironment();
return (
<div className="space-y-8">
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploying tasks"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
<>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="" title="Switch to a deployed environment" />
<StepContentContainer className="mb-4 flex flex-col gap-4">
<Paragraph>
This is the Development environment. When you're ready to deploy your tasks, switch to a
different environment.
</Paragraph>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous
Integration service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
<SwitcherPanel />
</div>
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
/>
</StepContentContainer>
</>
);
}
@@ -670,3 +590,99 @@ export function BulkActionsNone() {
</div>
);
}
function DeploymentOnboardingSteps() {
const environment = useEnvironment();
const organization = useOrganization();
const project = useProject();
return (
<PackageManagerProvider>
<div className="mb-2 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<ClientTabs defaultValue="github">
<ClientTabsList variant="segmented" className="mb-6">
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
GitHub
</ClientTabsTrigger>
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
Manual
</ClientTabsTrigger>
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
GitHub Actions
</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"github"}>
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
<StepContentContainer>
<Paragraph spacing>
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>
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"cli"}>
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"github-actions"}>
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
</ClientTabsContent>
</ClientTabs>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</PackageManagerProvider>
);
}
@@ -1,41 +1,185 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "~/utils/cn";
import { motion } from "framer-motion";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "~/utils/cn";
import { type Variants } from "./Tabs";
type ClientTabsContextValue = {
value?: string;
};
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
function useClientTabsContext() {
return React.useContext(ClientTabsContext);
}
const ClientTabs = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
React.useEffect(() => {
if (valueProp !== undefined) {
setValue(valueProp);
}
}, [valueProp]);
const handleValueChange = React.useCallback(
(nextValue: string) => {
if (valueProp === undefined) {
setValue(nextValue);
}
onValueChange?.(nextValue);
},
[onValueChange, valueProp]
);
const controlledProps =
valueProp !== undefined
? { value: valueProp }
: defaultValue !== undefined
? { defaultValue }
: {};
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
return (
<ClientTabsContext.Provider value={contextValue}>
<TabsPrimitive.Root
ref={ref}
onValueChange={handleValueChange}
{...controlledProps}
{...props}
/>
</ClientTabsContext.Provider>
);
});
ClientTabs.displayName = TabsPrimitive.Root.displayName;
const ClientTabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn("inline-flex items-center justify-center transition duration-100", className)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
variant?: Variants;
}
>(({ className, variant = "pipe-divider", ...props }, ref) => {
const variantClassName = (() => {
switch (variant) {
case "segmented":
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
case "underline":
return "flex gap-x-6 border-b border-grid-bright";
default:
return "inline-flex items-center justify-center transition duration-100";
}
})();
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
});
ClientTabsList.displayName = TabsPrimitive.List.displayName;
const ClientTabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
variant?: Variants;
layoutId?: string;
}
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
const context = useClientTabsContext();
const activeValue = context?.value;
const isActive = activeValue === props.value;
if (variant === "segmented") {
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
"flex-1 basis-0",
className
)}
{...props}
>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
isActive
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{isActive ? (
layoutId ? (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
/>
) : (
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
)
) : null}
</TabsPrimitive.Trigger>
);
}
if (variant === "underline") {
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
)}
>
{children}
</span>
{layoutId ? (
isActive ? (
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)
) : isActive ? (
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
}
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
{children}
</TabsPrimitive.Trigger>
);
});
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const ClientTabsContent = React.forwardRef<
@@ -61,39 +205,7 @@ export type TabsProps = {
currentValue: string;
className?: string;
layoutId: string;
variant?: Variants;
};
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
return (
<TabsPrimitive.List
className={cn(`flex flex-row gap-x-6 border-b border-charcoal-700`, className)}
>
{tabs.map((tab, index) => {
const isActive = currentValue === tab.value;
return (
<TabsPrimitive.Trigger
key={tab.value}
value={tab.value}
className={cn(`group flex flex-col items-center`, className)}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-indigo-500" : "text-charcoal-200"
)}
>
{tab.label}
</span>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
})}
</TabsPrimitive.List>
);
}
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
+112 -18
View File
@@ -1,10 +1,12 @@
import { NavLink } from "@remix-run/react";
import { motion } from "framer-motion";
import { ReactNode, useRef } from "react";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type ReactNode, useRef } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
export type Variants = "underline" | "pipe-divider" | "segmented";
export type TabsProps = {
tabs: {
label: string;
@@ -12,13 +14,14 @@ export type TabsProps = {
}[];
className?: string;
layoutId: string;
variant?: Variants;
};
export function Tabs({ tabs, className, layoutId }: TabsProps) {
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
return (
<TabContainer className={className}>
<TabContainer className={className} variant={variant}>
{tabs.map((tab, index) => (
<TabLink key={index} to={tab.to} layoutId={layoutId}>
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
{tab.label}
</TabLink>
))}
@@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) {
);
}
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
{children}
</div>
);
export function TabContainer({
children,
className,
variant = "underline",
}: {
children: ReactNode;
className?: string;
variant?: Variants;
}) {
if (variant === "segmented") {
return (
<div
className={cn("relative flex h-10 items-center rounded bg-charcoal-700/50 p-1", className)}
>
{children}
</div>
);
}
if (variant === "underline") {
return (
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
);
}
return <div className={cn(`flex`, className)}>{children}</div>;
}
export function TabLink({
to,
children,
layoutId,
variant = "underline",
}: {
to: string;
children: ReactNode;
layoutId: string;
variant?: Variants;
}) {
if (variant === "segmented") {
return (
<NavLink
to={to}
className="group relative flex h-full grow items-center justify-center focus-custom"
end
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
active
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{active && (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
/>
)}
</>
);
}}
</NavLink>
);
}
if (variant === "pipe-divider") {
return (
<NavLink
to={to}
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
end
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<span
className={cn(
"text-sm transition duration-200",
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
)}
>
{children}
</span>
);
}}
</NavLink>
);
}
// underline variant (default)
return (
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
{({ isActive, isPending }) => {
@@ -51,13 +138,19 @@ export function TabLink({
<span
className={cn(
"text-sm transition duration-200",
isActive || isPending ? "text-text-bright" : "text-text-bright"
isActive || isPending
? "text-text-bright"
: "text-text-dimmed hover:text-text-bright"
)}
>
{children}
</span>
{isActive || isPending ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
@@ -106,17 +199,18 @@ export function TabButton({
<>
<div className="flex items-center gap-1">
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-bright"
)}
className={"text-sm transition duration-200 text-text-bright"}
>
{props.children}
</span>
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
</div>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
@@ -0,0 +1,137 @@
import { type PrismaClient } from "@trigger.dev/database";
import { err, fromPromise, ok, ResultAsync } from "neverthrow";
import { env } from "~/env.server";
import { BranchTrackingConfigSchema } from "~/v3/github";
import { BasePresenter } from "./basePresenter.server";
type GitHubSettingsOptions = {
projectId: string;
organizationId: string;
};
export class GitHubSettingsPresenter extends BasePresenter {
public call({ projectId, organizationId }: GitHubSettingsOptions) {
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
if (!githubAppEnabled) {
return ok({
enabled: false,
connectedRepository: undefined,
installations: undefined,
isPreviewEnvironmentEnabled: undefined,
});
}
const findConnectedGithubRepository = () =>
fromPromise(
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
branchTracking: true,
previewDeploymentsEnabled: true,
createdAt: true,
repository: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
},
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((connectedGithubRepository) => {
if (!connectedGithubRepository) {
return undefined;
}
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
connectedGithubRepository.branchTracking
);
const branchTracking = branchTrackingOrFailure.success
? branchTrackingOrFailure.data
: undefined;
return {
...connectedGithubRepository,
branchTracking,
};
});
const listGithubAppInstallations = () =>
fromPromise(
(this._replica as PrismaClient).githubAppInstallation.findMany({
where: {
organizationId,
deletedAt: null,
suspendedAt: null,
},
select: {
id: true,
accountHandle: true,
targetType: true,
appInstallationId: true,
repositories: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
take: 200,
},
},
take: 20,
orderBy: {
createdAt: "desc",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
const isPreviewEnvironmentEnabled = () =>
fromPromise(
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: projectId,
slug: "preview",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((previewEnvironment) => previewEnvironment !== null);
return ResultAsync.combine([
isPreviewEnvironmentEnabled(),
findConnectedGithubRepository(),
listGithubAppInstallations(),
]).map(([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
enabled: true,
connectedRepository: connectedGithubRepository,
installations: githubAppInstallations,
isPreviewEnvironmentEnabled,
}));
}
}
@@ -359,11 +359,11 @@ export default function Page() {
</div>
</div>
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-md">
<MainCenteredContainer className="max-w-prose">
<DeploymentsNoneDev />
</MainCenteredContainer>
) : (
<MainCenteredContainer className="max-w-md">
<MainCenteredContainer className="max-w-prose">
<DeploymentsNone />
</MainCenteredContainer>
)}
@@ -1,35 +1,18 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
FolderIcon,
TrashIcon,
LockClosedIcon,
PlusIcon,
} from "@heroicons/react/20/solid";
import {
Form,
type MetaFunction,
useActionData,
useNavigation,
useNavigate,
useSearchParams,
} from "@remix-run/react";
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { OctoKitty } from "~/components/GitHubLoginButton";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -55,32 +38,12 @@ import {
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import {
organizationPath,
v3ProjectPath,
githubAppInstallPath,
EnvironmentParamSchema,
v3ProjectSettingsPath,
docsPath,
v3BillingPath,
} from "~/utils/pathBuilder";
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
import React, { useEffect, useState } from "react";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Switch } from "~/components/primitives/Switch";
import { type BranchTrackingConfig } from "~/v3/github";
import {
EnvironmentIcon,
environmentFullTitle,
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { GitBranchIcon } from "lucide-react";
import { useEnvironment } from "~/hooks/useEnvironment";
import { DateTime } from "~/components/primitives/DateTime";
import { TextLink } from "~/components/primitives/TextLink";
import { cn } from "~/utils/cn";
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
import { type BuildSettings } from "~/v3/buildSettings";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
export const meta: MetaFunction = () => {
return [
@@ -128,29 +91,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({
githubAppEnabled: gitHubApp.enabled,
githubAppInstallations: gitHubApp.installations,
connectedGithubRepository: gitHubApp.connectedRepository,
isPreviewEnvironmentEnabled: gitHubApp.isPreviewEnvironmentEnabled,
buildSettings,
});
};
const ConnectGitHubRepoFormSchema = z.object({
action: z.literal("connect-repo"),
installationId: z.string(),
repositoryId: z.string(),
});
const UpdateGitSettingsFormSchema = z.object({
action: z.literal("update-git-settings"),
productionBranch: z.string().trim().optional(),
stagingBranch: z.string().trim().optional(),
previewDeploymentsEnabled: z
.string()
.optional()
.transform((val) => val === "on"),
});
const UpdateBuildSettingsFormSchema = z.object({
action: z.literal("update-build-settings"),
triggerConfigFilePath: z
@@ -220,12 +164,7 @@ export function createSchema(
}
}),
}),
ConnectGitHubRepoFormSchema,
UpdateGitSettingsFormSchema,
UpdateBuildSettingsFormSchema,
z.object({
action: z.literal("disconnect-repo"),
}),
]);
}
@@ -260,7 +199,7 @@ export const action: ActionFunction = async ({ request, params }) => {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
const { projectId, organizationId } = membershipResultOrFail.value;
const { projectId } = membershipResultOrFail.value;
switch (submission.value.action) {
case "rename": {
@@ -316,101 +255,6 @@ export const action: ActionFunction = async ({ request, params }) => {
"Project deleted"
);
}
case "disconnect-repo": {
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to disconnect GitHub repository", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to disconnect GitHub repository");
}
}
}
return redirectBackWithSuccessMessage(request, "GitHub repository disconnected successfully");
}
case "update-git-settings": {
const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value;
const resultOrFail = await projectSettingsService.updateGitSettings(
projectId,
productionBranch,
stagingBranch,
previewDeploymentsEnabled
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "github_app_not_enabled": {
return redirectBackWithErrorMessage(request, "GitHub app is not enabled");
}
case "connected_gh_repository_not_found": {
return redirectBackWithErrorMessage(request, "Connected GitHub repository not found");
}
case "production_tracking_branch_not_found": {
return redirectBackWithErrorMessage(request, "Production tracking branch not found");
}
case "staging_tracking_branch_not_found": {
return redirectBackWithErrorMessage(request, "Staging tracking branch not found");
}
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to update Git settings", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to update Git settings");
}
}
}
return redirectBackWithSuccessMessage(request, "Git settings updated successfully");
}
case "connect-repo": {
const { repositoryId, installationId } = submission.value;
const resultOrFail = await projectSettingsService.connectGitHubRepo(
projectId,
organizationId,
repositoryId,
installationId
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "gh_repository_not_found": {
return redirectBackWithErrorMessage(request, "GitHub repository not found");
}
case "project_already_has_connected_repository": {
return redirectBackWithErrorMessage(
request,
"Project already has a connected repository"
);
}
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to connect GitHub repository", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to connect GitHub repository");
}
}
}
return json({
...submission,
success: true,
});
}
case "update-build-settings": {
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
submission.value;
@@ -446,13 +290,7 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
const {
githubAppInstallations,
connectedGithubRepository,
githubAppEnabled,
buildSettings,
isPreviewEnvironmentEnabled,
} = useTypedLoaderData<typeof loader>();
const { githubAppEnabled, buildSettings } = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
const environment = useEnvironment();
@@ -578,19 +416,12 @@ export default function Page() {
<div>
<Header2 spacing>Git settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
{connectedGithubRepository ? (
<ConnectedGitHubRepoForm
connectedGitHubRepo={connectedGithubRepository}
previewEnvironmentEnabled={isPreviewEnvironmentEnabled}
/>
) : (
<GitHubConnectionPrompt
gitHubAppInstallations={githubAppInstallations ?? []}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
/>
)}
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</div>
@@ -650,489 +481,6 @@ export default function Page() {
);
}
type GitHubRepository = {
id: string;
name: string;
fullName: string;
private: boolean;
htmlUrl: string;
};
type GitHubAppInstallation = {
id: string;
appInstallationId: bigint;
targetType: string;
accountHandle: string;
repositories: GitHubRepository[];
};
function ConnectGitHubRepoModal({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
open?: boolean;
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const lastSubmission = useActionData() as any;
const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<
GitHubAppInstallation | undefined
>(gitHubAppInstallations.at(0));
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
undefined
);
const navigation = useNavigation();
const isConnectRepositoryLoading =
navigation.formData?.get("action") === "connect-repo" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [form, { installationId, repositoryId }] = useForm({
id: "connect-repo",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: ConnectGitHubRepoFormSchema,
});
},
});
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const params = new URLSearchParams(searchParams);
if (params.get("openGithubRepoModal") === "1") {
setIsModalOpen(true);
params.delete("openGithubRepoModal");
setSearchParams(params);
}
}, [searchParams, setSearchParams]);
useEffect(() => {
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
setIsModalOpen(false);
}
}, [lastSubmission]);
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
Connect GitHub repo
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Connect GitHub repository</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<Form method="post" {...form.props} className="w-full">
<Paragraph className="mb-3">
Choose a GitHub repository to connect to your project.
</Paragraph>
<Fieldset className="max-w-full gap-y-3">
<InputGroup className="max-w-full">
<Label htmlFor={installationId.id}>Account</Label>
<Select
name={installationId.name}
id={installationId.id}
value={selectedInstallation?.id}
defaultValue={gitHubAppInstallations.at(0)?.id}
setValue={(value) => {
if (Array.isArray(value)) return;
const installation = gitHubAppInstallations.find((i) => i.id === value);
setSelectedInstallation(installation);
setSelectedRepository(undefined);
}}
items={gitHubAppInstallations}
variant="tertiary/small"
placeholder="Select account"
dropdownIcon
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
>
{[
...gitHubAppInstallations.map((installation) => (
<SelectItem
key={installation.id}
value={installation.id}
icon={<OctoKitty className="size-3 text-text-dimmed" />}
>
{installation.accountHandle}
</SelectItem>
)),
<SelectItem
onClick={(e) => {
e.preventDefault();
navigate(
githubAppInstallPath(
organizationSlug,
`${v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)}?openGithubRepoModal=1`
)
);
}}
key="new-account"
icon={<PlusIcon className="size-3 text-text-dimmed" />}
>
Add account
</SelectItem>,
]}
</Select>
<FormError id={installationId.errorId}>{installationId.error}</FormError>
</InputGroup>
<InputGroup className="max-w-full">
<Label htmlFor={repositoryId.id}>Repository</Label>
<Select
name={repositoryId.name}
id={repositoryId.id}
value={selectedRepository ? selectedRepository.id : undefined}
setValue={(value) => {
if (Array.isArray(value)) return;
const repository = selectedInstallation?.repositories.find(
(r) => r.id === value
);
setSelectedRepository(repository);
}}
variant="tertiary/small"
placeholder="Select repository"
heading="Filter repositories"
dropdownIcon
items={selectedInstallation?.repositories ?? []}
filter={{ keys: ["name"] }}
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
text={selectedRepository ? selectedRepository.name : null}
>
{(matches) =>
matches.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<div className="flex items-center gap-1">
{repo.name}
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
</div>
</SelectItem>
))
}
</Select>
<Hint className={cn("invisible", selectedInstallation && "visible")}>
Configure repository access in{" "}
<TextLink
target="_blank"
rel="noreferrer noopener"
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
>
GitHub
</TextLink>
.
</Hint>
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="connect-repo"
variant="primary/medium"
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
leadingIconClassName="text-white"
disabled={isConnectRepositoryLoading}
>
Connect repository
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
function GitHubConnectionPrompt({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
}) {
return (
<Fieldset>
<InputGroup fullWidth>
{gitHubAppInstallations.length === 0 && (
<LinkButton
to={githubAppInstallPath(
organizationSlug,
`${v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)}?openGithubRepoModal=1`
)}
variant={"secondary/medium"}
LeadingIcon={OctoKitty}
>
Install GitHub app
</LinkButton>
)}
{gitHubAppInstallations.length !== 0 && (
<div className="flex items-center gap-3">
<ConnectGitHubRepoModal
gitHubAppInstallations={gitHubAppInstallations}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
/>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
</span>
</div>
)}
<Hint>Connect your GitHub repository to automatically deploy your changes.</Hint>
</InputGroup>
</Fieldset>
);
}
type ConnectedGitHubRepo = {
branchTracking: BranchTrackingConfig | undefined;
previewDeploymentsEnabled: boolean;
createdAt: Date;
repository: GitHubRepository;
};
function ConnectedGitHubRepoForm({
connectedGitHubRepo,
previewEnvironmentEnabled,
}: {
connectedGitHubRepo: ConnectedGitHubRepo;
previewEnvironmentEnabled?: boolean;
}) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const organization = useOrganization();
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
const [gitSettingsValues, setGitSettingsValues] = useState({
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
});
useEffect(() => {
const hasChanges =
gitSettingsValues.productionBranch !==
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
gitSettingsValues.stagingBranch !==
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
setHasGitSettingsChanges(hasChanges);
}, [gitSettingsValues, connectedGitHubRepo]);
const [gitSettingsForm, fields] = useForm({
id: "update-git-settings",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: UpdateGitSettingsFormSchema,
});
},
});
const isGitSettingsLoading =
navigation.formData?.get("action") === "update-git-settings" &&
(navigation.state === "submitting" || navigation.state === "loading");
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">
<DateTime
date={connectedGitHubRepo.createdAt}
includeTime={false}
includeSeconds={false}
showTimezone={false}
showTooltip={false}
/>
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="minimal/small">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">
<input type="hidden" name="action" value="disconnect-repo" />
<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" {...gitSettingsForm.props}>
<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
{...conform.input(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
{...conform.input(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={v3BillingPath(organization)}>Upgrade</TextLink> your plan to
enable preview branches
</span>
}
/>
)}
</div>
</div>
<FormError>{fields.productionBranch?.error}</FormError>
<FormError>{fields.stagingBranch?.error}</FormError>
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
<FormError>{gitSettingsForm.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-git-settings"
variant="secondary/small"
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</>
);
}
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
@@ -0,0 +1,877 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { CheckCircleIcon, LockClosedIcon, PlusIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigation, useNavigate, useSearchParams, useLocation } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedFetcher } from "remix-typedjson";
import { z } from "zod";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { Button, LinkButton } from "~/components/primitives/Buttons";
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 { Input } from "~/components/primitives/Input";
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";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { Switch } from "~/components/primitives/Switch";
import { TextLink } from "~/components/primitives/TextLink";
import { DateTime } from "~/components/primitives/DateTime";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import {
EnvironmentIcon,
environmentFullTitle,
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { GitBranchIcon } from "lucide-react";
import {
redirectBackWithErrorMessage,
redirectBackWithSuccessMessage,
redirectWithErrorMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import {
githubAppInstallPath,
EnvironmentParamSchema,
v3ProjectSettingsPath,
} from "~/utils/pathBuilder";
import { cn } from "~/utils/cn";
import { type BranchTrackingConfig } from "~/v3/github";
import { GitHubSettingsPresenter } from "~/presenters/v3/GitHubSettingsPresenter.server";
import { useEffect, useState } from "react";
// ============================================================================
// Types
// ============================================================================
export type GitHubRepository = {
id: string;
name: string;
fullName: string;
private: boolean;
htmlUrl: string;
};
export type GitHubAppInstallation = {
id: string;
appInstallationId: bigint;
targetType: string;
accountHandle: string;
repositories: GitHubRepository[];
};
export type ConnectedGitHubRepo = {
branchTracking: BranchTrackingConfig | undefined;
previewDeploymentsEnabled: boolean;
createdAt: Date;
repository: GitHubRepository;
};
// ============================================================================
// Schemas
// ============================================================================
export const ConnectGitHubRepoFormSchema = z.object({
action: z.literal("connect-repo"),
installationId: z.string(),
repositoryId: z.string(),
redirectUrl: z.string().optional(),
});
export const DisconnectGitHubRepoFormSchema = z.object({
action: z.literal("disconnect-repo"),
redirectUrl: z.string().optional(),
});
export const UpdateGitSettingsFormSchema = z.object({
action: z.literal("update-git-settings"),
productionBranch: z.string().trim().optional(),
stagingBranch: z.string().trim().optional(),
previewDeploymentsEnabled: z
.string()
.optional()
.transform((val) => val === "on"),
redirectUrl: z.string().optional(),
});
const GitHubActionSchema = z.discriminatedUnion("action", [
ConnectGitHubRepoFormSchema,
DisconnectGitHubRepoFormSchema,
UpdateGitSettingsFormSchema,
]);
// ============================================================================
// Loader
// ============================================================================
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const presenter = new GitHubSettingsPresenter();
const resultOrFail = await presenter.call({
projectId: project.id,
organizationId: project.organizationId,
});
if (resultOrFail.isErr()) {
throw new Response("Failed to load GitHub settings", { status: 500 });
}
return typedjson(resultOrFail.value);
}
// ============================================================================
// Action
// ============================================================================
function redirectWithMessage(
request: Request,
redirectUrl: string | undefined,
message: string,
type: "success" | "error"
) {
if (type === "success") {
return redirectUrl
? redirectWithSuccessMessage(redirectUrl, request, message)
: redirectBackWithSuccessMessage(request, message);
}
return redirectUrl
? redirectWithErrorMessage(redirectUrl, request, message)
: redirectBackWithErrorMessage(request, message);
}
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const formData = await request.formData();
const submission = parse(formData, { schema: GitHubActionSchema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const projectSettingsService = new ProjectSettingsService();
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
organizationSlug,
projectParam,
userId
);
if (membershipResultOrFail.isErr()) {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
const { projectId, organizationId } = membershipResultOrFail.value;
const { action: actionType } = submission.value;
// Handle connect-repo action
if (actionType === "connect-repo") {
const { repositoryId, installationId, redirectUrl } = submission.value;
const resultOrFail = await projectSettingsService.connectGitHubRepo(
projectId,
organizationId,
repositoryId,
installationId
);
if (resultOrFail.isOk()) {
return redirectWithMessage(
request,
redirectUrl,
"GitHub repository connected successfully",
"success"
);
}
const errorType = resultOrFail.error.type;
if (errorType === "gh_repository_not_found") {
return redirectWithMessage(request, redirectUrl, "GitHub repository not found", "error");
}
if (errorType === "project_already_has_connected_repository") {
return redirectWithMessage(
request,
redirectUrl,
"Project already has a connected repository",
"error"
);
}
logger.error("Failed to connect GitHub repository", { error: resultOrFail.error });
return redirectWithMessage(
request,
redirectUrl,
"Failed to connect GitHub repository",
"error"
);
}
// Handle disconnect-repo action
if (actionType === "disconnect-repo") {
const { redirectUrl } = submission.value;
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
if (resultOrFail.isOk()) {
return redirectWithMessage(
request,
redirectUrl,
"GitHub repository disconnected successfully",
"success"
);
}
logger.error("Failed to disconnect GitHub repository", { error: resultOrFail.error });
return redirectWithMessage(
request,
redirectUrl,
"Failed to disconnect GitHub repository",
"error"
);
}
// Handle update-git-settings action
if (actionType === "update-git-settings") {
const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } =
submission.value;
const resultOrFail = await projectSettingsService.updateGitSettings(
projectId,
productionBranch,
stagingBranch,
previewDeploymentsEnabled
);
if (resultOrFail.isOk()) {
return redirectWithMessage(
request,
redirectUrl,
"Git settings updated successfully",
"success"
);
}
const errorType = resultOrFail.error.type;
const errorMessages: Record<string, string> = {
github_app_not_enabled: "GitHub app is not enabled",
connected_gh_repository_not_found: "Connected GitHub repository not found",
production_tracking_branch_not_found: "Production tracking branch not found",
staging_tracking_branch_not_found: "Staging tracking branch not found",
};
const message = errorMessages[errorType];
if (message) {
return redirectWithMessage(request, redirectUrl, message, "error");
}
logger.error("Failed to update Git settings", { error: resultOrFail.error });
return redirectWithMessage(request, redirectUrl, "Failed to update Git settings", "error");
}
// Exhaustive check - this should never be reached
submission.value satisfies never;
return redirectBackWithErrorMessage(request, "Failed to process request");
}
// ============================================================================
// Helper: Build resource URL for fetching GitHub data
// ============================================================================
export function gitHubResourcePath(
organizationSlug: string,
projectSlug: string,
environmentSlug: string
) {
return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/github`;
}
// ============================================================================
// Components
// ============================================================================
export function ConnectGitHubRepoModal({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
redirectUrl,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
redirectUrl?: string;
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const lastSubmission = useActionData() as any;
const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<
GitHubAppInstallation | undefined
>(gitHubAppInstallations.at(0));
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
undefined
);
const navigation = useNavigation();
const isConnectRepositoryLoading =
navigation.formData?.get("action") === "connect-repo" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [form, { installationId, repositoryId }] = useForm({
id: "connect-repo",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: ConnectGitHubRepoFormSchema,
});
},
});
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const params = new URLSearchParams(searchParams);
if (params.get("openGithubRepoModal") === "1") {
setIsModalOpen(true);
params.delete("openGithubRepoModal");
setSearchParams(params);
}
}, [searchParams, setSearchParams]);
useEffect(() => {
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
setIsModalOpen(false);
}
}, [lastSubmission]);
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
Connect GitHub repo
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Connect GitHub repository</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<Form method="post" action={actionUrl} {...form.props} className="w-full">
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
<Paragraph className="mb-3">
Choose a GitHub repository to connect to your project.
</Paragraph>
<Fieldset className="max-w-full gap-y-3">
<InputGroup className="max-w-full">
<Label htmlFor={installationId.id}>Account</Label>
<Select
name={installationId.name}
id={installationId.id}
value={selectedInstallation?.id}
defaultValue={gitHubAppInstallations.at(0)?.id}
setValue={(value) => {
if (Array.isArray(value)) return;
const installation = gitHubAppInstallations.find((i) => i.id === value);
setSelectedInstallation(installation);
setSelectedRepository(undefined);
}}
items={gitHubAppInstallations}
variant="tertiary/small"
placeholder="Select account"
dropdownIcon
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
>
{[
...gitHubAppInstallations.map((installation) => (
<SelectItem
key={installation.id}
value={installation.id}
icon={<OctoKitty className="size-3 text-text-dimmed" />}
>
{installation.accountHandle}
</SelectItem>
)),
<SelectItem
onClick={(e) => {
e.preventDefault();
navigate(
githubAppInstallPath(
organizationSlug,
`${v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)}?openGithubRepoModal=1`
)
);
}}
key="new-account"
icon={<PlusIcon className="size-3 text-text-dimmed" />}
>
Add account
</SelectItem>,
]}
</Select>
<FormError id={installationId.errorId}>{installationId.error}</FormError>
</InputGroup>
<InputGroup className="max-w-full">
<Label htmlFor={repositoryId.id}>Repository</Label>
<Select
name={repositoryId.name}
id={repositoryId.id}
value={selectedRepository ? selectedRepository.id : undefined}
setValue={(value) => {
if (Array.isArray(value)) return;
const repository = selectedInstallation?.repositories.find(
(r) => r.id === value
);
setSelectedRepository(repository);
}}
variant="tertiary/small"
placeholder="Select repository"
heading="Filter repositories"
dropdownIcon
items={selectedInstallation?.repositories ?? []}
filter={{ keys: ["name"] }}
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
text={selectedRepository ? selectedRepository.name : null}
>
{(matches) =>
matches.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<div className="flex items-center gap-1">
{repo.name}
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
</div>
</SelectItem>
))
}
</Select>
<Hint className={cn("invisible", selectedInstallation && "visible")}>
Configure repository access in{" "}
<TextLink
target="_blank"
rel="noreferrer noopener"
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
>
GitHub
</TextLink>
.
</Hint>
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="connect-repo"
variant="primary/medium"
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
leadingIconClassName="text-white"
disabled={isConnectRepositoryLoading}
>
Connect repository
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
export function GitHubConnectionPrompt({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
redirectUrl,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
redirectUrl?: string;
}) {
const githubInstallationRedirect = redirectUrl || v3ProjectSettingsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug });
return (
<Fieldset>
<InputGroup fullWidth>
{gitHubAppInstallations.length === 0 && (
<LinkButton
to={githubAppInstallPath(
organizationSlug,
`${githubInstallationRedirect}?openGithubRepoModal=1`
)}
variant={"secondary/medium"}
LeadingIcon={OctoKitty}
>
Install GitHub app
</LinkButton>
)}
{gitHubAppInstallations.length !== 0 && (
<div className="flex items-center gap-3">
<ConnectGitHubRepoModal
gitHubAppInstallations={gitHubAppInstallations}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
redirectUrl={redirectUrl}
/>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
</span>
</div>
)}
</InputGroup>
</Fieldset>
);
}
export function ConnectedGitHubRepoForm({
connectedGitHubRepo,
previewEnvironmentEnabled,
organizationSlug,
projectSlug,
environmentSlug,
billingPath,
redirectUrl,
}: {
connectedGitHubRepo: ConnectedGitHubRepo;
previewEnvironmentEnabled?: boolean;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
billingPath: string;
redirectUrl?: string;
}) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
const [gitSettingsValues, setGitSettingsValues] = useState({
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
});
useEffect(() => {
const hasChanges =
gitSettingsValues.productionBranch !==
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
gitSettingsValues.stagingBranch !==
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
setHasGitSettingsChanges(hasChanges);
}, [gitSettingsValues, connectedGitHubRepo]);
const [gitSettingsForm, fields] = useForm({
id: "update-git-settings",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: UpdateGitSettingsFormSchema,
});
},
});
const isGitSettingsLoading =
navigation.formData?.get("action") === "update-git-settings" &&
(navigation.state === "submitting" || navigation.state === "loading");
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
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">
<DateTime
date={connectedGitHubRepo.createdAt}
includeTime={false}
includeSeconds={false}
showTimezone={false}
showTooltip={false}
/>
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="minimal/small">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} {...gitSettingsForm.props}>
{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
{...conform.input(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
{...conform.input(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?.error}</FormError>
<FormError>{fields.stagingBranch?.error}</FormError>
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
<FormError>{gitSettingsForm.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-git-settings"
variant="secondary/small"
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</>
);
}
// ============================================================================
// Main GitHub Settings Panel Component
// ============================================================================
export function GitHubSettingsPanel({
organizationSlug,
projectSlug,
environmentSlug,
billingPath,
}: {
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
billingPath: string;
}) {
const fetcher = useTypedFetcher<typeof loader>();
const location = useLocation();
// Use provided redirectUrl or fall back to current path (without search params)
const effectiveRedirectUrl = location.pathname;
useEffect(() => {
fetcher.load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug));
}, [organizationSlug, projectSlug, environmentSlug]);
const data = fetcher.data;
// Loading state
if (fetcher.state === "loading" && !data) {
return (
<div className="flex items-center gap-2 text-text-dimmed">
<SpinnerWhite className="size-4" />
<span className="text-sm">Loading GitHub settings...</span>
</div>
);
}
// GitHub app not enabled
if (!data || !data.enabled) {
return null;
}
// Connected repository exists - show form
if (data.connectedRepository) {
return (
<ConnectedGitHubRepoForm
connectedGitHubRepo={data.connectedRepository}
previewEnvironmentEnabled={data.isPreviewEnvironmentEnabled}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
billingPath={billingPath}
redirectUrl={effectiveRedirectUrl}
/>
);
}
// No connected repository - show connection prompt
return (
<div className="flex flex-col gap-2">
<GitHubConnectionPrompt
gitHubAppInstallations={data.installations ?? []}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
redirectUrl={effectiveRedirectUrl}
/>
{!data.connectedRepository && (
<Hint>
Connect your GitHub repository to automatically deploy your changes.
</Hint>
)}
</div>
);
}
@@ -3,7 +3,7 @@ import { useParams } from "@remix-run/react";
export default function Story() {
const { tabNumber } = useParams();
return (
<div className="flex items-center justify-center py-8">
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">{tabNumber}</h1>
</div>
);
+180 -10
View File
@@ -1,18 +1,188 @@
import { Outlet } from "@remix-run/react";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "~/components/primitives/ClientTabs";
import { Header1 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Tabs } from "~/components/primitives/Tabs";
export default function Story() {
return (
<div className="w-96 p-8">
<Tabs
tabs={[
{ label: "My first tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs"
/>
<Outlet />
<div className="flex items-start justify-center gap-20 px-16 pt-20">
<div className="flex w-full max-w-2xl flex-col gap-4">
<div className="flex h-fit flex-col gap-2">
<div className="flex flex-col gap-2">
<Header1 spacing>{"<Tabs/>"} (updates the URL)</Header1>
<Paragraph>Variant="underline"</Paragraph>
</div>
<Tabs
tabs={[
{ label: "First tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs-1"
variant="underline"
/>
<Outlet />
</div>
<div className="flex h-fit flex-col gap-2">
<Paragraph>Variant="pipe-divider"</Paragraph>
<Tabs
tabs={[
{ label: "First tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs-2"
variant="pipe-divider"
/>
<Outlet />
</div>
<div className="flex h-fit flex-col gap-2">
<Paragraph>Variant="segmented"</Paragraph>
<Tabs
tabs={[
{ label: "First tab", to: "1" },
{ label: "Second tab", to: "2" },
{ label: "Third tab", to: "3" },
]}
layoutId="my-tabs-3"
variant="segmented"
/>
<Outlet />
</div>
</div>
<div className="flex w-full max-w-2xl flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<Header1 spacing>{"<ClientTabs/>"}</Header1>
<Paragraph>Variant="underline"</Paragraph>
</div>
<ClientTabs defaultValue="tab-1">
<div className="flex items-center gap-4">
<ClientTabsList variant="underline">
<ClientTabsTrigger
value={"tab-1"}
variant="underline"
layoutId="client-tabs-underline"
>
First tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-2"}
variant="underline"
layoutId="client-tabs-underline"
>
Second tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-3"}
variant="underline"
layoutId="client-tabs-underline"
>
Third tab
</ClientTabsTrigger>
</ClientTabsList>
</div>
<ClientTabsContent value={"tab-1"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">1</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-2"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">2</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-3"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">3</h1>
</div>
</ClientTabsContent>
</ClientTabs>
</div>
<div>
<Paragraph spacing>Variant="pipe-divider"</Paragraph>
<ClientTabs defaultValue="tab-1">
<div className="flex items-center gap-4">
<ClientTabsList variant="pipe-divider">
<ClientTabsTrigger value={"tab-1"} variant="pipe-divider">
First tab
</ClientTabsTrigger>
<ClientTabsTrigger value={"tab-2"} variant="pipe-divider">
Second tab
</ClientTabsTrigger>
<ClientTabsTrigger value={"tab-3"} variant="pipe-divider">
Third tab
</ClientTabsTrigger>
</ClientTabsList>
</div>
<ClientTabsContent value={"tab-1"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">1</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-2"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">2</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-3"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">3</h1>
</div>
</ClientTabsContent>
</ClientTabs>
</div>
<div>
<Paragraph spacing>Variant="segmented"</Paragraph>
<ClientTabs defaultValue="tab-1">
<ClientTabsList variant="segmented">
<ClientTabsTrigger
value={"tab-1"}
variant="segmented"
layoutId="client-tabs-segmented"
>
First tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-2"}
variant="segmented"
layoutId="client-tabs-segmented"
>
Second tab
</ClientTabsTrigger>
<ClientTabsTrigger
value={"tab-3"}
variant="segmented"
layoutId="client-tabs-segmented"
>
Third tab
</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"tab-1"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">1</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-2"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">2</h1>
</div>
</ClientTabsContent>
<ClientTabsContent value={"tab-3"}>
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
<h1 className="text-5xl">3</h1>
</div>
</ClientTabsContent>
</ClientTabs>
</div>
</div>
</div>
);
}