diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 380a6d990c..d0e798f168 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -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 ( - -
-
-
- - Deploy your tasks to {environmentFullTitle(environment)} -
-
- - } - content="Deploy docs" - /> - - } - content="Troubleshooting docs" - /> - -
-
- - - - This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read - the full guide. - - - - - - - Read the GitHub Actions guide to - get started. - - - - - This page will automatically refresh when your tasks are deployed. - -
-
- ); + return ; } export function SchedulesNoPossibleTaskPanel() { @@ -266,45 +218,7 @@ export function TestHasNoTasks() { } export function DeploymentsNone() { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - - return ( - - - There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration - service like GitHub Actions. Make sure you{" "} - - set your environment variables - {" "} - first. - -
- - Deploy with the CLI - - - Deploy with GitHub actions - -
-
- ); + return ; } export function DeploymentsNoneDev() { @@ -313,46 +227,52 @@ export function DeploymentsNoneDev() { const environment = useEnvironment(); return ( -
- - + <> +
+
+ + Deploy your tasks +
+
+ + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + +
+
+ + + This is the Development environment. When you're ready to deploy your tasks, switch to a different environment. - - There are several ways to deploy your tasks. You can use the CLI or a Continuous - Integration service like GitHub Actions. Make sure you{" "} - - set your environment variables - {" "} - first. - -
- - Deploy with the CLI - - - Deploy with GitHub actions - -
-
- -
+ + + ); } @@ -670,3 +590,99 @@ export function BulkActionsNone() { ); } + +function DeploymentOnboardingSteps() { + const environment = useEnvironment(); + const organization = useOrganization(); + const project = useProject(); + + return ( + +
+
+ + Deploy your tasks to {environmentFullTitle(environment)} +
+
+ + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + +
+
+ + + + GitHub + + + Manual + + + GitHub Actions + + + + + + + Deploy automatically with every push. Read the{" "} + full guide. + +
+ +
+
+
+ + + + + This will deploy your tasks to the {environmentFullTitle(environment)} environment. + Read the full guide. + + + + + + + + + Read the GitHub Actions guide to + get started. + + + +
+ + + + This page will automatically refresh when your tasks are deployed. + +
+ ); +} diff --git a/apps/webapp/app/components/primitives/ClientTabs.tsx b/apps/webapp/app/components/primitives/ClientTabs.tsx index 52d10b8cfe..bc3943e82b 100644 --- a/apps/webapp/app/components/primitives/ClientTabs.tsx +++ b/apps/webapp/app/components/primitives/ClientTabs.tsx @@ -1,41 +1,185 @@ "use client"; -import * as React from "react"; +import { motion } from "framer-motion"; import * as TabsPrimitive from "@radix-ui/react-tabs"; +import * as React from "react"; import { cn } from "~/utils/cn"; -import { motion } from "framer-motion"; +import { type Variants } from "./Tabs"; + +type ClientTabsContextValue = { + value?: string; +}; + +const ClientTabsContext = React.createContext(undefined); + +function useClientTabsContext() { + return React.useContext(ClientTabsContext); +} const ClientTabs = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->((props, ref) => ); +>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => { + const [value, setValue] = React.useState(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(() => ({ value }), [value]); + + return ( + + + + ); +}); ClientTabs.displayName = TabsPrimitive.Root.displayName; const ClientTabsList = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); + React.ComponentPropsWithoutRef & { + 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 ; +}); ClientTabsList.displayName = TabsPrimitive.List.displayName; const ClientTabsTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); + React.ComponentPropsWithoutRef & { + 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 ( + +
+ + {children} + +
+ {isActive ? ( + layoutId ? ( + + ) : ( +
+ ) + ) : null} + + ); + } + + if (variant === "underline") { + return ( + + + {children} + + {layoutId ? ( + isActive ? ( + + ) : ( +
+ ) + ) : isActive ? ( +
+ ) : ( +
+ )} + + ); + } + + return ( + + {children} + + ); +}); 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 ( - - {tabs.map((tab, index) => { - const isActive = currentValue === tab.value; - return ( - - - {tab.label} - - {isActive ? ( - - ) : ( -
- )} - - ); - })} - - ); -} - -export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent }; +export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger }; diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx index e3d3183d94..cbc5cf4275 100644 --- a/apps/webapp/app/components/primitives/Tabs.tsx +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -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 ( - + {tabs.map((tab, index) => ( - + {tab.label} ))} @@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) { ); } -export function TabContainer({ children, className }: { children: ReactNode; className?: string }) { - return ( -
- {children} -
- ); +export function TabContainer({ + children, + className, + variant = "underline", +}: { + children: ReactNode; + className?: string; + variant?: Variants; +}) { + if (variant === "segmented") { + return ( +
+ {children} +
+ ); + } + + if (variant === "underline") { + return ( +
{children}
+ ); + } + + return
{children}
; } export function TabLink({ to, children, layoutId, + variant = "underline", }: { to: string; children: ReactNode; layoutId: string; + variant?: Variants; }) { + if (variant === "segmented") { + return ( + + {({ isActive, isPending }) => { + const active = isActive || isPending; + return ( + <> +
+ + {children} + +
+ {active && ( + + )} + + ); + }} +
+ ); + } + + if (variant === "pipe-divider") { + return ( + + {({ isActive, isPending }) => { + const active = isActive || isPending; + return ( + + {children} + + ); + }} + + ); + } + + // underline variant (default) return ( {({ isActive, isPending }) => { @@ -51,13 +138,19 @@ export function TabLink({ {children} {isActive || isPending ? ( - + ) : (
)} @@ -106,17 +199,18 @@ export function TabButton({ <>
{props.children} {shortcut && }
{isActive ? ( - + ) : (
)} diff --git a/apps/webapp/app/presenters/v3/GitHubSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/GitHubSettingsPresenter.server.ts new file mode 100644 index 0000000000..c3f715deff --- /dev/null +++ b/apps/webapp/app/presenters/v3/GitHubSettingsPresenter.server.ts @@ -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, + })); + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 6f161eea98..9b2b78f98b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -359,11 +359,11 @@ export default function Page() {
) : environment.type === "DEVELOPMENT" ? ( - + ) : ( - + )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 06b6f6ad8a..66ea64cb36 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -1,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(); + const { githubAppEnabled, buildSettings } = useTypedLoaderData(); const project = useProject(); const organization = useOrganization(); const environment = useEnvironment(); @@ -578,19 +416,12 @@ export default function Page() {
Git settings
- {connectedGithubRepository ? ( - - ) : ( - - )} +
@@ -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( - 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 ( - - - - - - Connect GitHub repository -
-
- - Choose a GitHub repository to connect to your project. - -
- - - - {installationId.error} - - - - - - Configure repository access in{" "} - - GitHub - - . - - {repositoryId.error} - - {form.error} - - Connect repository - - } - cancelButton={ - - - - } - /> -
-
-
-
-
- ); -} - -function GitHubConnectionPrompt({ - gitHubAppInstallations, - organizationSlug, - projectSlug, - environmentSlug, -}: { - gitHubAppInstallations: GitHubAppInstallation[]; - organizationSlug: string; - projectSlug: string; - environmentSlug: string; -}) { - return ( -
- - {gitHubAppInstallations.length === 0 && ( - - Install GitHub app - - )} - {gitHubAppInstallations.length !== 0 && ( -
- - - GitHub app is installed - -
- )} - - Connect your GitHub repository to automatically deploy your changes. -
-
- ); -} - -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 ( - <> -
-
- - - {connectedGitHubRepo.repository.fullName} - - {connectedGitHubRepo.repository.private && ( - - )} - - - -
- - - - - - Disconnect GitHub repository -
- - Are you sure you want to disconnect{" "} - {connectedGitHubRepo.repository.fullName}? - This will stop automatic deployments from GitHub. - - - - - - } - cancelButton={ - - - - } - /> -
-
-
-
- -
-
- - - Every push to the selected tracking branch creates a deployment in the corresponding - environment. - -
-
- - - {environmentFullTitle({ type: "PRODUCTION" })} - -
- { - setGitSettingsValues((prev) => ({ - ...prev, - productionBranch: e.target.value, - })); - }} - /> -
- - - {environmentFullTitle({ type: "STAGING" })} - -
- { - setGitSettingsValues((prev) => ({ - ...prev, - stagingBranch: e.target.value, - })); - }} - /> - -
- - - {environmentFullTitle({ type: "PREVIEW" })} - -
-
- { - setGitSettingsValues((prev) => ({ - ...prev, - previewDeploymentsEnabled: checked, - })); - }} - /> - {!previewEnvironmentEnabled && ( - - Upgrade your plan to - enable preview branches - - } - /> - )} -
-
- {fields.productionBranch?.error} - {fields.stagingBranch?.error} - {fields.previewDeploymentsEnabled?.error} - {gitSettingsForm.error} -
- - - Save - - } - /> -
-
- - ); -} - function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) { const lastSubmission = useActionData() as any; const navigation = useNavigation(); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx new file mode 100644 index 0000000000..bb7406ed44 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -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 = { + 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( + 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 ( + + + + + + Connect GitHub repository +
+
+ {redirectUrl && } + + Choose a GitHub repository to connect to your project. + +
+ + + + {installationId.error} + + + + + + Configure repository access in{" "} + + GitHub + + . + + {repositoryId.error} + + {form.error} + + Connect repository + + } + cancelButton={ + + + + } + /> +
+
+
+
+
+ ); +} + +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 ( +
+ + {gitHubAppInstallations.length === 0 && ( + + Install GitHub app + + )} + {gitHubAppInstallations.length !== 0 && ( +
+ + + GitHub app is installed + +
+ )} +
+
+ ); +} + +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 ( + <> +
+
+ + + {connectedGitHubRepo.repository.fullName} + + {connectedGitHubRepo.repository.private && ( + + )} + + + +
+ + + + + + Disconnect GitHub repository +
+ + Are you sure you want to disconnect{" "} + {connectedGitHubRepo.repository.fullName}? + This will stop automatic deployments from GitHub. + + + + {redirectUrl && } + + + } + cancelButton={ + + + + } + /> +
+
+
+
+ +
+ {redirectUrl && } +
+ + + Every push to the selected tracking branch creates a deployment in the corresponding + environment. + +
+
+ + + {environmentFullTitle({ type: "PRODUCTION" })} + +
+ { + setGitSettingsValues((prev) => ({ + ...prev, + productionBranch: e.target.value, + })); + }} + /> +
+ + + {environmentFullTitle({ type: "STAGING" })} + +
+ { + setGitSettingsValues((prev) => ({ + ...prev, + stagingBranch: e.target.value, + })); + }} + /> + +
+ + + {environmentFullTitle({ type: "PREVIEW" })} + +
+
+ { + setGitSettingsValues((prev) => ({ + ...prev, + previewDeploymentsEnabled: checked, + })); + }} + /> + {!previewEnvironmentEnabled && ( + + Upgrade your plan to enable preview + branches + + } + /> + )} +
+
+ {fields.productionBranch?.error} + {fields.stagingBranch?.error} + {fields.previewDeploymentsEnabled?.error} + {gitSettingsForm.error} +
+ + + Save + + } + /> +
+
+ + ); +} + +// ============================================================================ +// Main GitHub Settings Panel Component +// ============================================================================ + +export function GitHubSettingsPanel({ + organizationSlug, + projectSlug, + environmentSlug, + billingPath, +}: { + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + billingPath: string; +}) { + const fetcher = useTypedFetcher(); + 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 ( +
+ + Loading GitHub settings... +
+ ); + } + + // GitHub app not enabled + if (!data || !data.enabled) { + return null; + } + + // Connected repository exists - show form + if (data.connectedRepository) { + return ( + + ); + } + + // No connected repository - show connection prompt + return ( +
+ + {!data.connectedRepository && ( + + Connect your GitHub repository to automatically deploy your changes. + + )} +
+ + ); +} diff --git a/apps/webapp/app/routes/storybook.tabs.$tabNumber/route.tsx b/apps/webapp/app/routes/storybook.tabs.$tabNumber/route.tsx index 549108143b..8cf7aaa164 100644 --- a/apps/webapp/app/routes/storybook.tabs.$tabNumber/route.tsx +++ b/apps/webapp/app/routes/storybook.tabs.$tabNumber/route.tsx @@ -3,7 +3,7 @@ import { useParams } from "@remix-run/react"; export default function Story() { const { tabNumber } = useParams(); return ( -
+

{tabNumber}

); diff --git a/apps/webapp/app/routes/storybook.tabs/route.tsx b/apps/webapp/app/routes/storybook.tabs/route.tsx index fc0c8003a4..f3389f2aff 100644 --- a/apps/webapp/app/routes/storybook.tabs/route.tsx +++ b/apps/webapp/app/routes/storybook.tabs/route.tsx @@ -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 ( -
- - +
+
+
+
+ {""} (updates the URL) + Variant="underline" +
+ + +
+
+ Variant="pipe-divider" + + +
+
+ Variant="segmented" + + +
+
+
+
+
+ {""} + Variant="underline" +
+ +
+ + + First tab + + + Second tab + + + Third tab + + +
+ +
+

1

+
+
+ +
+

2

+
+
+ +
+

3

+
+
+
+
+ +
+ Variant="pipe-divider" + +
+ + + First tab + + + Second tab + + + Third tab + + +
+ +
+

1

+
+
+ +
+

2

+
+
+ +
+

3

+
+
+
+
+
+ Variant="segmented" + + + + First tab + + + Second tab + + + Third tab + + + +
+

1

+
+
+ +
+

2

+
+
+ +
+

3

+
+
+
+
+
); }