Search and create sub groups (#387)

* fixed group section

* simplified create group dialog

* create subgroup

* initial search groups

* added initial search

* add empty state and links to details

* Added cypress tests

* fixed types

* changed to the more clear getId

* changed to use testid

* fixed merge error

* fixed test

* changed text for empty sub groups

* fix merge error

* fix test
This commit is contained in:
Erik Jan de Wit 2021-03-01 16:06:04 +01:00 committed by GitHub
parent bfa0c6e1ea
commit a48088765a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 434 additions and 105 deletions

View file

@ -0,0 +1,53 @@
import ListingPage from "../support/pages/admin_console/ListingPage";
import CreateGroupModal from "../support/pages/admin_console/manage/groups/CreateGroupModal";
import { SearchGroupPage } from "../support/pages/admin_console/manage/groups/SearchGroup";
import Masthead from "../support/pages/admin_console/Masthead";
import SidebarPage from "../support/pages/admin_console/SidebarPage";
import LoginPage from "../support/pages/LoginPage";
import ViewHeaderPage from "../support/pages/ViewHeaderPage";
describe("Group test", () => {
const loginPage = new LoginPage();
const masthead = new Masthead();
const sidebarPage = new SidebarPage();
const listingPage = new ListingPage();
const viewHeaderPage = new ViewHeaderPage();
const createGroupModal = new CreateGroupModal();
let groupName = "group";
describe("Group creation", () => {
beforeEach(function () {
cy.visit("");
loginPage.logIn();
sidebarPage.goToGroups();
});
it("Group CRUD test", () => {
groupName += "_" + (Math.random() + 1).toString(36).substring(7);
createGroupModal
.open("empty-primary-action")
.fillGroupForm(groupName)
.clickCreate();
masthead.checkNotificationMessage("Group created");
sidebarPage.goToGroups();
listingPage.searchItem(groupName).itemExist(groupName);
// Delete
listingPage.deleteItem(groupName);
masthead.checkNotificationMessage("Group deleted");
listingPage.itemExist(groupName, false);
});
const searchGroupPage = new SearchGroupPage();
it("Group search", () => {
viewHeaderPage.clickAction("searchGroup");
searchGroupPage.searchGroup("group").clickSearchButton();
searchGroupPage.checkTerm("group");
});
});
});

View file

@ -214,7 +214,7 @@ describe("User Fed Kerberos tests", () => {
.click();
cy.wait(1000);
cy.get('[data-cy="action-dropdown"]').click();
cy.get('[data-testid="action-dropdown"]').click();
cy.get('[data-cy="delete-provider-cmd"]').click();
modalUtils.checkModalTitle(deleteModalTitle).confirmModal();

View file

@ -23,3 +23,7 @@
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
Cypress.Commands.add("getId", (selector, ...args) => {
return cy.get(`[data-testid=${selector}]`, ...args);
});

View file

@ -0,0 +1,8 @@
export default class ListingPage {
private actionMenu = "action-dropdown";
clickAction(action: string) {
cy.getId(this.actionMenu).click().getId(action).click();
return this;
}
}

View file

@ -0,0 +1,21 @@
export default class CreateGroupModal {
private openButton = "openCreateGroupModal";
private nameInput = "groupNameInput";
private createButton = "createGroup";
open(name?: string) {
cy.getId(name || this.openButton).click();
return this;
}
fillGroupForm(name = "") {
cy.getId(this.nameInput).clear();
cy.getId(this.nameInput).type(name);
return this;
}
clickCreate() {
cy.getId(this.createButton).click();
return this;
}
}

View file

@ -0,0 +1,19 @@
export class SearchGroupPage {
private searchField = "group-search";
private searchButton = "search-button";
searchGroup(search: string) {
cy.getId(this.searchField).type(search);
return this;
}
clickSearchButton() {
cy.getId(this.searchButton).click();
return this;
}
checkTerm(searchTerm: string) {
cy.get(".pf-c-chip-group").children().contains(searchTerm).should("exist");
return this;
}
}

View file

@ -31,7 +31,7 @@ export const ListEmptyState = ({
message,
instructions,
onPrimaryAction,
hasIcon,
hasIcon = true,
isSearchVariant,
primaryActionText,
secondaryActions,
@ -42,14 +42,18 @@ export const ListEmptyState = ({
{hasIcon && isSearchVariant ? (
<EmptyStateIcon icon={SearchIcon} />
) : (
<EmptyStateIcon icon={PlusCircleIcon} />
hasIcon && <EmptyStateIcon icon={PlusCircleIcon} />
)}
<Title headingLevel="h4" size="lg">
{message}
</Title>
<EmptyStateBody>{instructions}</EmptyStateBody>
{primaryActionText && (
<Button variant="primary" onClick={onPrimaryAction}>
<Button
data-testid="empty-primary-action"
variant="primary"
onClick={onPrimaryAction}
>
{primaryActionText}
</Button>
)}

View file

@ -38,6 +38,7 @@ exports[`<ListEmptyState /> render 1`] = `
data-ouia-component-id="OUIA-Generated-Button-primary-1"
data-ouia-component-type="PF4/Button"
data-ouia-safe="true"
data-testid="empty-primary-action"
type="button"
>
Add it now!

View file

@ -243,7 +243,7 @@ export function KeycloakDataTable<T>({
return (
<>
{!rows && <Loading />}
{rows && isPaginated && (
{rows && (rows.length > 0 || !emptyState) && isPaginated && (
<PaginatingTableToolbar
count={rows.length}
first={first}
@ -263,7 +263,6 @@ export function KeycloakDataTable<T>({
searchTypeComponent={searchTypeComponent}
toolbarItem={toolbarItem}
>
{!loading && (emptyState === undefined || rows.length !== 0) && (
<DataTable
canSelectAll={canSelectAll}
onSelect={onSelect ? _onSelect : undefined}
@ -273,12 +272,10 @@ export function KeycloakDataTable<T>({
columns={columns}
ariaLabelKey={ariaLabelKey}
/>
)}
{!loading && rows.length === 0 && emptyState}
{loading && <Loading />}
</PaginatingTableToolbar>
)}
{rows && !isPaginated && (
{rows && (rows.length > 0 || !emptyState) && !isPaginated && (
<TableToolbar
inputGroupName={
searchPlaceholderKey ? `${ariaLabelKey}input` : undefined
@ -289,7 +286,6 @@ export function KeycloakDataTable<T>({
toolbarItem={toolbarItem}
searchTypeComponent={searchTypeComponent}
>
{(emptyState === undefined || rows.length !== 0) && (
<DataTable
canSelectAll={canSelectAll}
onSelect={onSelect ? _onSelect : undefined}
@ -299,10 +295,9 @@ export function KeycloakDataTable<T>({
columns={columns}
ariaLabelKey={ariaLabelKey}
/>
)}
{rows.length === 0 && emptyState}
</TableToolbar>
)}
<>{!loading && rows?.length === 0 && emptyState}</>
</>
);
}

View file

@ -59,6 +59,9 @@ export const PaginatingTableToolbar = ({
/>
);
if (count === 0) {
<>{children}</>;
}
return (
<TableToolbar
searchTypeComponent={searchTypeComponent}

View file

@ -51,6 +51,8 @@ export const TableToolbar = ({
<ToolbarItem>
<InputGroup>
{searchTypeComponent}
{inputGroupPlaceholder && (
<>
<TextInput
name={inputGroupName}
id={inputGroupName}
@ -66,6 +68,8 @@ export const TableToolbar = ({
>
<SearchIcon />
</Button>
</>
)}
</InputGroup>
</ToolbarItem>
)}

View file

@ -118,7 +118,7 @@ export const ViewHeader = ({
}
isOpen={isDropdownOpen}
dropdownItems={dropdownItems}
data-cy="action-dropdown"
data-testid="action-dropdown"
/>
</ToolbarItem>
)}

View file

@ -1,4 +1,4 @@
import React, { FormEvent } from "react";
import React from "react";
import {
AlertVariant,
Button,
@ -10,52 +10,41 @@ import {
ValidatedOptions,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
import { useForm } from "react-hook-form";
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
type GroupsCreateModalProps = {
id?: string;
handleModalToggle: () => void;
isCreateModalOpen: boolean;
setIsCreateModalOpen: (isCreateModalOpen: boolean) => void;
createGroupName: string;
setCreateGroupName: (createGroupName: string) => void;
refresh: () => void;
};
export const GroupsCreateModal = ({
id,
handleModalToggle,
isCreateModalOpen,
setIsCreateModalOpen,
createGroupName,
setCreateGroupName,
refresh,
}: GroupsCreateModalProps) => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const form = useForm();
const { register, errors } = form;
const { register, errors, handleSubmit } = useForm();
const valueChange = (createGroupName: string) => {
setCreateGroupName(createGroupName);
};
const submitForm = async (e: FormEvent) => {
e.preventDefault();
if (await form.trigger()) {
const submitForm = async (group: GroupRepresentation) => {
try {
await adminClient.groups.create({ name: createGroupName });
if (!id) {
await adminClient.groups.create({ name: group.name });
} else {
await adminClient.groups.setOrCreateChild({ id }, { name: group.name });
}
refresh();
setIsCreateModalOpen(false);
setCreateGroupName("");
handleModalToggle();
addAlert(t("groupCreated"), AlertVariant.success);
} catch (error) {
addAlert(
`${t("couldNotCreateGroup")} ': '${error}'`,
AlertVariant.danger
);
}
addAlert(t("couldNotCreateGroup", { error }), AlertVariant.danger);
}
};
@ -64,15 +53,21 @@ export const GroupsCreateModal = ({
<Modal
variant={ModalVariant.small}
title={t("createAGroup")}
isOpen={isCreateModalOpen}
isOpen={true}
onClose={handleModalToggle}
actions={[
<Button key="confirm" variant="primary" onClick={submitForm}>
<Button
data-testid="createGroup"
key="confirm"
variant="primary"
type="submit"
form="group-form"
>
{t("create")}
</Button>,
]}
>
<Form isHorizontal onSubmit={submitForm}>
<Form id="group-form" isHorizontal onSubmit={handleSubmit(submitForm)}>
<FormGroup
name="create-modal-group"
label={t("common:name")}
@ -84,13 +79,12 @@ export const GroupsCreateModal = ({
isRequired
>
<TextInput
data-testid="groupNameInput"
ref={register({ required: true })}
autoFocus
type="text"
id="create-group-name"
name="name"
value={createGroupName}
onChange={valueChange}
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}

View file

@ -84,7 +84,6 @@ export const GroupsSection = () => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const [isKebabOpen, setIsKebabOpen] = useState(false);
const [createGroupName, setCreateGroupName] = useState("");
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
const { subGroups, setSubGroups } = useSubGroups();
@ -189,7 +188,33 @@ export const GroupsSection = () => {
return (
<>
<ViewHeader titleKey="groups:groups" subKey="groups:groupsDescription" />
<ViewHeader
titleKey="groups:groups"
subKey="groups:groupsDescription"
dropdownItems={[
<DropdownItem
data-testid="searchGroup"
key="searchGroup"
onClick={() => history.push(`/${realm}/groups/search`)}
>
{t("searchGroup")}
</DropdownItem>,
<DropdownItem
data-testid="renameGroup"
key="renameGroup"
onClick={() => addAlert("Not implemented")}
>
{t("renameGroup")}
</DropdownItem>,
<DropdownItem
data-testid="deleteGroup"
key="deleteGroup"
onClick={() => deleteGroup({ id })}
>
{t("deleteGroup")}
</DropdownItem>,
]}
/>
<PageSection variant={PageSectionVariants.light}>
<KeycloakDataTable
key={key}
@ -201,7 +226,11 @@ export const GroupsSection = () => {
toolbarItem={
<>
<ToolbarItem>
<Button variant="primary" onClick={handleModalToggle}>
<Button
data-testid="openCreateGroupModal"
variant="primary"
onClick={handleModalToggle}
>
{t("createGroup")}
</Button>
</ToolbarItem>
@ -257,22 +286,23 @@ export const GroupsSection = () => {
emptyState={
<ListEmptyState
hasIcon={true}
message={t("noGroupsInThisRealm")}
instructions={t("noGroupsInThisRealmInstructions")}
message={t(`noGroupsInThis${id ? "SubGroup" : "Realm"}`)}
instructions={t(
`noGroupsInThis${id ? "SubGroup" : "Realm"}Instructions`
)}
primaryActionText={t("createGroup")}
onPrimaryAction={() => handleModalToggle()}
/>
}
/>
{isCreateModalOpen && (
<GroupsCreateModal
isCreateModalOpen={isCreateModalOpen}
id={id}
handleModalToggle={handleModalToggle}
setIsCreateModalOpen={setIsCreateModalOpen}
createGroupName={createGroupName}
setCreateGroupName={setCreateGroupName}
refresh={refresh}
/>
)}
</PageSection>
</>
);

168
src/groups/SearchGroups.tsx Normal file
View file

@ -0,0 +1,168 @@
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Button,
ButtonVariant,
Chip,
ChipGroup,
Form,
InputGroup,
PageSection,
PageSectionVariants,
Text,
TextContent,
TextInput,
} from "@patternfly/react-core";
import { SearchIcon } from "@patternfly/react-icons";
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { useAdminClient } from "../context/auth/AdminClient";
import { useRealm } from "../context/realm-context/RealmContext";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
type SearchGroup = GroupRepresentation & {
link?: string;
};
export const SearchGroups = () => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const { realm } = useRealm();
const [searchTerm, setSearchTerm] = useState("");
const [searchTerms, setSearchTerms] = useState<string[]>([]);
const [searchCount, setSearchCount] = useState(0);
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const deleteTerm = (id: string) => {
const index = searchTerms.indexOf(id);
searchTerms.splice(index, 1);
setSearchTerms([...searchTerms]);
refresh();
};
const addTerm = () => {
setSearchTerms([...searchTerms, searchTerm]);
setSearchTerm("");
refresh();
};
const GroupNameCell = (group: SearchGroup) => (
<>
<Link key={group.id} to={`/${realm}/groups/${group.link}`}>
{group.name}
</Link>
</>
);
const flatten = (
groups: GroupRepresentation[],
id?: string
): SearchGroup[] => {
let result: SearchGroup[] = [];
for (const group of groups) {
const link = `${id || ""}${id ? "/" : ""}${group.id}`;
result.push({ ...group, link });
if (group.subGroups) {
result = [...result, ...flatten(group.subGroups, link)];
}
}
return result;
};
const loader = async (first?: number, max?: number) => {
const params = {
first: first!,
max: max!,
};
let result: SearchGroup[] = [];
if (searchTerms[0]) {
result = await adminClient.groups.find({
...params,
search: searchTerms[0],
});
result = flatten(result);
for (const searchTerm of searchTerms) {
result = result.filter((group) => group.name?.includes(searchTerm));
}
}
setSearchCount(result.length);
return result;
};
return (
<>
<PageSection variant={PageSectionVariants.light}>
<TextContent className="pf-u-mr-sm">
<Text component="h1">{t("searchForGroups")}</Text>
</TextContent>
<Form
className="pf-u-mt-sm keycloak__form"
onSubmit={(e) => {
e.preventDefault();
addTerm();
}}
>
<InputGroup>
<TextInput
name="search"
data-testid="group-search"
type="search"
aria-label={t("search")}
placeholder={t("searchGroups")}
value={searchTerm}
onChange={(value) => setSearchTerm(value)}
/>
<Button
data-testid="search-button"
variant={ButtonVariant.control}
aria-label={t("search")}
onClick={addTerm}
>
<SearchIcon />
</Button>
</InputGroup>
<ChipGroup>
{searchTerms.map((term) => (
<Chip key={term} onClick={() => deleteTerm(term)}>
{term}
</Chip>
))}
</ChipGroup>
</Form>
</PageSection>
<PageSection variant={searchCount === 0 ? "light" : "default"}>
<KeycloakDataTable
key={key}
ariaLabelKey="groups:groups"
isPaginated
loader={loader}
columns={[
{
name: "name",
displayKey: "groups:groupName",
cellRenderer: GroupNameCell,
},
{
name: "path",
displayKey: "groups:path",
},
]}
emptyState={
<ListEmptyState
message={t("noSearchResults")}
instructions={t("noSearchResultsInstructions")}
hasIcon={false}
/>
}
/>
</PageSection>
</>
);
};

View file

@ -5,19 +5,25 @@
"groupName": "Group name",
"searchForGroups": "Search for groups",
"searchGroups": "Search groups",
"searchGroup": "Search group",
"renameGroup": "Rename group",
"deleteGroup": "Delete group",
"search": "Search",
"members": "Members",
"path": "Path",
"moveTo": "Move to",
"tableOfGroups": "Table of groups",
"groupsDescription": "Description goes here",
"groupCreated": "Group created",
"couldNotCreateGroup": "Could not create group",
"couldNotCreateGroup": "Could not create group {{error}}",
"createAGroup": "Create a group",
"create": "Create",
"noSearchResults": "No search results",
"noSearchResultsInstructions": "Click on the search bar above to search for groups",
"noGroupsInThisRealm": "No groups in this realm",
"noGroupsInThisRealmInstructions": "You haven't created any groups in this realm. Create a group to get started.",
"noGroupsInThisSubGroup": "No groups in this sub group",
"noGroupsInThisSubGroupInstructions": "You haven't created any groups in this sub group.",
"groupDelete": "Group deleted",
"groupsDeleted": "Groups deleted",
"groupDeleteError": "Error deleting group {error}"

View file

@ -25,6 +25,7 @@ import { UserFederationKerberosSettings } from "./user-federation/UserFederation
import { UserFederationLdapSettings } from "./user-federation/UserFederationLdapSettings";
import { RoleMappingForm } from "./client-scopes/add/RoleMappingForm";
import { RealmRoleTabs } from "./realm-roles/RealmRoleTabs";
import { SearchGroups } from "./groups/SearchGroups";
export type RouteDef = BreadcrumbsRoute & {
access: AccessType;
@ -244,6 +245,21 @@ export const routes: RoutesFn = (t: TFunction) => [
breadcrumb: t("common:home"),
access: "anyone",
},
{
path: "/:realm/groups/search",
component: SearchGroups,
breadcrumb: t("groups:searchGroups"),
access: "query-groups",
},
{
path: "/:realm/groups",
component: GroupsSection,
breadcrumb: null,
matchOptions: {
exact: false,
},
access: "query-groups",
},
{
path: "/",
component: DashboardSection,
@ -256,13 +272,4 @@ export const routes: RoutesFn = (t: TFunction) => [
breadcrumb: null,
access: "anyone",
},
{
path: "/:realm/groups",
component: GroupsSection,
breadcrumb: null,
matchOptions: {
exact: false,
},
access: "query-groups",
},
];

12
types/import.d.ts vendored
View file

@ -6,3 +6,15 @@ interface ImportMeta {
hot: any;
env: Record<string, any>;
}
declare namespace Cypress {
interface Chainable<Subject> {
/**
* Get one or more DOM elements by `data-testid`.
*
* @example
* cy.getId('searchButton') // Gets the <button data-testid="searchButton">Search</button>
*/
getId(selector: string, ...args): Chainable<any>;
}
}