United group filter and group search (#3259)

This commit is contained in:
Erik Jan de Wit 2022-09-15 20:12:20 +02:00 committed by GitHub
parent bd9dbfb78b
commit 3287927797
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 138 additions and 279 deletions

View file

@ -65,7 +65,7 @@ describe("Group test", () => {
.assertNoGroupsInThisRealmEmptyStateMessageExist(true)
.createGroup(groupName, true)
.assertNotificationGroupCreated()
.searchGroup(groupName)
.searchGroup(groupName, true)
.assertGroupItemExist(groupName, true);
});
@ -74,7 +74,7 @@ describe("Group test", () => {
.assertNoGroupsInThisRealmEmptyStateMessageExist(false)
.createGroup(groupName, false)
.assertNotificationGroupCreated()
.searchGroup(groupName)
.searchGroup(groupName, true)
.assertGroupItemExist(groupName, true);
});
@ -84,7 +84,6 @@ describe("Group test", () => {
.createGroup(" ", false)
.assertNotificationCouldNotCreateGroupWithEmptyName();
groupModal.closeModal();
groupPage.searchGroup(" ").assertNoSearchResultsMessageExist(true);
});
it("Fail to create group with duplicated name", () => {
@ -115,10 +114,9 @@ describe("Group test", () => {
it("Delete group from item bar", () => {
groupPage
.searchGroup(groupNames[0])
.searchGroup(groupNames[0], true)
.deleteGroupItem(groupNames[0])
.assertNotificationGroupDeleted()
.searchGroup(groupNames[0])
.assertNoSearchResultsMessageExist(true);
});
@ -162,9 +160,7 @@ describe("Group test", () => {
groupPage
.goToGroupChildGroupsTab(predefinedGroups[0])
.searchGroup(predefinedGroups[1])
.goToGroupChildGroupsTab(predefinedGroups[1])
.searchGroup(predefinedGroups[2])
.assertGroupItemExist(predefinedGroups[2], true);
.assertGroupItemExist(predefinedGroups[1], true);
});
it("Search non existing child group in group", () => {
@ -194,30 +190,7 @@ describe("Group test", () => {
});
describe("Search globally", () => {
it("Check empty state", () => {
groupPage
.headerActionsearchGroup()
.assertNoSearchResultsMessageExist(true);
});
it("Search with multiple words", () => {
groupPage.headerActionsearchGroup();
searchGroupPage
.searchGroup(
predefinedGroups[0].substr(0, predefinedGroups[0].length / 2)
)
.assertGroupItemExist(predefinedGroups[0], true)
.searchGroup(
predefinedGroups[0].substr(
predefinedGroups[0].length / 2,
predefinedGroups[0].length
)
)
.assertGroupItemExist(predefinedGroups[0], true);
});
it("Navigate to parent group details", () => {
groupPage.headerActionsearchGroup();
searchGroupPage
.searchGroup(predefinedGroups[0])
.goToGroupChildGroupsTab(predefinedGroups[0])
@ -225,7 +198,6 @@ describe("Group test", () => {
});
it("Navigate to sub-group details", () => {
groupPage.headerActionsearchGroup();
searchGroupPage
.searchGroup(predefinedGroups[1])
.goToGroupChildGroupsTab(predefinedGroups[1])

View file

@ -36,17 +36,8 @@ export default class GroupPage extends PageObject {
return this;
}
public searchGroup(groupName: string) {
listingPage.searchItem(groupName, false);
return this;
}
public headerActionsearchGroup() {
super.openDropdownMenu("", cy.findByTestId(this.actionDrpDwnButton));
super.clickDropdownMenuItem(
"",
cy.findByTestId(this.actionDrpDwnItemSearchGroup)
);
public searchGroup(groupName: string, wait: boolean = false) {
listingPage.searchItem(groupName, wait);
return this;
}

View file

@ -2,7 +2,7 @@ import GroupPage from "./GroupPage";
export class SearchGroupPage extends GroupPage {
private searchField = "group-search";
private searchButton = "search-button";
private searchButton = "[data-testid='group-search'] > button";
public searchGroup(groupName: string) {
this.typeSearchInput(groupName);
@ -16,7 +16,7 @@ export class SearchGroupPage extends GroupPage {
}
public clickSearchButton() {
cy.findByTestId(this.searchButton).click();
cy.get(this.searchButton).click();
return this;
}

View file

@ -5,6 +5,9 @@
"createGroup": "Create group",
"groupName": "Group name",
"searchForGroups": "Search for groups",
"searchFor": "Search for:",
"global": "Global",
"local": "Local",
"searchGroups": "Search groups",
"searchGroup": "Search group",
"renameGroup": "Rename group",

View file

@ -2,7 +2,13 @@ import { useState } from "react";
import { Link } from "react-router-dom-v5-compat";
import { useLocation, useNavigate } from "react-router-dom-v5-compat";
import { useTranslation } from "react-i18next";
import { cellWidth } from "@patternfly/react-table";
import {
Radio,
SearchInput,
Split,
SplitItem,
ToolbarItem,
} from "@patternfly/react-core";
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import { useAdminClient } from "../context/auth/AdminClient";
@ -19,11 +25,30 @@ import useToggle from "../utils/useToggle";
import { DeleteGroup } from "./components/DeleteGroup";
import { GroupToolbar, ViewType } from "./components/GroupToolbar";
import { MoveDialog } from "./components/MoveDialog";
import { GroupPath } from "../components/group/GroupPath";
type GroupTableProps = {
toggleView?: (viewType: ViewType) => void;
};
type SearchType = "global" | "local";
type SearchGroup = GroupRepresentation & {
link?: string;
};
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;
};
export const GroupTable = ({ toggleView }: GroupTableProps) => {
const { t } = useTranslation("groups");
@ -37,11 +62,15 @@ export const GroupTable = ({ toggleView }: GroupTableProps) => {
const { subGroups, currentGroup, setSubGroups } = useSubGroups();
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const refresh = () => setKey(key + 1);
const [search, setSearch] = useState<string>();
const navigate = useNavigate();
const location = useLocation();
const id = getLastId(location.pathname);
const [searchType, setSearchType] = useState<SearchType>(
id ? "local" : "global"
);
const { hasAccess } = useAccess();
const isManager = hasAccess("manage-users") || currentGroup()?.access?.manage;
@ -49,7 +78,20 @@ export const GroupTable = ({ toggleView }: GroupTableProps) => {
hasAccess("query-groups", "view-users") ||
hasAccess("manage-users", "query-groups");
const loader = async () => {
const loader = async (
first?: number,
max?: number
): Promise<SearchGroup[]> => {
const params: Record<string, string> = {
search: search || "",
first: first?.toString() || "",
max: max?.toString() || "",
};
if (searchType === "global" && search) {
const result = await fetchAdminUI(adminClient, "admin-ui-groups", params);
return flatten(result);
}
let groupsData = undefined;
if (id) {
const group = await adminClient.groups.findOne({ id });
@ -57,9 +99,14 @@ export const GroupTable = ({ toggleView }: GroupTableProps) => {
throw new Error(t("common:notFound"));
}
groupsData = group.subGroups;
groupsData = !search
? group.subGroups
: group.subGroups?.filter((g) => g.name?.includes(search));
} else {
groupsData = await fetchAdminUI(adminClient, "admin-ui-groups");
groupsData = await fetchAdminUI(adminClient, "admin-ui-groups", {
...params,
global: "false",
});
}
if (!groupsData) {
@ -92,6 +139,9 @@ export const GroupTable = ({ toggleView }: GroupTableProps) => {
setIsCreateModalOpen(!isCreateModalOpen);
};
const Path = (group: SearchGroup) =>
group.link ? <GroupPath group={group} /> : undefined;
return (
<>
<DeleteGroup
@ -109,8 +159,23 @@ export const GroupTable = ({ toggleView }: GroupTableProps) => {
canSelectAll
loader={loader}
ariaLabelKey="groups:groups"
searchPlaceholderKey="groups:searchForGroups"
isPaginated
isSearching={!!search}
toolbarItem={
<>
<ToolbarItem>
<SearchInput
data-testid="group-search"
placeholder={t("searchForGroups")}
value={search}
onChange={setSearch}
onSearch={refresh}
onClear={() => {
setSearch("");
refresh();
}}
/>
</ToolbarItem>
<GroupToolbar
currentView={ViewType.Table}
toggleView={toggleView}
@ -118,6 +183,41 @@ export const GroupTable = ({ toggleView }: GroupTableProps) => {
toggleDelete={toggleShowDelete}
kebabDisabled={selectedRows!.length === 0}
/>
</>
}
subToolbar={
!!search &&
!id && (
<ToolbarItem>
<Split hasGutter>
<SplitItem>{t("searchFor")}</SplitItem>
<SplitItem>
<Radio
id="global"
isChecked={searchType === "global"}
onChange={() => {
setSearchType("global");
refresh();
}}
name="searchType"
label={t("global")}
/>
</SplitItem>
<SplitItem>
<Radio
id="local"
isChecked={searchType === "local"}
onChange={() => {
setSearchType("local");
refresh();
}}
name="searchType"
label={t("local")}
/>
</SplitItem>
</Split>
</ToolbarItem>
)
}
actions={
!isManager
@ -145,7 +245,11 @@ export const GroupTable = ({ toggleView }: GroupTableProps) => {
name: "name",
displayKey: "groups:groupName",
cellRenderer: GroupNameCell,
transforms: [cellWidth(90)],
},
{
name: "path",
displayKey: "groups:path",
cellRenderer: Path,
},
]}
emptyState={

View file

@ -1,5 +1,4 @@
import { useState } from "react";
import { Link } from "react-router-dom-v5-compat";
import { useLocation, useNavigate } from "react-router-dom-v5-compat";
import { useTranslation } from "react-i18next";
import {
@ -23,7 +22,6 @@ import { Members } from "./Members";
import { GroupAttributes } from "./GroupAttributes";
import { GroupsModal } from "./GroupsModal";
import { toGroups } from "./routes/Groups";
import { toGroupsSearch } from "./routes/GroupsSearch";
import { GroupRoleMapping } from "./GroupRoleMapping";
import helpUrls from "../help-urls";
import { PermissionsTab } from "../components/permission-tab/PermissionTab";
@ -89,17 +87,6 @@ export default function GroupsSection() {
[id]
);
const SearchDropdown = (
<DropdownItem
key="searchGroup"
component={
<Link data-testid="searchGroup" to={toGroupsSearch({ realm })}>
{t("searchGroup")}
</Link>
}
/>
);
return (
<>
<DeleteGroup
@ -126,7 +113,6 @@ export default function GroupsSection() {
dropdownItems={
id && canManageGroup
? [
SearchDropdown,
<DropdownItem
data-testid="renameGroupAction"
key="renameGroup"
@ -142,7 +128,7 @@ export default function GroupsSection() {
{t("deleteGroup")}
</DropdownItem>,
]
: [SearchDropdown]
: undefined
}
/>
<PageSection variant={PageSectionVariants.light} className="pf-u-p-0">

View file

@ -1,183 +0,0 @@
import { useState } from "react";
import { Link } from "react-router-dom-v5-compat";
import { useTranslation } from "react-i18next";
import {
Button,
ButtonVariant,
Chip,
ChipGroup,
InputGroup,
PageSection,
PageSectionVariants,
Stack,
StackItem,
TextInput,
ToolbarItem,
} from "@patternfly/react-core";
import { SearchIcon } from "@patternfly/react-icons";
import type GroupRepresentation from "@keycloak/keycloak-admin-client/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";
import { GroupPath } from "../components/group/GroupPath";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAccess } from "../context/access/Access";
type SearchGroup = GroupRepresentation & {
link?: string;
};
export default function SearchGroups() {
const { t } = useTranslation("groups");
const { adminClient } = useAdminClient();
const { realm } = useRealm();
const [searchTerm, setSearchTerm] = useState("");
const [searchTerms, setSearchTerms] = useState<string[]>([]);
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const { hasAccess } = useAccess();
const isManager = hasAccess("manage-users", "query-clients");
const deleteTerm = (id: string) => {
const index = searchTerms.indexOf(id);
searchTerms.splice(index, 1);
setSearchTerms([...searchTerms]);
refresh();
};
const addTerm = () => {
if (searchTerm !== "") {
setSearchTerms([...searchTerms, searchTerm]);
setSearchTerm("");
refresh();
}
};
const GroupNameCell = (group: SearchGroup) => {
if (!isManager) return <span>{group.name}</span>;
return (
<Link key={group.id} to={`/${realm}/groups/search/${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));
}
}
return result;
};
const Path = (group: GroupRepresentation) => <GroupPath group={group} />;
return (
<>
<ViewHeader titleKey="groups:searchGroups" />
<PageSection variant={PageSectionVariants.light} className="pf-u-p-0">
<KeycloakDataTable
key={key}
isSearching
toolbarItem={
<ToolbarItem>
<Stack>
<StackItem className="pf-u-mb-sm">
<InputGroup>
<TextInput
name="search"
data-testid="group-search"
type="search"
aria-label={t("search")}
placeholder={t("searchGroups")}
value={searchTerm}
onChange={(value) => setSearchTerm(value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
addTerm();
}
}}
/>
<Button
data-testid="search-button"
variant={ButtonVariant.control}
aria-label={t("search")}
onClick={addTerm}
>
<SearchIcon />
</Button>
</InputGroup>
</StackItem>
<StackItem>
<ChipGroup>
{searchTerms.map((term) => (
<Chip key={term} onClick={() => deleteTerm(term)}>
{term}
</Chip>
))}
</ChipGroup>
</StackItem>
</Stack>
</ToolbarItem>
}
ariaLabelKey="groups:groups"
isPaginated
loader={loader}
columns={[
{
name: "name",
displayKey: "groups:groupName",
cellRenderer: GroupNameCell,
},
{
name: "path",
displayKey: "groups:path",
cellRenderer: Path,
},
]}
emptyState={
<ListEmptyState
message={t("noSearchResults")}
instructions={t("noSearchResultsInstructions")}
hasIcon={false}
/>
}
/>
</PageSection>
</>
);
}

View file

@ -1,7 +1,6 @@
import type { RouteDef } from "../route-config";
import { GroupsRoute, GroupsWithIdRoute } from "./routes/Groups";
import { GroupsSearchRoute } from "./routes/GroupsSearch";
const routes: RouteDef[] = [GroupsSearchRoute, GroupsRoute, GroupsWithIdRoute];
const routes: RouteDef[] = [GroupsRoute, GroupsWithIdRoute];
export default routes;

View file

@ -1,17 +0,0 @@
import { lazy } from "react";
import type { Path } from "react-router-dom-v5-compat";
import { generatePath } from "react-router-dom-v5-compat";
import type { RouteDef } from "../../route-config";
export type GroupsSearchParams = { realm: string };
export const GroupsSearchRoute: RouteDef = {
path: "/:realm/groups/search",
component: lazy(() => import("../SearchGroups")),
breadcrumb: (t) => t("groups:searchGroups"),
access: "query-groups",
};
export const toGroupsSearch = (params: GroupsSearchParams): Partial<Path> => ({
pathname: generatePath(GroupsSearchRoute.path, params),
});

View file

@ -53,11 +53,15 @@ public class GroupsResource {
)}
)
public final Stream<GroupRepresentation> listGroups(@QueryParam("search") @DefaultValue("") final String search, @QueryParam("first")
@DefaultValue("0") int first, @QueryParam("max") @DefaultValue("10") int max) {
@DefaultValue("0") int first, @QueryParam("max") @DefaultValue("10") int max, @QueryParam("global") @DefaultValue("true") boolean global) {
this.auth.groups().requireList();
final Stream<GroupModel> stream;
if ("".equals(search)) {
if (!"".equals(search)) {
if (global) {
stream = this.realm.searchForGroupByNameStream(search, first, max);
} else {
stream = this.realm.getTopLevelGroupsStream().filter(g -> g.getName().contains(search)).skip(first).limit(max);
}
} else {
stream = this.realm.getTopLevelGroupsStream(first, max);
}