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 UserDetailsPage from "../support/pages/admin_console/manage/users/UserDetailsPage";
import ModalUtils from "../support/util/ModalUtils"; import ModalUtils from "../support/util/ModalUtils";
import { keycloakBefore } from "../support/util/keycloak_before"; 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", () => { describe("Users test", () => {
const loginPage = new LoginPage(); const loginPage = new LoginPage();
const sidebarPage = new SidebarPage(); const sidebarPage = new SidebarPage();
const createUserPage = new CreateUserPage(); const createUserPage = new CreateUserPage();
const userGroupsPage = new UserGroupsPage();
const masthead = new Masthead(); const masthead = new Masthead();
const modalUtils = new ModalUtils(); const modalUtils = new ModalUtils();
const listingPage = new ListingPage(); const listingPage = new ListingPage();
@ -51,7 +84,7 @@ describe("Users test", () => {
sidebarPage.goToUsers(); sidebarPage.goToUsers();
}); });
it("Go to user details test", function () { it("User details test", function () {
cy.wait(1000); cy.wait(1000);
listingPage.searchItem(itemId).itemExist(itemId); listingPage.searchItem(itemId).itemExist(itemId);
@ -64,13 +97,37 @@ describe("Users test", () => {
cy.wait(1000); cy.wait(1000);
// Go to user details
cy.getId("user-groups-tab").click();
sidebarPage.goToUsers(); sidebarPage.goToUsers();
listingPage.searchItem(itemId).itemExist(itemId); 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 // Delete
cy.wait(1000); cy.wait(1000);
listingPage.deleteItem(itemId); listingPage.deleteItem(itemId);
@ -81,6 +138,5 @@ describe("Users test", () => {
listingPage.itemExist(itemId, false); 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={[ actions={[
<Button <Button
id="modal-confirm" id="modal-confirm"
data-testid="modalConfirm"
key="confirm" key="confirm"
variant={continueButtonVariant || ButtonVariant.primary} variant={continueButtonVariant || ButtonVariant.primary}
onClick={() => { onClick={() => {

View file

@ -22,6 +22,7 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
actions={ actions={
Array [ Array [
<Button <Button
data-testid="modalConfirm"
id="modal-confirm" id="modal-confirm"
onClick={[Function]} onClick={[Function]}
variant="primary" 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-id="OUIA-Generated-Button-primary-1"
data-ouia-component-type="PF4/Button" data-ouia-component-type="PF4/Button"
data-ouia-safe="true" data-ouia-safe="true"
data-testid="modalConfirm"
id="modal-confirm" id="modal-confirm"
type="button" type="button"
> >
@ -152,6 +154,7 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
actions={ actions={
Array [ Array [
<Button <Button
data-testid="modalConfirm"
id="modal-confirm" id="modal-confirm"
onClick={[Function]} onClick={[Function]}
variant="primary" variant="primary"
@ -316,6 +319,7 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
className="pf-c-modal-box__footer" className="pf-c-modal-box__footer"
> >
<Button <Button
data-testid="modalConfirm"
id="modal-confirm" id="modal-confirm"
key="confirm" key="confirm"
onClick={[Function]} 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-id="OUIA-Generated-Button-primary-1"
data-ouia-component-type="PF4/Button" data-ouia-component-type="PF4/Button"
data-ouia-safe={true} data-ouia-safe={true}
data-testid="modalConfirm"
disabled={false} disabled={false}
id="modal-confirm" id="modal-confirm"
onClick={[Function]} onClick={[Function]}

View file

@ -81,7 +81,7 @@ export const MoveGroupDialog = ({
onClose={onClose} onClose={onClose}
actions={[ actions={[
<Button <Button
data-testid="moveGroup" data-testid="joinGroup"
key="confirm" key="confirm"
variant="primary" variant="primary"
form="group-form" 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 { cellWidth } from "@patternfly/react-table";
import { useErrorHandler } from "react-error-boundary"; import { useErrorHandler } from "react-error-boundary";
import _ from "lodash"; 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 = () => { export const UserGroups = () => {
const { t } = useTranslation("roles"); const { t } = useTranslation("roles");
@ -27,7 +43,9 @@ export const UserGroups = () => {
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const [selectedGroup, setSelectedGroup] = useState<GroupRepresentation>(); const [selectedGroup, setSelectedGroup] = useState<GroupRepresentation>();
const [list, setList] = useState(false);
const [listGroups, setListGroups] = useState(true); const [listGroups, setListGroups] = useState(true);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@ -58,7 +76,7 @@ export const UserGroups = () => {
setSearch(searchParam); setSearch(searchParam);
} }
if (!searchParam && !listGroups) { if (!searchParam && !listGroups && !list) {
return []; return [];
} }
@ -141,6 +159,7 @@ export const UserGroups = () => {
); );
setDirectMembershipList(directMembership); setDirectMembershipList(directMembership);
const filterDupesfromGroups = allPaths.filter( const filterDupesfromGroups = allPaths.filter(
(thing, index, self) => (thing, index, self) =>
index === self.findIndex((t) => t.name === thing.name) index === self.findIndex((t) => t.name === thing.name)
@ -163,7 +182,7 @@ export const UserGroups = () => {
}, },
handleError handleError
); );
}); }, []);
useEffect(() => { useEffect(() => {
refresh(); refresh();
@ -173,7 +192,24 @@ export const UserGroups = () => {
return <>{group.name}</>; 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({ const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: t("users:leaveGroup", { titleKey: t("users:leaveGroup", {
@ -210,11 +246,16 @@ export const UserGroups = () => {
const LeaveButtonRenderer = (group: GroupRepresentation) => { const LeaveButtonRenderer = (group: GroupRepresentation) => {
if ( if (
directMembershipList.some((item) => item.id === group.id) || directMembershipList.some((item) => item.id === group.id) ||
directMembershipList.length === 0 directMembershipList.length === 0 ||
isDirectMembership
) { ) {
return ( return (
<> <>
<Button onClick={() => leave(group)} variant="link"> <Button
data-testid={`leave-${group.name}`}
onClick={() => leave(group)}
variant="link"
>
{t("users:Leave")} {t("users:Leave")}
</Button> </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 ( return (
<> <>
<PageSection variant="light"> <PageSection variant="light">
<DeleteConfirm /> <DeleteConfirm />
{open && (
<JoinGroupDialog
open={open}
onClose={() => setOpen(!open)}
onConfirm={addGroup}
toggleDialog={() => toggleModal()}
username={username}
/>
)}
<KeycloakDataTable <KeycloakDataTable
key={key} key={key}
loader={loader} loader={loader}
@ -241,11 +310,12 @@ export const UserGroups = () => {
<Button <Button
className="kc-join-group-button" className="kc-join-group-button"
key="join-group-button" key="join-group-button"
onClick={() => toggleModal()} onClick={toggleModal}
data-testid="add-group-button" data-testid="add-group-button"
> >
{t("users:joinGroup")} {t("users:joinGroup")}
</Button> </Button>
{JoinGroupButtonRenderer}
<Checkbox <Checkbox
label={t("users:directMembership")} label={t("users:directMembership")}
key="direct-membership-check" key="direct-membership-check"
@ -283,8 +353,6 @@ export const UserGroups = () => {
hasIcon={true} hasIcon={true}
message={t("users:noGroups")} message={t("users:noGroups")}
instructions={t("users:noGroupsText")} instructions={t("users:noGroupsText")}
primaryActionText={t("users:joinGroup")}
onPrimaryAction={() => {}}
/> />
) : ( ) : (
"" ""

View file

@ -10,11 +10,14 @@
"noGroups": "No groups", "noGroups": "No groups",
"noGroupsText": "You haven't added this user to any groups. Join a group to get started.", "noGroupsText": "You haven't added this user to any groups. Join a group to get started.",
"joinGroup": "Join Group", "joinGroup": "Join Group",
"searchForGroups": "Search for groups",
"leave": "Leave", "leave": "Leave",
"leaveGroup": "Leave group {{name}}?", "leaveGroup": "Leave group {{name}}?",
"leaveGroupConfirmDialog": "Are you sure you want to remove {{username}} from the group {{groupname}}?", "leaveGroupConfirmDialog": "Are you sure you want to remove {{username}} from the group {{groupname}}?",
"directMembership": "Direct membership", "directMembership": "Direct membership",
"groupMembership": "Group membership", "groupMembership": "Group membership",
"addedGroupMembership": "Added group membership",
"addedGroupMembershipError": "Error adding group membership",
"removedGroupMembership": "Removed group membership", "removedGroupMembership": "Removed group membership",
"removedGroupMembershipError": "Error removing group membership", "removedGroupMembershipError": "Error removing group membership",
"path": "Path", "path": "Path",