From 1e07c2fe239c72abb35ba95525dd576686b804d3 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Mon, 16 Jun 2025 15:00:40 -0700 Subject: [PATCH 1/2] improvements: add sorting, search, and pagination to project roles table and improve dropdown menu --- .../ProjectRoleList/ProjectRoleList.tsx | 275 +++++++++++++----- 1 file changed, 208 insertions(+), 67 deletions(-) diff --git a/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index 28bb56dbb..7420b616c 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -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 { useNavigate } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; @@ -12,6 +24,10 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Pagination, Table, TableContainer, TableSkeleton, @@ -19,15 +35,22 @@ import { Td, Th, THead, + Tooltip, Tr } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; +import { usePagination, usePopUp } from "@app/hooks"; 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 { DuplicateProjectRoleModal } from "@app/pages/project/RoleDetailsBySlugPage/components/DuplicateProjectRoleModal"; import { RoleModal } from "@app/pages/project/RoleDetailsBySlugPage/components/RoleModal"; +enum RolesOrderBy { + Name = "name", + Slug = "slug" +} + export const ProjectRoleList = () => { const navigate = useNavigate(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ @@ -57,6 +80,62 @@ export const ProjectRoleList = () => { } }; + const { + orderDirection, + toggleOrderDirection, + orderBy, + setOrderDirection, + setOrderBy, + search, + setSearch, + page, + perPage, + setPerPage, + setPage, + offset + } = usePagination(RolesOrderBy.Name); + + 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] + ); + + 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 (
@@ -75,18 +154,50 @@ export const ProjectRoleList = () => { )}
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search project roles..." + className="flex-1" + containerClassName="mb-4" + /> - - + + - {isRolesLoading && } - {roles?.map((role) => { + {isRolesLoading && } + {filteredRoles?.slice(offset, perPage * page).map((role) => { const { id, name, slug } = role; const isNonMutatable = Object.values(ProjectMembershipRole).includes( slug as ProjectMembershipRole @@ -109,88 +220,118 @@ export const ProjectRoleList = () => { ); })}
NameSlug +
+ Name + handleSort(RolesOrderBy.Name)} + > + + +
+
+
+ Slug + handleSort(RolesOrderBy.Slug)} + > + + +
+
{name} {slug} - - -
- -
-
- - - {(isAllowed) => ( - { - e.stopPropagation(); - navigate({ - to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const, - params: { - projectId: currentWorkspace.id, - roleSlug: slug - } - }); - }} - disabled={!isAllowed} - > - {`${isNonMutatable ? "View" : "Edit"} Role`} - - )} - - - {(isAllowed) => ( - { - e.stopPropagation(); - handlePopUpOpen("duplicateRole", role); - }} - disabled={!isAllowed} - > - Duplicate Role - - )} - - {!isNonMutatable && ( + + + + + + + + {(isAllowed) => ( } className={twMerge( - isAllowed - ? "hover:!bg-red-500 hover:!text-white" - : "pointer-events-none cursor-not-allowed opacity-50" + !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" )} onClick={(e) => { e.stopPropagation(); - handlePopUpOpen("deleteRole", role); + navigate({ + to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const, + params: { + projectId: currentWorkspace.id, + roleSlug: slug + } + }); }} disabled={!isAllowed} > - Delete Role + {`${isNonMutatable ? "View" : "Edit"} Role`} )} - )} - - + + {(isAllowed) => ( + } + className={twMerge( + !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" + )} + onClick={(e) => { + e.stopPropagation(); + handlePopUpOpen("duplicateRole", role); + }} + disabled={!isAllowed} + > + Duplicate Role + + )} + + {!isNonMutatable && ( + + {(isAllowed) => ( + } + 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 + + )} + + )} + +
+
+ {Boolean(filteredRoles?.length) && ( + + )} + {!filteredRoles?.length && !isRolesLoading && ( + + )}
Date: Mon, 16 Jun 2025 16:36:03 -0700 Subject: [PATCH 2/2] improvements: use stored preferred page size for project roles table and add reset helper --- .../ProjectRoleList/ProjectRoleList.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index 7420b616c..d1c925a33 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -39,7 +39,12 @@ import { Tr } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { usePagination, 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 { OrderByDirection } from "@app/hooks/api/generic/types"; import { ProjectMembershipRole, TProjectRole } from "@app/hooks/api/roles/types"; @@ -93,7 +98,14 @@ export const ProjectRoleList = () => { setPerPage, setPage, offset - } = usePagination(RolesOrderBy.Name); + } = usePagination(RolesOrderBy.Name, { + initPerPage: getUserTablePreference("projectRolesTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("projectRolesTable", PreferenceKey.PerPage, newPerPage); + }; const filteredRoles = useMemo( () => @@ -117,10 +129,16 @@ export const ProjectRoleList = () => { 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(); @@ -143,7 +161,7 @@ export const ProjectRoleList = () => { {(isAllowed) => (