From 689e01b461a3252c6c3ae5421b984927f5edff66 Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Wed, 17 Feb 2021 22:12:25 +0100 Subject: [PATCH] created better error handling (#362) by using this react-error-boundary package this works also in hooks --- package.json | 5 +- src/App.tsx | 47 +++++++++++-------- src/client-scopes/add/RoleMappingForm.tsx | 8 +++- src/client-scopes/details/MappingDetails.tsx | 5 +- src/client-scopes/form/ClientScopeForm.tsx | 5 +- src/clients/ClientDetails.tsx | 8 +++- src/clients/credentials/Credentials.tsx | 14 ++++-- src/clients/scopes/ClientScopes.tsx | 5 +- src/clients/scopes/EvaluateScopes.tsx | 14 ++++-- src/common-messages.json | 2 + src/components/data-loader/DataLoader.tsx | 6 ++- src/components/error/ErrorRenderer.tsx | 36 ++++++++++++++ .../table-toolbar/KeycloakDataTable.tsx | 5 +- src/context/auth/AdminClient.tsx | 20 +++++--- src/context/whoami/WhoAmI.tsx | 5 +- src/realm-settings/RealmSettingsSection.tsx | 5 +- yarn.lock | 7 +++ 17 files changed, 151 insertions(+), 46 deletions(-) create mode 100644 src/components/error/ErrorRenderer.tsx diff --git a/package.json b/package.json index 9baef43751..d1f35ef3f3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "main": "index.js", "license": "MIT", "private": true, - "workspaces": ["tests"], + "workspaces": [ + "tests" + ], "scripts": { "build": "snowpack build", "build-storybook": "build-storybook -s public", @@ -31,6 +33,7 @@ "moment": "^2.29.1", "react": "^16.8.5", "react-dom": "^16.8.5", + "react-error-boundary": "^3.1.0", "react-hook-form": "^6.8.3", "react-i18next": "^11.7.0", "react-router-dom": "^5.2.0", diff --git a/src/App.tsx b/src/App.tsx index ef84f11b19..07be6b2ccf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { Switch, useParams, } from "react-router-dom"; +import { ErrorBoundary, useErrorHandler } from "react-error-boundary"; import { Header } from "./PageHeader"; import { PageNav } from "./PageNav"; @@ -21,6 +22,7 @@ import { ForbiddenSection } from "./ForbiddenSection"; import { SubGroups } from "./groups/GroupsSection"; import { useRealm } from "./context/realm-context/RealmContext"; import { useAdminClient, asyncStateFetch } from "./context/auth/AdminClient"; +import { ErrorRenderer } from "./components/error/ErrorRenderer"; // This must match the id given as scrollableSelector in scroll-form const mainPageContentId = "kc-main-content-page-container"; @@ -42,6 +44,7 @@ const RealmPathSelector = ({ children }: { children: ReactNode }) => { const { setRealm } = useRealm(); const { realm } = useParams<{ realm: string }>(); const adminClient = useAdminClient(); + const handleError = useErrorHandler(); useEffect( () => asyncStateFetch( @@ -50,7 +53,8 @@ const RealmPathSelector = ({ children }: { children: ReactNode }) => { if (realms.findIndex((r) => r.realm == realm) !== -1) { setRealm(realm); } - } + }, + handleError ), [] ); @@ -79,24 +83,29 @@ export const App = () => { breadcrumb={} mainContainerId={mainPageContentId} > - - {routes(() => {}).map((route, i) => ( - ( - - - - )} - /> - ))} - + (location.href = "/")} + > + + {routes(() => {}).map((route, i) => ( + ( + + + + )} + /> + ))} + + diff --git a/src/client-scopes/add/RoleMappingForm.tsx b/src/client-scopes/add/RoleMappingForm.tsx index 4d974f54d9..655832344e 100644 --- a/src/client-scopes/add/RoleMappingForm.tsx +++ b/src/client-scopes/add/RoleMappingForm.tsx @@ -2,6 +2,7 @@ import React, { useContext, useEffect, useState } from "react"; import { useHistory, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Controller, useForm } from "react-hook-form"; +import { useErrorHandler } from "react-error-boundary"; import { FormGroup, PageSection, @@ -35,6 +36,7 @@ import { FormAccess } from "../../components/form-access/FormAccess"; export const RoleMappingForm = () => { const { realm } = useContext(RealmContext); const adminClient = useAdminClient(); + const handleError = useErrorHandler(); const history = useHistory(); const { addAlert } = useAlerts(); @@ -74,7 +76,8 @@ export const RoleMappingForm = () => { ); return filteredClients; }, - (filteredClients) => setClients(filteredClients) + (filteredClients) => setClients(filteredClients), + handleError ); }, []); @@ -91,7 +94,8 @@ export const RoleMappingForm = () => { return await adminClient.roles.find(); } }, - (clientRoles) => setClientRoles(clientRoles) + (clientRoles) => setClientRoles(clientRoles), + handleError ); }, [selectedClient]); diff --git a/src/client-scopes/details/MappingDetails.tsx b/src/client-scopes/details/MappingDetails.tsx index c43aaec3ed..1c977334a1 100644 --- a/src/client-scopes/details/MappingDetails.tsx +++ b/src/client-scopes/details/MappingDetails.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import { useHistory, useParams, useRouteMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import { useErrorHandler } from "react-error-boundary"; import { ActionGroup, AlertVariant, @@ -43,6 +44,7 @@ type Params = { export const MappingDetails = () => { const { t } = useTranslation("client-scopes"); const adminClient = useAdminClient(); + const handleError = useErrorHandler(); const { addAlert } = useAlerts(); const { scopeId, id } = useParams(); @@ -90,7 +92,8 @@ export const MappingDetails = () => { (result) => { setConfigProperties(result.configProperties); setMapping(result.mapping); - } + }, + handleError ); }, []); diff --git a/src/client-scopes/form/ClientScopeForm.tsx b/src/client-scopes/form/ClientScopeForm.tsx index 9d616624ac..908aa98c99 100644 --- a/src/client-scopes/form/ClientScopeForm.tsx +++ b/src/client-scopes/form/ClientScopeForm.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; import { useHistory, useParams } from "react-router-dom"; +import { useErrorHandler } from "react-error-boundary"; import { ActionGroup, AlertVariant, @@ -41,6 +42,7 @@ export const ClientScopeForm = () => { const [clientScope, setClientScope] = useState(); const adminClient = useAdminClient(); + const handleError = useErrorHandler(); const providers = useLoginProviders(); const { id } = useParams<{ id: string }>(); @@ -67,7 +69,8 @@ export const ClientScopeForm = () => { return data; } }, - (data) => setClientScope(data) + (data) => setClientScope(data), + handleError ); }, [key]); diff --git a/src/clients/ClientDetails.tsx b/src/clients/ClientDetails.tsx index 98b69853b3..21c45fa800 100644 --- a/src/clients/ClientDetails.tsx +++ b/src/clients/ClientDetails.tsx @@ -8,9 +8,10 @@ import { Tab, TabTitleText, } from "@patternfly/react-core"; +import { useParams } from "react-router-dom"; +import { useErrorHandler } from "react-error-boundary"; import { useTranslation } from "react-i18next"; import { Controller, useForm, useWatch } from "react-hook-form"; -import { useParams } from "react-router-dom"; import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation"; import { ClientSettings } from "./ClientSettings"; @@ -96,6 +97,8 @@ const ClientDetailHeader = ({ export const ClientDetails = () => { const { t } = useTranslation("clients"); const adminClient = useAdminClient(); + const handleError = useErrorHandler(); + const { addAlert } = useAlerts(); const form = useForm(); @@ -164,7 +167,8 @@ export const ClientDetails = () => { (fetchedClient) => { setClient(fetchedClient); setupForm(fetchedClient); - } + }, + handleError ); }, []); diff --git a/src/clients/credentials/Credentials.tsx b/src/clients/credentials/Credentials.tsx index a74f779f73..7dce67d0e6 100644 --- a/src/clients/credentials/Credentials.tsx +++ b/src/clients/credentials/Credentials.tsx @@ -1,3 +1,7 @@ +import React, { useEffect, useState } from "react"; +import { Controller, UseFormMethods, useWatch } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useErrorHandler } from "react-error-boundary"; import { ActionGroup, AlertVariant, @@ -14,18 +18,16 @@ import { SplitItem, } from "@patternfly/react-core"; import CredentialRepresentation from "keycloak-admin/lib/defs/credentialRepresentation"; -import React, { useEffect, useState } from "react"; -import { Controller, UseFormMethods, useWatch } from "react-hook-form"; -import { useTranslation } from "react-i18next"; + import { useAlerts } from "../../components/alert/Alerts"; import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog"; import { FormAccess } from "../../components/form-access/FormAccess"; import { HelpItem } from "../../components/help-enabler/HelpItem"; - import { useAdminClient, asyncStateFetch, } from "../../context/auth/AdminClient"; + import { ClientSecret } from "./ClientSecret"; import { SignedJWT } from "./SignedJWT"; import { X509 } from "./X509"; @@ -53,6 +55,7 @@ export type CredentialsProps = { export const Credentials = ({ clientId, form, save }: CredentialsProps) => { const { t } = useTranslation("clients"); const adminClient = useAdminClient(); + const handleError = useErrorHandler(); const { addAlert } = useAlerts(); const clientAuthenticatorType = useWatch({ control: form.control, @@ -84,7 +87,8 @@ export const Credentials = ({ clientId, form, save }: CredentialsProps) => { ({ providers, secret }) => { setProviders(providers); setSecret(secret); - } + }, + handleError ); }, []); diff --git a/src/clients/scopes/ClientScopes.tsx b/src/clients/scopes/ClientScopes.tsx index c14059c721..0841ae57e1 100644 --- a/src/clients/scopes/ClientScopes.tsx +++ b/src/clients/scopes/ClientScopes.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { useErrorHandler } from "react-error-boundary"; import { IFormatter, IFormatterValueType, @@ -123,6 +124,7 @@ type TableRow = { export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => { const { t } = useTranslation("clients"); const adminClient = useAdminClient(); + const handleError = useErrorHandler(); const { addAlert } = useAlerts(); const [searchToggle, setSearchToggle] = useState(false); @@ -182,7 +184,8 @@ export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => { ({ rows, rest }) => { setRows(rows); setRest(rest); - } + }, + handleError ); }, [key]); diff --git a/src/clients/scopes/EvaluateScopes.tsx b/src/clients/scopes/EvaluateScopes.tsx index ebb5b38814..6b560c8b01 100644 --- a/src/clients/scopes/EvaluateScopes.tsx +++ b/src/clients/scopes/EvaluateScopes.tsx @@ -1,5 +1,6 @@ import React, { useContext, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useErrorHandler } from "react-error-boundary"; import { ClipboardCopy, EmptyState, @@ -145,10 +146,12 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => { const tabContent2 = useRef(null); const tabContent3 = useRef(null); + const handleError = useErrorHandler(); useEffect(() => { return asyncStateFetch( () => adminClient.clients.listOptionalClientScopes({ id: clientId }), - (optionalClientScopes) => setSelectableScopes(optionalClientScopes) + (optionalClientScopes) => setSelectableScopes(optionalClientScopes), + handleError ); }, []); @@ -182,7 +185,8 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => { return user; }) .map((user) => ) - ) + ), + handleError ); }, [userSearch]); @@ -221,7 +225,8 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => { setProtocolMappers(mapperList); refresh(); - } + }, + handleError ); }, [selected]); @@ -241,7 +246,8 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => { }, (accessToken) => { setAccessToken(JSON.stringify(accessToken, undefined, 3)); - } + }, + handleError ); }, [user, selected]); diff --git a/src/common-messages.json b/src/common-messages.json index b28c4d4af9..dc60ccb45d 100644 --- a/src/common-messages.json +++ b/src/common-messages.json @@ -50,6 +50,8 @@ "type": "Type", "category": "Category", "priority": "Priority", + "unexpectedError": "An unexpected error occurred: '{{error}}'", + "retry": "Retry", "home": "Home", "manage": "Manage", diff --git a/src/components/data-loader/DataLoader.tsx b/src/components/data-loader/DataLoader.tsx index c15220a9a1..1ef1d9683d 100644 --- a/src/components/data-loader/DataLoader.tsx +++ b/src/components/data-loader/DataLoader.tsx @@ -1,5 +1,7 @@ import React, { DependencyList, useEffect, useState } from "react"; import { Spinner } from "@patternfly/react-core"; +import { useErrorHandler } from "react-error-boundary"; + import { asyncStateFetch } from "../../context/auth/AdminClient"; type DataLoaderProps = { @@ -10,11 +12,13 @@ type DataLoaderProps = { export function DataLoader(props: DataLoaderProps) { const [data, setData] = useState(); + const handleError = useErrorHandler(); useEffect(() => { return asyncStateFetch( () => props.loader(), - (result) => setData(result) + (result) => setData(result), + handleError ); }, props.deps || []); diff --git a/src/components/error/ErrorRenderer.tsx b/src/components/error/ErrorRenderer.tsx new file mode 100644 index 0000000000..04751f02c5 --- /dev/null +++ b/src/components/error/ErrorRenderer.tsx @@ -0,0 +1,36 @@ +import { + Alert, + AlertActionCloseButton, + AlertActionLink, + AlertVariant, + PageSection, +} from "@patternfly/react-core"; +import React from "react"; +import { FallbackProps } from "react-error-boundary"; +import { useTranslation } from "react-i18next"; + +export const ErrorRenderer = ({ error, resetErrorBoundary }: FallbackProps) => { + const { t } = useTranslation(); + return ( + + + } + actionLinks={ + + + {t("retry")} + + + } + > + + ); +}; diff --git a/src/components/table-toolbar/KeycloakDataTable.tsx b/src/components/table-toolbar/KeycloakDataTable.tsx index 6b8a8b803b..3bd7cb76dc 100644 --- a/src/components/table-toolbar/KeycloakDataTable.tsx +++ b/src/components/table-toolbar/KeycloakDataTable.tsx @@ -1,5 +1,6 @@ import React, { ReactNode, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useErrorHandler } from "react-error-boundary"; import { IAction, IActions, @@ -139,6 +140,7 @@ export function KeycloakDataTable({ const [key, setKey] = useState(0); const refresh = () => setKey(new Date().getTime()); + const handleError = useErrorHandler(); useEffect(() => { setRefresher && setRefresher(refresh); @@ -168,7 +170,8 @@ export function KeycloakDataTable({ setRows(result); setFilteredData(result); setLoading(false); - } + }, + handleError ); }, [key, first, max]); diff --git a/src/context/auth/AdminClient.tsx b/src/context/auth/AdminClient.tsx index 5270cea3af..9fc6db1c9e 100644 --- a/src/context/auth/AdminClient.tsx +++ b/src/context/auth/AdminClient.tsx @@ -32,18 +32,26 @@ export const useAdminClient = () => { * * @param adminClientCall use this to do your adminClient call * @param callback when the data is fetched this is where you set your state + * @param onError custom error handler */ export function asyncStateFetch( adminClientCall: () => Promise, - callback: (param: T) => void + callback: (param: T) => void, + onError?: (error: Error) => void ) { let canceled = false; - adminClientCall().then((result) => { - if (!canceled) { - callback(result); - } - }); + adminClientCall() + .then((result) => { + try { + if (!canceled) { + callback(result); + } + } catch (error) { + if (onError) onError(error); + } + }) + .catch(onError); return () => { canceled = true; diff --git a/src/context/whoami/WhoAmI.tsx b/src/context/whoami/WhoAmI.tsx index 4e22998bbb..65945604cd 100644 --- a/src/context/whoami/WhoAmI.tsx +++ b/src/context/whoami/WhoAmI.tsx @@ -1,4 +1,5 @@ import React, { useContext, useEffect, useState } from "react"; +import { useErrorHandler } from "react-error-boundary"; import i18n from "../../i18n"; import { AdminClient, asyncStateFetch } from "../auth/AdminClient"; @@ -62,6 +63,7 @@ export const WhoAmIContext = React.createContext({ type WhoAmIProviderProps = { children: React.ReactNode }; export const WhoAmIContextProvider = ({ children }: WhoAmIProviderProps) => { const adminClient = useContext(AdminClient)!; + const handleError = useErrorHandler(); const { realm, setRealm } = useContext(RealmContext); const [whoAmI, setWhoAmI] = useState(new WhoAmI()); const [key, setKey] = useState(0); @@ -78,7 +80,8 @@ export const WhoAmIContextProvider = ({ children }: WhoAmIProviderProps) => { setRealm(whoAmI.getHomeRealm()); } setWhoAmI(whoAmI); - } + }, + handleError ); }, [key]); diff --git a/src/realm-settings/RealmSettingsSection.tsx b/src/realm-settings/RealmSettingsSection.tsx index 09a109264c..2334a63946 100644 --- a/src/realm-settings/RealmSettingsSection.tsx +++ b/src/realm-settings/RealmSettingsSection.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import { useHistory } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Controller, useForm } from "react-hook-form"; +import { useErrorHandler } from "react-error-boundary"; import { ActionGroup, AlertVariant, @@ -119,6 +120,7 @@ const requireSslTypes = ["all", "external", "none"]; export const RealmSettingsSection = () => { const { t } = useTranslation("realm-settings"); const adminClient = useAdminClient(); + const handleError = useErrorHandler(); const { realm: realmName } = useRealm(); const { addAlert } = useAlerts(); const { register, control, getValues, setValue, handleSubmit } = useForm(); @@ -134,7 +136,8 @@ export const RealmSettingsSection = () => { (realm) => { setRealm(realm); setupForm(realm); - } + }, + handleError ); }, []); diff --git a/yarn.lock b/yarn.lock index b4c39597c2..c7a6f94d81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16880,6 +16880,13 @@ react-element-to-jsx-string@^14.0.2, react-element-to-jsx-string@^14.3.1: "@base2/pretty-print-object" "1.0.0" is-plain-object "3.0.0" +react-error-boundary@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.0.tgz#9487443df2f9ba1db90d8ab52351814907ea4af3" + integrity sha512-lmPrdi5SLRJR+AeJkqdkGlW/CRkAUvZnETahK58J4xb5wpbfDngasEGu+w0T1iXEhVrYBJZeW+c4V1hILCnMWQ== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-overlay@^6.0.7: version "6.0.7" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108"