Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Secrets Overview Page Pagination/Optimizations #2423

Merged
merged 5 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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[]) => {
scott-ray-wilson marked this conversation as resolved.
Show resolved Hide resolved
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)
scott-ray-wilson marked this conversation as resolved.
Show resolved Hide resolved
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) =>
scott-ray-wilson marked this conversation as resolved.
Show resolved Hide resolved
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
Loading