diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 846dc2176..e558062b1 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -30,6 +30,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv workspaceId: z.string().trim(), environment: z.string().trim().optional(), committer: z.string().trim().optional(), + search: z.string().trim().optional(), status: z.nativeEnum(RequestState).optional(), limit: z.coerce.number().default(20), offset: z.coerce.number().default(0) @@ -60,20 +61,20 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv committerUser: approvalRequestUser, commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), environment: z.string(), - secretPath: z.string(), reviewers: z.object({ userId: z.string(), status: z.string() }).array(), approvers: z .object({ userId: z.string().nullable().optional() }) .array() - }).array() + }).array(), + totalCount: z.number() }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const approvals = await server.services.secretApprovalRequest.getSecretApprovals({ + const { approvals, totalCount } = await server.services.secretApprovalRequest.getSecretApprovals({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -81,7 +82,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv ...req.query, projectId: req.query.workspaceId }); - return { approvals }; + return { approvals, totalCount }; } }); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 3bd35c3c8..543b1e722 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -24,6 +24,7 @@ type TFindQueryFilter = { committer?: string; limit?: number; offset?: number; + search?: string; }; export const secretApprovalRequestDALFactory = (db: TDbClient) => { @@ -340,7 +341,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { }; const findByProjectId = async ( - { status, limit = 20, offset = 0, projectId, committer, environment, userId }: TFindQueryFilter, + { status, limit = 20, offset = 0, projectId, committer, environment, userId, search }: TFindQueryFilter, tx?: Knex ) => { try { @@ -433,16 +434,36 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("email").withSchema("committerUser").as("committerUserEmail"), db.ref("username").withSchema("committerUser").as("committerUserUsername"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), - db.ref("lastName").withSchema("committerUser").as("committerUserLastName") + db.ref("lastName").withSchema("committerUser").as("committerUserLastName"), + + db.raw(`count(*) OVER() as total_count`) ) .orderBy("createdAt", "desc"); + if (search) { + void query + .whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [ + db.ref("firstName").withSchema("committerUser"), + db.ref("lastName").withSchema("committerUser"), + `%${search}%` + ]) + .orWhereRaw(`?? ilike ?`, [db.ref("username").withSchema("committerUser"), `%${search}%`]) + .orWhereRaw(`?? ilike ?`, [db.ref("email").withSchema("committerUser"), `%${search}%`]) + .orWhereILike(`${TableName.Environment}.name`, `%${search}%`) + .orWhereILike(`${TableName.Environment}.slug`, `%${search}%`) + .orWhereILike(`${TableName.SecretApprovalPolicy}.secretPath`, `%${search}%`); + } + const docs = await (tx || db) .with("w", query) .select("*") .from[number]>("w") .where("w.rank", ">=", offset) .andWhere("w.rank", "<", offset + limit); + + // @ts-expect-error knex does not infer + const totalCount = Number(docs[0]?.total_count || 0); + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", @@ -504,17 +525,20 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { } ] }); - return formattedDoc.map((el) => ({ - ...el, - policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } - })); + return { + approvals: formattedDoc.map((el) => ({ + ...el, + policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } + })), + totalCount + }; } catch (error) { throw new DatabaseError({ error, name: "FindSAR" }); } }; const findByProjectIdBridgeSecretV2 = async ( - { status, limit = 20, offset = 0, projectId, committer, environment, userId }: TFindQueryFilter, + { status, limit = 20, offset = 0, projectId, committer, environment, userId, search }: TFindQueryFilter, tx?: Knex ) => { try { @@ -607,16 +631,37 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("email").withSchema("committerUser").as("committerUserEmail"), db.ref("username").withSchema("committerUser").as("committerUserUsername"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), - db.ref("lastName").withSchema("committerUser").as("committerUserLastName") + db.ref("lastName").withSchema("committerUser").as("committerUserLastName"), + + db.raw(`count(*) OVER() as total_count`) ) .orderBy("createdAt", "desc"); + if (search) { + void query + .whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [ + db.ref("firstName").withSchema("committerUser"), + db.ref("lastName").withSchema("committerUser"), + `%${search}%` + ]) + .orWhereRaw(`?? ilike ?`, [db.ref("username").withSchema("committerUser"), `%${search}%`]) + .orWhereRaw(`?? ilike ?`, [db.ref("email").withSchema("committerUser"), `%${search}%`]) + .orWhereILike(`${TableName.Environment}.name`, `%${search}%`) + .orWhereILike(`${TableName.Environment}.slug`, `%${search}%`) + .orWhereILike(`${TableName.SecretApprovalPolicy}.secretPath`, `%${search}%`); + } + + const rankOffset = offset + 1; const docs = await (tx || db) .with("w", query) .select("*") .from[number]>("w") - .where("w.rank", ">=", offset) - .andWhere("w.rank", "<", offset + limit); + .where("w.rank", ">=", rankOffset) + .andWhere("w.rank", "<", rankOffset + limit); + + // @ts-expect-error knex does not infer + const totalCount = Number(docs[0]?.total_count || 0); + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", @@ -682,10 +727,13 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { } ] }); - return formattedDoc.map((el) => ({ - ...el, - policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } - })); + return { + approvals: formattedDoc.map((el) => ({ + ...el, + policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } + })), + totalCount + }; } catch (error) { throw new DatabaseError({ error, name: "FindSAR" }); } diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 1e000c05c..49f336111 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -194,7 +194,8 @@ export const secretApprovalRequestServiceFactory = ({ environment, committer, limit, - offset + offset, + search }: TListApprovalsDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); @@ -209,47 +210,29 @@ export const secretApprovalRequestServiceFactory = ({ const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); - const getSecretMapPath = async (folderIds: string[]) => { - const secretPaths = await folderDAL.findSecretPathByFolderIds(projectId, folderIds); - - const secretPathMap: Record = {}; - - secretPaths.forEach((folder) => { - if (folder) secretPathMap[folder.id] = folder.path; - }); - - return secretPathMap; - }; - if (shouldUseSecretV2Bridge) { - const approvalsV2 = await secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({ + return secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({ projectId, committer, environment, status, userId: actorId, limit, - offset + offset, + search }); - - const secretPathMap = await getSecretMapPath([...new Set(approvalsV2.map((approval) => approval.folderId))]); - - return approvalsV2.map((approval) => ({ ...approval, secretPath: secretPathMap[approval.folderId] })); } - const approvals = await secretApprovalRequestDAL.findByProjectId({ + return secretApprovalRequestDAL.findByProjectId({ projectId, committer, environment, status, userId: actorId, limit, - offset + offset, + search }); - - const secretPathMap = await getSecretMapPath([...new Set(approvals.map((approval) => approval.folderId))]); - - return approvals.map((approval) => ({ ...approval, secretPath: secretPathMap[approval.folderId] })); }; const getSecretApprovalDetails = async ({ diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts index 839833a9c..2fdb0bb9d 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts @@ -93,6 +93,7 @@ export type TListApprovalsDTO = { committer?: string; limit?: number; offset?: number; + search?: string; } & TProjectPermission; export type TSecretApprovalDetailsDTO = { diff --git a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx index e6ba62a6b..e96dcf34f 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx @@ -1,5 +1,5 @@ /* eslint-disable no-param-reassign */ -import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { decryptAssymmetric, @@ -25,10 +25,11 @@ export const secretApprovalRequestKeys = { status, committer, offset, - limit + limit, + search }: TGetSecretApprovalRequestList) => [ - { workspaceId, environment, status, committer, offset, limit }, + { workspaceId, environment, status, committer, offset, limit, search }, "secret-approval-requests" ] as const, detail: ({ id }: Omit) => @@ -118,23 +119,25 @@ const fetchSecretApprovalRequestList = async ({ committer, status = "open", limit = 20, - offset + offset = 0, + search = "" }: TGetSecretApprovalRequestList) => { - const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequest[] }>( - "/api/v1/secret-approval-requests", - { - params: { - workspaceId, - environment, - committer, - status, - limit, - offset - } + const { data } = await apiRequest.get<{ + approvals: TSecretApprovalRequest[]; + totalCount: number; + }>("/api/v1/secret-approval-requests", { + params: { + workspaceId, + environment, + committer, + status, + limit, + offset, + search } - ); + }); - return data.approvals; + return data; }; export const useGetSecretApprovalRequests = ({ @@ -143,31 +146,32 @@ export const useGetSecretApprovalRequests = ({ options = {}, status, limit = 20, + offset = 0, + search, committer }: TGetSecretApprovalRequestList & TReactQueryOptions) => - useInfiniteQuery({ - initialPageParam: 0, + useQuery({ queryKey: secretApprovalRequestKeys.list({ workspaceId, environment, committer, - status + status, + limit, + search, + offset }), - queryFn: ({ pageParam }) => + queryFn: () => fetchSecretApprovalRequestList({ workspaceId, environment, status, committer, limit, - offset: pageParam + offset, + search }), enabled: Boolean(workspaceId) && (options?.enabled ?? true), - getNextPageParam: (lastPage, pages) => { - if (lastPage.length && lastPage.length < limit) return undefined; - - return lastPage?.length !== 0 ? pages.length * limit : undefined; - } + placeholderData: (previousData) => previousData }); const fetchSecretApprovalRequestDetails = async ({ diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index 18360377f..3983d325a 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -113,6 +113,7 @@ export type TGetSecretApprovalRequestList = { committer?: string; limit?: number; offset?: number; + search?: string; }; export type TGetSecretApprovalRequestCount = { diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index c9bdd072b..e2b10b826 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { faArrowUpRightFromSquare, faBookOpen, @@ -24,6 +24,7 @@ import { DropdownMenuTrigger, EmptyState, Input, + Pagination, Skeleton } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; @@ -34,6 +35,12 @@ import { useUser, useWorkspace } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; +import { usePagination } from "@app/hooks"; import { useGetSecretApprovalRequestCount, useGetSecretApprovalRequests, @@ -58,18 +65,41 @@ export const SecretApprovalRequest = () => { const [usingUrlRequestId, setUsingUrlRequestId] = useState(false); const { - data: secretApprovalRequests, - isFetchingNextPage: isFetchingNextApprovalRequest, - fetchNextPage: fetchNextApprovalRequest, - hasNextPage: hasNextApprovalPage, + debouncedSearch: debouncedSearchFilter, + search: searchFilter, + setSearch: setSearchFilter, + setPage, + page, + perPage, + setPerPage, + offset, + limit + } = usePagination("", { + initPerPage: getUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, newPerPage); + }; + + const { + data, isPending: isApprovalRequestLoading, refetch } = useGetSecretApprovalRequests({ workspaceId, status: statusFilter, environment: envFilter, - committer: committerFilter + committer: committerFilter, + search: debouncedSearchFilter, + limit, + offset }); + + const totalApprovalCount = data?.totalCount ?? 0; + const secretApprovalRequests = data?.approvals ?? []; + const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } = useGetSecretApprovalRequestCount({ workspaceId }); const { user: userSession } = useUser(); @@ -94,30 +124,7 @@ export const SecretApprovalRequest = () => { refetch(); }; - const isRequestListEmpty = - !isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0; - - const [searchFilter, setSearchFilter] = useState(""); - - const filteredRequests = useMemo( - () => - secretApprovalRequests?.pages.flatMap((requests) => - requests.filter((request) => { - const { environment, committerUser, secretPath } = request; - - const searchValue = searchFilter.trim().toLowerCase(); - - return ( - environment?.toLowerCase().includes(searchValue) || - `${committerUser?.email ?? ""} ${committerUser?.firstName ?? ""} ${committerUser?.lastName ?? ""}` - .toLowerCase() - .includes(searchValue) || - secretPath?.toLowerCase().includes(searchValue) - ); - }) - ) ?? [], - [secretApprovalRequests?.pages, searchFilter] - ); + const isRequestListEmpty = !isApprovalRequestLoading && secretApprovalRequests?.length === 0; return ( @@ -171,7 +178,7 @@ export const SecretApprovalRequest = () => { value={searchFilter} onChange={(e) => setSearchFilter(e.target.value)} leftIcon={} - placeholder="Search change requests by author, environment slug or secret path..." + placeholder="Search change requests by author, environment or policy path..." className="flex-1" containerClassName="mb-4" /> @@ -285,14 +292,14 @@ export const SecretApprovalRequest = () => {
- {isRequestListEmpty && ( + {isRequestListEmpty && !searchFilter && (
)} - {filteredRequests.map((secretApproval) => { + {secretApprovalRequests.map((secretApproval) => { const { id: reqId, commits, @@ -337,9 +344,23 @@ export const SecretApprovalRequest = () => { ); })} {Boolean( - !filteredRequests.length && !isRequestListEmpty && !isApprovalRequestLoading - ) && } - {(isFetchingNextApprovalRequest || isApprovalRequestLoading) && ( + !secretApprovalRequests.length && searchFilter && !isApprovalRequestLoading + ) && ( +
+ +
+ )} + {Boolean(totalApprovalCount) && ( + + )} + {isApprovalRequestLoading && (
{Array.apply(0, Array(3)).map((_x, index) => (
{
)}
- {hasNextApprovalPage && ( - - )}
)}