Skip to content

Commit

Permalink
Merge pull request #2423 from scott-ray-wilson/secrets-pagination
Browse files Browse the repository at this point in the history
Feature: Secrets Overview Page Pagination/Optimizations
  • Loading branch information
maidul98 authored Sep 13, 2024
2 parents 03fdce6 + 8826bc5 commit 4d67c03
Show file tree
Hide file tree
Showing 9 changed files with 336 additions and 219 deletions.
22 changes: 22 additions & 0 deletions frontend/src/components/v2/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,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 (
<div
Expand Down Expand Up @@ -73,6 +75,16 @@ export const Pagination = ({
</DropdownMenu>
</div>
<div className="flex items-center space-x-4">
<IconButton
variant="plain"
ariaLabel="pagination-first"
className="relative"
onClick={() => onChangePage(1)}
isDisabled={!canGoFirst}
>
<FontAwesomeIcon className="absolute left-2.5 top-1 text-xs" icon={faChevronLeft} />
<FontAwesomeIcon className="text-xs" icon={faChevronLeft} />
</IconButton>
<IconButton
variant="plain"
ariaLabel="pagination-prev"
Expand All @@ -89,6 +101,16 @@ export const Pagination = ({
>
<FontAwesomeIcon className="text-xs" icon={faChevronRight} />
</IconButton>
<IconButton
variant="plain"
ariaLabel="pagination-last"
className="relative"
onClick={() => onChangePage(upperLimit)}
isDisabled={!canGoLast}
>
<FontAwesomeIcon className="absolute left-2.5 top-1 text-xs" icon={faChevronRight} />
<FontAwesomeIcon className="text-xs" icon={faChevronRight} />
</IconButton>
</div>
</div>
);
Expand Down
44 changes: 23 additions & 21 deletions frontend/src/hooks/api/secretImports/queries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,27 +163,29 @@ export const useGetImportedSecretsAllEnvs = ({
queryFn: () => fetchImportedSecrets(projectId, env, path).catch(() => []),
enabled: Boolean(projectId) && Boolean(env),
// eslint-disable-next-line react-hooks/rules-of-hooks
select: (data: TImportedSecrets[]) => {
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
};
})
}));
}
select: useCallback(
(data: Awaited<ReturnType<typeof fetchImportedSecrets>>) =>
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
};
})
})),
[]
)
}))
});

Expand Down
30 changes: 21 additions & 9 deletions frontend/src/hooks/api/secrets/queries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +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 () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
onError: (error) => {
if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string };
Expand All @@ -119,7 +119,10 @@ export const useGetProjectSecrets = ({
});
}
},
select: ({ secrets }) => mergePersonalSecrets(secrets)
select: useCallback(
(data: Awaited<ReturnType<typeof fetchProjectSecrets>>) => mergePersonalSecrets(data.secrets),
[]
)
});

export const useGetProjectSecretsAllEnv = ({
Expand All @@ -131,7 +134,11 @@ export const useGetProjectSecretsAllEnv = ({

const secrets = useQueries({
queries: envs.map((environment) => ({
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }),
queryKey: secretKeys.getProjectSecret({
workspaceId,
environment,
secretPath
}),
enabled: Boolean(workspaceId && environment),
onError: (error: unknown) => {
if (axios.isAxiosError(error) && !isErrorHandled) {
Expand All @@ -147,12 +154,17 @@ export const useGetProjectSecretsAllEnv = ({
setIsErrorHandled.on();
}
},
queryFn: async () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
select: (el: SecretV3RawResponse) =>
mergePersonalSecrets(el.secrets).reduce<Record<string, SecretV3RawSanitized>>(
(prev, curr) => ({ ...prev, [curr.key]: curr }),
{}
)
queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
staleTime: 60 * 1000,
// eslint-disable-next-line react-hooks/rules-of-hooks
select: useCallback(
(data: Awaited<ReturnType<typeof fetchProjectSecrets>>) =>
mergePersonalSecrets(data.secrets).reduce<Record<string, SecretV3RawSanitized>>(
(prev, curr) => ({ ...prev, [curr.key]: curr }),
{}
),
[]
)
}))
});

Expand Down
140 changes: 124 additions & 16 deletions frontend/src/views/SecretMainPage/SecretMainPage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -39,14 +39,15 @@ 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...",
"Fetching folders...",
"Getting secret import links..."
];

const INIT_PER_PAGE = 10;
export const SecretMainPage = () => {
const { t } = useTranslation();
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
Expand All @@ -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<string | null>(null);
const isRollbackMode = Boolean(snapshotId);
Expand Down Expand Up @@ -185,11 +190,6 @@ export const SecretMainPage = () => {
});
};

const handleGroupByChange = useCallback(
(groupBy?: GroupBy) => setFilter((state) => ({ ...state, groupBy })),
[]
);

const handleTagToggle = useCallback(
(tagId: string) =>
setFilter((state) => {
Expand Down Expand Up @@ -223,6 +223,107 @@ export const SecretMainPage = () => {
const loadingOnAccess =
canReadSecret &&
(isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading);

const rows = useMemo(() => {
const filteredSecrets =
secrets
?.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)
) ?? [];
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 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 =
filteredSecretImports.length +
filteredFolders.length +
filteredDynamicSecrets.length +
filteredSecrets.length;

const paginatedImports = filteredSecretImports.slice(
paginationOffset,
paginationOffset + perPage
);

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
? 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 {
imports: paginatedImports,
folders: paginatedFolders,
secrets: paginatiedSecrets,
dynamicSecrets: paginatiedDynamicSecrets,
totalRows
};
}, [
sortDir,
debouncedSearchFilter,
folders,
secrets,
dynamicSecrets,
paginationOffset,
perPage,
filter.tags,
importedSecrets
]);

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) {
Expand Down Expand Up @@ -258,7 +359,6 @@ export const SecretMainPage = () => {
filter={filter}
tags={tags}
onVisiblilityToggle={handleToggleVisibility}
onGroupByChange={handleGroupByChange}
onSearchChange={handleSearchChange}
onToggleTagFilter={handleTagToggle}
snapshotCount={snapshotCount || 0}
Expand Down Expand Up @@ -291,7 +391,7 @@ export const SecretMainPage = () => {
{canReadSecret && (
<SecretImportListView
searchTerm={filter.searchFilter}
secretImports={secretImports}
secretImports={rows.imports}
isFetching={isSecretImportsLoading || isSecretImportsFetching}
environment={environment}
workspaceId={workspaceId}
Expand All @@ -301,7 +401,7 @@ export const SecretMainPage = () => {
/>
)}
<FolderListView
folders={folders}
folders={rows.folders}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
Expand All @@ -314,15 +414,13 @@ export const SecretMainPage = () => {
environment={environment}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecrets={dynamicSecrets || []}
dynamicSecrets={rows.dynamicSecrets || []}
/>
)}
{canReadSecret && (
<SecretListView
secrets={secrets}
secrets={rows.secrets}
tags={tags}
filter={filter}
sortDir={sortDir}
isVisible={isVisible}
environment={environment}
workspaceId={workspaceId}
Expand All @@ -331,6 +429,16 @@ export const SecretMainPage = () => {
/>
)}
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
{!loadingOnAccess && rows.totalRows > INIT_PER_PAGE && (
<Pagination
className="border-t border-solid border-t-mineshaft-600"
count={rows.totalRows}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
</div>
</div>
<CreateSecretForm
Expand Down
Loading

0 comments on commit 4d67c03

Please sign in to comment.