feature: secret overview page pagination/optimizations

This commit is contained in:
Scott Wilson
2024-09-12 21:44:38 -07:00
parent 088e888560
commit a5f5f803df
4 changed files with 166 additions and 78 deletions
@@ -1,3 +1,4 @@
import {} from "@fortawesome/fontawesome-svg-core";
import { import {
faCaretDown, faCaretDown,
faCheck, faCheck,
@@ -40,6 +41,8 @@ export const Pagination = ({
const upperLimit = Math.ceil(count / perPage); const upperLimit = Math.ceil(count / perPage);
const nextPageNumber = Math.min(upperLimit, page + 1); const nextPageNumber = Math.min(upperLimit, page + 1);
const canGoNext = page + 1 <= upperLimit; const canGoNext = page + 1 <= upperLimit;
const canGoFirst = page > 1;
const canGoLast = page < upperLimit;
return ( return (
<div <div
@@ -73,6 +76,16 @@ export const Pagination = ({
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="flex items-center space-x-4"> <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 <IconButton
variant="plain" variant="plain"
ariaLabel="pagination-prev" ariaLabel="pagination-prev"
@@ -89,6 +102,16 @@ export const Pagination = ({
> >
<FontAwesomeIcon className="text-xs" icon={faChevronRight} /> <FontAwesomeIcon className="text-xs" icon={faChevronRight} />
</IconButton> </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>
</div> </div>
); );
@@ -160,10 +160,9 @@ export const useGetImportedSecretsAllEnvs = ({
projectId, projectId,
path path
}), }),
queryFn: () => fetchImportedSecrets(projectId, env, path).catch(() => []), queryFn: async () => {
enabled: Boolean(projectId) && Boolean(env), const data = await fetchImportedSecrets(projectId, env, path).catch(() => []);
// eslint-disable-next-line react-hooks/rules-of-hooks
select: (data: TImportedSecrets[]) => {
return data.map((el) => ({ return data.map((el) => ({
environment: el.environment, environment: el.environment,
secretPath: el.secretPath, secretPath: el.secretPath,
@@ -183,7 +182,8 @@ export const useGetImportedSecretsAllEnvs = ({
}; };
}) })
})); }));
} },
enabled: Boolean(projectId) && Boolean(env)
})) }))
}); });
+24 -9
View File
@@ -24,6 +24,12 @@ export const secretKeys = {
// this is also used in secretSnapshot part // this is also used in secretSnapshot part
getProjectSecret: ({ workspaceId, environment, secretPath }: TGetProjectSecretsKey) => getProjectSecret: ({ workspaceId, environment, secretPath }: TGetProjectSecretsKey) =>
[{ workspaceId, environment, secretPath }, "secrets"] as const, [{ 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 getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] as const
}; };
@@ -95,7 +101,7 @@ export const useGetProjectSecrets = ({
}: TGetProjectSecretsDTO & { }: TGetProjectSecretsDTO & {
options?: Omit< options?: Omit<
UseQueryOptions< UseQueryOptions<
SecretV3RawResponse, SecretV3RawSanitized[],
unknown, unknown,
SecretV3RawSanitized[], SecretV3RawSanitized[],
ReturnType<typeof secretKeys.getProjectSecret> ReturnType<typeof secretKeys.getProjectSecret>
@@ -108,7 +114,10 @@ export const useGetProjectSecrets = ({
// wait for all values to be available // wait for all values to be available
enabled: Boolean(workspaceId && environment) && (options?.enabled ?? true), enabled: Boolean(workspaceId && environment) && (options?.enabled ?? true),
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }), 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) => { onError: (error) => {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string }; const serverResponse = error.response?.data as { message: string };
@@ -118,8 +127,7 @@ export const useGetProjectSecrets = ({
text: serverResponse.message text: serverResponse.message
}); });
} }
}, }
select: ({ secrets }) => mergePersonalSecrets(secrets)
}); });
export const useGetProjectSecretsAllEnv = ({ export const useGetProjectSecretsAllEnv = ({
@@ -131,7 +139,11 @@ export const useGetProjectSecretsAllEnv = ({
const secrets = useQueries({ const secrets = useQueries({
queries: envs.map((environment) => ({ queries: envs.map((environment) => ({
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }), queryKey: secretKeys.getProjectSecretAllEnvironments({
workspaceId,
environment,
secretPath
}),
enabled: Boolean(workspaceId && environment), enabled: Boolean(workspaceId && environment),
onError: (error: unknown) => { onError: (error: unknown) => {
if (axios.isAxiosError(error) && !isErrorHandled) { if (axios.isAxiosError(error) && !isErrorHandled) {
@@ -147,12 +159,15 @@ export const useGetProjectSecretsAllEnv = ({
setIsErrorHandled.on(); setIsErrorHandled.on();
} }
}, },
queryFn: async () => fetchProjectSecrets({ workspaceId, environment, secretPath }), queryFn: async () => {
select: (el: SecretV3RawResponse) => const resp = await fetchProjectSecrets({ workspaceId, environment, secretPath });
mergePersonalSecrets(el.secrets).reduce<Record<string, SecretV3RawSanitized>>(
return mergePersonalSecrets(resp.secrets).reduce<Record<string, SecretV3RawSanitized>>(
(prev, curr) => ({ ...prev, [curr.key]: curr }), (prev, curr) => ({ ...prev, [curr.key]: curr }),
{} {}
) );
},
staleTime: 5 * 60 * 1000
})) }))
}); });
@@ -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 { useTranslation } from "react-i18next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -31,6 +31,7 @@ import {
Input, Input,
Modal, Modal,
ModalContent, ModalContent,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -49,7 +50,7 @@ import {
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { usePopUp } from "@app/hooks"; import { useDebounce, usePopUp } from "@app/hooks";
import { import {
useCreateFolder, useCreateFolder,
useCreateSecretV3, useCreateSecretV3,
@@ -78,6 +79,14 @@ export enum EntryType {
SECRET = "secret" SECRET = "secret"
} }
enum RowType {
Folder = "folder",
DynamicSecret = "dynamic",
Secret = "Secret"
}
const INIT_PER_PAGE = 10;
export const SecretOverviewPage = () => { export const SecretOverviewPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -101,6 +110,7 @@ export const SecretOverviewPage = () => {
const workspaceId = currentWorkspace?.id as string; const workspaceId = currentWorkspace?.id as string;
const projectSlug = currentWorkspace?.slug as string; const projectSlug = currentWorkspace?.slug as string;
const [searchFilter, setSearchFilter] = useState(""); const [searchFilter, setSearchFilter] = useState("");
const debouncedSearchFilter = useDebounce(searchFilter);
const secretPath = (router.query?.secretPath as string) || "/"; const secretPath = (router.query?.secretPath as string) || "/";
const [selectedEntries, setSelectedEntries] = useState<{ const [selectedEntries, setSelectedEntries] = useState<{
@@ -111,6 +121,9 @@ export const SecretOverviewPage = () => {
[EntryType.SECRET]: {} [EntryType.SECRET]: {}
}); });
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const toggleSelectedEntry = useCallback( const toggleSelectedEntry = useCallback(
(type: EntryType, key: string) => { (type: EntryType, key: string) => {
const isChecked = Boolean(selectedEntries[type]?.[key]); 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 ( return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
<img <img
@@ -454,32 +500,16 @@ export const SecretOverviewPage = () => {
); );
} }
const isTableLoading = !(
folders?.some(({ isLoading }) => !isLoading) && secrets?.some(({ isLoading }) => !isLoading)
);
const canViewOverviewPage = Boolean(userAvailableEnvs.length); const canViewOverviewPage = Boolean(userAvailableEnvs.length);
// This is needed to also show imports from other paths – right now those are missing. // 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 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 = const isTableEmpty =
!( !(
folders?.every(({ isLoading }) => isLoading) && folders?.every(({ isLoading }) => isLoading) &&
secrets?.every(({ isLoading }) => isLoading) && secrets?.every(({ isLoading }) => isLoading) &&
dynamicSecrets?.every(({ isLoading }) => isLoading) dynamicSecrets?.every(({ isLoading }) => isLoading)
) && ) && rows.length === 0;
filteredSecretNames?.length === 0 &&
filteredFolderNames?.length === 0 &&
filteredDynamicSecrets?.length === 0;
return ( return (
<> <>
@@ -656,7 +686,7 @@ export const SecretOverviewPage = () => {
resetSelectedEntries={resetSelectedEntries} resetSelectedEntries={resetSelectedEntries}
/> />
<div className="thin-scrollbar mt-4" ref={parentTableRef}> <div className="thin-scrollbar mt-4" ref={parentTableRef}>
<TableContainer className="max-h-[calc(100vh-250px)] overflow-y-auto"> <TableContainer>
<Table> <Table>
<THead> <THead>
<Tr className="sticky top-0 z-20 border-0"> <Tr className="sticky top-0 z-20 border-0">
@@ -753,7 +783,7 @@ export const SecretOverviewPage = () => {
<Td colSpan={visibleEnvs.length + 1}> <Td colSpan={visibleEnvs.length + 1}>
<EmptyState <EmptyState
title={ title={
searchFilter debouncedSearchFilter
? "No secret found for your search, add one now" ? "No secret found for your search, add one now"
: "Let's add some secrets" : "Let's add some secrets"
} }
@@ -774,48 +804,59 @@ export const SecretOverviewPage = () => {
</Tr> </Tr>
)} )}
{!isTableLoading && {!isTableLoading &&
filteredFolderNames.map((folderName, index) => ( rows.slice(paginationOffset, paginationOffset + perPage).map((row, index) => {
<SecretOverviewFolderRow switch (row.type) {
folderName={folderName} case RowType.Secret:
isFolderPresentInEnv={isFolderPresentInEnv} if (visibleEnvs?.length === 0) return null;
isSelected={selectedEntries.folder[folderName]} return (
onToggleFolderSelect={() => toggleSelectedEntry(EntryType.FOLDER, folderName)} <SecretOverviewTableRow
environments={visibleEnvs} isSelected={selectedEntries.secret[row.name]}
key={`overview-${folderName}-${index + 1}`} onToggleSecretSelect={() =>
onClick={handleFolderClick} toggleSelectedEntry(EntryType.SECRET, row.name)
onToggleFolderEdit={(name: string) => }
handlePopUpOpen("updateFolder", { name }) secretPath={secretPath}
} getImportedSecretByKey={getImportedSecretByKey}
/> isImportedSecretPresentInEnv={isImportedSecretPresentInEnv}
))} onSecretCreate={handleSecretCreate}
{!isTableLoading && onSecretDelete={handleSecretDelete}
filteredDynamicSecrets.map((dynamicSecretName, index) => ( onSecretUpdate={handleSecretUpdate}
<SecretOverviewDynamicSecretRow key={`overview-${row.name}-${index + 1}`}
dynamicSecretName={dynamicSecretName} environments={visibleEnvs}
isDynamicSecretInEnv={isDynamicSecretPresentInEnv} secretKey={row.name}
environments={visibleEnvs} getSecretByKey={getSecretByKey}
key={`overview-${dynamicSecretName}-${index + 1}`} expandableColWidth={expandableTableWidth}
/> />
))} );
{!isTableLoading && case RowType.DynamicSecret:
visibleEnvs?.length > 0 && return (
filteredSecretNames.map((key, index) => ( <SecretOverviewDynamicSecretRow
<SecretOverviewTableRow dynamicSecretName={row.name}
isSelected={selectedEntries.secret[key]} isDynamicSecretInEnv={isDynamicSecretPresentInEnv}
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)} environments={visibleEnvs}
secretPath={secretPath} key={`overview-${row.name}-${index + 1}`}
getImportedSecretByKey={getImportedSecretByKey} />
isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} );
onSecretCreate={handleSecretCreate} case RowType.Folder:
onSecretDelete={handleSecretDelete} return (
onSecretUpdate={handleSecretUpdate} <SecretOverviewFolderRow
key={`overview-${key}-${index + 1}`} folderName={row.name}
environments={visibleEnvs} isFolderPresentInEnv={isFolderPresentInEnv}
secretKey={key} isSelected={selectedEntries.folder[row.name]}
getSecretByKey={getSecretByKey} onToggleFolderSelect={() =>
expandableColWidth={expandableTableWidth} toggleSelectedEntry(EntryType.FOLDER, row.name)
/> }
))} environments={visibleEnvs}
key={`overview-${row.name}-${index + 1}`}
onClick={handleFolderClick}
onToggleFolderEdit={(name: string) =>
handlePopUpOpen("updateFolder", { name })
}
/>
);
default:
return null;
}
})}
</TBody> </TBody>
<TFoot> <TFoot>
<Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800"> <Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800">
@@ -842,6 +883,15 @@ export const SecretOverviewPage = () => {
</Tr> </Tr>
</TFoot> </TFoot>
</Table> </Table>
{!isTableLoading && rows.length > INIT_PER_PAGE && (
<Pagination
count={rows.length}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
</TableContainer> </TableContainer>
</div> </div>
</div> </div>