mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 04:27:29 +00:00
Merge pull request #3803 from Infisical/project-roles-table-improvements
improvement(project-roles): Add pagination, search and column sorting to Project Roles table
This commit is contained in:
+227
-68
@@ -1,4 +1,16 @@
|
|||||||
import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
faArrowDown,
|
||||||
|
faArrowUp,
|
||||||
|
faCopy,
|
||||||
|
faEdit,
|
||||||
|
faEllipsisV,
|
||||||
|
faEye,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faPlus,
|
||||||
|
faSearch,
|
||||||
|
faTrash
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
@@ -12,6 +24,10 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
@@ -19,15 +35,27 @@ 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 { usePopUp } from "@app/hooks";
|
import {
|
||||||
|
getUserTablePreference,
|
||||||
|
PreferenceKey,
|
||||||
|
setUserTablePreference
|
||||||
|
} from "@app/helpers/userTablePreferences";
|
||||||
|
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||||
import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api";
|
import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api";
|
||||||
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
import { ProjectMembershipRole, TProjectRole } from "@app/hooks/api/roles/types";
|
import { ProjectMembershipRole, TProjectRole } from "@app/hooks/api/roles/types";
|
||||||
import { DuplicateProjectRoleModal } from "@app/pages/project/RoleDetailsBySlugPage/components/DuplicateProjectRoleModal";
|
import { DuplicateProjectRoleModal } from "@app/pages/project/RoleDetailsBySlugPage/components/DuplicateProjectRoleModal";
|
||||||
import { RoleModal } from "@app/pages/project/RoleDetailsBySlugPage/components/RoleModal";
|
import { RoleModal } from "@app/pages/project/RoleDetailsBySlugPage/components/RoleModal";
|
||||||
|
|
||||||
|
enum RolesOrderBy {
|
||||||
|
Name = "name",
|
||||||
|
Slug = "slug"
|
||||||
|
}
|
||||||
|
|
||||||
export const ProjectRoleList = () => {
|
export const ProjectRoleList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||||
@@ -57,6 +85,75 @@ export const ProjectRoleList = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
orderDirection,
|
||||||
|
toggleOrderDirection,
|
||||||
|
orderBy,
|
||||||
|
setOrderDirection,
|
||||||
|
setOrderBy,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
setPage,
|
||||||
|
offset
|
||||||
|
} = usePagination<RolesOrderBy>(RolesOrderBy.Name, {
|
||||||
|
initPerPage: getUserTablePreference("projectRolesTable", PreferenceKey.PerPage, 20)
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePerPageChange = (newPerPage: number) => {
|
||||||
|
setPerPage(newPerPage);
|
||||||
|
setUserTablePreference("projectRolesTable", PreferenceKey.PerPage, newPerPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredRoles = useMemo(
|
||||||
|
() =>
|
||||||
|
roles
|
||||||
|
?.filter((role) => {
|
||||||
|
const { slug, name } = role;
|
||||||
|
|
||||||
|
const searchValue = search.trim().toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
name.toLowerCase().includes(searchValue) || slug.toLowerCase().includes(searchValue)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const [roleOne, roleTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||||
|
|
||||||
|
switch (orderBy) {
|
||||||
|
case RolesOrderBy.Slug:
|
||||||
|
return roleOne.slug.toLowerCase().localeCompare(roleTwo.slug.toLowerCase());
|
||||||
|
case RolesOrderBy.Name:
|
||||||
|
default:
|
||||||
|
return roleOne.name.toLowerCase().localeCompare(roleTwo.name.toLowerCase());
|
||||||
|
}
|
||||||
|
}) ?? [],
|
||||||
|
[roles, orderDirection, search, orderBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
useResetPageHelper({
|
||||||
|
totalCount: filteredRoles.length,
|
||||||
|
offset,
|
||||||
|
setPage
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (column: RolesOrderBy) => {
|
||||||
|
if (column === orderBy) {
|
||||||
|
toggleOrderDirection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderBy(column);
|
||||||
|
setOrderDirection(OrderByDirection.ASC);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClassName = (col: RolesOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||||
|
|
||||||
|
const getColSortIcon = (col: RolesOrderBy) =>
|
||||||
|
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="mb-4 flex justify-between">
|
<div className="mb-4 flex justify-between">
|
||||||
@@ -64,7 +161,7 @@ export const ProjectRoleList = () => {
|
|||||||
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
|
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<Button
|
<Button
|
||||||
colorSchema="primary"
|
colorSchema="secondary"
|
||||||
type="submit"
|
type="submit"
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
onClick={() => handlePopUpOpen("role")}
|
onClick={() => handlePopUpOpen("role")}
|
||||||
@@ -75,18 +172,50 @@ export const ProjectRoleList = () => {
|
|||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
</div>
|
</div>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search project roles..."
|
||||||
|
className="flex-1"
|
||||||
|
containerClassName="mb-4"
|
||||||
|
/>
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<Table>
|
<Table>
|
||||||
<THead>
|
<THead>
|
||||||
<Tr>
|
<Tr>
|
||||||
<Th>Name</Th>
|
<Th>
|
||||||
<Th>Slug</Th>
|
<div className="flex items-center">
|
||||||
|
Name
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
className={getClassName(RolesOrderBy.Name)}
|
||||||
|
ariaLabel="sort"
|
||||||
|
onClick={() => handleSort(RolesOrderBy.Name)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Name)} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Th>
|
||||||
|
<Th>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Slug
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
className={getClassName(RolesOrderBy.Slug)}
|
||||||
|
ariaLabel="sort"
|
||||||
|
onClick={() => handleSort(RolesOrderBy.Slug)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Slug)} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Th>
|
||||||
<Th aria-label="actions" className="w-5" />
|
<Th aria-label="actions" className="w-5" />
|
||||||
</Tr>
|
</Tr>
|
||||||
</THead>
|
</THead>
|
||||||
<TBody>
|
<TBody>
|
||||||
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
|
{isRolesLoading && <TableSkeleton columns={3} innerKey="project-roles" />}
|
||||||
{roles?.map((role) => {
|
{filteredRoles?.slice(offset, perPage * page).map((role) => {
|
||||||
const { id, name, slug } = role;
|
const { id, name, slug } = role;
|
||||||
const isNonMutatable = Object.values(ProjectMembershipRole).includes(
|
const isNonMutatable = Object.values(ProjectMembershipRole).includes(
|
||||||
slug as ProjectMembershipRole
|
slug as ProjectMembershipRole
|
||||||
@@ -109,88 +238,118 @@ export const ProjectRoleList = () => {
|
|||||||
<Td>{name}</Td>
|
<Td>{name}</Td>
|
||||||
<Td>{slug}</Td>
|
<Td>{slug}</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<DropdownMenu>
|
<Tooltip className="max-w-sm text-center" content="Options">
|
||||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
<DropdownMenu>
|
||||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
<DropdownMenuTrigger asChild>
|
||||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
<IconButton
|
||||||
</div>
|
ariaLabel="Options"
|
||||||
</DropdownMenuTrigger>
|
colorSchema="secondary"
|
||||||
<DropdownMenuContent align="start" className="p-1">
|
className="w-6"
|
||||||
<ProjectPermissionCan
|
variant="plain"
|
||||||
I={ProjectPermissionActions.Edit}
|
>
|
||||||
a={ProjectPermissionSub.Role}
|
<FontAwesomeIcon icon={faEllipsisV} />
|
||||||
>
|
</IconButton>
|
||||||
{(isAllowed) => (
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem
|
<DropdownMenuContent className="min-w-[12rem]" sideOffset={2} align="end">
|
||||||
className={twMerge(
|
|
||||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate({
|
|
||||||
to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const,
|
|
||||||
params: {
|
|
||||||
projectId: currentWorkspace.id,
|
|
||||||
roleSlug: slug
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={!isAllowed}
|
|
||||||
>
|
|
||||||
{`${isNonMutatable ? "View" : "Edit"} Role`}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</ProjectPermissionCan>
|
|
||||||
<ProjectPermissionCan
|
|
||||||
I={ProjectPermissionActions.Create}
|
|
||||||
a={ProjectPermissionSub.Role}
|
|
||||||
>
|
|
||||||
{(isAllowed) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={twMerge(
|
|
||||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handlePopUpOpen("duplicateRole", role);
|
|
||||||
}}
|
|
||||||
disabled={!isAllowed}
|
|
||||||
>
|
|
||||||
Duplicate Role
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</ProjectPermissionCan>
|
|
||||||
{!isNonMutatable && (
|
|
||||||
<ProjectPermissionCan
|
<ProjectPermissionCan
|
||||||
I={ProjectPermissionActions.Delete}
|
I={ProjectPermissionActions.Edit}
|
||||||
a={ProjectPermissionSub.Role}
|
a={ProjectPermissionSub.Role}
|
||||||
>
|
>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
icon={<FontAwesomeIcon icon={isNonMutatable ? faEye : faEdit} />}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
isAllowed
|
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||||
? "hover:!bg-red-500 hover:!text-white"
|
|
||||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handlePopUpOpen("deleteRole", role);
|
navigate({
|
||||||
|
to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const,
|
||||||
|
params: {
|
||||||
|
projectId: currentWorkspace.id,
|
||||||
|
roleSlug: slug
|
||||||
|
}
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
disabled={!isAllowed}
|
disabled={!isAllowed}
|
||||||
>
|
>
|
||||||
Delete Role
|
{`${isNonMutatable ? "View" : "Edit"} Role`}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
)}
|
<ProjectPermissionCan
|
||||||
</DropdownMenuContent>
|
I={ProjectPermissionActions.Create}
|
||||||
</DropdownMenu>
|
a={ProjectPermissionSub.Role}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
icon={<FontAwesomeIcon icon={faCopy} />}
|
||||||
|
className={twMerge(
|
||||||
|
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePopUpOpen("duplicateRole", role);
|
||||||
|
}}
|
||||||
|
disabled={!isAllowed}
|
||||||
|
>
|
||||||
|
Duplicate Role
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
{!isNonMutatable && (
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={ProjectPermissionSub.Role}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
className={twMerge(
|
||||||
|
isAllowed
|
||||||
|
? "hover:!bg-red-500 hover:!text-white"
|
||||||
|
: "pointer-events-none cursor-not-allowed opacity-50",
|
||||||
|
"transition-colors duration-100"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePopUpOpen("deleteRole", role);
|
||||||
|
}}
|
||||||
|
disabled={!isAllowed}
|
||||||
|
>
|
||||||
|
Delete Role
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Tooltip>
|
||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TBody>
|
</TBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
{Boolean(filteredRoles?.length) && (
|
||||||
|
<Pagination
|
||||||
|
count={filteredRoles!.length}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={handlePerPageChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!filteredRoles?.length && !isRolesLoading && (
|
||||||
|
<EmptyState
|
||||||
|
title={
|
||||||
|
roles?.length
|
||||||
|
? "No project roles match search..."
|
||||||
|
: "This project does not have any roles"
|
||||||
|
}
|
||||||
|
icon={roles?.length ? faSearch : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
|
|||||||
Reference in New Issue
Block a user