Sub group navigation in groups section (#274)

* list sub group of a group using the id

* introduced group bread crumb component to render

this builds up the groups as you click to subgroups

* added deeplinking

* fixed route config types

* fixed new realm prefix

* added null checks for if group not found

* changed labes to groups
This commit is contained in:
Erik Jan de Wit 2021-01-12 15:00:44 +01:00 committed by GitHub
parent bbfccea940
commit ce0ce6d59e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 345 additions and 77 deletions

View file

@ -18,6 +18,7 @@ import { AccessContextProvider, useAccess } from "./context/access/Access";
import { routes, RouteDef } from "./route-config"; import { routes, RouteDef } from "./route-config";
import { PageBreadCrumbs } from "./components/bread-crumb/PageBreadCrumbs"; import { PageBreadCrumbs } from "./components/bread-crumb/PageBreadCrumbs";
import { ForbiddenSection } from "./ForbiddenSection"; import { ForbiddenSection } from "./ForbiddenSection";
import { SubGroups } from "./groups/GroupsSection";
import { useRealm } from "./context/realm-context/RealmContext"; import { useRealm } from "./context/realm-context/RealmContext";
import { useAdminClient, asyncStateFetch } from "./context/auth/AdminClient"; import { useAdminClient, asyncStateFetch } from "./context/auth/AdminClient";
@ -28,7 +29,9 @@ const AppContexts = ({ children }: { children: ReactNode }) => (
<AccessContextProvider> <AccessContextProvider>
<Help> <Help>
<AlertProvider> <AlertProvider>
<ServerInfoProvider>{children}</ServerInfoProvider> <ServerInfoProvider>
<SubGroups>{children}</SubGroups>
</ServerInfoProvider>
</AlertProvider> </AlertProvider>
</Help> </Help>
</AccessContextProvider> </AccessContextProvider>
@ -79,7 +82,11 @@ export const App = () => {
<Switch> <Switch>
{routes(() => {}).map((route, i) => ( {routes(() => {}).map((route, i) => (
<Route <Route
exact exact={
route.matchOptions?.exact === undefined
? true
: route.matchOptions.exact
}
key={i} key={i}
path={route.path} path={route.path}
component={() => ( component={() => (

View file

@ -0,0 +1,51 @@
import React, { useEffect } from "react";
import { Link, useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core";
import { useSubGroups } from "../../groups/GroupsSection";
import { useRealm } from "../../context/realm-context/RealmContext";
export const GroupBreadCrumbs = () => {
const { t } = useTranslation();
const { clear, remove, subGroups } = useSubGroups();
const { realm } = useRealm();
const history = useHistory();
useEffect(() => {
return history.listen(({ pathname }) => {
if (pathname.indexOf("/groups") === -1 || pathname.endsWith("/groups")) {
clear();
}
});
}, [history]);
return (
<>
{subGroups.length !== 0 && (
<Breadcrumb>
<BreadcrumbItem key="home">
<Link to={`/${realm}/groups`}>{t("groups")}</Link>
</BreadcrumbItem>
{subGroups.map((group, i) => (
<BreadcrumbItem key={i} isActive={subGroups.length - 1 === i}>
{subGroups.length - 1 !== i && (
<Link
to={location.pathname.substr(
0,
location.pathname.indexOf(group.id!) + group.id!.length
)}
onClick={() => remove(group)}
>
{group.name}
</Link>
)}
{subGroups.length - 1 === i && <>{group.name}</>}
</BreadcrumbItem>
))}
</Breadcrumb>
)}
</>
);
};

View file

@ -6,6 +6,7 @@ import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core";
import { useRealm } from "../../context/realm-context/RealmContext"; import { useRealm } from "../../context/realm-context/RealmContext";
import { routes } from "../../route-config"; import { routes } from "../../route-config";
import { GroupBreadCrumbs } from "./GroupBreadCrumbs";
export const PageBreadCrumbs = () => { export const PageBreadCrumbs = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -25,6 +26,7 @@ export const PageBreadCrumbs = () => {
))} ))}
</Breadcrumb> </Breadcrumb>
)} )}
<GroupBreadCrumbs />
</> </>
); );
}; };

View file

@ -0,0 +1,32 @@
import React, { useEffect } from "react";
import { mount } from "enzyme";
import { MemoryRouter } from "react-router-dom";
import { GroupBreadCrumbs } from "../GroupBreadCrumbs";
import { SubGroups, useSubGroups } from "../../../groups/GroupsSection";
const GroupCrumbs = () => {
const { setSubGroups } = useSubGroups();
useEffect(() => {
setSubGroups([
{ id: "1", name: "first group" },
{ id: "123", name: "active group" },
]);
}, []);
return <GroupBreadCrumbs />;
};
describe("Group BreadCrumbs tests", () => {
it("couple of crumbs", () => {
const crumbs = mount(
<MemoryRouter initialEntries={["/groups"]}>
<SubGroups>
<GroupCrumbs />
</SubGroups>
</MemoryRouter>
);
expect(crumbs.find(GroupCrumbs)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,140 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Group BreadCrumbs tests couple of crumbs 1`] = `
<GroupCrumbs>
<GroupBreadCrumbs>
<Breadcrumb>
<nav
aria-label="Breadcrumb"
className="pf-c-breadcrumb"
data-ouia-component-id="OUIA-Generated-Breadcrumb-1"
data-ouia-component-type="PF4/Breadcrumb"
data-ouia-safe={true}
>
<ol
className="pf-c-breadcrumb__list"
>
<BreadcrumbItem
key=".$home"
showDivider={false}
>
<li
className="pf-c-breadcrumb__item"
>
<Link
to="//groups"
>
<LinkAnchor
href="//groups"
navigate={[Function]}
>
<a
href="//groups"
onClick={[Function]}
>
groups
</a>
</LinkAnchor>
</Link>
</li>
</BreadcrumbItem>
<BreadcrumbItem
isActive={false}
key=".1:$0"
showDivider={true}
>
<li
className="pf-c-breadcrumb__item"
>
<span
className="pf-c-breadcrumb__item-divider"
>
<AngleRightIcon
color="currentColor"
noVerticalAlign={false}
size="sm"
>
<svg
aria-hidden={true}
aria-labelledby={null}
fill="currentColor"
height="1em"
role="img"
style={
Object {
"verticalAlign": "-0.125em",
}
}
viewBox="0 0 256 512"
width="1em"
>
<path
d="M224.3 273l-136 136c-9.4 9.4-24.6 9.4-33.9 0l-22.6-22.6c-9.4-9.4-9.4-24.6 0-33.9l96.4-96.4-96.4-96.4c-9.4-9.4-9.4-24.6 0-33.9L54.3 103c9.4-9.4 24.6-9.4 33.9 0l136 136c9.5 9.4 9.5 24.6.1 34z"
/>
</svg>
</AngleRightIcon>
</span>
<Link
onClick={[Function]}
to=""
>
<LinkAnchor
href="/"
navigate={[Function]}
onClick={[Function]}
>
<a
href="/"
onClick={[Function]}
>
first group
</a>
</LinkAnchor>
</Link>
</li>
</BreadcrumbItem>
<BreadcrumbItem
isActive={true}
key=".1:$1"
showDivider={true}
>
<li
className="pf-c-breadcrumb__item"
>
<span
className="pf-c-breadcrumb__item-divider"
>
<AngleRightIcon
color="currentColor"
noVerticalAlign={false}
size="sm"
>
<svg
aria-hidden={true}
aria-labelledby={null}
fill="currentColor"
height="1em"
role="img"
style={
Object {
"verticalAlign": "-0.125em",
}
}
viewBox="0 0 256 512"
width="1em"
>
<path
d="M224.3 273l-136 136c-9.4 9.4-24.6 9.4-33.9 0l-22.6-22.6c-9.4-9.4-9.4-24.6 0-33.9l96.4-96.4-96.4-96.4c-9.4-9.4-9.4-24.6 0-33.9L54.3 103c9.4-9.4 24.6-9.4 33.9 0l136 136c9.5 9.4 9.5 24.6.1 34z"
/>
</svg>
</AngleRightIcon>
</span>
active group
</li>
</BreadcrumbItem>
</ol>
</nav>
</Breadcrumb>
</GroupBreadCrumbs>
</GroupCrumbs>
`;

View file

@ -21,63 +21,6 @@ exports[`BreadCrumbs tests couple of crumbs 1`] = `
<li <li
className="pf-c-breadcrumb__item" className="pf-c-breadcrumb__item"
> >
<Link
to="/master"
>
<LinkAnchor
href="/master"
navigate={[Function]}
>
<a
href="/master"
onClick={[Function]}
>
<span
key="/master"
>
Home
</span>
</a>
</LinkAnchor>
</Link>
</li>
</BreadcrumbItem>
<BreadcrumbItem
isActive={false}
key=".$1"
showDivider={true}
>
<li
className="pf-c-breadcrumb__item"
>
<span
className="pf-c-breadcrumb__item-divider"
>
<AngleRightIcon
color="currentColor"
noVerticalAlign={false}
size="sm"
>
<svg
aria-hidden={true}
aria-labelledby={null}
fill="currentColor"
height="1em"
role="img"
style={
Object {
"verticalAlign": "-0.125em",
}
}
viewBox="0 0 256 512"
width="1em"
>
<path
d="M224.3 273l-136 136c-9.4 9.4-24.6 9.4-33.9 0l-22.6-22.6c-9.4-9.4-9.4-24.6 0-33.9l96.4-96.4-96.4-96.4c-9.4-9.4-9.4-24.6 0-33.9L54.3 103c9.4-9.4 24.6-9.4 33.9 0l136 136c9.5 9.4 9.5 24.6.1 34z"
/>
</svg>
</AngleRightIcon>
</span>
<Link <Link
to="/master/clients" to="/master/clients"
> >
@ -101,7 +44,7 @@ exports[`BreadCrumbs tests couple of crumbs 1`] = `
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbItem <BreadcrumbItem
isActive={true} isActive={true}
key=".$2" key=".$1"
showDivider={true} showDivider={true}
> >
<li <li
@ -145,5 +88,6 @@ exports[`BreadCrumbs tests couple of crumbs 1`] = `
</ol> </ol>
</nav> </nav>
</Breadcrumb> </Breadcrumb>
<GroupBreadCrumbs />
</PageBreadCrumbs> </PageBreadCrumbs>
`; `;

View file

@ -1,4 +1,11 @@
import React, { useState } from "react"; import React, {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { Link, useHistory, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Button, Button,
@ -21,12 +28,58 @@ import { useAlerts } from "../components/alert/Alerts";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import "./GroupsSection.css"; import "./GroupsSection.css";
import { Link, useRouteMatch } from "react-router-dom"; import { useRealm } from "../context/realm-context/RealmContext";
type GroupTableData = GroupRepresentation & { type GroupTableData = GroupRepresentation & {
membersLength?: number; membersLength?: number;
}; };
type SubGroupsProps = {
subGroups: GroupRepresentation[];
setSubGroups: (group: GroupRepresentation[]) => void;
clear: () => void;
remove: (group: GroupRepresentation) => void;
};
const SubGroupContext = createContext<SubGroupsProps>({
subGroups: [],
setSubGroups: () => {},
clear: () => {},
remove: () => {},
});
export const SubGroups = ({ children }: { children: ReactNode }) => {
const [subGroups, setSubGroups] = useState<GroupRepresentation[]>([]);
const clear = () => setSubGroups([]);
const remove = (group: GroupRepresentation) =>
setSubGroups(
subGroups.slice(
0,
subGroups.findIndex((g) => g.id === group.id)
)
);
return (
<SubGroupContext.Provider
value={{ subGroups, setSubGroups, clear, remove }}
>
{children}
</SubGroupContext.Provider>
);
};
export const useSubGroups = () => useContext(SubGroupContext);
const getId = (pathname: string) => {
const pathParts = pathname.substr(1).split("/");
return pathParts.length > 1 ? pathParts.splice(2) : undefined;
};
const getLastId = (pathname: string) => {
const pathParts = getId(pathname);
return pathParts ? pathParts[pathParts.length - 1] : undefined;
};
export const GroupsSection = () => { export const GroupsSection = () => {
const { t } = useTranslation("groups"); const { t } = useTranslation("groups");
const adminClient = useAdminClient(); const adminClient = useAdminClient();
@ -34,8 +87,14 @@ export const GroupsSection = () => {
const [createGroupName, setCreateGroupName] = useState(""); const [createGroupName, setCreateGroupName] = useState("");
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]); const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
const { subGroups, setSubGroups } = useSubGroups();
const { addAlert } = useAlerts(); const { addAlert } = useAlerts();
const { url } = useRouteMatch(); const { realm } = useRealm();
const history = useHistory();
const location = useLocation();
const id = getLastId(location.pathname);
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const refresh = () => setKey(`${new Date().getTime()}`); const refresh = () => setKey(`${new Date().getTime()}`);
@ -45,17 +104,47 @@ export const GroupsSection = () => {
}; };
const loader = async () => { const loader = async () => {
const groupsData = await adminClient.groups.find(); let groupsData;
if (!id) {
groupsData = await adminClient.groups.find();
} else {
const ids = getId(location.pathname);
const isNavigationStateInValid = ids && ids.length !== subGroups.length;
if (isNavigationStateInValid) {
const groups = [];
for (const i of ids!) {
const group = await adminClient.groups.findOne({ id: i });
if (group) groups.push(group);
}
setSubGroups(groups);
groupsData = groups.pop()?.subGroups!;
} else {
const group = await adminClient.groups.findOne({ id });
if (group) {
setSubGroups([...subGroups, group]);
groupsData = group.subGroups!;
}
}
}
const memberPromises = groupsData.map((group) => getMembers(group.id!)); if (groupsData) {
const memberData = await Promise.all(memberPromises); const memberPromises = groupsData.map((group) => getMembers(group.id!));
const updatedObject = groupsData.map((group: GroupTableData, i) => { const memberData = await Promise.all(memberPromises);
group.membersLength = memberData[i]; return groupsData.map((group: GroupTableData, i) => {
return group; group.membersLength = memberData[i];
}); return group;
return updatedObject; });
} else {
history.push(`/${realm}/groups`);
}
return [];
}; };
useEffect(() => {
refresh();
}, [id]);
const handleModalToggle = () => { const handleModalToggle = () => {
setIsCreateModalOpen(!isCreateModalOpen); setIsCreateModalOpen(!isCreateModalOpen);
}; };
@ -85,7 +174,7 @@ export const GroupsSection = () => {
const GroupNameCell = (group: GroupTableData) => ( const GroupNameCell = (group: GroupTableData) => (
<> <>
<Link key={group.id} to={`${url}/${group.id}`}> <Link key={group.id} to={`${location.pathname}/${group.id}`}>
{group.name} {group.name}
</Link> </Link>
</> </>
@ -107,8 +196,8 @@ export const GroupsSection = () => {
onSelect={(rows) => setSelectedRows([...rows])} onSelect={(rows) => setSelectedRows([...rows])}
canSelectAll={false} canSelectAll={false}
loader={loader} loader={loader}
ariaLabelKey="client-scopes:clientScopeList" ariaLabelKey="groups:groups"
searchPlaceholderKey="client-scopes:searchFor" searchPlaceholderKey="groups:searchForGroups"
toolbarItem={ toolbarItem={
<> <>
<ToolbarItem> <ToolbarItem>

View file

@ -26,13 +26,13 @@ import { BreadcrumbsRoute } from "use-react-router-breadcrumbs";
import { RealmRoleTabs } from "./realm-roles/RealmRoleTabs"; import { RealmRoleTabs } from "./realm-roles/RealmRoleTabs";
export type RouteDef = BreadcrumbsRoute & { export type RouteDef = BreadcrumbsRoute & {
component: () => JSX.Element;
access: AccessType; access: AccessType;
component: () => JSX.Element;
}; };
type RoutesFn = (t: TFunction) => RouteDef[]; type RoutesFn = (t: TFunction) => RouteDef[];
export const routes: RoutesFn = (t) => [ export const routes: RoutesFn = (t: TFunction) => [
{ {
path: "/:realm/add-realm", path: "/:realm/add-realm",
component: NewRealmForm, component: NewRealmForm,
@ -126,7 +126,10 @@ export const routes: RoutesFn = (t) => [
{ {
path: "/:realm/groups", path: "/:realm/groups",
component: GroupsSection, component: GroupsSection,
breadcrumb: t("groups"), breadcrumb: null,
matchOptions: {
exact: false,
},
access: "query-groups", access: "query-groups",
}, },
{ {