Users(groups): Add modal for joining user groups (#513)
* add cypress test to add groups to user * add test to leave group * format * update snapshots * add user to groups modal wip * wip join group functionality * add modal to add user groups * add refresh * remove comment * lint and format * fix empty state * add cypress test to add groups to user * format * revert snap * remove existing joined groups from modal
This commit is contained in:
parent
3deea60a87
commit
6f4ea86ecb
8 changed files with 381 additions and 16 deletions
|
@ -6,11 +6,44 @@ import ListingPage from "../support/pages/admin_console/ListingPage";
|
|||
import UserDetailsPage from "../support/pages/admin_console/manage/users/UserDetailsPage";
|
||||
import ModalUtils from "../support/util/ModalUtils";
|
||||
import { keycloakBefore } from "../support/util/keycloak_before";
|
||||
import GroupModal from "../support/pages/admin_console/manage/groups/GroupModal";
|
||||
import UserGroupsPage from "../support/pages/admin_console/manage/users/UserGroupsPage";
|
||||
|
||||
let groupName = "group";
|
||||
|
||||
describe("Group creation", () => {
|
||||
const loginPage = new LoginPage();
|
||||
const masthead = new Masthead();
|
||||
const sidebarPage = new SidebarPage();
|
||||
const listingPage = new ListingPage();
|
||||
const groupModal = new GroupModal();
|
||||
|
||||
beforeEach(function () {
|
||||
keycloakBefore();
|
||||
loginPage.logIn();
|
||||
sidebarPage.goToGroups();
|
||||
});
|
||||
|
||||
it("Add group to be joined", () => {
|
||||
groupName += "_" + (Math.random() + 1).toString(36).substring(7);
|
||||
|
||||
groupModal
|
||||
.open("openCreateGroupModal")
|
||||
.fillGroupForm(groupName)
|
||||
.clickCreate();
|
||||
|
||||
masthead.checkNotificationMessage("Group created");
|
||||
|
||||
sidebarPage.goToGroups();
|
||||
listingPage.searchItem(groupName, false).itemExist(groupName);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Users test", () => {
|
||||
const loginPage = new LoginPage();
|
||||
const sidebarPage = new SidebarPage();
|
||||
const createUserPage = new CreateUserPage();
|
||||
const userGroupsPage = new UserGroupsPage();
|
||||
const masthead = new Masthead();
|
||||
const modalUtils = new ModalUtils();
|
||||
const listingPage = new ListingPage();
|
||||
|
@ -51,7 +84,7 @@ describe("Users test", () => {
|
|||
sidebarPage.goToUsers();
|
||||
});
|
||||
|
||||
it("Go to user details test", function () {
|
||||
it("User details test", function () {
|
||||
cy.wait(1000);
|
||||
listingPage.searchItem(itemId).itemExist(itemId);
|
||||
|
||||
|
@ -64,13 +97,37 @@ describe("Users test", () => {
|
|||
|
||||
cy.wait(1000);
|
||||
|
||||
// Go to user details
|
||||
|
||||
cy.getId("user-groups-tab").click();
|
||||
|
||||
sidebarPage.goToUsers();
|
||||
listingPage.searchItem(itemId).itemExist(itemId);
|
||||
});
|
||||
|
||||
it("Add user to group test", function () {
|
||||
// Go to user groups
|
||||
|
||||
listingPage.searchItem(itemId).itemExist(itemId);
|
||||
listingPage.goToItemDetails(itemId);
|
||||
|
||||
userGroupsPage.goToGroupsTab();
|
||||
userGroupsPage.toggleAddGroupModal();
|
||||
cy.getId(`${groupName}`).click();
|
||||
userGroupsPage.joinGroup();
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
listingPage.itemExist(groupName);
|
||||
});
|
||||
|
||||
it("Leave group test", function () {
|
||||
listingPage.searchItem(itemId).itemExist(itemId);
|
||||
listingPage.goToItemDetails(itemId);
|
||||
// Go to user groups
|
||||
userGroupsPage.goToGroupsTab();
|
||||
cy.getId(`leave-${groupName}`).click();
|
||||
cy.getId("modalConfirm").click();
|
||||
});
|
||||
|
||||
it("Delete user", function () {
|
||||
listingPage.searchItem(itemId).itemExist(itemId);
|
||||
// Delete
|
||||
cy.wait(1000);
|
||||
listingPage.deleteItem(itemId);
|
||||
|
@ -81,6 +138,5 @@ describe("Users test", () => {
|
|||
|
||||
listingPage.itemExist(itemId, false);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
export default class UserGroupsPage {
|
||||
userGroupsTab: string;
|
||||
addGroupButton: string;
|
||||
joinGroupButton: string;
|
||||
|
||||
constructor() {
|
||||
this.userGroupsTab = "user-groups-tab";
|
||||
this.addGroupButton = "add-group-button";
|
||||
this.joinGroupButton = "joinGroup";
|
||||
}
|
||||
|
||||
goToGroupsTab() {
|
||||
cy.getId(this.userGroupsTab).click();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
toggleAddGroupModal() {
|
||||
cy.getId(this.addGroupButton).click();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
joinGroup() {
|
||||
cy.getId(this.joinGroupButton).click();
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
|
@ -67,6 +67,7 @@ export const ConfirmDialogModal = ({
|
|||
actions={[
|
||||
<Button
|
||||
id="modal-confirm"
|
||||
data-testid="modalConfirm"
|
||||
key="confirm"
|
||||
variant={continueButtonVariant || ButtonVariant.primary}
|
||||
onClick={() => {
|
||||
|
|
|
@ -22,6 +22,7 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
|
|||
actions={
|
||||
Array [
|
||||
<Button
|
||||
data-testid="modalConfirm"
|
||||
id="modal-confirm"
|
||||
onClick={[Function]}
|
||||
variant="primary"
|
||||
|
@ -125,6 +126,7 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
|
|||
data-ouia-component-id="OUIA-Generated-Button-primary-1"
|
||||
data-ouia-component-type="PF4/Button"
|
||||
data-ouia-safe="true"
|
||||
data-testid="modalConfirm"
|
||||
id="modal-confirm"
|
||||
type="button"
|
||||
>
|
||||
|
@ -152,6 +154,7 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
|
|||
actions={
|
||||
Array [
|
||||
<Button
|
||||
data-testid="modalConfirm"
|
||||
id="modal-confirm"
|
||||
onClick={[Function]}
|
||||
variant="primary"
|
||||
|
@ -316,6 +319,7 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
|
|||
className="pf-c-modal-box__footer"
|
||||
>
|
||||
<Button
|
||||
data-testid="modalConfirm"
|
||||
id="modal-confirm"
|
||||
key="confirm"
|
||||
onClick={[Function]}
|
||||
|
@ -328,6 +332,7 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
|
|||
data-ouia-component-id="OUIA-Generated-Button-primary-1"
|
||||
data-ouia-component-type="PF4/Button"
|
||||
data-ouia-safe={true}
|
||||
data-testid="modalConfirm"
|
||||
disabled={false}
|
||||
id="modal-confirm"
|
||||
onClick={[Function]}
|
||||
|
|
|
@ -81,7 +81,7 @@ export const MoveGroupDialog = ({
|
|||
onClose={onClose}
|
||||
actions={[
|
||||
<Button
|
||||
data-testid="moveGroup"
|
||||
data-testid="joinGroup"
|
||||
key="confirm"
|
||||
variant="primary"
|
||||
form="group-form"
|
||||
|
|
203
src/user/JoinGroupDialog.tsx
Normal file
203
src/user/JoinGroupDialog.tsx
Normal file
|
@ -0,0 +1,203 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
Button,
|
||||
ButtonVariant,
|
||||
DataList,
|
||||
DataListAction,
|
||||
DataListCell,
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
InputGroup,
|
||||
Modal,
|
||||
ModalVariant,
|
||||
TextInput,
|
||||
Toolbar,
|
||||
ToolbarContent,
|
||||
ToolbarItem,
|
||||
} from "@patternfly/react-core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
|
||||
import { AngleRightIcon, SearchIcon } from "@patternfly/react-icons";
|
||||
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
||||
import { useErrorHandler } from "react-error-boundary";
|
||||
import { useParams } from "react-router-dom";
|
||||
import _ from "lodash";
|
||||
export type JoinGroupDialogProps = {
|
||||
open: boolean;
|
||||
toggleDialog: () => void;
|
||||
onClose: () => void;
|
||||
onConfirm: (newGroup: GroupRepresentation) => void;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export const JoinGroupDialog = ({
|
||||
onClose,
|
||||
open,
|
||||
toggleDialog,
|
||||
onConfirm,
|
||||
username,
|
||||
}: JoinGroupDialogProps) => {
|
||||
const { t } = useTranslation("roles");
|
||||
const adminClient = useAdminClient();
|
||||
|
||||
const errorHandler = useErrorHandler();
|
||||
|
||||
const [navigation, setNavigation] = useState<GroupRepresentation[]>([]);
|
||||
const [groups, setGroups] = useState<GroupRepresentation[]>([]);
|
||||
const [filtered, setFiltered] = useState<GroupRepresentation[]>();
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const [groupId, setGroupId] = useState<string>();
|
||||
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
asyncStateFetch(
|
||||
async () => {
|
||||
const existingUserGroups = await adminClient.users.listGroups({ id });
|
||||
const allGroups = await adminClient.groups.find();
|
||||
|
||||
if (groupId) {
|
||||
const group = await adminClient.groups.findOne({ id: groupId });
|
||||
return { group, groups: group.subGroups! };
|
||||
} else {
|
||||
return {
|
||||
groups: _.differenceBy(allGroups, existingUserGroups, "id"),
|
||||
};
|
||||
}
|
||||
},
|
||||
async ({ group: selectedGroup, groups }) => {
|
||||
if (selectedGroup) {
|
||||
setNavigation([...navigation, selectedGroup]);
|
||||
}
|
||||
|
||||
setGroups(groups);
|
||||
},
|
||||
errorHandler
|
||||
),
|
||||
[groupId]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
variant={ModalVariant.small}
|
||||
title={`Join groups for user ${username}`}
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
actions={[
|
||||
<Button
|
||||
data-testid="joinGroup"
|
||||
key="confirm"
|
||||
variant="primary"
|
||||
form="group-form"
|
||||
onClick={() => {
|
||||
toggleDialog();
|
||||
onConfirm(navigation[navigation.length - 1]);
|
||||
}}
|
||||
>
|
||||
{t("users:Join")}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem key="home">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setGroupId(undefined);
|
||||
setNavigation([]);
|
||||
}}
|
||||
>
|
||||
{t("groups")}
|
||||
</Button>
|
||||
</BreadcrumbItem>
|
||||
{navigation.map((group, i) => (
|
||||
<BreadcrumbItem key={i}>
|
||||
{navigation.length - 1 !== i && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setGroupId(group.id);
|
||||
setNavigation([...navigation].slice(0, i));
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
)}
|
||||
{navigation.length - 1 === i && <>{group.name}</>}
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumb>
|
||||
|
||||
<Toolbar>
|
||||
<ToolbarContent>
|
||||
<ToolbarItem>
|
||||
<InputGroup>
|
||||
<TextInput
|
||||
type="search"
|
||||
aria-label={t("common:search")}
|
||||
placeholder={t("users:searchForGroups")}
|
||||
onChange={(value) => {
|
||||
if (value === "") {
|
||||
setFiltered(undefined);
|
||||
}
|
||||
setFilter(value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant={ButtonVariant.control}
|
||||
aria-label={t("common:search")}
|
||||
onClick={() =>
|
||||
setFiltered(
|
||||
groups.filter((group) =>
|
||||
group.name?.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</ToolbarItem>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
<DataList
|
||||
onSelectDataListItem={(value) => setGroupId(value)}
|
||||
aria-label={t("groups")}
|
||||
isCompact
|
||||
>
|
||||
{(filtered || groups).map((group) => (
|
||||
<DataListItem
|
||||
aria-labelledby={group.name}
|
||||
key={group.id}
|
||||
id={group.id}
|
||||
>
|
||||
<DataListItemRow data-testid={group.name}>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key={`name-${group.id}`}>
|
||||
<>{group.name}</>
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-labelledby={`select-${group.name}`}
|
||||
id={`select-${group.name}`}
|
||||
aria-label={t("groupName")}
|
||||
isPlainButtonAction
|
||||
>
|
||||
<Button isDisabled variant="link">
|
||||
<AngleRightIcon />
|
||||
</Button>
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
))}
|
||||
</DataList>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -18,6 +18,22 @@ import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
|||
import { cellWidth } from "@patternfly/react-table";
|
||||
import { useErrorHandler } from "react-error-boundary";
|
||||
import _ from "lodash";
|
||||
import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
|
||||
import { JoinGroupDialog } from "./JoinGroupDialog";
|
||||
|
||||
type GroupTableData = GroupRepresentation & {
|
||||
membersLength?: number;
|
||||
};
|
||||
|
||||
export type UserFormProps = {
|
||||
username?: string;
|
||||
loader?: (
|
||||
first?: number,
|
||||
max?: number,
|
||||
search?: string
|
||||
) => Promise<UserRepresentation[]>;
|
||||
addGroup?: (newGroup: GroupRepresentation) => void;
|
||||
};
|
||||
|
||||
export const UserGroups = () => {
|
||||
const { t } = useTranslation("roles");
|
||||
|
@ -27,7 +43,9 @@ export const UserGroups = () => {
|
|||
const handleError = useErrorHandler();
|
||||
|
||||
const [selectedGroup, setSelectedGroup] = useState<GroupRepresentation>();
|
||||
const [list, setList] = useState(false);
|
||||
const [listGroups, setListGroups] = useState(true);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
|
||||
|
@ -58,7 +76,7 @@ export const UserGroups = () => {
|
|||
setSearch(searchParam);
|
||||
}
|
||||
|
||||
if (!searchParam && !listGroups) {
|
||||
if (!searchParam && !listGroups && !list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
@ -141,6 +159,7 @@ export const UserGroups = () => {
|
|||
);
|
||||
|
||||
setDirectMembershipList(directMembership);
|
||||
|
||||
const filterDupesfromGroups = allPaths.filter(
|
||||
(thing, index, self) =>
|
||||
index === self.findIndex((t) => t.name === thing.name)
|
||||
|
@ -163,7 +182,7 @@ export const UserGroups = () => {
|
|||
},
|
||||
handleError
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
|
@ -173,7 +192,24 @@ export const UserGroups = () => {
|
|||
return <>{group.name}</>;
|
||||
};
|
||||
|
||||
const toggleModal = () => setOpen(!open);
|
||||
const JoinGroupButtonRenderer = (group: GroupRepresentation) => {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => joinGroup(group)} variant="link">
|
||||
{t("users:joinGroup")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const toggleModal = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const joinGroup = (group: GroupRepresentation) => {
|
||||
setSelectedGroup(group);
|
||||
toggleModal();
|
||||
};
|
||||
|
||||
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
|
||||
titleKey: t("users:leaveGroup", {
|
||||
|
@ -210,11 +246,16 @@ export const UserGroups = () => {
|
|||
const LeaveButtonRenderer = (group: GroupRepresentation) => {
|
||||
if (
|
||||
directMembershipList.some((item) => item.id === group.id) ||
|
||||
directMembershipList.length === 0
|
||||
directMembershipList.length === 0 ||
|
||||
isDirectMembership
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => leave(group)} variant="link">
|
||||
<Button
|
||||
data-testid={`leave-${group.name}`}
|
||||
onClick={() => leave(group)}
|
||||
variant="link"
|
||||
>
|
||||
{t("users:Leave")}
|
||||
</Button>
|
||||
</>
|
||||
|
@ -224,10 +265,38 @@ export const UserGroups = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const addGroup = async (group: GroupRepresentation): Promise<void> => {
|
||||
const newGroup = group;
|
||||
|
||||
try {
|
||||
await adminClient.users.addToGroup({
|
||||
id: id,
|
||||
groupId: newGroup.id!,
|
||||
});
|
||||
setList(true);
|
||||
refresh();
|
||||
addAlert(t("users:addedGroupMembership"), AlertVariant.success);
|
||||
} catch (error) {
|
||||
addAlert(
|
||||
t("users:addedGroupMembershipError", { error }),
|
||||
AlertVariant.danger
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageSection variant="light">
|
||||
<DeleteConfirm />
|
||||
{open && (
|
||||
<JoinGroupDialog
|
||||
open={open}
|
||||
onClose={() => setOpen(!open)}
|
||||
onConfirm={addGroup}
|
||||
toggleDialog={() => toggleModal()}
|
||||
username={username}
|
||||
/>
|
||||
)}
|
||||
<KeycloakDataTable
|
||||
key={key}
|
||||
loader={loader}
|
||||
|
@ -241,11 +310,12 @@ export const UserGroups = () => {
|
|||
<Button
|
||||
className="kc-join-group-button"
|
||||
key="join-group-button"
|
||||
onClick={() => toggleModal()}
|
||||
onClick={toggleModal}
|
||||
data-testid="add-group-button"
|
||||
>
|
||||
{t("users:joinGroup")}
|
||||
</Button>
|
||||
{JoinGroupButtonRenderer}
|
||||
<Checkbox
|
||||
label={t("users:directMembership")}
|
||||
key="direct-membership-check"
|
||||
|
@ -283,8 +353,6 @@ export const UserGroups = () => {
|
|||
hasIcon={true}
|
||||
message={t("users:noGroups")}
|
||||
instructions={t("users:noGroupsText")}
|
||||
primaryActionText={t("users:joinGroup")}
|
||||
onPrimaryAction={() => {}}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
|
|
|
@ -10,11 +10,14 @@
|
|||
"noGroups": "No groups",
|
||||
"noGroupsText": "You haven't added this user to any groups. Join a group to get started.",
|
||||
"joinGroup": "Join Group",
|
||||
"searchForGroups": "Search for groups",
|
||||
"leave": "Leave",
|
||||
"leaveGroup": "Leave group {{name}}?",
|
||||
"leaveGroupConfirmDialog": "Are you sure you want to remove {{username}} from the group {{groupname}}?",
|
||||
"directMembership": "Direct membership",
|
||||
"groupMembership": "Group membership",
|
||||
"addedGroupMembership": "Added group membership",
|
||||
"addedGroupMembershipError": "Error adding group membership",
|
||||
"removedGroupMembership": "Removed group membership",
|
||||
"removedGroupMembershipError": "Error removing group membership",
|
||||
"path": "Path",
|
||||
|
|
Loading…
Reference in a new issue