From 08ead04c43ec2a25abc99d8191961e7279d3b5af Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Thu, 13 Jun 2024 11:29:57 +0200 Subject: [PATCH] added pagination to realm selector (#30219) * added pagination to realm selector fixes: #29978 Signed-off-by: Erik Jan de Wit * fix display name for recent and refresh on open Signed-off-by: Erik Jan de Wit --------- Signed-off-by: Erik Jan de Wit --- .../admin/messages/messages_en.properties | 1 + js/apps/admin-ui/src/App.tsx | 17 +- .../realm-selector/RealmSelector.tsx | 164 ++++++++++++------ .../admin-ui/src/context/RealmsContext.tsx | 82 --------- js/apps/admin-ui/src/context/RecentRealms.tsx | 28 +-- .../src/realm-settings/RealmSettingsTabs.tsx | 5 - .../admin-ui/src/realm/add/NewRealmForm.tsx | 3 - .../admin/ui/rest/UIRealmsResource.java | 28 ++- .../rest/model/RealmNameRepresentation.java | 5 + 9 files changed, 137 insertions(+), 196 deletions(-) delete mode 100644 js/apps/admin-ui/src/context/RealmsContext.tsx diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 24f14615285..0b614344422 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -2024,6 +2024,7 @@ eventTypes.REGISTER_ERROR.description=Register error infoDisabledFeatures=Shows all disabled features. userSession.modelNote.label=User Session Note next=Next +previous=Previous userLabel=User label pagination=Pagination changeAuthenticatorConfirm=If you change authenticator to {{clientAuthenticatorType}}, the Keycloak database will be updated and you may need to download a new adapter configuration for this client. diff --git a/js/apps/admin-ui/src/App.tsx b/js/apps/admin-ui/src/App.tsx index e4697ddca45..3d64f7d8c7f 100644 --- a/js/apps/admin-ui/src/App.tsx +++ b/js/apps/admin-ui/src/App.tsx @@ -18,7 +18,6 @@ import { ErrorBoundaryFallback, ErrorBoundaryProvider, } from "./context/ErrorBoundary"; -import { RealmsProvider } from "./context/RealmsContext"; import { RecentRealmsProvider } from "./context/RecentRealms"; import { AccessContextProvider } from "./context/access/Access"; import { RealmContextProvider } from "./context/realm-context/RealmContext"; @@ -33,15 +32,13 @@ const AppContexts = ({ children }: PropsWithChildren) => ( - - - - - {children} - - - - + + + + {children} + + + diff --git a/js/apps/admin-ui/src/components/realm-selector/RealmSelector.tsx b/js/apps/admin-ui/src/components/realm-selector/RealmSelector.tsx index 7e28df6cbac..d6f9e7792db 100644 --- a/js/apps/admin-ui/src/components/realm-selector/RealmSelector.tsx +++ b/js/apps/admin-ui/src/components/realm-selector/RealmSelector.tsx @@ -1,3 +1,4 @@ +import { NetworkError } from "@keycloak/keycloak-admin-client"; import { label } from "@keycloak/keycloak-ui-shared"; import { Button, @@ -15,19 +16,28 @@ import { Stack, StackItem, } from "@patternfly/react-core"; -import { CheckIcon } from "@patternfly/react-icons"; -import { Fragment, useMemo, useState } from "react"; +import { + AngleLeftIcon, + AngleRightIcon, + CheckIcon, +} from "@patternfly/react-icons"; +import { debounce } from "lodash-es"; +import { Fragment, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; -import { useRealms } from "../../context/RealmsContext"; +import { useAdminClient } from "../../admin-client"; import { useRecentRealms } from "../../context/RecentRealms"; +import { fetchAdminUI } from "../../context/auth/admin-ui-endpoint"; import { useRealm } from "../../context/realm-context/RealmContext"; import { useWhoAmI } from "../../context/whoami/WhoAmI"; import { toDashboard } from "../../dashboard/routes/Dashboard"; import { toAddRealm } from "../../realm/routes/AddRealm"; +import { useFetch } from "../../utils/useFetch"; import "./realm-selector.css"; +const MAX_RESULTS = 10; + type AddRealmProps = { onClick: () => void; }; @@ -80,49 +90,67 @@ const RealmText = ({ name, displayName, showIsRecent }: RealmTextProps) => { ); }; +type RealmNameRepresentation = { + name: string; + displayName?: string; +}; + export const RealmSelector = () => { const { realm } = useRealm(); - const { realms } = useRealms(); + const { adminClient } = useAdminClient(); const { whoAmI } = useWhoAmI(); const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); + const [realms, setRealms] = useState([]); const { t } = useTranslation(); const recentRealms = useRecentRealms(); const navigate = useNavigate(); - const all = useMemo( - () => - realms - .map((realm) => { - const used = recentRealms.some((name) => name === realm.name); - return { realm, used }; - }) - .sort((r1, r2) => { - if (r1.used == r2.used) return 0; - if (r1.used) return -1; - if (r2.used) return 1; - return 0; - }), - [recentRealms, realm, realms], + const [search, setSearch] = useState(""); + const [first, setFirst] = useState(0); + + const debounceFn = useCallback( + debounce((value: string) => { + setFirst(0); + setSearch(value); + }, 1000), + [], ); - const filteredItems = useMemo(() => { - const normalizedSearch = search.trim().toLowerCase(); - - if (normalizedSearch.length === 0) { - return all; - } - - return search.trim() === "" - ? all - : all.filter( - (r) => - r.realm.name.toLowerCase().includes(normalizedSearch) || - label(t, r.realm.displayName) - ?.toLowerCase() - .includes(normalizedSearch), + useFetch( + async () => { + try { + return await fetchAdminUI( + adminClient, + "ui-ext/realms/names", + { first: `${first}`, max: `${MAX_RESULTS + 1}`, search }, ); - }, [search, all]); + } catch (error) { + if (error instanceof NetworkError && error.response.status < 500) { + return []; + } + + throw error; + } + }, + setRealms, + [open, first, search], + ); + + const sortedRealms = useMemo( + () => [ + ...(first === 0 && !search + ? recentRealms.reduce((acc, name) => { + const realm = realms.find((r) => r.name === name); + if (realm) { + acc.push(realm); + } + return acc; + }, [] as RealmNameRepresentation[]) + : []), + ...realms.filter((r) => !recentRealms.includes(r.name)), + ], + [recentRealms, realms, first, search], + ); const realmDisplayName = useMemo( () => realms.find((r) => r.name === realm)?.displayName, @@ -148,13 +176,13 @@ export const RealmSelector = () => { )} > - {realms.length > 5 && ( + {(realms.length > 5 || search || first !== 0) && ( <> setSearch(value)} + onChange={(_, value) => debounceFn(value)} onClear={() => setSearch("")} /> @@ -163,25 +191,51 @@ export const RealmSelector = () => { )} {(realms.length !== 0 - ? filteredItems.map((i) => ( - { - navigate(toDashboard({ realm: i.realm.name })); - setOpen(false); - }} - > - 5 && i.used} - /> - - )) - : [ - - {t("loadingRealms")} - , + ? [ + first !== 0 ? ( + setFirst(first - MAX_RESULTS)}> + {t("previous")} + + ) : ( + [] + ), + ...sortedRealms.map((realm) => ( + { + navigate(toDashboard({ realm: realm.name })); + setOpen(false); + setSearch(""); + }} + > + 5 && recentRealms.includes(realm.name) + } + /> + + )), + realms.length > MAX_RESULTS ? ( + setFirst(first + MAX_RESULTS)}> + + {t("next")} + + ) : ( + [] + ), ] + : !search + ? [ + + {t("loadingRealms")} + , + ] + : [ + + {t("noResultsFound")} + , + ] ).concat([ {whoAmI.canCreateRealm() && ( diff --git a/js/apps/admin-ui/src/context/RealmsContext.tsx b/js/apps/admin-ui/src/context/RealmsContext.tsx deleted file mode 100644 index 27951ce09fa..00000000000 --- a/js/apps/admin-ui/src/context/RealmsContext.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { NetworkError } from "@keycloak/keycloak-admin-client"; -import { - createNamedContext, - label, - useEnvironment, - useRequiredContext, -} from "@keycloak/keycloak-ui-shared"; -import { PropsWithChildren, useCallback, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useAdminClient } from "../admin-client"; -import { useFetch } from "../utils/useFetch"; -import useLocaleSort from "../utils/useLocaleSort"; -import { fetchAdminUI } from "./auth/admin-ui-endpoint"; - -type RealmsContextProps = { - /** A list of all the realms. */ - realms: RealmNameRepresentation[]; - /** Refreshes the realms with the latest information. */ - refresh: () => Promise; -}; - -export interface RealmNameRepresentation { - name: string; - displayName?: string; -} - -export const RealmsContext = createNamedContext( - "RealmsContext", - undefined, -); - -export const RealmsProvider = ({ children }: PropsWithChildren) => { - const { keycloak } = useEnvironment(); - const { adminClient } = useAdminClient(); - - const [realms, setRealms] = useState([]); - const [refreshCount, setRefreshCount] = useState(0); - const localeSort = useLocaleSort(); - const { t } = useTranslation(); - - function updateRealms(realms: RealmNameRepresentation[]) { - setRealms(localeSort(realms, (r) => label(t, r.displayName, r.name))); - } - - useFetch( - async () => { - try { - return await fetchAdminUI( - adminClient, - "ui-ext/realms/names", - {}, - ); - } catch (error) { - if (error instanceof NetworkError && error.response.status < 500) { - return []; - } - - throw error; - } - }, - (realms) => updateRealms(realms), - [refreshCount], - ); - - const refresh = useCallback(async () => { - //this is needed otherwise the realm find function will not return - //new or renamed realms because of the cached realms in the token (perhaps?) - await keycloak.updateToken(Number.MAX_VALUE); - setRefreshCount((count) => count + 1); - }, []); - - const value = useMemo( - () => ({ realms, refresh }), - [realms, refresh], - ); - - return ( - {children} - ); -}; - -export const useRealms = () => useRequiredContext(RealmsContext); diff --git a/js/apps/admin-ui/src/context/RecentRealms.tsx b/js/apps/admin-ui/src/context/RecentRealms.tsx index 05d09fe8f63..c3145a7c7d7 100644 --- a/js/apps/admin-ui/src/context/RecentRealms.tsx +++ b/js/apps/admin-ui/src/context/RecentRealms.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren, useEffect, useMemo } from "react"; +import { PropsWithChildren, useEffect } from "react"; import { createNamedContext, @@ -6,7 +6,6 @@ import { useStoredState, } from "@keycloak/keycloak-ui-shared"; import { useRealm } from "./realm-context/RealmContext"; -import { RealmNameRepresentation, useRealms } from "./RealmsContext"; const MAX_REALMS = 4; @@ -16,7 +15,6 @@ export const RecentRealmsContext = createNamedContext( ); export const RecentRealmsProvider = ({ children }: PropsWithChildren) => { - const { realms } = useRealms(); const { realm } = useRealm(); const [storedRealms, setStoredRealms] = useStoredState( localStorage, @@ -24,36 +22,16 @@ export const RecentRealmsProvider = ({ children }: PropsWithChildren) => { [realm], ); - const recentRealms = useMemo( - () => filterRealmNames(realms, storedRealms), - [realms, storedRealms], - ); - useEffect(() => { - const newRealms = [...new Set([realm, ...recentRealms])]; + const newRealms = [...new Set([realm, ...storedRealms])]; setStoredRealms(newRealms.slice(0, MAX_REALMS)); }, [realm]); return ( - + {children} ); }; export const useRecentRealms = () => useRequiredContext(RecentRealmsContext); - -function filterRealmNames( - realms: RealmNameRepresentation[], - storedRealms: string[], -) { - // If no realms have been set yet we can't filter out any non-existent realm names. - if (realms.length === 0) { - return storedRealms; - } - - // Only keep realm names that actually still exist. - return storedRealms.filter((realm) => { - return realms.some((r) => r.name === realm); - }); -} diff --git a/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx b/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx index b3e1a490000..a6b983c30dd 100644 --- a/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx +++ b/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx @@ -26,7 +26,6 @@ import { useRoutableTab, } from "../components/routable-tabs/RoutableTabs"; import { ViewHeader } from "../components/view-header/ViewHeader"; -import { useRealms } from "../context/RealmsContext"; import { useAccess } from "../context/access/Access"; import { useRealm } from "../context/realm-context/RealmContext"; import { toDashboard } from "../dashboard/routes/Dashboard"; @@ -78,7 +77,6 @@ const RealmSettingsHeader = ({ const { adminClient } = useAdminClient(); const { environment } = useEnvironment(); const { t } = useTranslation(); - const { refresh: refreshRealms } = useRealms(); const { addAlert, addError } = useAlerts(); const navigate = useNavigate(); const [partialImportOpen, setPartialImportOpen] = useState(false); @@ -105,7 +103,6 @@ const RealmSettingsHeader = ({ try { await adminClient.realms.del({ realm: realmName }); addAlert(t("deletedSuccessRealmSetting"), AlertVariant.success); - await refreshRealms(); navigate(toDashboard({ realm: environment.masterRealm })); refresh(); } catch (error) { @@ -179,7 +176,6 @@ export const RealmSettingsTabs = () => { const { t } = useTranslation(); const { addAlert, addError } = useAlerts(); const { realm: realmName, realmRepresentation: realm, refresh } = useRealm(); - const { refresh: refreshRealms } = useRealms(); const combinedLocales = useLocale(); const navigate = useNavigate(); const isFeatureEnabled = useIsFeatureEnabled(); @@ -271,7 +267,6 @@ export const RealmSettingsTabs = () => { const isRealmRenamed = realmName !== (r.realm || realm?.realm); if (isRealmRenamed) { - await refreshRealms(); navigate(toRealmSettings({ realm: r.realm!, tab: "general" })); } refresh(); diff --git a/js/apps/admin-ui/src/realm/add/NewRealmForm.tsx b/js/apps/admin-ui/src/realm/add/NewRealmForm.tsx index a77357237dd..3fcdf8b9b6d 100644 --- a/js/apps/admin-ui/src/realm/add/NewRealmForm.tsx +++ b/js/apps/admin-ui/src/realm/add/NewRealmForm.tsx @@ -11,7 +11,6 @@ import { useAlerts } from "../../components/alert/Alerts"; import { FormAccess } from "../../components/form/FormAccess"; import { JsonFileUpload } from "../../components/json-file-upload/JsonFileUpload"; import { ViewHeader } from "../../components/view-header/ViewHeader"; -import { useRealms } from "../../context/RealmsContext"; import { useWhoAmI } from "../../context/whoami/WhoAmI"; import { toDashboard } from "../../dashboard/routes/Dashboard"; import { convertFormValuesToObject, convertToFormValues } from "../../util"; @@ -22,7 +21,6 @@ export default function NewRealmForm() { const { t } = useTranslation(); const navigate = useNavigate(); const { refresh, whoAmI } = useWhoAmI(); - const { refresh: refreshRealms } = useRealms(); const { addAlert, addError } = useAlerts(); const [realm, setRealm] = useState(); @@ -47,7 +45,6 @@ export default function NewRealmForm() { addAlert(t("saveRealmSuccess")); refresh(); - await refreshRealms(); navigate(toDashboard({ realm: fields.realm })); } catch (error) { addError("saveRealmError", error); diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmsResource.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmsResource.java index e84bb904eab..7935db5f468 100644 --- a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmsResource.java +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmsResource.java @@ -1,13 +1,12 @@ package org.keycloak.admin.ui.rest; -import static org.keycloak.utils.StreamsUtil.throwIfEmpty; - import java.util.stream.Stream; -import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; @@ -51,19 +50,16 @@ public class UIRealmsResource { ) )} ) - public Stream getRealms() { + public Stream getRealms(@QueryParam("first") @DefaultValue("0") int first, + @QueryParam("max") @DefaultValue("10") int max, + @QueryParam("search") @DefaultValue("") String search) { final RealmsPermissionEvaluator eval = AdminPermissions.realms(session, auth.adminAuth()); - - Stream realms = session.realms().getRealmsStream() - .filter(realm -> { - return eval.canView(realm) || eval.isAdmin(realm); - }) - .map((RealmModel realm) -> { - RealmNameRepresentation realmNameRep = new RealmNameRepresentation(); - realmNameRep.setDisplayName(realm.getDisplayName()); - realmNameRep.setName(realm.getName()); - return realmNameRep; - }); - return throwIfEmpty(realms, new ForbiddenException()); + + return session.realms().getRealmsStream() + .filter(realm -> eval.canView(realm) || eval.isAdmin(realm)) + .filter(realm -> search.isEmpty() || realm.getName().toLowerCase().contains(search.trim().toLowerCase())) + .skip(first) + .limit(max) + .map((RealmModel realm) -> new RealmNameRepresentation(realm.getName(), realm.getDisplayName())); } } diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/RealmNameRepresentation.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/RealmNameRepresentation.java index 06d5787a581..a306fcbb617 100644 --- a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/RealmNameRepresentation.java +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/RealmNameRepresentation.java @@ -4,6 +4,11 @@ public class RealmNameRepresentation { private String name; private String displayName; + public RealmNameRepresentation(String name, String displayName) { + this.name = name; + this.displayName = displayName; + } + public String getName() { return this.name; }