Added the option to switch to use a tree view for groups (#2970)

This commit is contained in:
Erik Jan de Wit 2022-08-15 14:29:41 +02:00 committed by GitHub
parent 156dd4507c
commit 9844a4f011
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 585 additions and 131 deletions

View file

@ -50,8 +50,8 @@
"noGroupsInThisSubGroupInstructions": "You haven't created any groups in this sub group.",
"deleteConfirmTitle_one": "Delete group?",
"deleteConfirmTitle_other": "Delete groups?",
"deleteConfirm_one": "Are you sure you want to delete this group",
"deleteConfirm_other": "Are you sure you want to delete this groups.",
"deleteConfirm_one": "Are you sure you want to delete this group.",
"deleteConfirm_other": "Are you sure you want to delete these groups.",
"groupDeleted_one": "Group deleted",
"groupDeleted_other": "Groups deleted",
"groupDeleteError": "Error deleting group {error}",

View file

@ -15,7 +15,11 @@ import {
ButtonVariant,
DropdownItem,
} from "@patternfly/react-core";
import { CheckCircleIcon, TableIcon } from "@patternfly/react-icons";
import {
CheckCircleIcon,
TableIcon,
DomainIcon,
} from "@patternfly/react-icons";
import type AuthenticationExecutionInfoRepresentation from "@keycloak/keycloak-admin-client/lib/defs/authenticationExecutionInfoRepresentation";
import type { AuthenticationProviderRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/authenticatorConfigRepresentation";
@ -331,7 +335,7 @@ export default function FlowDetails() {
onChange={() => setTableView(true)}
/>
<ToggleGroupItem
icon={<i className="fas fa-project-diagram"></i>}
icon={<DomainIcon />}
aria-label={t("diagramView")}
buttonId="diagramView"
isSelected={!tableView}

View file

@ -1,40 +1,35 @@
import { useState } from "react";
import { Link, useHistory, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
AlertVariant,
Button,
ButtonVariant,
Dropdown,
DropdownItem,
KebabToggle,
ToolbarItem,
} from "@patternfly/react-core";
import { cellWidth } from "@patternfly/react-table";
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
import { useRealm } from "../context/realm-context/RealmContext";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { GroupsModal } from "./GroupsModal";
import { getLastId } from "./groupIdUtils";
import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
import { useSubGroups } from "./SubGroupsContext";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { toGroups } from "./routes/Groups";
import { useAccess } from "../context/access/Access";
import useToggle from "../utils/useToggle";
import { DeleteGroup } from "./components/DeleteGroup";
import { GroupToolbar, ViewType } from "./components/GroupToolbar";
import { MoveDialog } from "./components/MoveDialog";
export const GroupTable = () => {
type GroupTableProps = {
toggleView?: (viewType: ViewType) => void;
};
export const GroupTable = ({ toggleView }: GroupTableProps) => {
const { t } = useTranslation("groups");
const { adminClient } = useAdminClient();
const { addAlert, addError } = useAlerts();
const { realm } = useRealm();
const [isKebabOpen, setIsKebabOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
const [showDelete, toggleShowDelete] = useToggle();
const [move, setMove] = useState<GroupRepresentation>();
const { subGroups, currentGroup, setSubGroups } = useSubGroups();
@ -74,24 +69,6 @@ export const GroupTable = () => {
return groupsData || [];
};
const multiDelete = async () => {
try {
for (const group of selectedRows) {
await adminClient.groups.del({
id: group.id!,
});
}
addAlert(
t("groupDeleted", { count: selectedRows.length }),
AlertVariant.success
);
setSelectedRows([]);
} catch (error) {
addError("groups:groupDeleteError", error);
}
refresh();
};
const GroupNameCell = (group: GroupRepresentation) => {
if (!canView) return <span>{group.name}</span>;
@ -115,17 +92,17 @@ export const GroupTable = () => {
setIsCreateModalOpen(!isCreateModalOpen);
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: t("deleteConfirmTitle", { count: selectedRows.length }),
messageKey: t("deleteConfirm", { count: selectedRows.length }),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: multiDelete,
});
return (
<>
<DeleteConfirm />
<DeleteGroup
show={showDelete}
toggleDialog={toggleShowDelete}
selectedRows={selectedRows}
refresh={() => {
refresh();
setSelectedRows([]);
}}
/>
<KeycloakDataTable
key={`${id}${key}`}
onSelect={(rows) => setSelectedRows([...rows])}
@ -134,43 +111,13 @@ export const GroupTable = () => {
ariaLabelKey="groups:groups"
searchPlaceholderKey="groups:searchForGroups"
toolbarItem={
isManager && (
<>
<ToolbarItem>
<Button
data-testid="openCreateGroupModal"
variant="primary"
onClick={handleModalToggle}
>
{t("createGroup")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Dropdown
toggle={
<KebabToggle
onToggle={() => setIsKebabOpen(!isKebabOpen)}
isDisabled={selectedRows!.length === 0}
/>
}
isOpen={isKebabOpen}
isPlain
dropdownItems={[
<DropdownItem
key="action"
component="button"
onClick={() => {
toggleDeleteDialog();
setIsKebabOpen(false);
}}
>
{t("common:delete")}
</DropdownItem>,
]}
/>
</ToolbarItem>
</>
)
<GroupToolbar
currentView={ViewType.Table}
toggleView={toggleView}
toggleCreate={handleModalToggle}
toggleDelete={toggleShowDelete}
kebabDisabled={selectedRows!.length === 0}
/>
}
actions={
!isManager
@ -187,7 +134,7 @@ export const GroupTable = () => {
title: t("common:delete"),
onRowClick: async (group: GroupRepresentation) => {
setSelectedRows([group]);
toggleDeleteDialog();
toggleShowDelete();
return true;
},
},
@ -221,54 +168,13 @@ export const GroupTable = () => {
/>
)}
{move && (
<GroupPickerDialog
type="selectOne"
filterGroups={[move.name!]}
text={{
title: "groups:moveToGroup",
ok: "groups:moveHere",
<MoveDialog
source={move}
refresh={() => {
setMove(undefined);
refresh();
}}
onClose={() => setMove(undefined)}
onConfirm={async (group) => {
try {
if (group !== undefined) {
try {
await adminClient.groups.setOrCreateChild(
{ id: group[0].id! },
move
);
} catch (error: any) {
if (error.response) {
throw error;
}
}
} else {
await adminClient.groups.del({ id: move.id! });
const { id } = await adminClient.groups.create({
...move,
id: undefined,
});
if (move.subGroups) {
await Promise.all(
move.subGroups.map((s) =>
adminClient.groups.setOrCreateChild(
{ id: id! },
{
...s,
id: undefined,
}
)
)
);
}
}
setMove(undefined);
refresh();
addAlert(t("moveGroupSuccess"), AlertVariant.success);
} catch (error) {
addError("groups:moveGroupError", error);
}
}}
/>
)}
</>

View file

@ -29,6 +29,8 @@ import { GroupRoleMapping } from "./GroupRoleMapping";
import helpUrls from "../help-urls";
import { PermissionsTab } from "../components/permission-tab/PermissionTab";
import { useAccess } from "../context/access/Access";
import { GroupTree } from "./components/GroupTree";
import { ViewType } from "./components/GroupToolbar";
import "./GroupsSection.css";
@ -42,6 +44,7 @@ export default function GroupsSection() {
const { realm } = useRealm();
const [rename, setRename] = useState<string>();
const [viewType, setViewType] = useState<ViewType>(ViewType.Table);
const history = useHistory();
const location = useLocation();
@ -204,7 +207,16 @@ export default function GroupsSection() {
)}
</Tabs>
)}
{subGroups.length === 0 && <GroupTable />}
{subGroups.length === 0 && (
<>
{viewType === ViewType.Table && (
<GroupTable toggleView={setViewType} />
)}
{viewType === ViewType.Tree && (
<GroupTree toggleView={setViewType} />
)}
</>
)}
</PageSection>
</>
);

View file

@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import { TreeView, TreeViewDataItem } from "@patternfly/react-core";
type CheckableTreeViewProps = {
data: TreeViewDataItem[];
onSelect: (items: TreeViewDataItem[]) => void;
};
export const CheckableTreeView = ({
data,
onSelect,
}: CheckableTreeViewProps) => {
const [state, setState] = useState<{
options: TreeViewDataItem[];
checkedItems: TreeViewDataItem[];
}>({ options: [], checkedItems: [] });
useEffect(() => {
onSelect(state.checkedItems.filter((i) => i.checkProps?.checked === true));
}, [state]);
useEffect(() => {
setState({ options: data, checkedItems: [] });
}, [data]);
const flattenTree = (tree: TreeViewDataItem[]) => {
let result: TreeViewDataItem[] = [];
tree.forEach((item) => {
result.push(item);
if (item.children) {
result = result.concat(flattenTree(item.children));
}
});
return result;
};
const onCheck = (evt: React.ChangeEvent, treeViewItem: TreeViewDataItem) => {
const checked = (evt.target as HTMLInputElement).checked;
const flatCheckedItems = flattenTree([treeViewItem]);
setState((prevState) => {
return {
options: prevState.options,
checkedItems: checked
? prevState.checkedItems.concat(
flatCheckedItems.filter(
(item) => !prevState.checkedItems.some((i) => i.id === item.id)
)
)
: prevState.checkedItems.filter(
(item) => !flatCheckedItems.some((i) => i.id === item.id)
),
};
});
};
const isChecked = (item: TreeViewDataItem) =>
state.checkedItems.some((i) => i.id === item.id);
const areSomeDescendantsChecked = (dataItem: TreeViewDataItem): boolean =>
dataItem.children
? dataItem.children.some((child) => areSomeDescendantsChecked(child))
: isChecked(dataItem);
const mapTree = (item: TreeViewDataItem): TreeViewDataItem => {
const hasCheck = isChecked(item);
// Reset checked properties to be updated
item.checkProps!.checked = false;
if (hasCheck) {
item.checkProps!.checked = true;
} else {
const hasPartialCheck = areSomeDescendantsChecked(item);
if (hasPartialCheck) {
item.checkProps!.checked = null;
}
}
if (item.children) {
return {
...item,
children: item.children.map((child) => mapTree(child)),
};
}
return item;
};
const mapped = state.options.map((item) => mapTree(item));
return <TreeView data={mapped} onCheck={onCheck} hasChecks />;
};

View file

@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next";
import { ButtonVariant } from "@patternfly/react-core";
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import { ConfirmDialogModal } from "../../components/confirm-dialog/ConfirmDialog";
import { useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../../components/alert/Alerts";
type DeleteConfirmProps = {
selectedRows: GroupRepresentation[];
show: boolean;
toggleDialog: () => void;
refresh: () => void;
};
export const DeleteGroup = ({
selectedRows,
show,
toggleDialog,
refresh,
}: DeleteConfirmProps) => {
const { t } = useTranslation("groups");
const { adminClient } = useAdminClient();
const { addAlert, addError } = useAlerts();
const multiDelete = async () => {
try {
for (const group of selectedRows) {
await adminClient.groups.del({
id: group.id!,
});
}
refresh();
addAlert(t("groupDeleted", { count: selectedRows.length }));
} catch (error) {
addError("groups:groupDeleteError", error);
}
};
return (
<ConfirmDialogModal
titleKey={t("deleteConfirmTitle", { count: selectedRows.length })}
messageKey={t("deleteConfirm", { count: selectedRows.length })}
continueButtonLabel="common:delete"
continueButtonVariant={ButtonVariant.danger}
onConfirm={multiDelete}
open={show}
toggleDialog={toggleDialog}
/>
);
};

View file

@ -0,0 +1,100 @@
import { useTranslation } from "react-i18next";
import {
Button,
Dropdown,
DropdownItem,
KebabToggle,
ToggleGroup,
ToggleGroupItem,
ToolbarItem,
} from "@patternfly/react-core";
import { TableIcon, DomainIcon } from "@patternfly/react-icons";
import { useSubGroups } from "../SubGroupsContext";
import { useAccess } from "../../context/access/Access";
import useToggle from "../../utils/useToggle";
export enum ViewType {
Table,
Tree,
}
type GroupToolbarProps = {
toggleCreate: () => void;
toggleDelete: () => void;
currentView?: ViewType;
toggleView?: (type: ViewType) => void;
kebabDisabled: boolean;
};
export const GroupToolbar = ({
toggleCreate,
toggleDelete,
currentView,
toggleView,
kebabDisabled,
}: GroupToolbarProps) => {
const { t } = useTranslation("groups");
const { currentGroup } = useSubGroups();
const { hasAccess } = useAccess();
const isManager = hasAccess("manage-users") || currentGroup()?.access?.manage;
const [openKebab, toggleKebab] = useToggle();
if (!isManager) return <div />;
return (
<>
{toggleView && (
<ToolbarItem>
<ToggleGroup>
<ToggleGroupItem
icon={<TableIcon />}
aria-label={t("tableView")}
buttonId="tableView"
isSelected={currentView === ViewType.Table}
onChange={() => toggleView(ViewType.Table)}
/>
<ToggleGroupItem
icon={<DomainIcon />}
aria-label={t("diagramView")}
buttonId="diagramView"
isSelected={currentView === ViewType.Tree}
onChange={() => toggleView(ViewType.Tree)}
/>
</ToggleGroup>
</ToolbarItem>
)}
<ToolbarItem>
<Button
data-testid="openCreateGroupModal"
variant="primary"
onClick={toggleCreate}
>
{t("createGroup")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Dropdown
toggle={
<KebabToggle onToggle={toggleKebab} isDisabled={kebabDisabled} />
}
isOpen={openKebab}
isPlain
dropdownItems={[
<DropdownItem
key="action"
component="button"
onClick={() => {
toggleDelete();
toggleKebab();
}}
>
{t("common:delete")}
</DropdownItem>,
]}
/>
</ToolbarItem>
</>
);
};

View file

@ -0,0 +1,207 @@
import { useState } from "react";
import { useHistory, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Dropdown,
DropdownItem,
DropdownPosition,
KebabToggle,
TreeViewDataItem,
} from "@patternfly/react-core";
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
import { TableToolbar } from "../../components/table-toolbar/TableToolbar";
import useToggle from "../../utils/useToggle";
import { CheckableTreeView } from "./CheckableTreeView";
import { DeleteGroup } from "./DeleteGroup";
import { GroupToolbar, ViewType } from "./GroupToolbar";
import { GroupsModal } from "../GroupsModal";
import { MoveDialog } from "./MoveDialog";
type GroupTreeContextMenuProps = {
group: GroupRepresentation;
refresh: () => void;
};
const GroupTreeContextMenu = ({
group,
refresh,
}: GroupTreeContextMenuProps) => {
const { t } = useTranslation("groups");
const location = useLocation();
const history = useHistory();
const [isOpen, toggleOpen] = useToggle();
const [createOpen, toggleCreateOpen] = useToggle();
const [moveOpen, toggleMoveOpen] = useToggle();
const [deleteOpen, toggleDeleteOpen] = useToggle();
return (
<>
{createOpen && (
<GroupsModal
id={group.id}
handleModalToggle={toggleCreateOpen}
refresh={refresh}
/>
)}
{moveOpen && (
<MoveDialog source={group} refresh={refresh} onClose={toggleMoveOpen} />
)}
<DeleteGroup
show={deleteOpen}
toggleDialog={toggleDeleteOpen}
selectedRows={[group]}
refresh={refresh}
/>
<Dropdown
toggle={<KebabToggle onToggle={toggleOpen} />}
isOpen={isOpen}
isPlain
position={DropdownPosition.right}
dropdownItems={[
<DropdownItem key="create" onClick={toggleCreateOpen}>
{t("createGroup")}
</DropdownItem>,
<DropdownItem key="move" onClick={toggleMoveOpen}>
{t("moveTo")}
</DropdownItem>,
<DropdownItem
key="edit"
onClick={() => history.push(`${location.pathname}/${group.id}`)}
>
{t("common:edit")}
</DropdownItem>,
<DropdownItem key="delete" onClick={toggleDeleteOpen}>
{t("common:delete")}
</DropdownItem>,
]}
/>
</>
);
};
const mapGroup = (
group: GroupRepresentation,
refresh: () => void
): TreeViewDataItem => ({
id: group.id,
name: group.name,
checkProps: { checked: false },
children:
group.subGroups && group.subGroups.length > 0
? group.subGroups.map((g) => mapGroup(g, refresh))
: undefined,
action: <GroupTreeContextMenu group={group} refresh={refresh} />,
});
const filterGroup = (
group: TreeViewDataItem,
search: string
): TreeViewDataItem | null => {
const name = group.name as string;
if (name.toLowerCase().includes(search)) {
return { ...group, defaultExpanded: true, children: undefined };
}
const children: TreeViewDataItem[] = [];
if (group.children) {
for (const g of group.children) {
const found = filterGroup(g, search);
if (found) children.push(found);
}
if (children.length > 0) {
return { ...group, defaultExpanded: true, children };
}
}
return null;
};
const filterGroups = (
groups: TreeViewDataItem[],
search: string
): TreeViewDataItem[] => {
const result: TreeViewDataItem[] = [];
groups
.map((g) => filterGroup(g, search))
.forEach((g) => {
if (g !== null) result.push(g);
});
return result;
};
type GroupTreeProps = {
toggleView?: (viewType: ViewType) => void;
};
export const GroupTree = ({ toggleView }: GroupTreeProps) => {
const { t } = useTranslation("groups");
const { adminClient } = useAdminClient();
const [data, setData] = useState<TreeViewDataItem[]>();
const [filteredData, setFilteredData] = useState<TreeViewDataItem[]>();
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
const [showDelete, toggleShowDelete] = useToggle();
const [showCreate, toggleShowCreate] = useToggle();
const [key, setKey] = useState(0);
const refresh = () => setKey(key + 1);
useFetch(
() =>
adminClient.groups.find({
briefRepresentation: false,
}),
(groups) => setData(groups.map((g) => mapGroup(g, refresh))),
[key]
);
return (
<>
<DeleteGroup
show={showDelete}
toggleDialog={toggleShowDelete}
selectedRows={selectedRows}
refresh={refresh}
/>
{showCreate && (
<GroupsModal handleModalToggle={toggleShowCreate} refresh={refresh} />
)}
{data ? (
<>
<TableToolbar
inputGroupName="searchForGroups"
inputGroupPlaceholder={t("groups:searchForGroups")}
inputGroupOnEnter={(search) => {
if (search === "") {
setFilteredData(undefined);
} else {
setFilteredData(filterGroups(data, search));
}
}}
toolbarItem={
<GroupToolbar
currentView={ViewType.Tree}
toggleView={toggleView}
toggleDelete={toggleShowDelete}
toggleCreate={toggleShowCreate}
kebabDisabled={selectedRows.length === 0}
/>
}
/>
<CheckableTreeView
data={filteredData || data}
onSelect={(items) =>
setSelectedRows(items.reverse() as GroupRepresentation[])
}
/>
</>
) : (
<KeycloakSpinner />
)}
</>
);
};

View file

@ -0,0 +1,83 @@
import { useTranslation } from "react-i18next";
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import type KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import { useAlerts } from "../../components/alert/Alerts";
import { GroupPickerDialog } from "../../components/group/GroupPickerDialog";
import { useAdminClient } from "../../context/auth/AdminClient";
type MoveDialogProps = {
source: GroupRepresentation;
onClose: () => void;
refresh: () => void;
};
const moveToRoot = async (
adminClient: KeycloakAdminClient,
source: GroupRepresentation
) => {
await adminClient.groups.del({ id: source.id! });
const { id } = await adminClient.groups.create({
...source,
id: undefined,
});
if (source.subGroups) {
await Promise.all(
source.subGroups.map((s) =>
adminClient.groups.setOrCreateChild(
{ id: id! },
{
...s,
id: undefined,
}
)
)
);
}
};
const moveToGroup = async (
adminClient: KeycloakAdminClient,
source: GroupRepresentation,
dest: GroupRepresentation
) => {
try {
await adminClient.groups.setOrCreateChild({ id: dest.id! }, source);
} catch (error: any) {
if (error.response) {
throw error;
}
}
};
export const MoveDialog = ({ source, onClose, refresh }: MoveDialogProps) => {
const { t } = useTranslation("groups");
const { adminClient } = useAdminClient();
const { addAlert, addError } = useAlerts();
const moveGroup = async (group?: GroupRepresentation[]) => {
try {
await (group
? moveToGroup(adminClient, source, group[0])
: moveToRoot(adminClient, source));
refresh();
addAlert(t("moveGroupSuccess"));
} catch (error) {
addError("groups:moveGroupError", error);
}
};
return (
<GroupPickerDialog
type="selectOne"
filterGroups={[source.name!]}
text={{
title: "groups:moveToGroup",
ok: "groups:moveHere",
}}
onClose={onClose}
onConfirm={moveGroup}
/>
);
};