diff --git a/src/app-routes.tsx b/src/app-routes.tsx index fdd494b..dd21576 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -2,8 +2,8 @@ import { Trans } from '@lingui/react/macro'; import { Banner, Flex, FlexItem } from '@patternfly/react-core'; import WrenchIcon from '@patternfly/react-icons/dist/esm/icons/wrench-icon'; import { type ElementType } from 'react'; -import { Navigate, Route, Routes, useLocation } from 'react-router'; -import { ExternalLink, NotFound } from 'src/components'; +import { Navigate, redirect, useLocation } from 'react-router'; +import { ErrorBoundary, ExternalLink, NotFound } from 'src/components'; import { AboutProject, AnsibleRemoteDetail, @@ -365,26 +365,47 @@ const AuthHandler = ({ ); }; -export const AppRoutes = () => ( - - {routes.map(({ beta, component, noAuth, path }, index) => ( - - } - key={index} +const appRoutes = () => + routes.map(({ beta, component, noAuth, path, ...rest }) => ({ + element: ( + - ))} - } - /> - } /> - -); + ), + path: path, + ...rest, + })); + +const convert = (m) => { + const { + default: Component, + clientLoader: loader, + clientAction: action, + ...rest + } = m; + return { ...rest, loader, action, Component }; +}; + +export const dataRoutes = [ + { + id: 'root', + lazy: () => import('src/routes/root').then((m) => convert(m)), + children: [ + { + errorElement: , + children: [ + { + index: true, + loader: () => redirect(formatPath(Paths.core.status)), + }, + ...appRoutes(), + // "No matching route" is not handled by the error boundary. + { path: '*', element: }, + ], + }, + ], + }, +]; diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx new file mode 100644 index 0000000..27b2262 --- /dev/null +++ b/src/components/error-boundary.tsx @@ -0,0 +1,27 @@ +import { isRouteErrorResponse, useRouteError } from 'react-router'; + +export const ErrorBoundary = () => { + const error = useRouteError(); + + if (isRouteErrorResponse(error)) { + return ( + <> +

+ {error.status} {error.statusText} +

+

{error.data.toString()}

+ + ); + } else if (error instanceof Error) { + return ( +
+

Error

+

{error.message}

+

The stack trace is:

+
{error.stack}
+
+ ); + console.error(error); + return
Something went horribly wrong!
; + } +}; diff --git a/src/components/index.ts b/src/components/index.ts index b953c46..d9626b0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -42,6 +42,7 @@ export { EmptyStateNoData } from './empty-state-no-data'; export { EmptyStateUnauthorized } from './empty-state-unauthorized'; export { EmptyStateNotImplemented } from './empty-state-under-construction'; export { EmptyStateXs } from './empty-state-xs'; +export { ErrorBoundary } from './error-boundary'; export { ExecutionEnvironmentHeader } from './execution-environment-header'; export { ExternalLink } from './external-link'; export { FormFieldHelper } from './form-field-helper'; diff --git a/src/entrypoint.tsx b/src/entrypoint.tsx index 9ad9b92..c06b7fb 100644 --- a/src/entrypoint.tsx +++ b/src/entrypoint.tsx @@ -6,15 +6,12 @@ import '@patternfly/patternfly/patternfly.scss'; import { Button } from '@patternfly/react-core'; import { StrictMode, useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router'; -import { Alert, LoadingSpinner, UIVersion } from 'src/components'; -import { AppContextProvider } from './app-context'; -import { AppRoutes } from './app-routes'; +import { RouterProvider, createBrowserRouter } from 'react-router'; +import { Alert, LoadingSpinner } from 'src/components'; +import { dataRoutes } from './app-routes'; import './darkmode'; import './l10n'; -import { StandaloneLayout } from './layout'; import { configFallback, configPromise } from './ui-config'; -import { UserContextProvider } from './user-context'; // App entrypoint @@ -94,16 +91,9 @@ function LoadConfig(_props) { ); } - return ( - - - - - - - - - - - ); + const router = createBrowserRouter(dataRoutes, { + basename: config.UI_BASE_PATH, + }); + + return ; } diff --git a/src/layout.tsx b/src/layout.tsx index 43c4cb5..84f9728 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -27,7 +27,7 @@ import { SmallLogo, StatefulDropdown, } from 'src/components'; -import { StandaloneMenu } from './menu'; +import { PulpMenu } from './menu'; import { Paths, formatPath } from './paths'; import { useUserContext } from './user-context'; @@ -84,7 +84,7 @@ const UserDropdown = ({ /> ); -export const StandaloneLayout = ({ children }: { children: ReactNode }) => { +export const Layout = ({ children }: { children: ReactNode }) => { const [aboutModalVisible, setAboutModalVisible] = useState(false); const { credentials, clearCredentials } = useUserContext(); @@ -127,7 +127,7 @@ export const StandaloneLayout = ({ children }: { children: ReactNode }) => { const Sidebar = ( - + ); diff --git a/src/menu.tsx b/src/menu.tsx index 880b30d..614e615 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -286,7 +286,7 @@ function usePlugins() { return plugins; } -export const StandaloneMenu = () => { +export const PulpMenu = () => { const [expandedSections, setExpandedSections] = useState([]); const location = useLocation(); diff --git a/src/routes/root.tsx b/src/routes/root.tsx new file mode 100644 index 0000000..417fad1 --- /dev/null +++ b/src/routes/root.tsx @@ -0,0 +1,22 @@ +import { Outlet, useNavigation } from 'react-router'; +import { AppContextProvider } from 'src/app-context'; +import { LoadingSpinner, UIVersion } from 'src/components'; +import { Layout } from 'src/layout'; +import { UserContextProvider } from 'src/user-context'; + +export default function Root() { + const navigation = useNavigation(); + const isNavigating = Boolean(navigation.location); + + return ( + + + + {isNavigating && } + + + + + + ); +} diff --git a/src/ui-config.ts b/src/ui-config.ts index 26f5f62..872cc43 100644 --- a/src/ui-config.ts +++ b/src/ui-config.ts @@ -1,13 +1,13 @@ import defaults from '../pulp-ui-config.json'; -export const configPromise = fetch('/pulp-ui-config.json').then((data) => - data.status > 0 && data.status < 300 - ? data.json() - : Promise.reject(`${data.status}: ${data.statusText}`), -); +export const configPromise = fetch('/pulp-ui-config.json') + .then((data) => + data.status > 0 && data.status < 300 + ? data.json() + : Promise.reject(`${data.status}: ${data.statusText}`), + ) + .then((data) => (config = data)); export let config = null; export const configFallback = () => (config = defaults); - -configPromise.then((data) => (config = data));