improvement: add pagination, search and column sort to service token table and improve table rows

This commit is contained in:
Scott Wilson
2025-06-16 16:13:09 -07:00
parent a89bd08c08
commit 6eaa16bd07
3 changed files with 269 additions and 71 deletions
@@ -70,7 +70,15 @@ export const Pagination = ({
key={`pagination-per-page-options-${perPageOption}`} key={`pagination-per-page-options-${perPageOption}`}
icon={perPage === perPageOption && <FontAwesomeIcon size="sm" icon={faCheck} />} icon={perPage === perPageOption && <FontAwesomeIcon size="sm" icon={faCheck} />}
iconPos="right" iconPos="right"
onClick={() => onChangePerPage(perPageOption)} onClick={() => {
const totalPages = Math.ceil(count / perPageOption);
if (page > totalPages) {
onChangePage(totalPages);
}
onChangePerPage(perPageOption);
}}
> >
{perPageOption} rows per page {perPageOption} rows per page
</DropdownMenuItem> </DropdownMenuItem>
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -48,8 +48,29 @@ export const ServiceTokenSection = withProjectPermission(
return ( return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-2 flex justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p> <div>
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p>
<a
href="https://infisical.com/docs/documentation/platform/token"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">
{t("section.token.service-tokens-description")}
</p>
</div>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.ServiceTokens} a={ProjectPermissionSub.ServiceTokens}
@@ -63,12 +84,11 @@ export const ServiceTokenSection = withProjectPermission(
}} }}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Create token Create Token
</Button> </Button>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
<p className="mb-8 text-gray-400">{t("section.token.service-tokens-description")}</p>
<ServiceTokenTable handlePopUpOpen={handlePopUpOpen} /> <ServiceTokenTable handlePopUpOpen={handlePopUpOpen} />
<AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal <DeleteActionModal
@@ -1,10 +1,28 @@
import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faEllipsisV,
faFolder,
faKey,
faMagnifyingGlass,
faSearch,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState, EmptyState,
IconButton, IconButton,
Input,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -12,10 +30,18 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetUserWsServiceTokens } from "@app/hooks/api"; import { useGetUserWsServiceTokens } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
@@ -31,78 +57,222 @@ type Props = {
) => void; ) => void;
}; };
enum TokensOrderBy {
Name = "name",
Expiration = "expiration"
}
export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => { export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { data, isPending } = useGetUserWsServiceTokens({ const { data, isPending } = useGetUserWsServiceTokens({
workspaceID: currentWorkspace?.id || "" workspaceID: currentWorkspace?.id || ""
}); });
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<TokensOrderBy>(TokensOrderBy.Name, {
initPerPage: getUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, newPerPage);
};
const filteredTokens = useMemo(
() =>
data
?.filter((token) => {
const { name } = token;
const searchValue = search.trim().toLowerCase();
return name.toLowerCase().includes(searchValue);
})
.sort((a, b) => {
const [tokenOne, tokenTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case TokensOrderBy.Expiration:
if (!tokenOne.expiresAt && !tokenTwo.expiresAt) return 0;
if (!tokenOne.expiresAt) return 1;
if (!tokenTwo.expiresAt) return -1;
return (
new Date(tokenOne.expiresAt).getTime() - new Date(tokenTwo.expiresAt).getTime()
);
case TokensOrderBy.Name:
default:
return tokenOne.name.toLowerCase().localeCompare(tokenTwo.name.toLowerCase());
}
}) ?? [],
[data, orderDirection, search, orderBy]
);
useResetPageHelper({
totalCount: filteredTokens.length,
offset,
setPage
});
const handleSort = (column: TokensOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: TokensOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: TokensOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
return ( return (
<TableContainer> <div>
<Table> <Input
<THead> value={search}
<Tr> onChange={(e) => setSearch(e.target.value)}
<Th>Token Name</Th> leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
<Th>Environment - Secret Path</Th> placeholder="Search service tokens..."
<Th>Valid Until</Th> className="flex-1"
<Th aria-label="button" /> containerClassName="mb-4 mt-2"
</Tr> />
</THead> <TableContainer>
<TBody> <Table>
{isPending && <TableSkeleton columns={4} innerKey="project-service-tokens" />} <THead>
{!isPending &&
data &&
data.map((row) => (
<Tr key={row.id}>
<Td>{row.name}</Td>
<Td>
<div className="mb-2 flex flex-col flex-wrap space-y-1">
{row?.scopes.map(({ secretPath, environment }) => (
<div
key={`${row.id}-${environment}-${secretPath}`}
className="inline-flex items-center space-x-1 rounded-md border border-mineshaft-600 p-1 px-2"
>
<div className="mr-2 border-r border-mineshaft-600 pr-2">{environment}</div>
<FontAwesomeIcon icon={faFolder} size="sm" />
<span className="pl-2">{secretPath}</span>
</div>
))}
</div>
</Td>
<Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<IconButton
onClick={() =>
handlePopUpOpen("deleteAPITokenConfirmation", {
name: row.name,
id: row.id
})
}
colorSchema="danger"
ariaLabel="delete"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
))}
{!isPending && data && data?.length === 0 && (
<Tr> <Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400"> <Th>
<EmptyState title="No service tokens found" icon={faKey} /> <div className="flex items-center">
</Td> Name
<IconButton
variant="plain"
className={getClassName(TokensOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(TokensOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(TokensOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>Environment / Secret Path</Th>
<Th>
<div className="flex items-center">
Valid Until
<IconButton
variant="plain"
className={getClassName(TokensOrderBy.Expiration)}
ariaLabel="sort"
onClick={() => handleSort(TokensOrderBy.Expiration)}
>
<FontAwesomeIcon icon={getColSortIcon(TokensOrderBy.Expiration)} />
</IconButton>
</div>
</Th>
<Th className="w-5" aria-label="button" />
</Tr> </Tr>
)} </THead>
</TBody> <TBody>
</Table> {isPending && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
</TableContainer> {!isPending &&
filteredTokens.slice(offset, perPage * page).map((row) => (
<Tr key={row.id}>
<Td>{row.name}</Td>
<Td>
<div className="flex flex-row flex-wrap gap-1">
{row?.scopes.map(({ secretPath, environment }) => (
<div
key={`${row.id}-${environment}-${secretPath}`}
className="inline-flex items-center space-x-1 rounded-md border border-mineshaft-500 p-1 px-2"
>
<div className="mr-1 border-r border-mineshaft-500 pr-2">
{environment}
</div>
<FontAwesomeIcon icon={faFolder} size="sm" className="text-yellow" />
<span className="pl-1">{secretPath}</span>
</div>
))}
</div>
</Td>
<Td>
{row.expiresAt ? (
format(row.expiresAt, "MM/dd/yyyy h:mm:ss aa")
) : (
<span className="text-mineshaft-400">N/A</span>
)}
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[12rem]" sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faTrash} />}
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteAPITokenConfirmation", {
name: row.name,
id: row.id
});
}}
>
Delete Token
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
))}
</TBody>
</Table>
{Boolean(filteredTokens.length) && (
<Pagination
count={filteredTokens.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredTokens?.length && (
<EmptyState
title={data?.length ? "No service tokens match search..." : "No service tokens found"}
icon={data?.length ? faSearch : faKey}
/>
)}
</TableContainer>
</div>
); );
}; };