improvement: move user groups to own tab and add pagination/search/sort to groups tables

This commit is contained in:
Scott Wilson
2024-11-27 20:35:15 -08:00
parent 588b3c77f9
commit 18a2547b24
9 changed files with 381 additions and 245 deletions
@@ -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,6 +36,7 @@ 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>
@@ -46,6 +47,9 @@ export const MembersPage = withPermission(
<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>
@@ -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,7 +219,9 @@ 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
.slice(offset, perPage * page)
.map(({ id, name, slug, role, customRole }) => {
return ( return (
<Tr className="h-10" key={`org-group-${id}`}> <Tr className="h-10" key={`org-group-${id}`}>
<Td>{name}</Td> <Td>{name}</Td>
@@ -263,7 +282,8 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
{(isAllowed) => ( {(isAllowed) => (
<DropdownMenuItem <DropdownMenuItem
className={twMerge( className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50" !isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -285,7 +305,8 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
{(isAllowed) => ( {(isAllowed) => (
<DropdownMenuItem <DropdownMenuItem
className={twMerge( className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50" !isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -335,10 +356,23 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
})} })}
</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>
); );
}; };
@@ -171,7 +171,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
setOrderBy, setOrderBy,
setOrderDirection, setOrderDirection,
toggleOrderDirection toggleOrderDirection
} = usePagination<OrgMembersOrderBy>(OrgMembersOrderBy.Name); } = usePagination<OrgMembersOrderBy>(OrgMembersOrderBy.Name, { initPerPage: 20 });
const filteredUsers = useMemo( const filteredUsers = useMemo(
() => () =>
+2 -1
View File
@@ -1,9 +1,10 @@
export enum TabSections { export enum TabSections {
Member = "members", Member = "members",
Groups = "groups",
Roles = "roles", Roles = "roles",
Identities = "identities" Identities = "identities"
} }
export const isTabSection = (value: string): value is TabSections => { export const isTabSection = (value: string): value is TabSections => {
return (Object.values(TabSections) as string[]).includes(value); return (Object.values(TabSections) as string[]).includes(value);
} };
@@ -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,15 +44,79 @@ 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>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th> <Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th> <Th>Role</Th>
<Th>Added on</Th> <Th>Added on</Th>
<Th className="w-5" /> <Th className="w-5" />
@@ -49,9 +125,11 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => {
<TBody> <TBody>
{isLoading && <TableSkeleton columns={4} innerKey="project-groups" />} {isLoading && <TableSkeleton columns={4} innerKey="project-groups" />}
{!isLoading && {!isLoading &&
data && filteredGroupMemberships &&
data.length > 0 && filteredGroupMemberships.length > 0 &&
data.map(({ group: { id, name }, roles, createdAt }) => { filteredGroupMemberships
.slice(offset, perPage * page)
.map(({ group: { id, name }, roles, createdAt }) => {
return ( return (
<Tr className="group h-10" key={`st-v3-${id}`}> <Tr className="group h-10" key={`st-v3-${id}`}>
<Td>{name}</Td> <Td>{name}</Td>
@@ -99,9 +177,26 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => {
})} })}
</TBody> </TBody>
</Table> </Table>
{!isLoading && data?.length === 0 && ( {Boolean(filteredGroupMemberships.length) && (
<EmptyState title="No groups have been added to this project" icon={faServer} /> <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> </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>
); );
}; };
@@ -86,7 +86,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
setOrderBy, setOrderBy,
setOrderDirection, setOrderDirection,
toggleOrderDirection toggleOrderDirection
} = usePagination<MembersOrderBy>(MembersOrderBy.Name); } = usePagination<MembersOrderBy>(MembersOrderBy.Name, { initPerPage: 20 });
const { data: members = [], isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId); const { data: members = [], isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);