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:
Eugenia 2021-04-14 14:19:39 -04:00 committed by GitHub
parent 3deea60a87
commit 6f4ea86ecb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 381 additions and 16 deletions

View file

@ -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);
});
});
});
});

View file

@ -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;
}
}

View file

@ -67,6 +67,7 @@ export const ConfirmDialogModal = ({
actions={[
<Button
id="modal-confirm"
data-testid="modalConfirm"
key="confirm"
variant={continueButtonVariant || ButtonVariant.primary}
onClick={() => {

View file

@ -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]}

View file

@ -81,7 +81,7 @@ export const MoveGroupDialog = ({
onClose={onClose}
actions={[
<Button
data-testid="moveGroup"
data-testid="joinGroup"
key="confirm"
variant="primary"
form="group-form"

View 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>
);
};

View file

@ -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={() => {}}
/>
) : (
""

View file

@ -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",