Added the option to switch to use a tree view for groups (#2970)
This commit is contained in:
parent
156dd4507c
commit
9844a4f011
9 changed files with 585 additions and 131 deletions
|
@ -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}",
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
91
src/groups/components/CheckableTreeView.tsx
Normal file
91
src/groups/components/CheckableTreeView.tsx
Normal 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 />;
|
||||
};
|
51
src/groups/components/DeleteGroup.tsx
Normal file
51
src/groups/components/DeleteGroup.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
100
src/groups/components/GroupToolbar.tsx
Normal file
100
src/groups/components/GroupToolbar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
207
src/groups/components/GroupTree.tsx
Normal file
207
src/groups/components/GroupTree.tsx
Normal 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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
83
src/groups/components/MoveDialog.tsx
Normal file
83
src/groups/components/MoveDialog.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
Loading…
Reference in a new issue