Merge pull request #2423 from scott-ray-wilson/secrets-pagination

Feature: Secrets Overview Page Pagination/Optimizations
This commit is contained in:
Maidul Islam
2024-09-13 09:56:48 -04:00
committed by GitHub
9 changed files with 336 additions and 219 deletions
@@ -40,6 +40,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 +75,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 +101,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>
); );
@@ -163,27 +163,29 @@ export const useGetImportedSecretsAllEnvs = ({
queryFn: () => fetchImportedSecrets(projectId, env, path).catch(() => []), queryFn: () => fetchImportedSecrets(projectId, env, path).catch(() => []),
enabled: Boolean(projectId) && Boolean(env), enabled: Boolean(projectId) && Boolean(env),
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
select: (data: TImportedSecrets[]) => { select: useCallback(
return data.map((el) => ({ (data: Awaited<ReturnType<typeof fetchImportedSecrets>>) =>
environment: el.environment, data.map((el) => ({
secretPath: el.secretPath, environment: el.environment,
environmentInfo: el.environmentInfo, secretPath: el.secretPath,
folderId: el.folderId, environmentInfo: el.environmentInfo,
secrets: el.secrets.map((encSecret) => { folderId: el.folderId,
return { secrets: el.secrets.map((encSecret) => {
id: encSecret.id, return {
env: encSecret.environment, id: encSecret.id,
key: encSecret.secretKey, env: encSecret.environment,
value: encSecret.secretValue, key: encSecret.secretKey,
tags: encSecret.tags, value: encSecret.secretValue,
comment: encSecret.secretComment, tags: encSecret.tags,
createdAt: encSecret.createdAt, comment: encSecret.secretComment,
updatedAt: encSecret.updatedAt, createdAt: encSecret.createdAt,
version: encSecret.version updatedAt: encSecret.updatedAt,
}; version: encSecret.version
}) };
})); })
} })),
[]
)
})) }))
}); });
+21 -9
View File
@@ -108,7 +108,7 @@ 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: () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
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 };
@@ -119,7 +119,10 @@ export const useGetProjectSecrets = ({
}); });
} }
}, },
select: ({ secrets }) => mergePersonalSecrets(secrets) select: useCallback(
(data: Awaited<ReturnType<typeof fetchProjectSecrets>>) => mergePersonalSecrets(data.secrets),
[]
)
}); });
export const useGetProjectSecretsAllEnv = ({ export const useGetProjectSecretsAllEnv = ({
@@ -131,7 +134,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.getProjectSecret({
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 +154,17 @@ export const useGetProjectSecretsAllEnv = ({
setIsErrorHandled.on(); setIsErrorHandled.on();
} }
}, },
queryFn: async () => fetchProjectSecrets({ workspaceId, environment, secretPath }), queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
select: (el: SecretV3RawResponse) => staleTime: 60 * 1000,
mergePersonalSecrets(el.secrets).reduce<Record<string, SecretV3RawSanitized>>( // eslint-disable-next-line react-hooks/rules-of-hooks
(prev, curr) => ({ ...prev, [curr.key]: curr }), select: useCallback(
{} (data: Awaited<ReturnType<typeof fetchProjectSecrets>>) =>
) mergePersonalSecrets(data.secrets).reduce<Record<string, SecretV3RawSanitized>>(
(prev, curr) => ({ ...prev, [curr.key]: curr }),
{}
),
[]
)
})) }))
}); });
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
@@ -8,14 +8,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import NavHeader from "@app/components/navigation/NavHeader"; import NavHeader from "@app/components/navigation/NavHeader";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { PermissionDeniedBanner } from "@app/components/permissions"; import { PermissionDeniedBanner } from "@app/components/permissions";
import { ContentLoader } from "@app/components/v2"; import { ContentLoader, Pagination } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { usePopUp } from "@app/hooks"; import { useDebounce, usePopUp } from "@app/hooks";
import { import {
useGetDynamicSecrets, useGetDynamicSecrets,
useGetImportedSecretsSingleEnv, useGetImportedSecretsSingleEnv,
@@ -39,7 +39,7 @@ import { SecretImportListView } from "./components/SecretImportListView";
import { SecretListView } from "./components/SecretListView"; import { SecretListView } from "./components/SecretListView";
import { SnapshotView } from "./components/SnapshotView"; import { SnapshotView } from "./components/SnapshotView";
import { StoreProvider } from "./SecretMainPage.store"; import { StoreProvider } from "./SecretMainPage.store";
import { Filter, GroupBy, SortDir } from "./SecretMainPage.types"; import { Filter, SortDir } from "./SecretMainPage.types";
const LOADER_TEXT = [ const LOADER_TEXT = [
"Retrieving your encrypted secrets...", "Retrieving your encrypted secrets...",
@@ -47,6 +47,7 @@ const LOADER_TEXT = [
"Getting secret import links..." "Getting secret import links..."
]; ];
const INIT_PER_PAGE = 10;
export const SecretMainPage = () => { export const SecretMainPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace(); const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
@@ -59,6 +60,10 @@ export const SecretMainPage = () => {
tags: {}, tags: {},
searchFilter: (router.query.searchFilter as string) || "" 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 [snapshotId, setSnapshotId] = useState<string | null>(null);
const isRollbackMode = Boolean(snapshotId); const isRollbackMode = Boolean(snapshotId);
@@ -185,11 +190,6 @@ export const SecretMainPage = () => {
}); });
}; };
const handleGroupByChange = useCallback(
(groupBy?: GroupBy) => setFilter((state) => ({ ...state, groupBy })),
[]
);
const handleTagToggle = useCallback( const handleTagToggle = useCallback(
(tagId: string) => (tagId: string) =>
setFilter((state) => { setFilter((state) => {
@@ -223,6 +223,107 @@ export const SecretMainPage = () => {
const loadingOnAccess = const loadingOnAccess =
canReadSecret && canReadSecret &&
(isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading); (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 // loading screen when you don't have permission but as folder's is viewable need to wait for that
const loadingOnDenied = !canReadSecret && isFoldersLoading; const loadingOnDenied = !canReadSecret && isFoldersLoading;
if (loadingOnAccess || loadingOnDenied) { if (loadingOnAccess || loadingOnDenied) {
@@ -258,7 +359,6 @@ export const SecretMainPage = () => {
filter={filter} filter={filter}
tags={tags} tags={tags}
onVisiblilityToggle={handleToggleVisibility} onVisiblilityToggle={handleToggleVisibility}
onGroupByChange={handleGroupByChange}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onToggleTagFilter={handleTagToggle} onToggleTagFilter={handleTagToggle}
snapshotCount={snapshotCount || 0} snapshotCount={snapshotCount || 0}
@@ -291,7 +391,7 @@ export const SecretMainPage = () => {
{canReadSecret && ( {canReadSecret && (
<SecretImportListView <SecretImportListView
searchTerm={filter.searchFilter} searchTerm={filter.searchFilter}
secretImports={secretImports} secretImports={rows.imports}
isFetching={isSecretImportsLoading || isSecretImportsFetching} isFetching={isSecretImportsLoading || isSecretImportsFetching}
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
@@ -301,7 +401,7 @@ export const SecretMainPage = () => {
/> />
)} )}
<FolderListView <FolderListView
folders={folders} folders={rows.folders}
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
secretPath={secretPath} secretPath={secretPath}
@@ -314,15 +414,13 @@ export const SecretMainPage = () => {
environment={environment} environment={environment}
projectSlug={projectSlug} projectSlug={projectSlug}
secretPath={secretPath} secretPath={secretPath}
dynamicSecrets={dynamicSecrets || []} dynamicSecrets={rows.dynamicSecrets || []}
/> />
)} )}
{canReadSecret && ( {canReadSecret && (
<SecretListView <SecretListView
secrets={secrets} secrets={rows.secrets}
tags={tags} tags={tags}
filter={filter}
sortDir={sortDir}
isVisible={isVisible} isVisible={isVisible}
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
@@ -331,6 +429,16 @@ export const SecretMainPage = () => {
/> />
)} )}
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />} {!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>
</div> </div>
<CreateSecretForm <CreateSecretForm
@@ -1,7 +1,6 @@
export type Filter = { export type Filter = {
tags: Record<string, boolean>; tags: Record<string, boolean>;
searchFilter: string; searchFilter: string;
groupBy?: GroupBy | null;
}; };
export enum SortDir { export enum SortDir {
@@ -9,6 +8,8 @@ export enum SortDir {
DESC = "desc" DESC = "desc"
} }
export enum GroupBy { export enum RowType {
PREFIX = "prefix" Folder = "folder",
DynamicSecret = "dynamic",
Secret = "Secret"
} }
@@ -62,7 +62,7 @@ import {
useSelectedSecretActions, useSelectedSecretActions,
useSelectedSecrets useSelectedSecrets
} from "../../SecretMainPage.store"; } from "../../SecretMainPage.store";
import { Filter, GroupBy } from "../../SecretMainPage.types"; import { Filter } from "../../SecretMainPage.types";
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm"; import { CreateSecretImportForm } from "./CreateSecretImportForm";
import { FolderForm } from "./FolderForm"; import { FolderForm } from "./FolderForm";
@@ -81,7 +81,6 @@ type Props = {
isVisible?: boolean; isVisible?: boolean;
snapshotCount: number; snapshotCount: number;
isSnapshotCountLoading?: boolean; isSnapshotCountLoading?: boolean;
onGroupByChange: (opt?: GroupBy) => void;
onSearchChange: (term: string) => void; onSearchChange: (term: string) => void;
onToggleTagFilter: (tagId: string) => void; onToggleTagFilter: (tagId: string) => void;
onVisiblilityToggle: () => void; onVisiblilityToggle: () => void;
@@ -101,7 +100,6 @@ export const ActionBar = ({
isSnapshotCountLoading, isSnapshotCountLoading,
onSearchChange, onSearchChange,
onToggleTagFilter, onToggleTagFilter,
onGroupByChange,
onVisiblilityToggle, onVisiblilityToggle,
onClickRollbackMode onClickRollbackMode
}: Props) => { }: Props) => {
@@ -307,16 +305,6 @@ export const ActionBar = ({
</IconButton> </IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0"> <DropdownMenuContent align="end" className="p-0">
<DropdownMenuGroup>Group By</DropdownMenuGroup>
<DropdownMenuItem
iconPos="right"
icon={
filter?.groupBy === GroupBy.PREFIX && <FontAwesomeIcon icon={faCheckCircle} />
}
onClick={() => onGroupByChange(!filter.groupBy ? GroupBy.PREFIX : undefined)}
>
Prefix
</DropdownMenuItem>
<DropdownMenuGroup>Filter By</DropdownMenuGroup> <DropdownMenuGroup>Filter By</DropdownMenuGroup>
<DropdownSubMenu> <DropdownSubMenu>
<DropdownSubMenuTrigger <DropdownSubMenuTrigger
@@ -123,7 +123,7 @@ export const SecretImportListView = ({
if (!isFetching) { if (!isFetching) {
setItems(secretImports); setItems(secretImports);
} }
}, [isFetching]); }, [isFetching, secretImports]);
const { mutateAsync: deleteSecretImport } = useDeleteSecretImport(); const { mutateAsync: deleteSecretImport } = useDeleteSecretImport();
const { mutate: updateSecretImport } = useUpdateSecretImport(); const { mutate: updateSecretImport } = useUpdateSecretImport();
@@ -1,7 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { CreateTagModal } from "@app/components/tags/CreateTagModal"; import { CreateTagModal } from "@app/components/tags/CreateTagModal";
@@ -16,7 +15,7 @@ import { WsTag } from "@app/hooks/api/types";
import { AddShareSecretModal } from "@app/views/ShareSecretPage/components/AddShareSecretModal"; import { AddShareSecretModal } from "@app/views/ShareSecretPage/components/AddShareSecretModal";
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store"; import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
import { Filter, GroupBy, SortDir } from "../../SecretMainPage.types"; import { Filter } from "../../SecretMainPage.types";
import { SecretDetailSidebar } from "./SecretDetaiSidebar"; import { SecretDetailSidebar } from "./SecretDetaiSidebar";
import { SecretItem } from "./SecretItem"; import { SecretItem } from "./SecretItem";
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils"; import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
@@ -26,53 +25,11 @@ type Props = {
environment: string; environment: string;
workspaceId: string; workspaceId: string;
secretPath?: string; secretPath?: string;
filter: Filter;
sortDir?: SortDir;
tags?: WsTag[]; tags?: WsTag[];
isVisible?: boolean; isVisible?: boolean;
isProtectedBranch?: boolean; isProtectedBranch?: boolean;
}; };
const reorderSecretGroupByUnderscore = (secrets: SecretV3RawSanitized[], sortDir: SortDir) => {
const groupedSecrets: Record<string, SecretV3RawSanitized[]> = {};
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) => export const filterSecrets = (secrets: SecretV3RawSanitized[], filter: Filter) =>
secrets.filter(({ key, value, tags }) => { secrets.filter(({ key, value, tags }) => {
const isTagFilterActive = Boolean(Object.keys(filter.tags).length); const isTagFilterActive = Boolean(Object.keys(filter.tags).length);
@@ -88,8 +45,6 @@ export const SecretListView = ({
environment, environment,
workspaceId, workspaceId,
secretPath = "/", secretPath = "/",
filter,
sortDir = SortDir.ASC,
tags: wsTags = [], tags: wsTags = [],
isVisible, isVisible,
isProtectedBranch = false isProtectedBranch = false
@@ -331,52 +286,30 @@ export const SecretListView = ({
return ( return (
<> <>
{reorderSecret(secrets, sortDir, filter.groupBy).map( {FontAwesomeSpriteSymbols.map(({ icon, symbol }) => (
({ namespace, secrets: groupedSecrets }) => { <FontAwesomeIcon icon={icon} symbol={symbol} key={`font-awesome-svg-spritie-${symbol}`} />
const filteredSecrets = filterSecrets(groupedSecrets, filter); ))}
return ( {secrets.map((secret) => (
<div className="flex flex-col" key={`${namespace}-${groupedSecrets.length}`}> <SecretItem
<div environment={environment}
className={twMerge( secretPath={secretPath}
"text-md h-0 bg-bunker-600 capitalize transition-all", tags={wsTags}
Boolean(namespace) && Boolean(filteredSecrets.length) && "h-11 py-3 pl-4 " isSelected={selectedSecrets?.[secret.id]}
)} onToggleSecretSelect={toggleSelectedSecret}
key={namespace} isVisible={isVisible}
> secret={secret}
{namespace} key={secret.id}
</div> onSaveSecret={handleSaveSecret}
{FontAwesomeSpriteSymbols.map(({ icon, symbol }) => ( onDeleteSecret={onDeleteSecret}
<FontAwesomeIcon onDetailViewSecret={onDetailViewSecret}
icon={icon} onCreateTag={onCreateTag}
symbol={symbol} handleSecretShare={() =>
key={`font-awesome-svg-spritie-${symbol}`} handlePopUpOpen("createSharedSecret", {
/> value: secret.valueOverride ?? secret.value
))} })
{filteredSecrets.map((secret) => ( }
<SecretItem />
environment={environment} ))}
secretPath={secretPath}
tags={wsTags}
isSelected={selectedSecrets?.[secret.id]}
onToggleSecretSelect={toggleSelectedSecret}
isVisible={isVisible}
secret={secret}
key={secret.id}
onSaveSecret={handleSaveSecret}
onDeleteSecret={onDeleteSecret}
onDetailViewSecret={onDetailViewSecret}
onCreateTag={onCreateTag}
handleSecretShare={() =>
handlePopUpOpen("createSharedSecret", {
value: secret.valueOverride ?? secret.value
})
}
/>
))}
</div>
);
}
)}
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteSecret.isOpen} isOpen={popUp.deleteSecret.isOpen}
deleteKey={(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key} deleteKey={(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key}
@@ -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,16 @@ export const SecretOverviewPage = () => {
</Tr> </Tr>
</TFoot> </TFoot>
</Table> </Table>
{!isTableLoading && rows.length > INIT_PER_PAGE && (
<Pagination
className="border-t border-solid border-t-mineshaft-600"
count={rows.length}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
</TableContainer> </TableContainer>
</div> </div>
</div> </div>