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:
parent
bfa0c6e1ea
commit
a48088765a
18 changed files with 434 additions and 105 deletions
53
cypress/integration/group_test.spec.ts
Normal file
53
cypress/integration/group_test.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -214,7 +214,7 @@ describe("User Fed Kerberos tests", () => {
|
||||||
.click();
|
.click();
|
||||||
cy.wait(1000);
|
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();
|
cy.get('[data-cy="delete-provider-cmd"]').click();
|
||||||
|
|
||||||
modalUtils.checkModalTitle(deleteModalTitle).confirmModal();
|
modalUtils.checkModalTitle(deleteModalTitle).confirmModal();
|
||||||
|
|
|
@ -23,3 +23,7 @@
|
||||||
//
|
//
|
||||||
// -- This will overwrite an existing command --
|
// -- This will overwrite an existing command --
|
||||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
|
Cypress.Commands.add("getId", (selector, ...args) => {
|
||||||
|
return cy.get(`[data-testid=${selector}]`, ...args);
|
||||||
|
});
|
||||||
|
|
8
cypress/support/pages/ViewHeaderPage.ts
Normal file
8
cypress/support/pages/ViewHeaderPage.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,7 +31,7 @@ export const ListEmptyState = ({
|
||||||
message,
|
message,
|
||||||
instructions,
|
instructions,
|
||||||
onPrimaryAction,
|
onPrimaryAction,
|
||||||
hasIcon,
|
hasIcon = true,
|
||||||
isSearchVariant,
|
isSearchVariant,
|
||||||
primaryActionText,
|
primaryActionText,
|
||||||
secondaryActions,
|
secondaryActions,
|
||||||
|
@ -42,14 +42,18 @@ export const ListEmptyState = ({
|
||||||
{hasIcon && isSearchVariant ? (
|
{hasIcon && isSearchVariant ? (
|
||||||
<EmptyStateIcon icon={SearchIcon} />
|
<EmptyStateIcon icon={SearchIcon} />
|
||||||
) : (
|
) : (
|
||||||
<EmptyStateIcon icon={PlusCircleIcon} />
|
hasIcon && <EmptyStateIcon icon={PlusCircleIcon} />
|
||||||
)}
|
)}
|
||||||
<Title headingLevel="h4" size="lg">
|
<Title headingLevel="h4" size="lg">
|
||||||
{message}
|
{message}
|
||||||
</Title>
|
</Title>
|
||||||
<EmptyStateBody>{instructions}</EmptyStateBody>
|
<EmptyStateBody>{instructions}</EmptyStateBody>
|
||||||
{primaryActionText && (
|
{primaryActionText && (
|
||||||
<Button variant="primary" onClick={onPrimaryAction}>
|
<Button
|
||||||
|
data-testid="empty-primary-action"
|
||||||
|
variant="primary"
|
||||||
|
onClick={onPrimaryAction}
|
||||||
|
>
|
||||||
{primaryActionText}
|
{primaryActionText}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -38,6 +38,7 @@ exports[`<ListEmptyState /> render 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="empty-primary-action"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Add it now!
|
Add it now!
|
||||||
|
|
|
@ -243,7 +243,7 @@ export function KeycloakDataTable<T>({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!rows && <Loading />}
|
{!rows && <Loading />}
|
||||||
{rows && isPaginated && (
|
{rows && (rows.length > 0 || !emptyState) && isPaginated && (
|
||||||
<PaginatingTableToolbar
|
<PaginatingTableToolbar
|
||||||
count={rows.length}
|
count={rows.length}
|
||||||
first={first}
|
first={first}
|
||||||
|
@ -263,7 +263,6 @@ export function KeycloakDataTable<T>({
|
||||||
searchTypeComponent={searchTypeComponent}
|
searchTypeComponent={searchTypeComponent}
|
||||||
toolbarItem={toolbarItem}
|
toolbarItem={toolbarItem}
|
||||||
>
|
>
|
||||||
{!loading && (emptyState === undefined || rows.length !== 0) && (
|
|
||||||
<DataTable
|
<DataTable
|
||||||
canSelectAll={canSelectAll}
|
canSelectAll={canSelectAll}
|
||||||
onSelect={onSelect ? _onSelect : undefined}
|
onSelect={onSelect ? _onSelect : undefined}
|
||||||
|
@ -273,12 +272,10 @@ export function KeycloakDataTable<T>({
|
||||||
columns={columns}
|
columns={columns}
|
||||||
ariaLabelKey={ariaLabelKey}
|
ariaLabelKey={ariaLabelKey}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{!loading && rows.length === 0 && emptyState}
|
|
||||||
{loading && <Loading />}
|
{loading && <Loading />}
|
||||||
</PaginatingTableToolbar>
|
</PaginatingTableToolbar>
|
||||||
)}
|
)}
|
||||||
{rows && !isPaginated && (
|
{rows && (rows.length > 0 || !emptyState) && !isPaginated && (
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
inputGroupName={
|
inputGroupName={
|
||||||
searchPlaceholderKey ? `${ariaLabelKey}input` : undefined
|
searchPlaceholderKey ? `${ariaLabelKey}input` : undefined
|
||||||
|
@ -289,7 +286,6 @@ export function KeycloakDataTable<T>({
|
||||||
toolbarItem={toolbarItem}
|
toolbarItem={toolbarItem}
|
||||||
searchTypeComponent={searchTypeComponent}
|
searchTypeComponent={searchTypeComponent}
|
||||||
>
|
>
|
||||||
{(emptyState === undefined || rows.length !== 0) && (
|
|
||||||
<DataTable
|
<DataTable
|
||||||
canSelectAll={canSelectAll}
|
canSelectAll={canSelectAll}
|
||||||
onSelect={onSelect ? _onSelect : undefined}
|
onSelect={onSelect ? _onSelect : undefined}
|
||||||
|
@ -299,10 +295,9 @@ export function KeycloakDataTable<T>({
|
||||||
columns={columns}
|
columns={columns}
|
||||||
ariaLabelKey={ariaLabelKey}
|
ariaLabelKey={ariaLabelKey}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{rows.length === 0 && emptyState}
|
|
||||||
</TableToolbar>
|
</TableToolbar>
|
||||||
)}
|
)}
|
||||||
|
<>{!loading && rows?.length === 0 && emptyState}</>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,6 +59,9 @@ export const PaginatingTableToolbar = ({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
<>{children}</>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
searchTypeComponent={searchTypeComponent}
|
searchTypeComponent={searchTypeComponent}
|
||||||
|
|
|
@ -51,6 +51,8 @@ export const TableToolbar = ({
|
||||||
<ToolbarItem>
|
<ToolbarItem>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
{searchTypeComponent}
|
{searchTypeComponent}
|
||||||
|
{inputGroupPlaceholder && (
|
||||||
|
<>
|
||||||
<TextInput
|
<TextInput
|
||||||
name={inputGroupName}
|
name={inputGroupName}
|
||||||
id={inputGroupName}
|
id={inputGroupName}
|
||||||
|
@ -66,6 +68,8 @@ export const TableToolbar = ({
|
||||||
>
|
>
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -118,7 +118,7 @@ export const ViewHeader = ({
|
||||||
}
|
}
|
||||||
isOpen={isDropdownOpen}
|
isOpen={isDropdownOpen}
|
||||||
dropdownItems={dropdownItems}
|
dropdownItems={dropdownItems}
|
||||||
data-cy="action-dropdown"
|
data-testid="action-dropdown"
|
||||||
/>
|
/>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FormEvent } from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
Button,
|
Button,
|
||||||
|
@ -10,52 +10,41 @@ import {
|
||||||
ValidatedOptions,
|
ValidatedOptions,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAdminClient } from "../context/auth/AdminClient";
|
|
||||||
import { useAlerts } from "../components/alert/Alerts";
|
|
||||||
import { useForm } from "react-hook-form";
|
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 = {
|
type GroupsCreateModalProps = {
|
||||||
|
id?: string;
|
||||||
handleModalToggle: () => void;
|
handleModalToggle: () => void;
|
||||||
isCreateModalOpen: boolean;
|
|
||||||
setIsCreateModalOpen: (isCreateModalOpen: boolean) => void;
|
|
||||||
createGroupName: string;
|
|
||||||
setCreateGroupName: (createGroupName: string) => void;
|
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GroupsCreateModal = ({
|
export const GroupsCreateModal = ({
|
||||||
|
id,
|
||||||
handleModalToggle,
|
handleModalToggle,
|
||||||
isCreateModalOpen,
|
|
||||||
setIsCreateModalOpen,
|
|
||||||
createGroupName,
|
|
||||||
setCreateGroupName,
|
|
||||||
refresh,
|
refresh,
|
||||||
}: GroupsCreateModalProps) => {
|
}: GroupsCreateModalProps) => {
|
||||||
const { t } = useTranslation("groups");
|
const { t } = useTranslation("groups");
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
const { addAlert } = useAlerts();
|
const { addAlert } = useAlerts();
|
||||||
const form = useForm();
|
const { register, errors, handleSubmit } = useForm();
|
||||||
const { register, errors } = form;
|
|
||||||
|
|
||||||
const valueChange = (createGroupName: string) => {
|
const submitForm = async (group: GroupRepresentation) => {
|
||||||
setCreateGroupName(createGroupName);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitForm = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (await form.trigger()) {
|
|
||||||
try {
|
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();
|
refresh();
|
||||||
setIsCreateModalOpen(false);
|
handleModalToggle();
|
||||||
setCreateGroupName("");
|
|
||||||
addAlert(t("groupCreated"), AlertVariant.success);
|
addAlert(t("groupCreated"), AlertVariant.success);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addAlert(
|
addAlert(t("couldNotCreateGroup", { error }), AlertVariant.danger);
|
||||||
`${t("couldNotCreateGroup")} ': '${error}'`,
|
|
||||||
AlertVariant.danger
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -64,15 +53,21 @@ export const GroupsCreateModal = ({
|
||||||
<Modal
|
<Modal
|
||||||
variant={ModalVariant.small}
|
variant={ModalVariant.small}
|
||||||
title={t("createAGroup")}
|
title={t("createAGroup")}
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={true}
|
||||||
onClose={handleModalToggle}
|
onClose={handleModalToggle}
|
||||||
actions={[
|
actions={[
|
||||||
<Button key="confirm" variant="primary" onClick={submitForm}>
|
<Button
|
||||||
|
data-testid="createGroup"
|
||||||
|
key="confirm"
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
form="group-form"
|
||||||
|
>
|
||||||
{t("create")}
|
{t("create")}
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Form isHorizontal onSubmit={submitForm}>
|
<Form id="group-form" isHorizontal onSubmit={handleSubmit(submitForm)}>
|
||||||
<FormGroup
|
<FormGroup
|
||||||
name="create-modal-group"
|
name="create-modal-group"
|
||||||
label={t("common:name")}
|
label={t("common:name")}
|
||||||
|
@ -84,13 +79,12 @@ export const GroupsCreateModal = ({
|
||||||
isRequired
|
isRequired
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
data-testid="groupNameInput"
|
||||||
ref={register({ required: true })}
|
ref={register({ required: true })}
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
id="create-group-name"
|
id="create-group-name"
|
||||||
name="name"
|
name="name"
|
||||||
value={createGroupName}
|
|
||||||
onChange={valueChange}
|
|
||||||
validated={
|
validated={
|
||||||
errors.name ? ValidatedOptions.error : ValidatedOptions.default
|
errors.name ? ValidatedOptions.error : ValidatedOptions.default
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,7 +84,6 @@ export const GroupsSection = () => {
|
||||||
const { t } = useTranslation("groups");
|
const { t } = useTranslation("groups");
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
const [isKebabOpen, setIsKebabOpen] = useState(false);
|
const [isKebabOpen, setIsKebabOpen] = useState(false);
|
||||||
const [createGroupName, setCreateGroupName] = useState("");
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
|
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
|
||||||
const { subGroups, setSubGroups } = useSubGroups();
|
const { subGroups, setSubGroups } = useSubGroups();
|
||||||
|
@ -189,7 +188,33 @@ export const GroupsSection = () => {
|
||||||
|
|
||||||
return (
|
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}>
|
<PageSection variant={PageSectionVariants.light}>
|
||||||
<KeycloakDataTable
|
<KeycloakDataTable
|
||||||
key={key}
|
key={key}
|
||||||
|
@ -201,7 +226,11 @@ export const GroupsSection = () => {
|
||||||
toolbarItem={
|
toolbarItem={
|
||||||
<>
|
<>
|
||||||
<ToolbarItem>
|
<ToolbarItem>
|
||||||
<Button variant="primary" onClick={handleModalToggle}>
|
<Button
|
||||||
|
data-testid="openCreateGroupModal"
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleModalToggle}
|
||||||
|
>
|
||||||
{t("createGroup")}
|
{t("createGroup")}
|
||||||
</Button>
|
</Button>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
|
@ -257,22 +286,23 @@ export const GroupsSection = () => {
|
||||||
emptyState={
|
emptyState={
|
||||||
<ListEmptyState
|
<ListEmptyState
|
||||||
hasIcon={true}
|
hasIcon={true}
|
||||||
message={t("noGroupsInThisRealm")}
|
message={t(`noGroupsInThis${id ? "SubGroup" : "Realm"}`)}
|
||||||
instructions={t("noGroupsInThisRealmInstructions")}
|
instructions={t(
|
||||||
|
`noGroupsInThis${id ? "SubGroup" : "Realm"}Instructions`
|
||||||
|
)}
|
||||||
primaryActionText={t("createGroup")}
|
primaryActionText={t("createGroup")}
|
||||||
onPrimaryAction={() => handleModalToggle()}
|
onPrimaryAction={() => handleModalToggle()}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isCreateModalOpen && (
|
||||||
<GroupsCreateModal
|
<GroupsCreateModal
|
||||||
isCreateModalOpen={isCreateModalOpen}
|
id={id}
|
||||||
handleModalToggle={handleModalToggle}
|
handleModalToggle={handleModalToggle}
|
||||||
setIsCreateModalOpen={setIsCreateModalOpen}
|
|
||||||
createGroupName={createGroupName}
|
|
||||||
setCreateGroupName={setCreateGroupName}
|
|
||||||
refresh={refresh}
|
refresh={refresh}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</PageSection>
|
</PageSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
168
src/groups/SearchGroups.tsx
Normal file
168
src/groups/SearchGroups.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -5,19 +5,25 @@
|
||||||
"groupName": "Group name",
|
"groupName": "Group name",
|
||||||
"searchForGroups": "Search for groups",
|
"searchForGroups": "Search for groups",
|
||||||
"searchGroups": "Search groups",
|
"searchGroups": "Search groups",
|
||||||
|
"searchGroup": "Search group",
|
||||||
|
"renameGroup": "Rename group",
|
||||||
|
"deleteGroup": "Delete group",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
|
"path": "Path",
|
||||||
"moveTo": "Move to",
|
"moveTo": "Move to",
|
||||||
"tableOfGroups": "Table of groups",
|
"tableOfGroups": "Table of groups",
|
||||||
"groupsDescription": "Description goes here",
|
"groupsDescription": "Description goes here",
|
||||||
"groupCreated": "Group created",
|
"groupCreated": "Group created",
|
||||||
"couldNotCreateGroup": "Could not create group",
|
"couldNotCreateGroup": "Could not create group {{error}}",
|
||||||
"createAGroup": "Create a group",
|
"createAGroup": "Create a group",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"noSearchResults": "No search results",
|
"noSearchResults": "No search results",
|
||||||
"noSearchResultsInstructions": "Click on the search bar above to search for groups",
|
"noSearchResultsInstructions": "Click on the search bar above to search for groups",
|
||||||
"noGroupsInThisRealm": "No groups in this realm",
|
"noGroupsInThisRealm": "No groups in this realm",
|
||||||
"noGroupsInThisRealmInstructions": "You haven't created any groups in this realm. Create a group to get started.",
|
"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",
|
"groupDelete": "Group deleted",
|
||||||
"groupsDeleted": "Groups deleted",
|
"groupsDeleted": "Groups deleted",
|
||||||
"groupDeleteError": "Error deleting group {error}"
|
"groupDeleteError": "Error deleting group {error}"
|
||||||
|
|
|
@ -25,6 +25,7 @@ import { UserFederationKerberosSettings } from "./user-federation/UserFederation
|
||||||
import { UserFederationLdapSettings } from "./user-federation/UserFederationLdapSettings";
|
import { UserFederationLdapSettings } from "./user-federation/UserFederationLdapSettings";
|
||||||
import { RoleMappingForm } from "./client-scopes/add/RoleMappingForm";
|
import { RoleMappingForm } from "./client-scopes/add/RoleMappingForm";
|
||||||
import { RealmRoleTabs } from "./realm-roles/RealmRoleTabs";
|
import { RealmRoleTabs } from "./realm-roles/RealmRoleTabs";
|
||||||
|
import { SearchGroups } from "./groups/SearchGroups";
|
||||||
|
|
||||||
export type RouteDef = BreadcrumbsRoute & {
|
export type RouteDef = BreadcrumbsRoute & {
|
||||||
access: AccessType;
|
access: AccessType;
|
||||||
|
@ -244,6 +245,21 @@ export const routes: RoutesFn = (t: TFunction) => [
|
||||||
breadcrumb: t("common:home"),
|
breadcrumb: t("common:home"),
|
||||||
access: "anyone",
|
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: "/",
|
path: "/",
|
||||||
component: DashboardSection,
|
component: DashboardSection,
|
||||||
|
@ -256,13 +272,4 @@ export const routes: RoutesFn = (t: TFunction) => [
|
||||||
breadcrumb: null,
|
breadcrumb: null,
|
||||||
access: "anyone",
|
access: "anyone",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/:realm/groups",
|
|
||||||
component: GroupsSection,
|
|
||||||
breadcrumb: null,
|
|
||||||
matchOptions: {
|
|
||||||
exact: false,
|
|
||||||
},
|
|
||||||
access: "query-groups",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
12
types/import.d.ts
vendored
12
types/import.d.ts
vendored
|
@ -6,3 +6,15 @@ interface ImportMeta {
|
||||||
hot: any;
|
hot: any;
|
||||||
env: Record<string, 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue