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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom-v5-compat";
import { useLocation, useNavigate } from "react-router-dom-v5-compat"; import { useLocation, useNavigate } from "react-router-dom-v5-compat";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@ -23,7 +22,6 @@ import { Members } from "./Members";
import { GroupAttributes } from "./GroupAttributes"; import { GroupAttributes } from "./GroupAttributes";
import { GroupsModal } from "./GroupsModal"; import { GroupsModal } from "./GroupsModal";
import { toGroups } from "./routes/Groups"; import { toGroups } from "./routes/Groups";
import { toGroupsSearch } from "./routes/GroupsSearch";
import { GroupRoleMapping } from "./GroupRoleMapping"; import { GroupRoleMapping } from "./GroupRoleMapping";
import helpUrls from "../help-urls"; import helpUrls from "../help-urls";
import { PermissionsTab } from "../components/permission-tab/PermissionTab"; import { PermissionsTab } from "../components/permission-tab/PermissionTab";
@ -89,17 +87,6 @@ export default function GroupsSection() {
[id] [id]
); );
const SearchDropdown = (
<DropdownItem
key="searchGroup"
component={
<Link data-testid="searchGroup" to={toGroupsSearch({ realm })}>
{t("searchGroup")}
</Link>
}
/>
);
return ( return (
<> <>
<DeleteGroup <DeleteGroup
@ -126,7 +113,6 @@ export default function GroupsSection() {
dropdownItems={ dropdownItems={
id && canManageGroup id && canManageGroup
? [ ? [
SearchDropdown,
<DropdownItem <DropdownItem
data-testid="renameGroupAction" data-testid="renameGroupAction"
key="renameGroup" key="renameGroup"
@ -142,7 +128,7 @@ export default function GroupsSection() {
{t("deleteGroup")} {t("deleteGroup")}
</DropdownItem>, </DropdownItem>,
] ]
: [SearchDropdown] : undefined
} }
/> />
<PageSection variant={PageSectionVariants.light} className="pf-u-p-0"> <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 type { RouteDef } from "../route-config";
import { GroupsRoute, GroupsWithIdRoute } from "./routes/Groups"; 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; 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") 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(); this.auth.groups().requireList();
final Stream<GroupModel> stream; final Stream<GroupModel> stream;
if ("".equals(search)) { if (!"".equals(search)) {
if (global) {
stream = this.realm.searchForGroupByNameStream(search, first, max); stream = this.realm.searchForGroupByNameStream(search, first, max);
} else {
stream = this.realm.getTopLevelGroupsStream().filter(g -> g.getName().contains(search)).skip(first).limit(max);
}
} else { } else {
stream = this.realm.getTopLevelGroupsStream(first, max); stream = this.realm.getTopLevelGroupsStream(first, max);
} }