feat(project-members): Persist pagination setting and add role filtering

This commit is contained in:
carlosmonastyrski
2025-04-24 10:06:09 -03:00
parent 0e488d840f
commit aa893a40a9
7 changed files with 134 additions and 21 deletions
@@ -78,7 +78,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
includeGroupMembers: z includeGroupMembers: z
.enum(["true", "false"]) .enum(["true", "false"])
.default("false") .default("false")
.transform((value) => value === "true") .transform((value) => value === "true"),
roles: z.string().trim().transform(decodeURIComponent).optional()
}), }),
params: z.object({ params: z.object({
workspaceId: z.string().trim() workspaceId: z.string().trim()
@@ -117,13 +118,15 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const roles = req.query.roles?.split(",") ?? [];
const users = await server.services.projectMembership.getProjectMemberships({ const users = await server.services.projectMembership.getProjectMemberships({
actorId: req.permission.id, actorId: req.permission.id,
actor: req.permission.type, actor: req.permission.type,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
includeGroupMembers: req.query.includeGroupMembers, includeGroupMembers: req.query.includeGroupMembers,
projectId: req.params.workspaceId, projectId: req.params.workspaceId,
actorOrgId: req.permission.orgId actorOrgId: req.permission.orgId,
roles
}); });
return { users }; return { users };
@@ -13,7 +13,7 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
// special query // special query
const findAllProjectMembers = async ( const findAllProjectMembers = async (
projectId: string, projectId: string,
filter: { usernames?: string[]; username?: string; id?: string } = {} filter: { usernames?: string[]; username?: string; id?: string; roles?: string[] } = {}
) => { ) => {
try { try {
const docs = await db const docs = await db
@@ -126,6 +126,11 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
} }
] ]
}); });
if (filter.roles && filter.roles.length > 0) {
return members.filter((member) =>
member.roles.some((role) => filter.roles?.includes(role.role) || filter.roles?.includes(role.customRoleSlug))
);
}
return members; return members;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find all project members" }); throw new DatabaseError({ error, name: "Find all project members" });
@@ -79,7 +79,8 @@ export const projectMembershipServiceFactory = ({
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
includeGroupMembers, includeGroupMembers,
projectId projectId,
roles
}: TGetProjectMembershipDTO) => { }: TGetProjectMembershipDTO) => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
actor, actor,
@@ -91,7 +92,7 @@ export const projectMembershipServiceFactory = ({
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member);
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles });
// projectMembers[0].project // projectMembers[0].project
if (includeGroupMembers) { if (includeGroupMembers) {
@@ -1,6 +1,6 @@
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean } & TProjectPermission; export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean; roles?: string[] } & TProjectPermission;
export type TLeaveProjectDTO = Omit<TProjectPermission, "actorOrgId" | "actorAuthMethod">; export type TLeaveProjectDTO = Omit<TProjectPermission, "actorOrgId" | "actorAuthMethod">;
export enum ProjectUserMembershipTemporaryMode { export enum ProjectUserMembershipTemporaryMode {
Relative = "relative" Relative = "relative"
+8 -3
View File
@@ -430,9 +430,13 @@ export const useDeleteWsEnvironment = () => {
}); });
}; };
export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?: boolean) => { export const useGetWorkspaceUsers = (
workspaceId: string,
includeGroupMembers?: boolean,
roles?: string[]
) => {
return useQuery({ return useQuery({
queryKey: workspaceKeys.getWorkspaceUsers(workspaceId), queryKey: workspaceKeys.getWorkspaceUsers(workspaceId, includeGroupMembers, roles),
queryFn: async () => { queryFn: async () => {
const { const {
data: { users } data: { users }
@@ -440,7 +444,8 @@ export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?:
`/api/v1/workspace/${workspaceId}/users`, `/api/v1/workspace/${workspaceId}/users`,
{ {
params: { params: {
includeGroupMembers includeGroupMembers,
roles: roles && roles.length > 0 ? encodeURIComponent(roles.join(",")) : undefined
} }
} }
); );
@@ -15,7 +15,8 @@ export const workspaceKeys = {
type ? ["workspaces", { type }] : (["workspaces"] as const), type ? ["workspaces", { type }] : (["workspaces"] as const),
getWorkspaceAuditLogs: (workspaceId: string) => getWorkspaceAuditLogs: (workspaceId: string) =>
[{ workspaceId }, "workspace-audit-logs"] as const, [{ workspaceId }, "workspace-audit-logs"] as const,
getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const, getWorkspaceUsers: (workspaceId: string, includeGroupMembers?: boolean, roles?: string[]) =>
[{ workspaceId, includeGroupMembers, roles }, "workspace-users"] as const,
getWorkspaceUserDetails: (workspaceId: string, membershipId: string) => getWorkspaceUserDetails: (workspaceId: string, membershipId: string) =>
[{ workspaceId, membershipId }, "workspace-user-details"] as const, [{ workspaceId, membershipId }, "workspace-user-details"] as const,
getWorkspaceIdentityMemberships: (workspaceId: string) => getWorkspaceIdentityMemberships: (workspaceId: string) =>
@@ -1,9 +1,12 @@
import { useMemo } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
faArrowDown, faArrowDown,
faArrowUp, faArrowUp,
faCheckCircle,
faChevronRight,
faClock, faClock,
faEllipsisV, faEllipsisV,
faFilter,
faMagnifyingGlass, faMagnifyingGlass,
faSearch, faSearch,
faTrash, faTrash,
@@ -15,6 +18,14 @@ import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
EmptyState, EmptyState,
HoverCard, HoverCard,
HoverCardContent, HoverCardContent,
@@ -40,7 +51,7 @@ import {
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks"; import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetWorkspaceUsers } from "@app/hooks/api"; import { useGetProjectRoles, useGetWorkspaceUsers } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrderByDirection } from "@app/hooks/api/generic/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -65,13 +76,22 @@ enum MembersOrderBy {
Email = "email" Email = "email"
} }
type Filter = {
roles: string[];
};
export const MembersTable = ({ handlePopUpOpen }: Props) => { export const MembersTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { user } = useUser(); const { user } = useUser();
const navigate = useNavigate(); const navigate = useNavigate();
const [filter, setFilter] = useState<Filter>({
roles: []
});
const filterRoles = useMemo(() => filter.roles, [filter.roles]);
const userId = user?.id || ""; const userId = user?.id || "";
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles } = useGetProjectRoles(workspaceId);
const { const {
search, search,
@@ -86,9 +106,20 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
setOrderBy, setOrderBy,
setOrderDirection, setOrderDirection,
toggleOrderDirection toggleOrderDirection
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, { initPerPage: 20 }); } = usePagination<MembersOrderBy>(MembersOrderBy.Name, {
initPerPage: parseInt(localStorage.getItem("PROJECT_MEMBERS_TABLE_PER_PAGE") || "20", 10)
});
const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers(workspaceId); const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
localStorage.setItem("PROJECT_MEMBERS_TABLE_PER_PAGE", newPerPage.toString());
};
const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers(
workspaceId,
undefined,
filterRoles
);
const filteredUsers = useMemo( const filteredUsers = useMemo(
() => () =>
@@ -142,14 +173,81 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
setOrderDirection(OrderByDirection.ASC); setOrderDirection(OrderByDirection.ASC);
}; };
const isTableFiltered = Boolean(Object.values(filter.roles).length);
const handleRoleToggle = useCallback(
(roleSlug: string) =>
setFilter((state) => {
const roles = state.roles || [];
if (roles.includes(roleSlug)) {
return { ...state, roles: roles.filter((role) => role !== roleSlug) };
}
return { ...state, roles: [...roles, roleSlug] };
}),
[]
);
return ( return (
<div> <div>
<Input <div className="flex gap-2">
value={search} <DropdownMenu>
onChange={(e) => setSearch(e.target.value)} <DropdownMenuTrigger asChild>
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} <IconButton
placeholder="Search members..." ariaLabel="Filter Users"
/> variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-0">
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
<DropdownSubMenu>
<DropdownSubMenuTrigger
iconPos="right"
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
>
Roles
</DropdownSubMenuTrigger>
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Apply Roles to Filter Users
</DropdownMenuLabel>
{projectRoles?.map(({ id, slug, name }) => (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
handleRoleToggle(slug);
}}
key={id}
icon={filter.roles.includes(slug) && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: "#bec2c8" }}
/>
{name}
</div>
</DropdownMenuItem>
))}
</DropdownSubMenuContent>
</DropdownSubMenu>
</DropdownMenuContent>
</DropdownMenu>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
</div>
<TableContainer className="mt-4"> <TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
@@ -358,7 +456,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
page={page} page={page}
perPage={perPage} perPage={perPage}
onChangePage={setPage} onChangePage={setPage}
onChangePerPage={setPerPage} onChangePerPage={handlePerPageChange}
/> />
)} )}
{!isMembersLoading && !filteredUsers?.length && ( {!isMembersLoading && !filteredUsers?.length && (