From 38fdb82a8a17c24a3c01d5f1b9ac7544814343c3 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 22 Apr 2026 12:45:52 -0700 Subject: [PATCH 1/2] feat: enable scroll and search for version decisions dialog --- .../EnvironmentVersionDecisions.tsx | 123 ++++++++++++++---- .../ws/deployments/page.$deploymentId.tsx | 1 - packages/trpc/src/routes/deployments.ts | 44 ++++++- 3 files changed, 143 insertions(+), 25 deletions(-) diff --git a/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/EnvironmentVersionDecisions.tsx b/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/EnvironmentVersionDecisions.tsx index 4db6eeea3..a2bf590ef 100644 --- a/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/EnvironmentVersionDecisions.tsx +++ b/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/EnvironmentVersionDecisions.tsx @@ -1,7 +1,11 @@ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ -import { ShieldOffIcon } from "lucide-react"; +import { useState } from "react"; +import { keepPreviousData } from "@tanstack/react-query"; +import { Loader2, SearchIcon, ShieldOffIcon } from "lucide-react"; +import { useDebounce } from "react-use"; import type { DeploymentVersionStatus } from "../types"; +import { trpc } from "~/api/trpc"; import { Button } from "~/components/ui/button"; import { Dialog, @@ -9,17 +13,22 @@ import { DialogHeader, DialogTitle, } from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; import { DeploymentVersion } from "./DeploymentVersion"; import { PolicySkipDialog } from "./policy-skip/PolicySkipDialog"; import { usePolicyRulesForVersion } from "./usePolicyRulesForVersion"; +const PAGE_SIZE = 20; + +type Version = { + id: string; + name?: string; + tag?: string; + status: DeploymentVersionStatus; +}; + type VersionRowProps = { - version: { - id: string; - name?: string; - tag?: string; - status: DeploymentVersionStatus; - }; + version: Version; environment: { id: string; name: string }; }; @@ -57,22 +66,44 @@ function VersionRow({ version, environment }: VersionRowProps) { type EnvironmentVersionDecisionsProps = { environment: { id: string; name: string }; deploymentId: string; - versions: { - id: string; - name?: string; - tag?: string; - status: DeploymentVersionStatus; - }[]; open: boolean; onOpenChange: (open: boolean) => void; }; export function EnvironmentVersionDecisions({ environment, - versions, + deploymentId, open, onOpenChange, }: EnvironmentVersionDecisionsProps) { + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [pageCount, setPageCount] = useState(1); + + useDebounce( + () => { + setDebouncedSearch(search); + setPageCount(1); + }, + 250, + [search], + ); + + const versionsQuery = trpc.deployment.searchVersions.useQuery( + { + deploymentId, + query: debouncedSearch || undefined, + limit: PAGE_SIZE * pageCount, + offset: 0, + }, + { refetchInterval: 5000, placeholderData: keepPreviousData }, + ); + + const versions = versionsQuery.data ?? []; + const hasMore = versions.length === PAGE_SIZE * pageCount; + const isInitialLoading = versionsQuery.isLoading; + const isEmpty = !isInitialLoading && versions.length === 0; + return ( @@ -80,16 +111,62 @@ export function EnvironmentVersionDecisions({ {environment.name} -
-
- {versions.map((version) => ( - - ))} +
+
+ + setSearch(e.target.value)} + placeholder="Search by version name or tag..." + className="pl-8" + />
+ {isInitialLoading && ( +
+ + Loading versions... +
+ )} + + {isEmpty && ( +
+ {debouncedSearch + ? `No versions match "${debouncedSearch}"` + : "No versions found"} +
+ )} + + {!isInitialLoading && versions.length > 0 && ( +
+ {versions.map((version) => ( + + ))} + + {hasMore && ( +
+ +
+ )} +
+ )}
diff --git a/apps/web/app/routes/ws/deployments/page.$deploymentId.tsx b/apps/web/app/routes/ws/deployments/page.$deploymentId.tsx index 810433f6b..4c32d2122 100644 --- a/apps/web/app/routes/ws/deployments/page.$deploymentId.tsx +++ b/apps/web/app/routes/ws/deployments/page.$deploymentId.tsx @@ -233,7 +233,6 @@ export default function DeploymentDetail() { { if (!open) setSearchParams({}); diff --git a/packages/trpc/src/routes/deployments.ts b/packages/trpc/src/routes/deployments.ts index c39e878b8..e925eb90f 100644 --- a/packages/trpc/src/routes/deployments.ts +++ b/packages/trpc/src/routes/deployments.ts @@ -3,7 +3,16 @@ import { parse } from "cel-js"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; -import { and, asc, desc, eq, inArray, takeFirst } from "@ctrlplane/db"; +import { + and, + asc, + desc, + eq, + ilike, + inArray, + or, + takeFirst, +} from "@ctrlplane/db"; import { enqueueDeploymentSelectorEval, enqueuePolicyEval, @@ -144,6 +153,39 @@ export const deploymentsRouter = router({ return versions; }), + searchVersions: protectedProcedure + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.DeploymentVersionList) + .on({ type: "deployment", id: input.deploymentId }), + }) + .input( + z.object({ + deploymentId: z.uuid(), + query: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ input, ctx }) => { + const search = input.query?.trim(); + return ctx.db.query.deploymentVersion.findMany({ + where: and( + eq(schema.deploymentVersion.deploymentId, input.deploymentId), + search + ? or( + ilike(schema.deploymentVersion.name, `%${search}%`), + ilike(schema.deploymentVersion.tag, `%${search}%`), + ) + : undefined, + ), + limit: input.limit, + offset: input.offset, + orderBy: desc(schema.deploymentVersion.createdAt), + }); + }), + create: protectedProcedure .input( z.object({ From 37971384386c5621a503090b1e13667741e2ef42 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 22 Apr 2026 13:18:14 -0700 Subject: [PATCH 2/2] cleanup --- .../EnvironmentVersionDecisions.tsx | 34 ++++++++----------- packages/trpc/src/routes/deployments.ts | 4 +-- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/EnvironmentVersionDecisions.tsx b/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/EnvironmentVersionDecisions.tsx index a2bf590ef..4560fd77f 100644 --- a/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/EnvironmentVersionDecisions.tsx +++ b/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/EnvironmentVersionDecisions.tsx @@ -78,29 +78,25 @@ export function EnvironmentVersionDecisions({ }: EnvironmentVersionDecisionsProps) { const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); - const [pageCount, setPageCount] = useState(1); - useDebounce( - () => { - setDebouncedSearch(search); - setPageCount(1); - }, - 250, - [search], - ); + useDebounce(() => setDebouncedSearch(search), 250, [search]); - const versionsQuery = trpc.deployment.searchVersions.useQuery( + const versionsQuery = trpc.deployment.searchVersions.useInfiniteQuery( { deploymentId, query: debouncedSearch || undefined, - limit: PAGE_SIZE * pageCount, - offset: 0, + limit: PAGE_SIZE, }, - { refetchInterval: 5000, placeholderData: keepPreviousData }, + { + initialCursor: 0, + getNextPageParam: (lastPage: Version[], allPages: Version[][]) => + lastPage.length < PAGE_SIZE ? undefined : allPages.length * PAGE_SIZE, + refetchInterval: 5000, + placeholderData: keepPreviousData, + } as Parameters[1], ); - const versions = versionsQuery.data ?? []; - const hasMore = versions.length === PAGE_SIZE * pageCount; + const versions = versionsQuery.data?.pages.flat() ?? []; const isInitialLoading = versionsQuery.isLoading; const isEmpty = !isInitialLoading && versions.length === 0; @@ -146,15 +142,15 @@ export function EnvironmentVersionDecisions({ /> ))} - {hasMore && ( + {versionsQuery.hasNextPage && (