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..4560fd77f 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,40 @@ 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(""); + + useDebounce(() => setDebouncedSearch(search), 250, [search]); + + const versionsQuery = trpc.deployment.searchVersions.useInfiniteQuery( + { + deploymentId, + query: debouncedSearch || undefined, + limit: PAGE_SIZE, + }, + { + 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?.pages.flat() ?? []; + const isInitialLoading = versionsQuery.isLoading; + const isEmpty = !isInitialLoading && versions.length === 0; + return ( @@ -80,16 +107,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) => ( + + ))} + + {versionsQuery.hasNextPage && ( +
+ +
+ )} +
+ )}
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..be4a99745 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), + cursor: 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.cursor, + orderBy: desc(schema.deploymentVersion.createdAt), + }); + }), + create: protectedProcedure .input( z.object({