From 3287927797262592d0db4b7e2994b63b01305cc0 Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Thu, 15 Sep 2022 20:12:20 +0200 Subject: [PATCH] United group filter and group search (#3259) --- apps/admin-ui/cypress/e2e/group_test.spec.ts | 36 +--- .../admin_console/manage/groups/GroupPage.ts | 13 +- .../manage/groups/SearchGroupPage.ts | 4 +- apps/admin-ui/public/resources/en/groups.json | 3 + apps/admin-ui/src/groups/GroupTable.tsx | 132 +++++++++++-- apps/admin-ui/src/groups/GroupsSection.tsx | 16 +- apps/admin-ui/src/groups/SearchGroups.tsx | 183 ------------------ apps/admin-ui/src/groups/routes.ts | 3 +- .../src/groups/routes/GroupsSearch.tsx | 17 -- .../admin/ui/rest/GroupsResource.java | 10 +- 10 files changed, 138 insertions(+), 279 deletions(-) delete mode 100644 apps/admin-ui/src/groups/SearchGroups.tsx delete mode 100644 apps/admin-ui/src/groups/routes/GroupsSearch.tsx diff --git a/apps/admin-ui/cypress/e2e/group_test.spec.ts b/apps/admin-ui/cypress/e2e/group_test.spec.ts index f825e936d6..2c86b1716d 100644 --- a/apps/admin-ui/cypress/e2e/group_test.spec.ts +++ b/apps/admin-ui/cypress/e2e/group_test.spec.ts @@ -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]) diff --git a/apps/admin-ui/cypress/support/pages/admin_console/manage/groups/GroupPage.ts b/apps/admin-ui/cypress/support/pages/admin_console/manage/groups/GroupPage.ts index e70f89d55b..ee6279225b 100644 --- a/apps/admin-ui/cypress/support/pages/admin_console/manage/groups/GroupPage.ts +++ b/apps/admin-ui/cypress/support/pages/admin_console/manage/groups/GroupPage.ts @@ -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; } diff --git a/apps/admin-ui/cypress/support/pages/admin_console/manage/groups/SearchGroupPage.ts b/apps/admin-ui/cypress/support/pages/admin_console/manage/groups/SearchGroupPage.ts index 37deb10f73..2f31d40749 100644 --- a/apps/admin-ui/cypress/support/pages/admin_console/manage/groups/SearchGroupPage.ts +++ b/apps/admin-ui/cypress/support/pages/admin_console/manage/groups/SearchGroupPage.ts @@ -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; } diff --git a/apps/admin-ui/public/resources/en/groups.json b/apps/admin-ui/public/resources/en/groups.json index 0d667d067d..4d12729383 100644 --- a/apps/admin-ui/public/resources/en/groups.json +++ b/apps/admin-ui/public/resources/en/groups.json @@ -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", diff --git a/apps/admin-ui/src/groups/GroupTable.tsx b/apps/admin-ui/src/groups/GroupTable.tsx index 3030fa4c21..06827dfdf9 100644 --- a/apps/admin-ui/src/groups/GroupTable.tsx +++ b/apps/admin-ui/src/groups/GroupTable.tsx @@ -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(); const navigate = useNavigate(); const location = useLocation(); const id = getLastId(location.pathname); + const [searchType, setSearchType] = useState( + 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 => { + const params: Record = { + 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 ? : undefined; + return ( <> { canSelectAll loader={loader} ariaLabelKey="groups:groups" - searchPlaceholderKey="groups:searchForGroups" + isPaginated + isSearching={!!search} toolbarItem={ - + <> + + { + setSearch(""); + refresh(); + }} + /> + + + + } + subToolbar={ + !!search && + !id && ( + + + {t("searchFor")} + + { + setSearchType("global"); + refresh(); + }} + name="searchType" + label={t("global")} + /> + + + { + setSearchType("local"); + refresh(); + }} + name="searchType" + label={t("local")} + /> + + + + ) } 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={ diff --git a/apps/admin-ui/src/groups/GroupsSection.tsx b/apps/admin-ui/src/groups/GroupsSection.tsx index cf986eb215..1eeb239f12 100644 --- a/apps/admin-ui/src/groups/GroupsSection.tsx +++ b/apps/admin-ui/src/groups/GroupsSection.tsx @@ -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 = ( - - {t("searchGroup")} - - } - /> - ); - return ( <> , ] - : [SearchDropdown] + : undefined } /> diff --git a/apps/admin-ui/src/groups/SearchGroups.tsx b/apps/admin-ui/src/groups/SearchGroups.tsx deleted file mode 100644 index 5d8b3638cc..0000000000 --- a/apps/admin-ui/src/groups/SearchGroups.tsx +++ /dev/null @@ -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([]); - - 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 {group.name}; - - return ( - - {group.name} - - ); - }; - - 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) => ; - - return ( - <> - - - - - - - setSearchTerm(value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - addTerm(); - } - }} - /> - - - - - - {searchTerms.map((term) => ( - deleteTerm(term)}> - {term} - - ))} - - - - - } - ariaLabelKey="groups:groups" - isPaginated - loader={loader} - columns={[ - { - name: "name", - displayKey: "groups:groupName", - cellRenderer: GroupNameCell, - }, - { - name: "path", - displayKey: "groups:path", - cellRenderer: Path, - }, - ]} - emptyState={ - - } - /> - - - ); -} diff --git a/apps/admin-ui/src/groups/routes.ts b/apps/admin-ui/src/groups/routes.ts index 9355d645bf..32400b4268 100644 --- a/apps/admin-ui/src/groups/routes.ts +++ b/apps/admin-ui/src/groups/routes.ts @@ -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; diff --git a/apps/admin-ui/src/groups/routes/GroupsSearch.tsx b/apps/admin-ui/src/groups/routes/GroupsSearch.tsx deleted file mode 100644 index 578fc115d2..0000000000 --- a/apps/admin-ui/src/groups/routes/GroupsSearch.tsx +++ /dev/null @@ -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 => ({ - pathname: generatePath(GroupsSearchRoute.path, params), -}); diff --git a/keycloak-theme/src/main/java/org/keycloak/admin/ui/rest/GroupsResource.java b/keycloak-theme/src/main/java/org/keycloak/admin/ui/rest/GroupsResource.java index 5afaead677..b56a4ecba0 100644 --- a/keycloak-theme/src/main/java/org/keycloak/admin/ui/rest/GroupsResource.java +++ b/keycloak-theme/src/main/java/org/keycloak/admin/ui/rest/GroupsResource.java @@ -53,11 +53,15 @@ public class GroupsResource { )} ) public final Stream 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 stream; - if ("".equals(search)) { - stream = this.realm.searchForGroupByNameStream(search, first, max); + 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); }