From 2c3cb4a43a77af2f10bd35e2ce77a558352cb95a Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 9 Dec 2025 13:10:11 +0000 Subject: [PATCH 1/6] feat(webapp): UX improvements for TaskRun page and TaskRun table (#2760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ✅ 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 Manual testing of the task run pages --- ## Changelog - Add previous/next run navigation buttons to run detail page header - Support [ and ] keyboard shortcuts to jump between adjacent runs - Preserve runs table state (filters, pagination) when navigating - Preload adjacent page runs at boundaries for seamless navigation - Add actions prop to PageTitle component - Document shortcut in keyboard shortcuts panel - Store current filter state from runs table as `tableState` search param when navigating to individual run pages - Restore filters when navigating back from run detail view to runs list - Update `v3RunPath` and `v3RunSpanPath` helpers to accept optional searchParams - Use `useOptimisticLocation` to capture current search params in TaskRunsTable - Parse `tableState` param in run detail route and pass filters to back button - This improves UX by remembering filter selections (task, status, date range, etc.) when users click into a run and then navigate back to the runs list - Add new text-below variant that shows "Click to copy" tooltip on hover and "Copied" on click. Also add controlled open/onOpenChange props to SimpleTooltip for managing tooltip visibility. --- ## Screenshots https://github.com/user-attachments/assets/5067bbe0-1bcd-4e75-80a7-f56dabd5ed69 --- apps/webapp/app/components/Shortcuts.tsx | 4 + .../app/components/primitives/Buttons.tsx | 6 +- .../components/primitives/CopyableText.tsx | 110 +++++--- .../app/components/primitives/ShortcutKey.tsx | 6 +- .../app/components/primitives/Tooltip.tsx | 8 +- .../app/components/runs/v3/TaskRunsTable.tsx | 11 +- .../route.tsx | 264 +++++++++++++++++- apps/webapp/app/utils/pathBuilder.ts | 15 +- 8 files changed, 360 insertions(+), 64 deletions(-) diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index ab328afde..b21c55659 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -134,6 +134,10 @@ function ShortcutContent() { + + + + diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 47c8d3d67..67ba3c092 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -372,7 +372,7 @@ export const LinkButton = ({ {({ isActive, isPending }) => ( diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 67e01af79..fa02e5647 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -3,59 +3,95 @@ import { useState } from "react"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { useCopy } from "~/hooks/useCopy"; import { cn } from "~/utils/cn"; +import { Button } from "./Buttons"; export function CopyableText({ value, copyValue, className, asChild, + variant, }: { value: string; copyValue?: string; className?: string; asChild?: boolean; + variant?: "icon-right" | "text-below"; }) { const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(copyValue ?? value); - return ( - setIsHovered(false)} - > - setIsHovered(true)}>{value} + const resolvedVariant = variant ?? "icon-right"; + + if (resolvedVariant === "icon-right") { + return ( e.stopPropagation()} - className={cn( - "absolute -right-6 top-0 z-10 size-6 font-sans", - isHovered ? "flex" : "hidden" - )} + className={cn("group relative inline-flex h-6 items-center", className)} + onMouseLeave={() => setIsHovered(false)} > - - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - className="font-sans" - disableHoverableContent - asChild={asChild} - /> + setIsHovered(true)}>{value} + e.stopPropagation()} + className={cn( + "absolute -right-6 top-0 z-10 size-6 font-sans", + isHovered ? "flex" : "hidden" + )} + > + + {copied ? ( + + ) : ( + + )} + + } + content={copied ? "Copied!" : "Copy"} + className="font-sans" + disableHoverableContent + asChild={asChild} + /> + - - ); + ); + } + + if (resolvedVariant === "text-below") { + return ( + { + e.stopPropagation(); + copy(); + }} + className={cn( + "cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent", + className + )} + > + {value} + + } + content={copied ? "Copied" : "Click to copy"} + className="font-sans px-2 py-1" + disableHoverableContent + open={isHovered || copied} + onOpenChange={setIsHovered} + /> + ); + } + + return null; } diff --git a/apps/webapp/app/components/primitives/ShortcutKey.tsx b/apps/webapp/app/components/primitives/ShortcutKey.tsx index 04b1f3673..567cf68d6 100644 --- a/apps/webapp/app/components/primitives/ShortcutKey.tsx +++ b/apps/webapp/app/components/primitives/ShortcutKey.tsx @@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider"; import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon"; const medium = - "text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase"; + "justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase"; export const variants = { small: - "text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase", + "justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase", medium: cn(medium, "group-hover:border-charcoal-550"), "medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"), }; @@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps) function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") { key = key.toLowerCase(); - const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5"; + const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5"; switch (key) { case "enter": diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx index 15dd72894..5c681927b 100644 --- a/apps/webapp/app/components/primitives/Tooltip.tsx +++ b/apps/webapp/app/components/primitives/Tooltip.tsx @@ -6,7 +6,7 @@ import { cn } from "~/utils/cn"; const variantClasses = { basic: "bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50", - dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50", + dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50" }; type Variant = keyof typeof variantClasses; @@ -64,6 +64,8 @@ function SimpleTooltip({ buttonStyle, asChild = false, sideOffset, + open, + onOpenChange, }: { button: React.ReactNode; content: React.ReactNode; @@ -76,10 +78,12 @@ function SimpleTooltip({ buttonStyle?: React.CSSProperties; asChild?: boolean; sideOffset?: number; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { return ( - + ([]); const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection); const { isManagedCloud } = useFeatures(); + const location = useOptimisticLocation(); + const tableStateParam = encodeURIComponent(location.search ? `${location.search}&rt=1` : "rt=1"); const showCompute = isManagedCloud; @@ -293,16 +296,20 @@ export function TaskRunsTable({ ) : ( runs.map((run, index) => { + const searchParams = new URLSearchParams(); + if (tableStateParam) { + searchParams.set("tableState", tableStateParam); + } const path = v3RunSpanPath(organization, project, run.environment, run, { spanId: run.spanId, - }); + }, searchParams); return ( {allowSelection && ( { + onChange={() => { toggle(run.friendlyId); }} ref={(r) => { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 79ab0b8e5..5253e56f2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -2,6 +2,7 @@ import { ArrowUturnLeftIcon, BoltSlashIcon, BookOpenIcon, + ChevronUpIcon, ChevronDownIcon, ChevronRightIcon, InformationCircleIcon, @@ -22,7 +23,7 @@ import { } from "@trigger.dev/core/v3"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { motion } from "framer-motion"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { redirect } from "remix-typedjson"; import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon"; @@ -68,7 +69,6 @@ import { eventBorderClassName, } from "~/components/runs/v3/SpanTitle"; import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus"; -import { env } from "~/env.server"; import { useDebounce } from "~/hooks/useDebounce"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useEventSource } from "~/hooks/useEventSource"; @@ -88,6 +88,7 @@ import { docsPath, v3BillingPath, v3RunParamsSchema, + v3RunPath, v3RunRedirectPath, v3RunSpanPath, v3RunStreamingPath, @@ -98,6 +99,13 @@ import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectP import { useSearchParams } from "~/hooks/useSearchParam"; import { CopyableText } from "~/components/primitives/CopyableText"; import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types"; +import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters"; +import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { $replica } from "~/db.server"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { logger } from "~/services/logger.server"; const resizableSettings = { parent: { @@ -131,6 +139,101 @@ const resizableSettings = { type TraceEvent = NonNullable["trace"]>["events"][0]; +type RunsListNavigation = { + runs: Array<{ friendlyId: string }>; + pagination: { next?: string; previous?: string }; + prevPageLastRun?: { friendlyId: string; cursor: string }; + nextPageFirstRun?: { friendlyId: string; cursor: string }; +}; + +async function getRunsListFromTableState({ + tableStateParam, + organizationSlug, + projectParam, + envParam, + runParam, + userId, +}: { + tableStateParam: string | null; + organizationSlug: string; + projectParam: string; + envParam: string; + runParam: string; + userId: string; +}): Promise { + if (!tableStateParam) { + return null; + } + + try { + const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam)); + const filters = getRunFiltersFromSearchParams(tableStateSearchParams); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId); + + if (!project || !environment) { + return null; + } + + const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient); + const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + pageSize: 25, // Load enough runs to provide navigation context + }); + + const runsList: RunsListNavigation = { + runs: currentPageResult.runs, + pagination: currentPageResult.pagination, + }; + + const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam); + + if (currentRunIndex === 0 && currentPageResult.pagination.previous) { + const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + cursor: currentPageResult.pagination.previous, + direction: "backward", + pageSize: 1, // We only need the last run from the previous page + }); + + if (prevPageResult.runs.length > 0) { + runsList.prevPageLastRun = { + friendlyId: prevPageResult.runs[0].friendlyId, + cursor: currentPageResult.pagination.previous, + }; + } + } + + if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) { + const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + cursor: currentPageResult.pagination.next, + direction: "forward", + pageSize: 1, // We only need the first run from the next page + }); + + if (nextPageResult.runs.length > 0) { + runsList.nextPageFirstRun = { + friendlyId: nextPageResult.runs[0].friendlyId, + cursor: currentPageResult.pagination.next, + }; + } + } + + return runsList; + } catch (error) { + logger.error("Error loading runs list from tableState:", { error }); + return null; + } +} + export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const impersonationId = await getImpersonationId(request); @@ -169,6 +272,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId); const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId); + const runsList = await getRunsListFromTableState({ + tableStateParam: url.searchParams.get("tableState"), + organizationSlug, + projectParam, + envParam, + runParam, + userId, + }); + return json({ run: result.run, trace: result.trace, @@ -177,13 +289,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { parent, tree, }, + runsList, }); }; type LoaderData = SerializeFrom; export default function Page() { - const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData(); + const { run, trace, resizable, maximumLiveReloadingSetting, runsList } = useLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -191,16 +304,28 @@ export default function Page() { logCount: trace?.events.length ?? 0, isCompleted: run.completedAt !== null, }); + const { value } = useSearchParams(); + const tableState = decodeURIComponent(value("tableState") ?? ""); + const tableStateSearchParams = new URLSearchParams(tableState); + const filters = getRunFiltersFromSearchParams(tableStateSearchParams); + + const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableState, run, runsList}); return ( <> } + title={<> + + {tableState && (
+ + +
)} + } /> {environment.type === "DEVELOPMENT" && } @@ -276,14 +401,10 @@ export default function Page() { run={run} trace={trace} maximumLiveReloadingSetting={maximumLiveReloadingSetting} - resizable={resizable} /> ) : ( )} @@ -291,7 +412,7 @@ export default function Page() { ); } -function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) { +function TraceView({ run, trace, maximumLiveReloadingSetting }: Pick) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -385,7 +506,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade ); } -function NoLogsView({ run, resizable }: LoaderData) { +function NoLogsView({ run }: Pick) { const plan = useCurrentPlan(); const organization = useOrganization(); @@ -1432,6 +1553,7 @@ function KeyboardShortcuts({ return ( <> + expandAllBelowDepth(0)} @@ -1448,6 +1570,16 @@ function KeyboardShortcuts({ ); } +function AdjacentRunsShortcuts() { + return (
+ + + + Adjacent runs + +
); +} + function ArrowKeyShortcuts() { return (
@@ -1494,7 +1626,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void return (
0 - + 9 Toggle level @@ -1526,3 +1658,111 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) { /> ); } + +function useAdjacentRunPaths({ + organization, + project, + environment, + tableState, + run, + runsList, +}: { + organization: { slug: string }; + project: { slug: string }; + environment: { slug: string }; + tableState: string; + run: { friendlyId: string }; + runsList: RunsListNavigation | null; +}): [string | null, string | null] { + return useMemo(() => { + if (!runsList || runsList.runs.length === 0) { + return [null, null]; + } + + const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId); + + if (currentIndex === -1) { + return [null, null]; + } + + // Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list + let previousRun: { friendlyId: string } | null = null; + const previousRunTableState = new URLSearchParams(tableState); + if (currentIndex > 0) { + previousRun = runsList.runs[currentIndex - 1]; + } else if (runsList.prevPageLastRun) { + previousRun = runsList.prevPageLastRun; + // Update tableState with the new cursor for the previous page + previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor); + previousRunTableState.set("direction", "backward"); + } + + // Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list + let nextRun: { friendlyId: string } | null = null; + const nextRunTableState = new URLSearchParams(tableState); + if (currentIndex < runsList.runs.length - 1) { + nextRun = runsList.runs[currentIndex + 1]; + } else if (runsList.nextPageFirstRun) { + nextRun = runsList.nextPageFirstRun; + // Update tableState with the new cursor for the next page + nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor); + nextRunTableState.set("direction", "forward"); + } + + const previousURLSearchParams = new URLSearchParams(); + previousURLSearchParams.set("tableState", previousRunTableState.toString()); + const previousRunPath = previousRun + ? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams) + : null; + + const nextURLSearchParams = new URLSearchParams(); + nextURLSearchParams.set("tableState", nextRunTableState.toString()); + const nextRunPath = nextRun + ? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams) + : null; + + return [previousRunPath, nextRunPath]; + }, [organization, project, environment, tableState, run.friendlyId, runsList]); +} + + +function PreviousRunButton({ to }: { to: string | null }) { + return ( +
+ !to && e.preventDefault()} + shortcut={{ key: "[" }} + tooltip="Previous Run" + disabled={!to} + /> +
+ ); +} + +function NextRunButton({ to }: { to: string | null }) { + return ( +
+ !to && e.preventDefault()} + shortcut={{ key: "]" }} + tooltip="Next Run" + disabled={!to} + /> +
+ ); +} + diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index f82165ae9..3061082ed 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -288,15 +288,17 @@ export function v3RunPath( organization: OrgForPath, project: ProjectForPath, environment: EnvironmentForPath, - run: v3RunForPath + run: v3RunForPath, + searchParams?: URLSearchParams ) { - return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}`; + const query = searchParams ? `?${searchParams.toString()}` : ""; + return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}${query}`; } export function v3RunRedirectPath( organization: OrgForPath, project: ProjectForPath, - run: v3RunForPath + run: v3RunForPath, ) { return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`; } @@ -310,9 +312,12 @@ export function v3RunSpanPath( project: ProjectForPath, environment: EnvironmentForPath, run: v3RunForPath, - span: v3SpanForPath + span: v3SpanForPath, + searchParams?: URLSearchParams ) { - return `${v3RunPath(organization, project, environment, run)}?span=${span.spanId}`; + searchParams = searchParams ?? new URLSearchParams(); + searchParams.set("span", span.spanId); + return `${v3RunPath(organization, project, environment, run, searchParams)}`; } export function v3RunStreamingPath( From da111e220f82616935158c3348e852e480c22c82 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 10 Dec 2025 12:03:56 +0100 Subject: [PATCH 2/6] fix(api): whitelist deployment endpoints from the general API rate limits (#2774) Deployments are affected by general API rate limits, this is just a quick fix by whitelisting the deployment related endpoints. In a follow up PR we'll add a separate rate limiter for this group of endpoints. --- apps/webapp/app/services/apiRateLimit.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index 416d7834e..4cf27450a 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -61,6 +61,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ "/api/v1/auth/jwt/claims", /^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts /^\/api\/v1\/waitpoints\/tokens\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/waitpoints/tokens/$waitpointFriendlyId/callback/$hash + /^\/api\/v1\/deployments/, // /api/v1/deployments/* ], log: { rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1", From 07a31d3732ff1a6763033d4aef0e45bf9730c13b Mon Sep 17 00:00:00 2001 From: Mihai Popescu Date: Wed, 10 Dec 2025 12:05:16 +0000 Subject: [PATCH 3/6] fix(webapp) : fixed Cmd+Left Arrow was intercepted by the TreeView component (#2772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed the issue where Cmd+Left Arrow was being intercepted by the TreeView component on task runs screen. Solution: Added a check in the getTreeProps keyboard handler to detect when metaKey (Cmd on macOS) is pressed with Left Arrow. When detected, the handler returns early without preventing the default browser behavior, allowing Chrome's native back navigation to work. Closes # ## ✅ 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 Went to the task details page and confirmed that Cmd + Left Arrow will navigate back --- ## Changelog Modified TreeView.tsx to check for e.metaKey before handling Left Arrow key events When Cmd+Left is pressed, the event is no longer prevented, allowing browser default behavior --- Co-authored-by: Mihai Popescu --- apps/webapp/app/components/primitives/TreeView/TreeView.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx index 7a2e368be..bb9ca4c46 100644 --- a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx +++ b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx @@ -423,6 +423,10 @@ export function useTree({ } case "Left": case "ArrowLeft": { + if (e.metaKey) { + return; + } + e.preventDefault(); const selected = selectedIdFromState(state.nodes); From 74e9246bfa862cc0b14ddd12e0d76677face490f Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:49:44 +0000 Subject: [PATCH 4/6] feat(cli): enable zstd compression for deployment images (#2773) This will speed up ice cold starts (*) for two reasons: - better compression ratio - faster decompression This is a minor release because zstd compression will now be enabled by default for all deployments. (*) ice cold starts happen when deploy images are not cached on the worker node yet. These cold start durations are highly dependent on image size and as it turns out, also the type of compression used. --- .changeset/great-pillows-look.md | 5 ++ packages/cli-v3/src/commands/deploy.ts | 46 ++++++++++- packages/cli-v3/src/deploy/buildImage.ts | 99 +++++++++++++++++++++++- 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 .changeset/great-pillows-look.md diff --git a/.changeset/great-pillows-look.md b/.changeset/great-pillows-look.md new file mode 100644 index 000000000..72a0589b2 --- /dev/null +++ b/.changeset/great-pillows-look.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": minor +--- + +feat(cli): enable zstd compression for deployment images diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 818051d68..f4de03281 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -71,7 +71,7 @@ const DeployCommandOptions = CommonCommandOptions.extend({ saveLogs: z.boolean().default(false), skipUpdateCheck: z.boolean().default(false), skipPromotion: z.boolean().default(false), - noCache: z.boolean().default(false), + cache: z.boolean().default(true), envFile: z.string().optional(), // Local build options forceLocalBuild: z.boolean().optional(), @@ -83,6 +83,10 @@ const DeployCommandOptions = CommonCommandOptions.extend({ nativeBuildServer: z.boolean().default(false), detach: z.boolean().default(false), plain: z.boolean().default(false), + compression: z.enum(["zstd", "gzip"]).default("zstd"), + cacheCompression: z.enum(["zstd", "gzip"]).default("zstd"), + compressionLevel: z.number().optional(), + forceCompression: z.boolean().default(true), }); type DeployCommandOptions = z.infer; @@ -157,6 +161,40 @@ export function configureDeployCommand(program: Command) { "If provided, will save logs even for successful builds" ).hideHelp() ) + .addOption( + new CommandOption( + "--compression ", + "Compression algorithm for image layers: zstd or gzip (default: zstd)" + ) + .choices(["zstd", "gzip"]) + .hideHelp() + ) + .addOption( + new CommandOption( + "--cache-compression ", + "Compression algorithm for build cache: zstd or gzip (default: zstd)" + ) + .choices(["zstd", "gzip"]) + .hideHelp() + ) + .addOption( + new CommandOption( + "--compression-level ", + "The compression level to use when building the image." + ).hideHelp() + ) + .addOption( + new CommandOption( + "--force-compression", + "Force recompression of all layers. Enabled by default when using zstd." + ).hideHelp() + ) + .addOption( + new CommandOption( + "--no-force-compression", + "Disable forced recompression of layers." + ).hideHelp() + ) // Local build options .addOption( new CommandOption("--force-local-build", "Deprecated alias for --local-build").implies({ @@ -480,7 +518,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const buildResult = await buildImage({ isLocalBuild, useRegistryCache: options.useRegistryCache, - noCache: options.noCache, + noCache: !options.cache, deploymentId: deployment.id, deploymentVersion: deployment.version, imageTag: deployment.imageTag, @@ -499,6 +537,10 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { authAccessToken: authorization.auth.accessToken, compilationPath: destination.path, buildEnvVars: buildManifest.build.env, + compression: options.compression, + cacheCompression: options.cacheCompression, + compressionLevel: options.compressionLevel, + forceCompression: options.forceCompression, onLog: (logMessage) => { if (options.plain || isCI) { console.log(logMessage); diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index e325b58d8..d6957bf97 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -20,6 +20,10 @@ export interface BuildImageOptions { imagePlatform: string; noCache?: boolean; load?: boolean; + compression?: "zstd" | "gzip"; + cacheCompression?: "zstd" | "gzip"; + compressionLevel?: number; + forceCompression?: boolean; // Local build options push?: boolean; @@ -79,6 +83,10 @@ export async function buildImage(options: BuildImageOptions): Promise; + compression?: "zstd" | "gzip"; + compressionLevel?: number; + forceCompression?: boolean; onLog?: (log: string) => void; } @@ -180,6 +198,14 @@ async function remoteBuildImage(options: DepotBuildImageOptions): Promise value) .flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]); + const outputOptions = getOutputOptions({ + imageTag: undefined, // This is already handled via the --save flag + push: true, // We always push the image to the registry + compression: options.compression, + compressionLevel: options.compressionLevel, + forceCompression: options.forceCompression, + }); + const args = [ "build", "-f", @@ -214,6 +240,8 @@ async function remoteBuildImage(options: DepotBuildImageOptions): Promise void; } async function localBuildImage(options: SelfHostedBuildImageOptions): Promise { - const { builder, imageTag, deploymentId, apiClient, useRegistryCache } = options; + const { + builder, + imageTag, + deploymentId, + apiClient, + useRegistryCache, + compression, + cacheCompression, + compressionLevel, + forceCompression, + } = options; // Ensure multi-platform build is supported on the local machine let builderExists = false; @@ -489,6 +531,14 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise Date: Wed, 10 Dec 2025 15:52:32 +0100 Subject: [PATCH 5/6] docs(kubernetes): use v4 helm chart instead of beta (#2671) --- docs/self-hosting/kubernetes.mdx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/self-hosting/kubernetes.mdx b/docs/self-hosting/kubernetes.mdx index f3a827701..4506d6da9 100644 --- a/docs/self-hosting/kubernetes.mdx +++ b/docs/self-hosting/kubernetes.mdx @@ -61,7 +61,7 @@ webapp: ```bash helm upgrade -n trigger --install trigger \ oci://ghcr.io/triggerdotdev/charts/trigger \ - --version "~4.0.0-beta" \ + --version "~4.0.0" \ --create-namespace ``` @@ -107,11 +107,11 @@ The following commands will display the default values: ```bash # Specific version helm show values oci://ghcr.io/triggerdotdev/charts/trigger \ - --version "4.0.0-beta.5" + --version "4.0.5" -# Latest v4 beta +# Latest v4 helm show values oci://ghcr.io/triggerdotdev/charts/trigger \ - --version "~4.0.0-beta" + --version "~4.0.0" ``` ### Custom values @@ -171,7 +171,7 @@ Deploy with your custom values: ```bash helm upgrade -n trigger --install trigger \ oci://ghcr.io/triggerdotdev/charts/trigger \ - --version "~4.0.0-beta" \ + --version "~4.0.0" \ --create-namespace \ -f values-custom.yaml ``` @@ -489,14 +489,14 @@ You can lock versions in two ways: # Pin to a specific version for production helm upgrade -n trigger --install trigger \ oci://ghcr.io/triggerdotdev/charts/trigger \ - --version "4.0.0-beta.5" + --version "4.0.5" # The app version will be different from the chart version # This is the version of the Trigger.dev webapp and supervisor # ..and should always match your Trigger.dev CLI version helm show chart \ oci://ghcr.io/triggerdotdev/charts/trigger \ - --version "4.0.0-beta.5" | grep appVersion + --version "4.0.5" | grep appVersion ``` **Specific image tags:** From d28707826ca804b4e474905dbbe74ec0356a96d2 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Wed, 10 Dec 2025 17:03:16 +0000 Subject: [PATCH 6/6] feat(webapp): add GitHub onboarding flow to empty Tasks and Deployments pages (#2775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ✅ 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 --- .../app/components/BlankStatePanels.tsx | 280 +++--- .../app/components/primitives/ClientTabs.tsx | 226 +++-- .../webapp/app/components/primitives/Tabs.tsx | 130 ++- .../v3/GitHubSettingsPresenter.server.ts | 137 +++ .../route.tsx | 4 +- .../route.tsx | 678 +------------- ...cts.$projectParam.env.$envParam.github.tsx | 877 ++++++++++++++++++ .../storybook.tabs.$tabNumber/route.tsx | 2 +- .../app/routes/storybook.tabs/route.tsx | 190 +++- 9 files changed, 1639 insertions(+), 885 deletions(-) create mode 100644 apps/webapp/app/presenters/v3/GitHubSettingsPresenter.server.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 380a6d990..d0e798f16 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 52d10b8cf..bc3943e82 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 * 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(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 e3d3183d9..cbc5cf427 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 000000000..c3f715def --- /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 6f161eea9..9b2b78f98 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 06b6f6ad8..66ea64cb3 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 000000000..bb7406ed4 --- /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 549108143..8cf7aaa16 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 fc0c8003a..f3389f2af 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

+
+
+
+
+
); }