mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: pagination for main secret page
This commit is contained in:
@@ -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<string | null>(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 = () => {
|
||||
/>
|
||||
)}
|
||||
<FolderListView
|
||||
folders={folders}
|
||||
folders={rows.folders}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
@@ -314,15 +373,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}
|
||||
@@ -331,6 +388,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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export type Filter = {
|
||||
tags: Record<string, boolean>;
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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 = ({
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<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>
|
||||
<DropdownSubMenu>
|
||||
<DropdownSubMenuTrigger
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
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 { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
|
||||
import { Filter, GroupBy, SortDir } from "../../SecretMainPage.types";
|
||||
import { Filter } from "../../SecretMainPage.types";
|
||||
import { SecretDetailSidebar } from "./SecretDetaiSidebar";
|
||||
import { SecretItem } from "./SecretItem";
|
||||
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
|
||||
@@ -26,53 +25,11 @@ type Props = {
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
filter: Filter;
|
||||
sortDir?: SortDir;
|
||||
tags?: WsTag[];
|
||||
isVisible?: 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) =>
|
||||
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 (
|
||||
<div className="flex flex-col" key={`${namespace}-${groupedSecrets.length}`}>
|
||||
<div
|
||||
className={twMerge(
|
||||
"text-md h-0 bg-bunker-600 capitalize transition-all",
|
||||
Boolean(namespace) && Boolean(filteredSecrets.length) && "h-11 py-3 pl-4 "
|
||||
)}
|
||||
key={namespace}
|
||||
>
|
||||
{namespace}
|
||||
</div>
|
||||
{FontAwesomeSpriteSymbols.map(({ icon, symbol }) => (
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
symbol={symbol}
|
||||
key={`font-awesome-svg-spritie-${symbol}`}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{FontAwesomeSpriteSymbols.map(({ icon, symbol }) => (
|
||||
<FontAwesomeIcon icon={icon} symbol={symbol} key={`font-awesome-svg-spritie-${symbol}`} />
|
||||
))}
|
||||
{secrets.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
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteSecret.isOpen}
|
||||
deleteKey={(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key}
|
||||
|
||||
@@ -885,6 +885,7 @@ export const SecretOverviewPage = () => {
|
||||
</Table>
|
||||
{!isTableLoading && rows.length > INIT_PER_PAGE && (
|
||||
<Pagination
|
||||
className="border-t border-solid border-t-mineshaft-600"
|
||||
count={rows.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
|
||||
Reference in New Issue
Block a user