From a5f5f803df8fb1dcd0933b8eb1267cb2df5b22ec Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 12 Sep 2024 21:44:38 -0700 Subject: [PATCH] feature: secret overview page pagination/optimizations --- .../components/v2/Pagination/Pagination.tsx | 23 +++ .../src/hooks/api/secretImports/queries.tsx | 10 +- frontend/src/hooks/api/secrets/queries.tsx | 33 +++- .../SecretOverviewPage/SecretOverviewPage.tsx | 178 +++++++++++------- 4 files changed, 166 insertions(+), 78 deletions(-) diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index d27c95453..0af0debe5 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -1,3 +1,4 @@ +import {} from "@fortawesome/fontawesome-svg-core"; import { faCaretDown, faCheck, @@ -40,6 +41,8 @@ export const Pagination = ({ const upperLimit = Math.ceil(count / perPage); const nextPageNumber = Math.min(upperLimit, page + 1); const canGoNext = page + 1 <= upperLimit; + const canGoFirst = page > 1; + const canGoLast = page < upperLimit; return (
+ onChangePage(1)} + isDisabled={!canGoFirst} + > + + + + onChangePage(upperLimit)} + isDisabled={!canGoLast} + > + + +
); diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx index 95f1621a2..6e45ea29c 100644 --- a/frontend/src/hooks/api/secretImports/queries.tsx +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -160,10 +160,9 @@ export const useGetImportedSecretsAllEnvs = ({ projectId, path }), - queryFn: () => fetchImportedSecrets(projectId, env, path).catch(() => []), - enabled: Boolean(projectId) && Boolean(env), - // eslint-disable-next-line react-hooks/rules-of-hooks - select: (data: TImportedSecrets[]) => { + queryFn: async () => { + const data = await fetchImportedSecrets(projectId, env, path).catch(() => []); + return data.map((el) => ({ environment: el.environment, secretPath: el.secretPath, @@ -183,7 +182,8 @@ export const useGetImportedSecretsAllEnvs = ({ }; }) })); - } + }, + enabled: Boolean(projectId) && Boolean(env) })) }); diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 2a24dda47..ebd6f925d 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -24,6 +24,12 @@ export const secretKeys = { // this is also used in secretSnapshot part getProjectSecret: ({ workspaceId, environment, secretPath }: TGetProjectSecretsKey) => [{ workspaceId, environment, secretPath }, "secrets"] as const, + getProjectSecretAllEnvironments: ({ + workspaceId, + environment, + secretPath + }: TGetProjectSecretsKey) => + [...secretKeys.getProjectSecret({ workspaceId, environment, secretPath }), "all"] as const, getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] as const }; @@ -95,7 +101,7 @@ export const useGetProjectSecrets = ({ }: TGetProjectSecretsDTO & { options?: Omit< UseQueryOptions< - SecretV3RawResponse, + SecretV3RawSanitized[], unknown, SecretV3RawSanitized[], ReturnType @@ -108,7 +114,10 @@ export const useGetProjectSecrets = ({ // wait for all values to be available enabled: Boolean(workspaceId && environment) && (options?.enabled ?? true), queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }), - queryFn: async () => fetchProjectSecrets({ workspaceId, environment, secretPath }), + queryFn: async () => { + const resp = await fetchProjectSecrets({ workspaceId, environment, secretPath }); + return mergePersonalSecrets(resp.secrets); + }, onError: (error) => { if (axios.isAxiosError(error)) { const serverResponse = error.response?.data as { message: string }; @@ -118,8 +127,7 @@ export const useGetProjectSecrets = ({ text: serverResponse.message }); } - }, - select: ({ secrets }) => mergePersonalSecrets(secrets) + } }); export const useGetProjectSecretsAllEnv = ({ @@ -131,7 +139,11 @@ export const useGetProjectSecretsAllEnv = ({ const secrets = useQueries({ queries: envs.map((environment) => ({ - queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }), + queryKey: secretKeys.getProjectSecretAllEnvironments({ + workspaceId, + environment, + secretPath + }), enabled: Boolean(workspaceId && environment), onError: (error: unknown) => { if (axios.isAxiosError(error) && !isErrorHandled) { @@ -147,12 +159,15 @@ export const useGetProjectSecretsAllEnv = ({ setIsErrorHandled.on(); } }, - queryFn: async () => fetchProjectSecrets({ workspaceId, environment, secretPath }), - select: (el: SecretV3RawResponse) => - mergePersonalSecrets(el.secrets).reduce>( + queryFn: async () => { + const resp = await fetchProjectSecrets({ workspaceId, environment, secretPath }); + + return mergePersonalSecrets(resp.secrets).reduce>( (prev, curr) => ({ ...prev, [curr.key]: curr }), {} - ) + ); + }, + staleTime: 5 * 60 * 1000 })) }); diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index c678343c9..3ff602d1c 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -31,6 +31,7 @@ import { Input, Modal, ModalContent, + Pagination, Table, TableContainer, TableSkeleton, @@ -49,7 +50,7 @@ import { useProjectPermission, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; +import { useDebounce, usePopUp } from "@app/hooks"; import { useCreateFolder, useCreateSecretV3, @@ -78,6 +79,14 @@ export enum EntryType { SECRET = "secret" } +enum RowType { + Folder = "folder", + DynamicSecret = "dynamic", + Secret = "Secret" +} + +const INIT_PER_PAGE = 10; + export const SecretOverviewPage = () => { const { t } = useTranslation(); @@ -101,6 +110,7 @@ export const SecretOverviewPage = () => { const workspaceId = currentWorkspace?.id as string; const projectSlug = currentWorkspace?.slug as string; const [searchFilter, setSearchFilter] = useState(""); + const debouncedSearchFilter = useDebounce(searchFilter); const secretPath = (router.query?.secretPath as string) || "/"; const [selectedEntries, setSelectedEntries] = useState<{ @@ -111,6 +121,9 @@ export const SecretOverviewPage = () => { [EntryType.SECRET]: {} }); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(INIT_PER_PAGE); + const toggleSelectedEntry = useCallback( (type: EntryType, key: string) => { const isChecked = Boolean(selectedEntries[type]?.[key]); @@ -439,7 +452,40 @@ export const SecretOverviewPage = () => { } }; - if (isWorkspaceLoading) { + const rows = useMemo(() => { + const filteredSecretNames = + secKeys + ?.filter((name) => name.toUpperCase().includes(debouncedSearchFilter.toUpperCase())) + .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? []; + const filteredFolderNames = + folderNames + ?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) + .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? []; + const filteredDynamicSecrets = + dynamicSecretNames + ?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) + .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? []; + + return [ + ...filteredFolderNames.map((name) => ({ name, type: RowType.Folder })), + ...filteredDynamicSecrets.map((name) => ({ name, type: RowType.DynamicSecret })), + ...filteredSecretNames.map((name) => ({ name, type: RowType.Secret })) + ]; + }, [sortDir, debouncedSearchFilter, secKeys, folderNames, dynamicSecretNames]); + + const paginationOffset = (page - 1) * perPage; + + useEffect(() => { + // reset page if no longer valid + if (rows.length < paginationOffset) setPage(1); + }, [rows.length]); + + const isTableLoading = + folders?.some(({ isLoading }) => isLoading) || + secrets?.some(({ isLoading }) => isLoading) || + dynamicSecrets?.some(({ isLoading }) => isLoading); + + if (isWorkspaceLoading || isTableLoading) { return (
{ ); } - const isTableLoading = !( - folders?.some(({ isLoading }) => !isLoading) && secrets?.some(({ isLoading }) => !isLoading) - ); - const canViewOverviewPage = Boolean(userAvailableEnvs.length); // This is needed to also show imports from other paths – right now those are missing. // const combinedKeys = [...secKeys, ...secretImports.map((impSecrets) => impSecrets?.data?.map((impSec) => impSec.secrets?.map((impSecKey) => impSecKey.key))).flat().flat()]; - const filteredSecretNames = secKeys - ?.filter((name) => name.toUpperCase().includes(searchFilter.toUpperCase())) - .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))); - const filteredFolderNames = folderNames - ?.filter((name) => name.toLowerCase().includes(searchFilter.toLowerCase())) - .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))); - const filteredDynamicSecrets = dynamicSecretNames - ?.filter((name) => name.toLowerCase().includes(searchFilter.toLowerCase())) - .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))); const isTableEmpty = !( folders?.every(({ isLoading }) => isLoading) && secrets?.every(({ isLoading }) => isLoading) && dynamicSecrets?.every(({ isLoading }) => isLoading) - ) && - filteredSecretNames?.length === 0 && - filteredFolderNames?.length === 0 && - filteredDynamicSecrets?.length === 0; + ) && rows.length === 0; return ( <> @@ -656,7 +686,7 @@ export const SecretOverviewPage = () => { resetSelectedEntries={resetSelectedEntries} />
- + @@ -753,7 +783,7 @@ export const SecretOverviewPage = () => { )} {!isTableLoading && - filteredFolderNames.map((folderName, index) => ( - toggleSelectedEntry(EntryType.FOLDER, folderName)} - environments={visibleEnvs} - key={`overview-${folderName}-${index + 1}`} - onClick={handleFolderClick} - onToggleFolderEdit={(name: string) => - handlePopUpOpen("updateFolder", { name }) - } - /> - ))} - {!isTableLoading && - filteredDynamicSecrets.map((dynamicSecretName, index) => ( - - ))} - {!isTableLoading && - visibleEnvs?.length > 0 && - filteredSecretNames.map((key, index) => ( - toggleSelectedEntry(EntryType.SECRET, key)} - secretPath={secretPath} - getImportedSecretByKey={getImportedSecretByKey} - isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} - onSecretCreate={handleSecretCreate} - onSecretDelete={handleSecretDelete} - onSecretUpdate={handleSecretUpdate} - key={`overview-${key}-${index + 1}`} - environments={visibleEnvs} - secretKey={key} - getSecretByKey={getSecretByKey} - expandableColWidth={expandableTableWidth} - /> - ))} + rows.slice(paginationOffset, paginationOffset + perPage).map((row, index) => { + switch (row.type) { + case RowType.Secret: + if (visibleEnvs?.length === 0) return null; + return ( + + toggleSelectedEntry(EntryType.SECRET, row.name) + } + secretPath={secretPath} + getImportedSecretByKey={getImportedSecretByKey} + isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} + onSecretCreate={handleSecretCreate} + onSecretDelete={handleSecretDelete} + onSecretUpdate={handleSecretUpdate} + key={`overview-${row.name}-${index + 1}`} + environments={visibleEnvs} + secretKey={row.name} + getSecretByKey={getSecretByKey} + expandableColWidth={expandableTableWidth} + /> + ); + case RowType.DynamicSecret: + return ( + + ); + case RowType.Folder: + return ( + + toggleSelectedEntry(EntryType.FOLDER, row.name) + } + environments={visibleEnvs} + key={`overview-${row.name}-${index + 1}`} + onClick={handleFolderClick} + onToggleFolderEdit={(name: string) => + handlePopUpOpen("updateFolder", { name }) + } + /> + ); + default: + return null; + } + })} @@ -842,6 +883,15 @@ export const SecretOverviewPage = () => {
{
+ {!isTableLoading && rows.length > INIT_PER_PAGE && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )}