Merge pull request #2804 from Infisical/users-projects-table-pagination

Improvement: Users, Groups and Projects Table Pagination
This commit is contained in:
Scott Wilson
2024-11-28 09:40:19 -08:00
committed by GitHub
15 changed files with 1096 additions and 538 deletions
@@ -62,7 +62,7 @@ export const IdentityProjectRow = ({
}); });
}} }}
> >
<Td>{project.name}</Td> <Td className="max-w-0 truncate">{project.name}</Td>
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${ <Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
roles.length > 1 ? ` (+${roles.length - 1})` : "" roles.length > 1 ? ` (+${roles.length - 1})` : ""
}`}</Td> }`}</Td>
@@ -1,7 +1,18 @@
import { faFolder } from "@fortawesome/free-solid-svg-icons"; import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
EmptyState, EmptyState,
IconButton,
Input,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -10,7 +21,9 @@ import {
THead, THead,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetIdentityProjectMemberships } from "@app/hooks/api"; import { useGetIdentityProjectMemberships } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityProjectRow } from "./IdentityProjectRow"; import { IdentityProjectRow } from "./IdentityProjectRow";
@@ -23,36 +36,115 @@ type Props = {
) => void; ) => void;
}; };
enum IdentityProjectsOrderBy {
Name = "name"
}
export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => { export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => {
const { data: projectMemberships, isLoading } = useGetIdentityProjectMemberships(identityId); const { data: projectMemberships = [], isLoading } = useGetIdentityProjectMemberships(identityId);
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection
} = usePagination(IdentityProjectsOrderBy.Name, { initPerPage: 10 });
const filteredProjectMemberships = useMemo(
() =>
projectMemberships
?.filter((membership) =>
membership.project.name.toLowerCase().includes(search.trim().toLowerCase())
)
.sort((a, b) => {
const [membershipOne, membershipTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
return membershipOne.project.name
.toLowerCase()
.localeCompare(membershipTwo.project.name.toLowerCase());
}),
[projectMemberships, orderDirection, search]
);
useResetPageHelper({
totalCount: filteredProjectMemberships.length,
offset,
setPage
});
return ( return (
<TableContainer> <div>
<Table> <Input
<THead> value={search}
<Tr> onChange={(e) => setSearch(e.target.value)}
<Th>Name</Th> leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
<Th>Role</Th> placeholder="Search projects..."
<Th>Added On</Th> />
<Th className="w-5" /> <TableContainer className="mt-4">
</Tr> <Table>
</THead> <THead>
<TBody> <Tr>
{isLoading && <TableSkeleton columns={4} innerKey="identity-project-memberships" />} <Th className="w-2/3">
{!isLoading && <div className="flex items-center">
projectMemberships?.map((membership) => { Name
return ( <IconButton
<IdentityProjectRow variant="plain"
key={`identity-project-membership-${membership.id}`} className="ml-2"
membership={membership} ariaLabel="sort"
handlePopUpOpen={handlePopUpOpen} onClick={toggleOrderDirection}
/> >
); <FontAwesomeIcon
})} icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
</TBody> />
</Table> </IconButton>
{!isLoading && !projectMemberships?.length && ( </div>
<EmptyState title="This identity has not been assigned to any projects" icon={faFolder} /> </Th>
)}
</TableContainer> <Th>Role</Th>
<Th>Added On</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="identity-project-memberships" />}
{!isLoading &&
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
return (
<IdentityProjectRow
key={`identity-project-membership-${membership.id}`}
membership={membership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredProjectMemberships.length) && (
<Pagination
count={filteredProjectMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredProjectMemberships?.length && (
<EmptyState
title={
projectMemberships.length
? "No projects match search..."
: "This identity has not been assigned to any projects"
}
icon={projectMemberships.length ? faSearch : faFolder}
/>
)}
</TableContainer>
</div>
); );
}; };
@@ -7,7 +7,7 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc"; import { withPermission } from "@app/hoc";
import { isTabSection, TabSections } from "@app/views/Org/Types"; import { isTabSection, TabSections } from "@app/views/Org/Types";
import { OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components"; import { OrgGroupsTab, OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
export const MembersPage = withPermission( export const MembersPage = withPermission(
() => { () => {
@@ -25,9 +25,9 @@ export const MembersPage = withPermission(
const updateSelectedTab = (tab: string) => { const updateSelectedTab = (tab: string) => {
router.push({ router.push({
pathname: router.pathname, pathname: router.pathname,
query: { ...router.query, selectedTab: tab }, query: { ...router.query, selectedTab: tab }
}); });
} };
return ( return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white"> <div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
@@ -36,16 +36,20 @@ export const MembersPage = withPermission(
<Tabs value={activeTab} onValueChange={updateSelectedTab}> <Tabs value={activeTab} onValueChange={updateSelectedTab}>
<TabList> <TabList>
<Tab value={TabSections.Member}>Users</Tab> <Tab value={TabSections.Member}>Users</Tab>
<Tab value={TabSections.Groups}>Groups</Tab>
<Tab value={TabSections.Identities}> <Tab value={TabSections.Identities}>
<div className="flex items-center"> <div className="flex items-center">
<p>Machine Identities</p> <p>Machine Identities</p>
</div> </div>
</Tab> </Tab>
<Tab value={TabSections.Roles}>Organization Roles</Tab> <Tab value={TabSections.Roles}>Organization Roles</Tab>
</TabList> </TabList>
<TabPanel value={TabSections.Member}> <TabPanel value={TabSections.Member}>
<OrgMembersTab /> <OrgMembersTab />
</TabPanel> </TabPanel>
<TabPanel value={TabSections.Groups}>
<OrgGroupsTab />
</TabPanel>
<TabPanel value={TabSections.Identities}> <TabPanel value={TabSections.Identities}>
<OrgIdentityTab /> <OrgIdentityTab />
</TabPanel> </TabPanel>
@@ -57,7 +57,7 @@ export const OrgGroupsSection = () => {
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-4 flex justify-between"> <div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">User Groups</p> <p className="text-xl font-semibold text-mineshaft-100">Groups</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Groups}> <OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -1,9 +1,10 @@
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { import {
faArrowDown, faArrowDown,
faArrowUp, faArrowUp,
faEllipsis, faEllipsis,
faMagnifyingGlass, faMagnifyingGlass,
faSearch,
faUsers faUsers
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -19,6 +20,7 @@ import {
EmptyState, EmptyState,
IconButton, IconButton,
Input, Input,
Pagination,
Select, Select,
SelectItem, SelectItem,
Table, Table,
@@ -31,7 +33,7 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useDebounce } from "@app/hooks"; import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetOrganizationGroups, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api"; import { useGetOrganizationGroups, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -59,14 +61,10 @@ enum GroupsOrderBy {
} }
export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
const [searchGroupsFilter, setSearchGroupsFilter] = useState("");
const [debouncedSearch] = useDebounce(searchGroupsFilter.trim());
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || ""; const orgId = currentOrg?.id || "";
const { isLoading, data: groups } = useGetOrganizationGroups(orgId); const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId);
const { mutateAsync: updateMutateAsync } = useUpdateGroup(); const { mutateAsync: updateMutateAsync } = useUpdateGroup();
const [orderBy, setOrderBy] = useState(GroupsOrderBy.Name);
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
const { data: roles } = useGetOrgRoles(orgId); const { data: roles } = useGetOrgRoles(orgId);
@@ -90,12 +88,27 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
} }
}; };
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<GroupsOrderBy>(GroupsOrderBy.Name, { initPerPage: 20 });
const filteredGroups = useMemo(() => { const filteredGroups = useMemo(() => {
const filtered = debouncedSearch const filtered = search
? groups?.filter( ? groups?.filter(
({ name, slug }) => ({ name, slug }) =>
name.toLowerCase().includes(debouncedSearch.toLowerCase()) || name.toLowerCase().includes(search.toLowerCase()) ||
slug.toLowerCase().includes(debouncedSearch.toLowerCase()) slug.toLowerCase().includes(search.toLowerCase())
) )
: groups; : groups;
@@ -113,13 +126,11 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
}); });
return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse(); return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse();
}, [debouncedSearch, groups, orderBy, orderDirection]); }, [search, groups, orderBy, orderDirection]);
const handleSort = (column: GroupsOrderBy) => { const handleSort = (column: GroupsOrderBy) => {
if (column === orderBy) { if (column === orderBy) {
setOrderDirection((prev) => toggleOrderDirection();
prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
);
return; return;
} }
@@ -127,11 +138,17 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
setOrderDirection(OrderByDirection.ASC); setOrderDirection(OrderByDirection.ASC);
}; };
useResetPageHelper({
totalCount: filteredGroups.length,
offset,
setPage
});
return ( return (
<div> <div>
<Input <Input
value={searchGroupsFilter} value={search}
onChange={(e) => setSearchGroupsFilter(e.target.value)} onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search groups..." placeholder="Search groups..."
/> />
@@ -202,143 +219,160 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
<TBody> <TBody>
{isLoading && <TableSkeleton columns={4} innerKey="org-groups" />} {isLoading && <TableSkeleton columns={4} innerKey="org-groups" />}
{!isLoading && {!isLoading &&
filteredGroups?.map(({ id, name, slug, role, customRole }) => { filteredGroups
return ( .slice(offset, perPage * page)
<Tr className="h-10" key={`org-group-${id}`}> .map(({ id, name, slug, role, customRole }) => {
<Td>{name}</Td> return (
<Td>{slug}</Td> <Tr className="h-10" key={`org-group-${id}`}>
<Td> <Td>{name}</Td>
<OrgPermissionCan <Td>{slug}</Td>
I={OrgPermissionActions.Edit} <Td>
a={OrgPermissionSubjects.Groups} <OrgPermissionCan
> I={OrgPermissionActions.Edit}
{(isAllowed) => { a={OrgPermissionSubjects.Groups}
return ( >
<Select {(isAllowed) => {
value={role === "custom" ? (customRole?.slug as string) : role} return (
isDisabled={!isAllowed} <Select
className="w-48 bg-mineshaft-600" value={role === "custom" ? (customRole?.slug as string) : role}
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800" isDisabled={!isAllowed}
onValueChange={(selectedRole) => className="w-48 bg-mineshaft-600"
handleChangeRole({ dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
id, onValueChange={(selectedRole) =>
role: selectedRole handleChangeRole({
}) id,
} role: selectedRole
})
}
>
{(roles || []).map(({ slug: roleSlug, name: roleName }) => (
<SelectItem value={roleSlug} key={`role-option-${roleSlug}`}>
{roleName}
</SelectItem>
))}
</Select>
);
}}
</OrgPermissionCan>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
createNotification({
text: "Copied group ID to clipboard",
type: "info"
});
navigator.clipboard.writeText(id);
}}
> >
{(roles || []).map(({ slug: roleSlug, name: roleName }) => ( Copy Group ID
<SelectItem value={roleSlug} key={`role-option-${roleSlug}`}> </DropdownMenuItem>
{roleName} <OrgPermissionCan
</SelectItem> I={OrgPermissionActions.Edit}
))} a={OrgPermissionSubjects.Identity}
</Select> >
); {(isAllowed) => (
}} <DropdownMenuItem
</OrgPermissionCan> className={twMerge(
</Td> !isAllowed &&
<Td> "pointer-events-none cursor-not-allowed opacity-50"
<DropdownMenu> )}
<DropdownMenuTrigger asChild className="rounded-lg"> onClick={(e) => {
<div className="hover:text-primary-400 data-[state=open]:text-primary-400"> e.stopPropagation();
<FontAwesomeIcon size="sm" icon={faEllipsis} /> handlePopUpOpen("groupMembers", {
</div> groupId: id,
</DropdownMenuTrigger> slug
<DropdownMenuContent align="start" className="p-1"> });
<DropdownMenuItem }}
onClick={(e) => { disabled={!isAllowed}
e.stopPropagation(); >
createNotification({ Manage Users
text: "Copied group ID to clipboard", </DropdownMenuItem>
type: "info" )}
}); </OrgPermissionCan>
navigator.clipboard.writeText(id); <OrgPermissionCan
}} I={OrgPermissionActions.Edit}
> a={OrgPermissionSubjects.Identity}
Copy Group ID >
</DropdownMenuItem> {(isAllowed) => (
<OrgPermissionCan <DropdownMenuItem
I={OrgPermissionActions.Edit} className={twMerge(
a={OrgPermissionSubjects.Identity} !isAllowed &&
> "pointer-events-none cursor-not-allowed opacity-50"
{(isAllowed) => ( )}
<DropdownMenuItem onClick={(e) => {
className={twMerge( e.stopPropagation();
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50" handlePopUpOpen("group", {
)} groupId: id,
onClick={(e) => { name,
e.stopPropagation(); slug,
handlePopUpOpen("groupMembers", { role,
groupId: id, customRole
slug });
}); }}
}} disabled={!isAllowed}
disabled={!isAllowed} >
> Edit Group
Manage Users </DropdownMenuItem>
</DropdownMenuItem> )}
)} </OrgPermissionCan>
</OrgPermissionCan> <OrgPermissionCan
<OrgPermissionCan I={OrgPermissionActions.Delete}
I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}
a={OrgPermissionSubjects.Identity} >
> {(isAllowed) => (
{(isAllowed) => ( <DropdownMenuItem
<DropdownMenuItem 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) => { )}
e.stopPropagation(); onClick={(e) => {
handlePopUpOpen("group", { e.stopPropagation();
groupId: id, handlePopUpOpen("deleteGroup", {
name, groupId: id,
slug, name
role, });
customRole }}
}); disabled={!isAllowed}
}} >
disabled={!isAllowed} Delete Group
> </DropdownMenuItem>
Edit Group )}
</DropdownMenuItem> </OrgPermissionCan>
)} </DropdownMenuContent>
</OrgPermissionCan> </DropdownMenu>
<OrgPermissionCan </Td>
I={OrgPermissionActions.Delete} </Tr>
a={OrgPermissionSubjects.Groups} );
> })}
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteGroup", {
groupId: id,
name
});
}}
disabled={!isAllowed}
>
Delete Group
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody> </TBody>
</Table> </Table>
{filteredGroups?.length === 0 && ( {Boolean(filteredGroups.length) && (
<Pagination
count={filteredGroups.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredGroups?.length && (
<EmptyState <EmptyState
title={groups?.length === 0 ? "No groups found" : "No groups match search"} title={
icon={faUsers} groups.length
? "No organization groups match search..."
: "No organization groups found"
}
icon={groups.length ? faSearch : faUsers}
/> />
)} )}
</TableContainer> </TableContainer>
@@ -1,6 +1,5 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { OrgGroupsSection } from "../OrgGroupsTab/components";
import { OrgMembersSection } from "./components"; import { OrgMembersSection } from "./components";
export const OrgMembersTab = () => { export const OrgMembersTab = () => {
@@ -13,7 +12,6 @@ export const OrgMembersTab = () => {
exit={{ opacity: 0, translateX: 30 }} exit={{ opacity: 0, translateX: 30 }}
> >
<OrgMembersSection /> <OrgMembersSection />
<OrgGroupsSection />
</motion.div> </motion.div>
); );
}; };
@@ -1,6 +1,13 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { faEllipsis, faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons"; import {
faArrowDown,
faArrowUp,
faEllipsis,
faMagnifyingGlass,
faSearch,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -14,7 +21,9 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
IconButton,
Input, Input,
Pagination,
Select, Select,
SelectItem, SelectItem,
Table, Table,
@@ -33,6 +42,7 @@ import {
useSubscription, useSubscription,
useUser useUser
} from "@app/context"; } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { import {
useAddUsersToOrg, useAddUsersToOrg,
useFetchServerStatus, useFetchServerStatus,
@@ -40,6 +50,7 @@ import {
useGetOrgUsers, useGetOrgUsers,
useUpdateOrgMembership useUpdateOrgMembership
} from "@app/hooks/api"; } 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 = {
@@ -54,6 +65,11 @@ type Props = {
setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void; setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void;
}; };
enum OrgMembersOrderBy {
Name = "firstName",
Email = "email"
}
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Props) => { export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Props) => {
const router = useRouter(); const router = useRouter();
const { subscription } = useSubscription(); const { subscription } = useSubscription();
@@ -64,10 +80,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
const { data: roles, isLoading: isRolesLoading } = useGetOrgRoles(orgId); const { data: roles, isLoading: isRolesLoading } = useGetOrgRoles(orgId);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const { data: serverDetails } = useFetchServerStatus(); const { data: serverDetails } = useFetchServerStatus();
const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId); const { data: members = [], isLoading: isMembersLoading } = useGetOrgUsers(orgId);
const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg(); const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
@@ -144,24 +158,78 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
[roles] [roles]
); );
const filterdUser = useMemo( const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<OrgMembersOrderBy>(OrgMembersOrderBy.Name, { initPerPage: 20 });
const filteredUsers = useMemo(
() => () =>
members?.filter( members
({ user: u, inviteEmail }) => ?.filter(
u?.firstName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || ({ user: u, inviteEmail }) =>
u?.lastName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
u?.username?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
u?.email?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || u?.username?.toLowerCase().includes(search.toLowerCase()) ||
inviteEmail?.includes(searchMemberFilter.toLowerCase()) u?.email?.toLowerCase().includes(search.toLowerCase()) ||
), inviteEmail?.toLowerCase().includes(search.toLowerCase())
[members, searchMemberFilter] )
.sort((a, b) => {
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
let valueOne: string;
let valueTwo: string;
switch (orderBy) {
case OrgMembersOrderBy.Email:
valueOne = memberOne.user.email || memberOne.inviteEmail;
valueTwo = memberTwo.user.email || memberTwo.inviteEmail;
break;
case OrgMembersOrderBy.Name:
default:
valueOne = memberOne.user.firstName;
valueTwo = memberTwo.user.firstName;
}
if (!valueOne) return 1;
if (!valueTwo) return -1;
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
}),
[members, search, orderDirection, orderBy]
); );
const handleSort = (column: OrgMembersOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
useResetPageHelper({
totalCount: filteredUsers.length,
offset,
setPage
});
return ( return (
<div> <div>
<Input <Input
value={searchMemberFilter} value={search}
onChange={(e) => setSearchMemberFilter(e.target.value)} onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..." placeholder="Search members..."
/> />
@@ -169,8 +237,46 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th> <Th className="w-1/3">
<Th>Username</Th> <div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgMembersOrderBy.Name ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(OrgMembersOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === OrgMembersOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Email
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgMembersOrderBy.Email ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(OrgMembersOrderBy.Email)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === OrgMembersOrderBy.Email
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th> <Th>Role</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
@@ -178,212 +284,231 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
<TBody> <TBody>
{isLoading && <TableSkeleton columns={5} innerKey="org-members" />} {isLoading && <TableSkeleton columns={5} innerKey="org-members" />}
{!isLoading && {!isLoading &&
filterdUser?.map( filteredUsers
({ user: u, inviteEmail, role, roleId, id: orgMembershipId, status, isActive }) => { .slice(offset, perPage * page)
const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-"; .map(
const email = u?.email || inviteEmail; ({
const username = u?.username ?? inviteEmail ?? "-"; user: u,
return ( inviteEmail,
<Tr role,
key={`org-membership-${orgMembershipId}`} roleId,
className="h-10 w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" id: orgMembershipId,
onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)} status,
> isActive
<Td className={isActive ? "" : "text-mineshaft-400"}> }) => {
{name} const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-";
{u.superAdmin && ( const email = u?.email || inviteEmail;
<Badge variant="primary" className="ml-2"> const username = u?.username ?? inviteEmail ?? "-";
Server Admin return (
</Badge> <Tr
)} key={`org-membership-${orgMembershipId}`}
</Td> className="h-10 w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
<Td className={isActive ? "" : "text-mineshaft-400"}>{username}</Td> onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)}
<Td> >
<OrgPermissionCan <Td className={isActive ? "" : "text-mineshaft-400"}>
I={OrgPermissionActions.Edit} {name}
a={OrgPermissionSubjects.Member} {u.superAdmin && (
> <Badge variant="primary" className="ml-2">
{(isAllowed) => ( Server Admin
<> </Badge>
{!isActive && ( )}
<Button </Td>
isDisabled <Td className={isActive ? "" : "text-mineshaft-400"}>{username}</Td>
className="w-40" <Td>
colorSchema="primary" <OrgPermissionCan
variant="outline_bg" I={OrgPermissionActions.Edit}
onClick={() => {}} a={OrgPermissionSubjects.Member}
> >
Suspended {(isAllowed) => (
</Button> <>
)} {!isActive && (
{isActive && status === "accepted" && (
<Select
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
isDisabled={userId === u?.id || !isAllowed}
className="w-48 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(orgMembershipId, selectedRole)
}
>
{(roles || [])
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)}
{isActive &&
(status === "invited" || status === "verified") &&
email &&
serverDetails?.emailConfigured && (
<Button <Button
isDisabled={!isAllowed} isDisabled
className="w-48" className="w-40"
colorSchema="primary" colorSchema="primary"
variant="outline_bg" variant="outline_bg"
onClick={() => onResendInvite(email)} onClick={() => {}}
> >
Resend invite Suspended
</Button> </Button>
)} )}
</> {isActive && status === "accepted" && (
)} <Select
</OrgPermissionCan> value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
</Td> isDisabled={userId === u?.id || !isAllowed}
<Td> className="w-48 bg-mineshaft-600"
{userId !== u?.id && ( dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
<DropdownMenu> onValueChange={(selectedRole) =>
<DropdownMenuTrigger asChild className="rounded-lg"> onRoleChange(orgMembershipId, selectedRole)
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${orgId}/memberships/${orgMembershipId}`);
}}
disabled={!isAllowed}
>
Edit User
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={
isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
} }
onClick={async (e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
if (!isActive) {
// activate user
await updateOrgMembership({
organizationId: orgId,
membershipId: orgMembershipId,
isActive: true
});
return;
}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
> >
{`${isActive ? "Deactivate" : "Activate"} User`} {(roles || [])
</DropdownMenuItem> .filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)} )}
</OrgPermissionCan> {isActive &&
<OrgPermissionCan (status === "invited" || status === "verified") &&
I={OrgPermissionActions.Delete} email &&
a={OrgPermissionSubjects.Member} serverDetails?.emailConfigured && (
> <Button
{(isAllowed) => ( isDisabled={!isAllowed}
<DropdownMenuItem className="w-48"
className={twMerge( colorSchema="primary"
isAllowed variant="outline_bg"
? "hover:!bg-red-500 hover:!text-white" onClick={() => onResendInvite(email)}
: "pointer-events-none cursor-not-allowed opacity-50" >
)} Resend invite
onClick={(e) => { </Button>
e.stopPropagation(); )}
</>
if (currentOrg?.scimEnabled) { )}
createNotification({ </OrgPermissionCan>
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", </Td>
type: "error" <Td>
}); {userId !== u?.id && (
return; <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${orgId}/memberships/${orgMembershipId}`);
}}
disabled={!isAllowed}
>
Edit User
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={
isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
} }
onClick={async (e) => {
e.stopPropagation();
handlePopUpOpen("removeMember", { if (currentOrg?.scimEnabled) {
orgMembershipId, createNotification({
username text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
}); type: "error"
}} });
disabled={!isAllowed} return;
> }
Remove User
</DropdownMenuItem> if (!isActive) {
)} // activate user
</OrgPermissionCan> await updateOrgMembership({
</DropdownMenuContent> organizationId: orgId,
</DropdownMenu> membershipId: orgMembershipId,
)} isActive: true
</Td> });
</Tr>
); return;
} }
)}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
{`${isActive ? "Deactivate" : "Activate"} User`}
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
handlePopUpOpen("removeMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
Remove User
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</Td>
</Tr>
);
}
)}
</TBody> </TBody>
</Table> </Table>
{!isLoading && filterdUser?.length === 0 && ( {Boolean(filteredUsers.length) && (
<Pagination
count={filteredUsers.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isMembersLoading && !filteredUsers?.length && (
<EmptyState <EmptyState
title={ title={
members?.length === 0 members.length
? "No organization members found" ? "No organization members match search..."
: "No organization members match search" : "No organization members found"
} }
icon={faUsers} icon={members.length ? faSearch : faUsers}
/> />
)} )}
</TableContainer> </TableContainer>
+10 -9
View File
@@ -1,9 +1,10 @@
export enum TabSections { export enum TabSections {
Member = "members", Member = "members",
Roles = "roles", Groups = "groups",
Identities = "identities" Roles = "roles",
} Identities = "identities"
}
export const isTabSection = (value: string): value is TabSections => {
return (Object.values(TabSections) as string[]).includes(value); export const isTabSection = (value: string): value is TabSections => {
} return (Object.values(TabSections) as string[]).includes(value);
};
@@ -58,7 +58,7 @@ export const UserProjectRow = ({
}); });
}} }}
> >
<Td>{project.name}</Td> <Td className="max-w-0 truncate">{project.name}</Td>
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${ <Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
roles.length > 1 ? ` (+${roles.length - 1})` : "" roles.length > 1 ? ` (+${roles.length - 1})` : ""
}`}</Td> }`}</Td>
@@ -1,7 +1,18 @@
import { faFolder } from "@fortawesome/free-solid-svg-icons"; import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
EmptyState, EmptyState,
IconButton,
Input,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -11,7 +22,9 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api"; import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { UserProjectRow } from "./UserProjectRow"; import { UserProjectRow } from "./UserProjectRow";
@@ -21,42 +34,118 @@ type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void; handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void;
}; };
enum UserProjectsOrderBy {
Name = "Name"
}
export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => { export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || ""; const orgId = currentOrg?.id || "";
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection
} = usePagination(UserProjectsOrderBy.Name, { initPerPage: 10 });
const { data: projectMemberships, isLoading } = useGetOrgMembershipProjectMemberships( const { data: projectMemberships = [], isLoading } = useGetOrgMembershipProjectMemberships(
orgId, orgId,
membershipId membershipId
); );
const filteredProjectMemberships = useMemo(
() =>
projectMemberships
?.filter((membership) =>
membership.project.name.toLowerCase().includes(search.trim().toLowerCase())
)
.sort((a, b) => {
const [membershipOne, membershipTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
return membershipOne.project.name
.toLowerCase()
.localeCompare(membershipTwo.project.name.toLowerCase());
}),
[projectMemberships, orderDirection, search]
);
useResetPageHelper({
totalCount: filteredProjectMemberships.length,
offset,
setPage
});
return ( return (
<TableContainer> <div>
<Table> <Input
<THead> value={search}
<Tr> onChange={(e) => setSearch(e.target.value)}
<Th>Name</Th> leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
<Th>Role</Th> placeholder="Search projects..."
<Th className="w-5" /> />
</Tr> <TableContainer className="mt-4">
</THead> <Table>
<TBody> <THead>
{isLoading && <TableSkeleton columns={3} innerKey="user-project-memberships" />} <Tr>
{!isLoading && <Th className="w-2/3">
projectMemberships?.map((membership) => { <div className="flex items-center">
return ( Name
<UserProjectRow <IconButton
key={`user-project-membership-${membership.id}`} variant="plain"
membership={membership} className="ml-2"
handlePopUpOpen={handlePopUpOpen} ariaLabel="sort"
/> onClick={toggleOrderDirection}
); >
})} <FontAwesomeIcon
</TBody> icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
</Table> />
{!isLoading && !projectMemberships?.length && ( </IconButton>
<EmptyState title="This user has not been assigned to any projects" icon={faFolder} /> </div>
)} </Th>
</TableContainer> <Th>Role</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
{!isLoading &&
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
return (
<UserProjectRow
key={`user-project-membership-${membership.id}`}
membership={membership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredProjectMemberships.length) && (
<Pagination
count={filteredProjectMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredProjectMemberships?.length && (
<EmptyState
title={
projectMemberships.length
? "No projects match search..."
: "This user has not been assigned to any projects"
}
icon={projectMemberships.length ? faSearch : faFolder}
/>
)}
</TableContainer>
</div>
); );
}; };
@@ -6,9 +6,14 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc"; import { withProjectPermission } from "@app/hoc";
import { isTabSection,TabSections } from "../Types"; import { isTabSection, TabSections } from "../Types";
import { IdentityTab, MembersTab,ProjectRoleListTab, ServiceTokenTab } from "./components"; import {
GroupsTab,
IdentityTab,
MembersTab,
ProjectRoleListTab,
ServiceTokenTab
} from "./components";
export const MembersPage = withProjectPermission( export const MembersPage = withProjectPermission(
() => { () => {
@@ -26,9 +31,9 @@ export const MembersPage = withProjectPermission(
const updateSelectedTab = (tab: string) => { const updateSelectedTab = (tab: string) => {
router.push({ router.push({
pathname: router.pathname, pathname: router.pathname,
query: { ...router.query, selectedTab: tab }, query: { ...router.query, selectedTab: tab }
}); });
} };
return ( return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white"> <div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
@@ -37,6 +42,7 @@ export const MembersPage = withProjectPermission(
<Tabs value={activeTab} onValueChange={updateSelectedTab}> <Tabs value={activeTab} onValueChange={updateSelectedTab}>
<TabList> <TabList>
<Tab value={TabSections.Member}>Users</Tab> <Tab value={TabSections.Member}>Users</Tab>
<Tab value={TabSections.Groups}>Groups</Tab>
<Tab value={TabSections.Identities}> <Tab value={TabSections.Identities}>
<div className="flex items-center"> <div className="flex items-center">
<p>Machine Identities</p> <p>Machine Identities</p>
@@ -48,6 +54,9 @@ export const MembersPage = withProjectPermission(
<TabPanel value={TabSections.Member}> <TabPanel value={TabSections.Member}>
<MembersTab /> <MembersTab />
</TabPanel> </TabPanel>
<TabPanel value={TabSections.Groups}>
<GroupsTab />
</TabPanel>
<TabPanel value={TabSections.Identities}> <TabPanel value={TabSections.Identities}>
<IdentityTab /> <IdentityTab />
</TabPanel> </TabPanel>
@@ -1,4 +1,12 @@
import { faServer, faTrash } from "@fortawesome/free-solid-svg-icons"; import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faMagnifyingGlass,
faSearch,
faTrash,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -6,6 +14,8 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
EmptyState, EmptyState,
IconButton, IconButton,
Input,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -17,7 +27,9 @@ import {
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 { usePagination, useResetPageHelper } from "@app/hooks";
import { useListWorkspaceGroups } from "@app/hooks/api"; import { useListWorkspaceGroups } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { GroupRoles } from "./GroupRoles"; import { GroupRoles } from "./GroupRoles";
@@ -32,76 +44,159 @@ type Props = {
) => void; ) => void;
}; };
enum GroupsOrderBy {
Name = "name"
}
export const GroupTable = ({ handlePopUpOpen }: Props) => { export const GroupTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useListWorkspaceGroups(currentWorkspace?.id || "");
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
toggleOrderDirection
} = usePagination(GroupsOrderBy.Name, { initPerPage: 20 });
const { data: groupMemberships = [], isLoading } = useListWorkspaceGroups(
currentWorkspace?.id || ""
);
const filteredGroupMemberships = useMemo(() => {
const filtered = search
? groupMemberships?.filter(
({ group: { name, slug } }) =>
name.toLowerCase().includes(search.toLowerCase()) ||
slug.toLowerCase().includes(search.toLowerCase())
)
: groupMemberships;
const ordered = filtered?.sort((a, b) =>
a.group.name.toLowerCase().localeCompare(b.group.name.toLowerCase())
);
return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse();
}, [search, groupMemberships, orderBy, orderDirection]);
useResetPageHelper({
totalCount: filteredGroupMemberships.length,
offset,
setPage
});
return ( return (
<TableContainer> <div>
<Table> <Input
<THead> value={search}
<Tr> onChange={(e) => setSearch(e.target.value)}
<Th>Name</Th> leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
<Th>Role</Th> placeholder="Search members..."
<Th>Added on</Th> />
<Th className="w-5" /> <TableContainer className="mt-4">
</Tr> <Table>
</THead> <THead>
<TBody> <Tr>
{isLoading && <TableSkeleton columns={4} innerKey="project-groups" />} <Th className="w-1/3">
{!isLoading && <div className="flex items-center">
data && Name
data.length > 0 && <IconButton
data.map(({ group: { id, name }, roles, createdAt }) => { variant="plain"
return ( className="ml-2"
<Tr className="group h-10" key={`st-v3-${id}`}> ariaLabel="sort"
<Td>{name}</Td> onClick={toggleOrderDirection}
<Td> >
<ProjectPermissionCan <FontAwesomeIcon
I={ProjectPermissionActions.Edit} icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
a={ProjectPermissionSub.Groups} />
> </IconButton>
{(isAllowed) => ( </div>
<GroupRoles roles={roles} disableEdit={!isAllowed} groupId={id} /> </Th>
)} <Th>Role</Th>
</ProjectPermissionCan> <Th>Added on</Th>
</Td> <Th className="w-5" />
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td> </Tr>
<Td className="flex justify-end"> </THead>
<ProjectPermissionCan <TBody>
I={ProjectPermissionActions.Delete} {isLoading && <TableSkeleton columns={4} innerKey="project-groups" />}
a={ProjectPermissionSub.Groups} {!isLoading &&
> filteredGroupMemberships &&
{(isAllowed) => ( filteredGroupMemberships.length > 0 &&
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100"> filteredGroupMemberships
<Tooltip content="Remove"> .slice(offset, perPage * page)
<IconButton .map(({ group: { id, name }, roles, createdAt }) => {
onClick={() => { return (
handlePopUpOpen("deleteGroup", { <Tr className="group h-10" key={`st-v3-${id}`}>
id, <Td>{name}</Td>
name <Td>
}); <ProjectPermissionCan
}} I={ProjectPermissionActions.Edit}
colorSchema="danger" a={ProjectPermissionSub.Groups}
variant="plain" >
ariaLabel="update" {(isAllowed) => (
className="ml-4" <GroupRoles roles={roles} disableEdit={!isAllowed} groupId={id} />
isDisabled={!isAllowed} )}
> </ProjectPermissionCan>
<FontAwesomeIcon icon={faTrash} /> </Td>
</IconButton> <Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
</Tooltip> <Td className="flex justify-end">
</div> <ProjectPermissionCan
)} I={ProjectPermissionActions.Delete}
</ProjectPermissionCan> a={ProjectPermissionSub.Groups}
</Td> >
</Tr> {(isAllowed) => (
); <div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
})} <Tooltip content="Remove">
</TBody> <IconButton
</Table> onClick={() => {
{!isLoading && data?.length === 0 && ( handlePopUpOpen("deleteGroup", {
<EmptyState title="No groups have been added to this project" icon={faServer} /> id,
)} name
</TableContainer> });
}}
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
<Pagination
count={filteredGroupMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredGroupMemberships?.length && (
<EmptyState
title={
groupMemberships.length
? "No project groups match search..."
: "No project groups found"
}
icon={groupMemberships.length ? faSearch : faUsers}
/>
)}
</TableContainer>
</div>
); );
}; };
@@ -1,12 +1,8 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useWorkspace } from "@app/context";
import { GroupsSection } from "../GroupsTab/components";
import { MembersSection } from "./components"; import { MembersSection } from "./components";
export const MembersTab = () => { export const MembersTab = () => {
const { currentWorkspace } = useWorkspace();
return ( return (
<motion.div <motion.div
key="panel-project-members" key="panel-project-members"
@@ -16,7 +12,6 @@ export const MembersTab = () => {
exit={{ opacity: 0, translateX: 30 }} exit={{ opacity: 0, translateX: 30 }}
> >
<MembersSection /> <MembersSection />
{currentWorkspace?.version && currentWorkspace.version > 1 && <GroupsSection />}
</motion.div> </motion.div>
); );
}; };
@@ -121,9 +121,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
}); });
return (orgUsers || []) return (orgUsers || [])
.filter(({ user: u }) => !wsUserUsernames.has(u.username)) .filter(({ user: u }) => !wsUserUsernames.has(u.username))
.map((member) => ({ .map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
value: member.id, value: id,
label: `${member.user.firstName} ${member.user.lastName}` label:
firstName && lastName
? `${firstName} ${lastName}`
: firstName || lastName || email || inviteEmail
})); }));
}, [orgUsers, members]); }, [orgUsers, members]);
@@ -1,9 +1,12 @@
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { import {
faArrowDown,
faArrowUp,
faClock, faClock,
faEllipsisV, faEllipsisV,
faMagnifyingGlass, faMagnifyingGlass,
faSearch,
faTrash, faTrash,
faUsers faUsers
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
@@ -18,6 +21,7 @@ import {
HoverCardTrigger, HoverCardTrigger,
IconButton, IconButton,
Input, Input,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -35,7 +39,9 @@ import {
useUser, useUser,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetWorkspaceUsers } from "@app/hooks/api"; import { useGetWorkspaceUsers } from "@app/hooks/api";
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";
@@ -54,9 +60,12 @@ type Props = {
) => void; ) => void;
}; };
export const MembersTable = ({ handlePopUpOpen }: Props) => { enum MembersOrderBy {
const [searchMemberFilter, setSearchMemberFilter] = useState(""); Name = "firstName",
Email = "email"
}
export const MembersTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { user } = useUser(); const { user } = useUser();
const router = useRouter(); const router = useRouter();
@@ -64,26 +73,80 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
const userId = user?.id || ""; const userId = user?.id || "";
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId); const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, { initPerPage: 20 });
const filterdUsers = useMemo( const { data: members = [], isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
const filteredUsers = useMemo(
() => () =>
members?.filter( members
({ user: u, inviteEmail }) => ?.filter(
u?.firstName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || ({ user: u, inviteEmail }) =>
u?.lastName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
u?.username?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
u?.email?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || u?.username?.toLowerCase().includes(search.toLowerCase()) ||
inviteEmail?.includes(searchMemberFilter.toLowerCase()) u?.email?.toLowerCase().includes(search.toLowerCase()) ||
), inviteEmail?.toLowerCase().includes(search.toLowerCase())
[members, searchMemberFilter] )
.sort((a, b) => {
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
let valueOne: string;
let valueTwo: string;
switch (orderBy) {
case MembersOrderBy.Email:
valueOne = memberOne.user.email || memberOne.inviteEmail;
valueTwo = memberTwo.user.email || memberTwo.inviteEmail;
break;
case MembersOrderBy.Name:
default:
valueOne = memberOne.user.firstName;
valueTwo = memberTwo.user.firstName;
}
if (!valueOne) return 1;
if (!valueTwo) return -1;
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
}),
[members, search, orderDirection, orderBy]
); );
useResetPageHelper({
totalCount: filteredUsers.length,
offset,
setPage
});
const handleSort = (column: MembersOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
return ( return (
<div> <div>
<Input <Input
value={searchMemberFilter} value={search}
onChange={(e) => setSearchMemberFilter(e.target.value)} onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..." placeholder="Search members..."
/> />
@@ -91,8 +154,44 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th> <Th className="w-1/3">
<Th>Username</Th> <div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${orderBy === MembersOrderBy.Name ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(MembersOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Email
<IconButton
variant="plain"
className={`ml-2 ${orderBy === MembersOrderBy.Email ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(MembersOrderBy.Email)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Email
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th> <Th>Role</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
@@ -100,7 +199,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
<TBody> <TBody>
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />} {isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
{!isMembersLoading && {!isMembersLoading &&
filterdUsers?.map((projectMember) => { filteredUsers.slice(offset, perPage * page).map((projectMember) => {
const { user: u, inviteEmail, id: membershipId, roles } = projectMember; const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-"; const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-";
const email = u?.email || inviteEmail; const email = u?.email || inviteEmail;
@@ -239,8 +338,22 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
})} })}
</TBody> </TBody>
</Table> </Table>
{!isMembersLoading && filterdUsers?.length === 0 && ( {Boolean(filteredUsers.length) && (
<EmptyState title="No project members found" icon={faUsers} /> <Pagination
count={filteredUsers.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isMembersLoading && !filteredUsers?.length && (
<EmptyState
title={
members.length ? "No project members match search..." : "No project members found"
}
icon={members.length ? faSearch : faUsers}
/>
)} )}
</TableContainer> </TableContainer>
</div> </div>