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:
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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>
|
||||
)}
|
||||
|
||||
+13
-665
@@ -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();
|
||||
|
||||
+877
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user