From a5f5f803df8fb1dcd0933b8eb1267cb2df5b22ec Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 12 Sep 2024 21:44:38 -0700 Subject: [PATCH 1/5] 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)} + /> + )}
From 3fe53d51833b7165ebb5ff4c97cfa0fe41bee26e Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 12 Sep 2024 22:08:16 -0700 Subject: [PATCH 2/5] remove unused import --- frontend/src/components/v2/Pagination/Pagination.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index 0af0debe5..7b3178707 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -1,4 +1,3 @@ -import {} from "@fortawesome/fontawesome-svg-core"; import { faCaretDown, faCheck, From f742bd01d9ad585329cb5037ad5bab5efa3d97f8 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 12 Sep 2024 22:47:23 -0700 Subject: [PATCH 3/5] refactor to useCallback select instead of queryFn --- .../src/hooks/api/secretImports/queries.tsx | 50 ++++++++++--------- frontend/src/hooks/api/secrets/queries.tsx | 41 +++++++-------- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx index 6e45ea29c..a3136e2a8 100644 --- a/frontend/src/hooks/api/secretImports/queries.tsx +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -160,30 +160,32 @@ export const useGetImportedSecretsAllEnvs = ({ projectId, path }), - queryFn: async () => { - const data = await fetchImportedSecrets(projectId, env, path).catch(() => []); - - return data.map((el) => ({ - environment: el.environment, - secretPath: el.secretPath, - environmentInfo: el.environmentInfo, - folderId: el.folderId, - secrets: el.secrets.map((encSecret) => { - return { - id: encSecret.id, - env: encSecret.environment, - key: encSecret.secretKey, - value: encSecret.secretValue, - tags: encSecret.tags, - comment: encSecret.secretComment, - createdAt: encSecret.createdAt, - updatedAt: encSecret.updatedAt, - version: encSecret.version - }; - }) - })); - }, - enabled: Boolean(projectId) && Boolean(env) + queryFn: () => fetchImportedSecrets(projectId, env, path).catch(() => []), + enabled: Boolean(projectId) && Boolean(env), + // eslint-disable-next-line react-hooks/rules-of-hooks + select: useCallback( + (data: Awaited>) => + data.map((el) => ({ + environment: el.environment, + secretPath: el.secretPath, + environmentInfo: el.environmentInfo, + folderId: el.folderId, + secrets: el.secrets.map((encSecret) => { + return { + id: encSecret.id, + env: encSecret.environment, + key: encSecret.secretKey, + value: encSecret.secretValue, + tags: encSecret.tags, + comment: encSecret.secretComment, + createdAt: encSecret.createdAt, + updatedAt: encSecret.updatedAt, + version: encSecret.version + }; + }) + })), + [] + ) })) }); diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index ebd6f925d..ca26b96ca 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -24,12 +24,6 @@ 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 }; @@ -101,7 +95,7 @@ export const useGetProjectSecrets = ({ }: TGetProjectSecretsDTO & { options?: Omit< UseQueryOptions< - SecretV3RawSanitized[], + SecretV3RawResponse, unknown, SecretV3RawSanitized[], ReturnType @@ -114,10 +108,7 @@ 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 () => { - const resp = await fetchProjectSecrets({ workspaceId, environment, secretPath }); - return mergePersonalSecrets(resp.secrets); - }, + queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }), onError: (error) => { if (axios.isAxiosError(error)) { const serverResponse = error.response?.data as { message: string }; @@ -127,7 +118,11 @@ export const useGetProjectSecrets = ({ text: serverResponse.message }); } - } + }, + select: useCallback( + (data: Awaited>) => mergePersonalSecrets(data.secrets), + [] + ) }); export const useGetProjectSecretsAllEnv = ({ @@ -139,7 +134,7 @@ export const useGetProjectSecretsAllEnv = ({ const secrets = useQueries({ queries: envs.map((environment) => ({ - queryKey: secretKeys.getProjectSecretAllEnvironments({ + queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath @@ -159,15 +154,17 @@ export const useGetProjectSecretsAllEnv = ({ setIsErrorHandled.on(); } }, - queryFn: async () => { - const resp = await fetchProjectSecrets({ workspaceId, environment, secretPath }); - - return mergePersonalSecrets(resp.secrets).reduce>( - (prev, curr) => ({ ...prev, [curr.key]: curr }), - {} - ); - }, - staleTime: 5 * 60 * 1000 + queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }), + staleTime: 60 * 1000, + // eslint-disable-next-line react-hooks/rules-of-hooks + select: useCallback( + (data: Awaited>) => + mergePersonalSecrets(data.secrets).reduce>( + (prev, curr) => ({ ...prev, [curr.key]: curr }), + {} + ), + [] + ) })) }); From 217de6250f7d28c1ba8501faf969c9f0615e55a5 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 13 Sep 2024 14:12:53 +0530 Subject: [PATCH 4/5] feat: pagination for main secret page --- .../views/SecretMainPage/SecretMainPage.tsx | 97 ++++++++++++--- .../SecretMainPage/SecretMainPage.types.ts | 7 +- .../components/ActionBar/ActionBar.tsx | 14 +-- .../SecretListView/SecretListView.tsx | 117 ++++-------------- .../SecretOverviewPage/SecretOverviewPage.tsx | 1 + 5 files changed, 113 insertions(+), 123 deletions(-) diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.tsx b/frontend/src/views/SecretMainPage/SecretMainPage.tsx index a86d5d0d6..3938217a2 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.tsx +++ b/frontend/src/views/SecretMainPage/SecretMainPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; import { subject } from "@casl/ability"; @@ -8,14 +8,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import NavHeader from "@app/components/navigation/NavHeader"; import { createNotification } from "@app/components/notifications"; import { PermissionDeniedBanner } from "@app/components/permissions"; -import { ContentLoader } from "@app/components/v2"; +import { ContentLoader, Pagination } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; +import { useDebounce, usePopUp } from "@app/hooks"; import { useGetDynamicSecrets, useGetImportedSecretsSingleEnv, @@ -39,7 +39,7 @@ import { SecretImportListView } from "./components/SecretImportListView"; import { SecretListView } from "./components/SecretListView"; import { SnapshotView } from "./components/SnapshotView"; import { StoreProvider } from "./SecretMainPage.store"; -import { Filter, GroupBy, SortDir } from "./SecretMainPage.types"; +import { Filter, SortDir } from "./SecretMainPage.types"; const LOADER_TEXT = [ "Retrieving your encrypted secrets...", @@ -47,6 +47,7 @@ const LOADER_TEXT = [ "Getting secret import links..." ]; +const INIT_PER_PAGE = 10; export const SecretMainPage = () => { const { t } = useTranslation(); const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace(); @@ -59,6 +60,10 @@ export const SecretMainPage = () => { tags: {}, searchFilter: (router.query.searchFilter as string) || "" }); + const debouncedSearchFilter = useDebounce(filter.searchFilter); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(INIT_PER_PAGE); + const paginationOffset = (page - 1) * perPage; const [snapshotId, setSnapshotId] = useState(null); const isRollbackMode = Boolean(snapshotId); @@ -185,11 +190,6 @@ export const SecretMainPage = () => { }); }; - const handleGroupByChange = useCallback( - (groupBy?: GroupBy) => setFilter((state) => ({ ...state, groupBy })), - [] - ); - const handleTagToggle = useCallback( (tagId: string) => setFilter((state) => { @@ -223,6 +223,66 @@ export const SecretMainPage = () => { const loadingOnAccess = canReadSecret && (isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading); + + const rows = useMemo(() => { + const filteredSecrets = + secrets + ?.filter(({ key }) => key.toUpperCase().includes(debouncedSearchFilter.toUpperCase())) + .sort((a, b) => + sortDir === SortDir.ASC ? a.key.localeCompare(b.key) : b.key.localeCompare(a.key) + ) ?? []; + const filteredFolders = + folders + ?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) + .sort((a, b) => + sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name) + ) ?? []; + const filteredDynamicSecrets = + dynamicSecrets + ?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) + .sort((a, b) => + sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name) + ) ?? []; + + const totalRows = + filteredFolders.length + filteredDynamicSecrets.length + filteredSecrets.length; + + const paginatedFolders = filteredFolders.slice(paginationOffset, paginationOffset + perPage); + + let remainingRows = perPage - paginatedFolders.length; + const dynamicSecretStartIndex = Math.max(0, paginationOffset - filteredFolders.length); + const paginatiedDynamicSecrets = + remainingRows > 0 + ? filteredDynamicSecrets.slice( + dynamicSecretStartIndex, + dynamicSecretStartIndex + remainingRows + ) + : []; + + remainingRows -= paginatiedDynamicSecrets.length; + const secretStartIndex = Math.max( + 0, + paginationOffset - filteredFolders.length - filteredDynamicSecrets.length + ); + + const paginatiedSecrets = + remainingRows > 0 + ? filteredSecrets.slice(secretStartIndex, secretStartIndex + remainingRows) + : []; + + return { + folders: paginatedFolders, + secrets: paginatiedSecrets, + dynamicSecrets: paginatiedDynamicSecrets, + totalRows + }; + }, [sortDir, debouncedSearchFilter, folders, secrets, dynamicSecrets, paginationOffset, perPage]); + + useEffect(() => { + // reset page if no longer valid + if (rows.totalRows < paginationOffset) setPage(1); + }, [rows.totalRows]); + // loading screen when you don't have permission but as folder's is viewable need to wait for that const loadingOnDenied = !canReadSecret && isFoldersLoading; if (loadingOnAccess || loadingOnDenied) { @@ -258,7 +318,6 @@ export const SecretMainPage = () => { filter={filter} tags={tags} onVisiblilityToggle={handleToggleVisibility} - onGroupByChange={handleGroupByChange} onSearchChange={handleSearchChange} onToggleTagFilter={handleTagToggle} snapshotCount={snapshotCount || 0} @@ -301,7 +360,7 @@ export const SecretMainPage = () => { /> )} { environment={environment} projectSlug={projectSlug} secretPath={secretPath} - dynamicSecrets={dynamicSecrets || []} + dynamicSecrets={rows.dynamicSecrets || []} /> )} {canReadSecret && ( { /> )} {!canReadSecret && folders?.length === 0 && } + {!loadingOnAccess && rows.totalRows > INIT_PER_PAGE && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} ; searchFilter: string; - groupBy?: GroupBy | null; }; export enum SortDir { @@ -9,6 +8,8 @@ export enum SortDir { DESC = "desc" } -export enum GroupBy { - PREFIX = "prefix" +export enum RowType { + Folder = "folder", + DynamicSecret = "dynamic", + Secret = "Secret" } diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index 65cb0389f..94ac029bd 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -62,7 +62,7 @@ import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store"; -import { Filter, GroupBy } from "../../SecretMainPage.types"; +import { Filter } from "../../SecretMainPage.types"; import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; import { CreateSecretImportForm } from "./CreateSecretImportForm"; import { FolderForm } from "./FolderForm"; @@ -81,7 +81,6 @@ type Props = { isVisible?: boolean; snapshotCount: number; isSnapshotCountLoading?: boolean; - onGroupByChange: (opt?: GroupBy) => void; onSearchChange: (term: string) => void; onToggleTagFilter: (tagId: string) => void; onVisiblilityToggle: () => void; @@ -101,7 +100,6 @@ export const ActionBar = ({ isSnapshotCountLoading, onSearchChange, onToggleTagFilter, - onGroupByChange, onVisiblilityToggle, onClickRollbackMode }: Props) => { @@ -307,16 +305,6 @@ export const ActionBar = ({ - Group By - - } - onClick={() => onGroupByChange(!filter.groupBy ? GroupBy.PREFIX : undefined)} - > - Prefix - Filter By { - const groupedSecrets: Record = {}; - secrets.forEach((secret) => { - const lastSeperatorIndex = secret.key.lastIndexOf("_"); - const namespace = - lastSeperatorIndex !== -1 ? secret.key.substring(0, lastSeperatorIndex) : "misc"; - if (!groupedSecrets?.[namespace]) groupedSecrets[namespace] = []; - groupedSecrets[namespace].push(secret); - }); - - return Object.keys(groupedSecrets) - .sort((a, b) => - sortDir === SortDir.ASC - ? a.toLowerCase().localeCompare(b.toLowerCase()) - : b.toLowerCase().localeCompare(a.toLowerCase()) - ) - .map((namespace) => ({ namespace, secrets: groupedSecrets[namespace] })); -}; - -const reorderSecret = ( - secrets: SecretV3RawSanitized[], - sortDir: SortDir, - filter?: GroupBy | null -) => { - if (filter === GroupBy.PREFIX) { - return reorderSecretGroupByUnderscore(secrets, sortDir); - } - - return [ - { - namespace: "", - secrets: secrets?.sort((a, b) => - sortDir === SortDir.ASC - ? a.key.toLowerCase().localeCompare(b.key.toLowerCase()) - : b.key.toLowerCase().localeCompare(a.key.toLowerCase()) - ) - } - ]; -}; - export const filterSecrets = (secrets: SecretV3RawSanitized[], filter: Filter) => secrets.filter(({ key, value, tags }) => { const isTagFilterActive = Boolean(Object.keys(filter.tags).length); @@ -88,8 +45,6 @@ export const SecretListView = ({ environment, workspaceId, secretPath = "/", - filter, - sortDir = SortDir.ASC, tags: wsTags = [], isVisible, isProtectedBranch = false @@ -331,52 +286,30 @@ export const SecretListView = ({ return ( <> - {reorderSecret(secrets, sortDir, filter.groupBy).map( - ({ namespace, secrets: groupedSecrets }) => { - const filteredSecrets = filterSecrets(groupedSecrets, filter); - return ( -
-
- {namespace} -
- {FontAwesomeSpriteSymbols.map(({ icon, symbol }) => ( - - ))} - {filteredSecrets.map((secret) => ( - - handlePopUpOpen("createSharedSecret", { - value: secret.valueOverride ?? secret.value - }) - } - /> - ))} -
- ); - } - )} + {FontAwesomeSpriteSymbols.map(({ icon, symbol }) => ( + + ))} + {secrets.map((secret) => ( + + handlePopUpOpen("createSharedSecret", { + value: secret.valueOverride ?? secret.value + }) + } + /> + ))} { {!isTableLoading && rows.length > INIT_PER_PAGE && ( Date: Fri, 13 Sep 2024 06:25:13 -0700 Subject: [PATCH 5/5] fix: include imports in secret pagination, and rectify tag/value search not working for secrets --- .../views/SecretMainPage/SecretMainPage.tsx | 53 ++++++++++++++++--- .../SecretImportListView.tsx | 2 +- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.tsx b/frontend/src/views/SecretMainPage/SecretMainPage.tsx index 3938217a2..3727d1d2b 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.tsx +++ b/frontend/src/views/SecretMainPage/SecretMainPage.tsx @@ -227,7 +227,14 @@ export const SecretMainPage = () => { const rows = useMemo(() => { const filteredSecrets = secrets - ?.filter(({ key }) => key.toUpperCase().includes(debouncedSearchFilter.toUpperCase())) + ?.filter(({ key, tags: secretTags, value }) => { + const isTagFilterActive = Boolean(Object.keys(filter.tags).length); + return ( + (!isTagFilterActive || secretTags?.some(({ id }) => filter.tags?.[id])) && + (key.toUpperCase().includes(debouncedSearchFilter.toUpperCase()) || + value?.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) + ); + }) .sort((a, b) => sortDir === SortDir.ASC ? a.key.localeCompare(b.key) : b.key.localeCompare(a.key) ) ?? []; @@ -243,13 +250,36 @@ export const SecretMainPage = () => { .sort((a, b) => sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name) ) ?? []; + const filteredSecretImports = + secretImports + ?.filter(({ importPath }) => + importPath.toLowerCase().includes(debouncedSearchFilter.toLowerCase()) + ) + .sort((a, b) => + sortDir === "asc" + ? a.importPath.localeCompare(b.importPath) + : b.importPath.localeCompare(a.importPath) + ) ?? []; const totalRows = - filteredFolders.length + filteredDynamicSecrets.length + filteredSecrets.length; + filteredSecretImports.length + + filteredFolders.length + + filteredDynamicSecrets.length + + filteredSecrets.length; - const paginatedFolders = filteredFolders.slice(paginationOffset, paginationOffset + perPage); + const paginatedImports = filteredSecretImports.slice( + paginationOffset, + paginationOffset + perPage + ); - let remainingRows = perPage - paginatedFolders.length; + let remainingRows = perPage - paginatedImports.length; + const foldersStartIndex = Math.max(0, paginationOffset - filteredSecretImports.length); + const paginatedFolders = + remainingRows > 0 + ? filteredFolders.slice(foldersStartIndex, foldersStartIndex + remainingRows) + : []; + + remainingRows -= paginatedFolders.length; const dynamicSecretStartIndex = Math.max(0, paginationOffset - filteredFolders.length); const paginatiedDynamicSecrets = remainingRows > 0 @@ -271,12 +301,23 @@ export const SecretMainPage = () => { : []; return { + imports: paginatedImports, folders: paginatedFolders, secrets: paginatiedSecrets, dynamicSecrets: paginatiedDynamicSecrets, totalRows }; - }, [sortDir, debouncedSearchFilter, folders, secrets, dynamicSecrets, paginationOffset, perPage]); + }, [ + sortDir, + debouncedSearchFilter, + folders, + secrets, + dynamicSecrets, + paginationOffset, + perPage, + filter.tags, + importedSecrets + ]); useEffect(() => { // reset page if no longer valid @@ -350,7 +391,7 @@ export const SecretMainPage = () => { {canReadSecret && (