From 0c12cbc742435c09cbcc7c749e491fbc6a2fe92f Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Fri, 8 Nov 2024 16:09:22 -0500 Subject: [PATCH 1/2] Add error states and some tests Added error pages. Router should route to different error pages depending on the error. Also added some tests. --- jest.config.js | 3 +- setupTests.ts | 6 ++ src/app/ErrorPage/ErrorBoundary.test.tsx | 100 ++++++++++++++++++ src/app/ErrorPage/ErrorBoundary.tsx | 57 ++++++++++ src/app/ErrorPage/ErrorLayout.test.tsx | 26 +++++ src/app/ErrorPage/ErrorLayout.tsx | 59 +++++++++++ .../HeaderDropdown/HeaderDropdown.test.tsx | 59 +++++++++++ src/app/LoginPage/LoginPage.tsx | 27 +++++ src/app/NotFound/NotFound.tsx | 27 ----- src/app/app.css | 91 ++++++++++++++++ src/app/routes.tsx | 6 +- src/app/types/CannedChatbot.ts | 6 +- src/app/types/ErrorObject.ts | 4 + src/app/utils/utils.test.tsx | 48 +++++++++ src/app/utils/utils.ts | 23 +++- 15 files changed, 502 insertions(+), 40 deletions(-) create mode 100644 setupTests.ts create mode 100644 src/app/ErrorPage/ErrorBoundary.test.tsx create mode 100644 src/app/ErrorPage/ErrorBoundary.tsx create mode 100644 src/app/ErrorPage/ErrorLayout.test.tsx create mode 100644 src/app/ErrorPage/ErrorLayout.tsx create mode 100644 src/app/HeaderDropdown/HeaderDropdown.test.tsx create mode 100644 src/app/LoginPage/LoginPage.tsx delete mode 100644 src/app/NotFound/NotFound.tsx create mode 100644 src/app/types/ErrorObject.ts create mode 100644 src/app/utils/utils.test.tsx diff --git a/jest.config.js b/jest.config.js index 671b137..1cb0c09 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,7 @@ module.exports = { // Indicates whether the coverage information should be collected while executing the test collectCoverage: true, - collectCoverageFrom: ['/src/app/**/*.tsx'], + collectCoverageFrom: ['/src/app/**/*.tsx', '/src/app/utils/utils.ts'], // The directory where Jest should output its coverage files coverageDirectory: 'coverage', @@ -37,4 +37,5 @@ module.exports = { transform: { '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', }, + setupFilesAfterEnv: ['./setupTests.ts'], }; diff --git a/setupTests.ts b/setupTests.ts new file mode 100644 index 0000000..2893d77 --- /dev/null +++ b/setupTests.ts @@ -0,0 +1,6 @@ +global.Response = class { + constructor(public body) {} + json() { + return Promise.resolve(this.body); + } +} as unknown as typeof Response; diff --git a/src/app/ErrorPage/ErrorBoundary.test.tsx b/src/app/ErrorPage/ErrorBoundary.test.tsx new file mode 100644 index 0000000..adad6a5 --- /dev/null +++ b/src/app/ErrorPage/ErrorBoundary.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ErrorBoundary } from './ErrorBoundary'; +import { isRouteErrorResponse, useRouteError } from 'react-router-dom'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteError: jest.fn(), + useNavigate: () => jest.fn, + isRouteErrorResponse: jest.fn(), +})); + +const mockedUseRouteError = useRouteError as jest.Mock; +const mockedIsRouteErrorResponse = isRouteErrorResponse as unknown as jest.Mock; +const MOCK_401 = { status: 401 }; +const MOCK_403 = { status: 403 }; +const MOCK_404 = { status: 404 }; +const MOCK_500 = { status: 500 }; +const MOCK_503 = { status: 503 }; + +const checkHome = () => { + const home = screen.getByRole('link', { name: /Go back home/i }); + expect(home).toBeTruthy(); + expect(home).toHaveAttribute('href', '/'); +}; + +const checkRetry = () => { + const retry = screen.getByRole('button', { name: /Retry/i }); + expect(retry).toBeTruthy(); +}; + +describe('Error boundary', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('should render 401 correctly', () => { + mockedIsRouteErrorResponse.mockReturnValueOnce(true); + mockedUseRouteError.mockReturnValueOnce(MOCK_401); + + render(); + expect( + screen.getByText( + 'Accelerate innovation with a unified platform that transforms ideas into impactful AI solutions, from concept to deployment. Seamless integration, top-tier security, and full control over your data for fast, efficient AI development.', + ), + ).toBeTruthy(); + expect(screen.getByText('Sign in')).toBeTruthy(); + }); + it('should render 403 correctly', () => { + mockedUseRouteError.mockReturnValueOnce(MOCK_403); + mockedIsRouteErrorResponse.mockReturnValueOnce(true); + render(); + expect(screen.getByText('Error Code 403')).toBeTruthy(); + expect(screen.getByText('Access denied')).toBeTruthy(); + expect( + screen.getByText( + "You don't have permission to access this page. Please check your credentials or contact an administrator for assistance.", + ), + ).toBeTruthy(); + checkHome(); + }); + it('should render 404 correctly', () => { + mockedUseRouteError.mockReturnValue(MOCK_404); + mockedIsRouteErrorResponse.mockReturnValueOnce(true); + render(); + expect(screen.getByText('Error Code 404')).toBeTruthy(); + expect(screen.getByText('Page not found')).toBeTruthy(); + expect( + screen.getByText("The page you're looking for doesn't exit or may have been moved. Let's get you back on track!"), + ).toBeTruthy(); + checkHome(); + }); + it('should render 500 correctly', () => { + mockedUseRouteError.mockReturnValue(MOCK_500); + mockedIsRouteErrorResponse.mockReturnValueOnce(false); + render(); + expect(screen.getByText('Error Code 500')).toBeTruthy(); + expect(screen.getByText('Something went wrong')).toBeTruthy(); + expect( + screen.getByText( + "We're experiencing a server issue. Please try again later or contact support if the problem persists.", + ), + ).toBeTruthy(); + checkHome(); + }); + it('should render 503 correctly', () => { + mockedUseRouteError.mockReturnValue(MOCK_503); + mockedIsRouteErrorResponse.mockReturnValueOnce(true); + render(); + expect(screen.getByText('Error Code 503')).toBeTruthy(); + expect(screen.getByText('Service unavailable')).toBeTruthy(); + expect( + screen.getByText( + 'Our servers are currently down for maintenance or experiencing high demand. Please check back later.', + ), + ).toBeTruthy(); + checkHome(); + checkRetry(); + }); +}); diff --git a/src/app/ErrorPage/ErrorBoundary.tsx b/src/app/ErrorPage/ErrorBoundary.tsx new file mode 100644 index 0000000..6ca21ca --- /dev/null +++ b/src/app/ErrorPage/ErrorBoundary.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { isRouteErrorResponse, useRouteError } from 'react-router-dom'; +import { ErrorLayout } from './ErrorLayout'; +import { LoginPage } from '@app/LoginPage/LoginPage'; + +const ErrorBoundary: React.FunctionComponent = () => { + const error = useRouteError(); + console.error(error); + + if (isRouteErrorResponse(error)) { + if (error.status === 404) { + return ( + + ); + } + + if (error.status === 401) { + return ; + } + + if (error.status === 403) { + return ( + + ); + } + + if (error.status === 503) { + return ( + + ); + } + } + + return ( + + ); +}; + +export { ErrorBoundary }; diff --git a/src/app/ErrorPage/ErrorLayout.test.tsx b/src/app/ErrorPage/ErrorLayout.test.tsx new file mode 100644 index 0000000..62c89f4 --- /dev/null +++ b/src/app/ErrorPage/ErrorLayout.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ErrorLayout } from './ErrorLayout'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => jest.fn, +})); + +describe('Error layout', () => { + it('should render correctly', () => { + render(); + expect(screen.getByText('Error Code 500')).toBeTruthy(); + expect(screen.getByText('Title')).toBeTruthy(); + expect(screen.getByText('Body')).toBeTruthy(); + const button = screen.getByRole('link', { name: /Go back home/i }); + expect(button).toBeTruthy(); + expect(button).toHaveAttribute('href', '/'); + }); + it('should render retry button if hasRetry prop is passed in', () => { + render(); + const button = screen.getByRole('button', { name: /Retry/i }); + expect(button).toBeTruthy(); + }); +}); diff --git a/src/app/ErrorPage/ErrorLayout.tsx b/src/app/ErrorPage/ErrorLayout.tsx new file mode 100644 index 0000000..fde3bb4 --- /dev/null +++ b/src/app/ErrorPage/ErrorLayout.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { Brand, Button } from '@patternfly/react-core'; +import { useNavigate } from 'react-router-dom'; +import logo from '@app/bgimages/Logo-Red_Hat-Composer_AI_Studio-A-Standard-RGB.svg'; +import logoDark from '@app/bgimages/Logo-Red_Hat-Composer_AI_Studio-A-Reverse.svg'; + +interface ErrorLayoutProps { + hasRetry?: boolean; + title: string; + body: string; + errorCode: string; +} +const ErrorLayout: React.FunctionComponent = ({ hasRetry, title, body, errorCode }) => { + const supportLink = process.env.SUPPORT_LINK ?? ''; + const navigate = useNavigate(); + + const handleReload = () => { + navigate(0); + }; + + return ( +
+
+
+
+ +
+
+ +
+
+
+
+ Error Code {errorCode} +

{title}

+

{body}

+
+
+ {hasRetry && ( + + )} + + {supportLink && ( + + )} +
+
+
+
+ ); +}; + +export { ErrorLayout }; diff --git a/src/app/HeaderDropdown/HeaderDropdown.test.tsx b/src/app/HeaderDropdown/HeaderDropdown.test.tsx new file mode 100644 index 0000000..f4aa411 --- /dev/null +++ b/src/app/HeaderDropdown/HeaderDropdown.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { HeaderDropdown } from './HeaderDropdown'; +import userEvent from '@testing-library/user-event'; + +const MOCK_CHATBOTS = [ + { name: 'test1', displayName: 'Test1' }, + { name: 'test2', displayName: 'Test2' }, +]; +describe('Header dropdown', () => { + it('should render correctly', async () => { + render(); + const toggle = screen.getByRole('button', { name: /Red Hat AI Assistant/i }); + await userEvent.click(toggle); + expect(screen.getByRole('textbox', { name: /Search assistants.../i })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: 'Test1' })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: 'Test2' })).toBeTruthy(); + }); + it('should be able to select', async () => { + const spy = jest.fn(); + render(); + const toggle = screen.getByRole('button', { name: /Red Hat AI Assistant/i }); + await userEvent.click(toggle); + await userEvent.click(screen.getByRole('menuitem', { name: 'Test1' })); + expect(spy).toHaveBeenCalledTimes(1); + }); + it('should be able to search', async () => { + render(); + const toggle = screen.getByRole('button', { name: /Red Hat AI Assistant/i }); + await userEvent.click(toggle); + const textbox = screen.getByRole('textbox', { name: /Search assistants.../i }); + expect(textbox).toBeTruthy(); + await userEvent.type(textbox, 'Test1'); + expect(screen.getByRole('menuitem', { name: 'Test1' })).toBeTruthy(); + expect(screen.queryByRole('menuitem', { name: 'Test2' })).toBeFalsy(); + }); + it('should be able to search and see no results', async () => { + render(); + const toggle = screen.getByRole('button', { name: /Red Hat AI Assistant/i }); + await userEvent.click(toggle); + const textbox = screen.getByRole('textbox', { name: /Search assistants.../i }); + expect(textbox).toBeTruthy(); + await userEvent.type(textbox, 'ffff'); + screen.getByRole('menuitem', { name: /No results found/i }); + }); + it('should be able to clear input field after search', async () => { + render(); + const toggle = screen.getByRole('button', { name: /Red Hat AI Assistant/i }); + await userEvent.click(toggle); + const textbox = screen.getByRole('textbox', { name: /Search assistants.../i }); + expect(textbox).toBeTruthy(); + await userEvent.type(textbox, 'f'); + screen.getByRole('menuitem', { name: /No results found/i }); + await userEvent.clear(textbox); + expect(screen.getByRole('menuitem', { name: 'Test1' })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: 'Test2' })).toBeTruthy(); + }); +}); diff --git a/src/app/LoginPage/LoginPage.tsx b/src/app/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..db17ca0 --- /dev/null +++ b/src/app/LoginPage/LoginPage.tsx @@ -0,0 +1,27 @@ +import { Brand, Button } from '@patternfly/react-core'; +import * as React from 'react'; +import logo from '@app/bgimages/Logo-Red_Hat-Composer_AI_Studio-A-Standard-RGB.svg'; +import logoDark from '@app/bgimages/Logo-Red_Hat-Composer_AI_Studio-A-Reverse.svg'; + +const LoginPage: React.FunctionComponent = () => { + return ( +
+
+ +
+
+ +
+
+ Accelerate innovation with a unified platform that transforms ideas into impactful AI solutions, from concept to + deployment. Seamless integration, top-tier security, and full control over your data for fast, efficient AI + development. +
+
+ +
+
+ ); +}; + +export { LoginPage }; diff --git a/src/app/NotFound/NotFound.tsx b/src/app/NotFound/NotFound.tsx deleted file mode 100644 index 970ac94..0000000 --- a/src/app/NotFound/NotFound.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from 'react'; -import { ExclamationTriangleIcon } from '@patternfly/react-icons'; -import { Button, EmptyState, EmptyStateBody, EmptyStateFooter, PageSection } from '@patternfly/react-core'; -import { useNavigate } from 'react-router-dom'; - -const NotFound: React.FunctionComponent = () => { - function GoHomeBtn() { - const navigate = useNavigate(); - function handleClick() { - navigate('/'); - } - return ; - } - - return ( - - - We didn't find a page that matches the address you navigated to. - - - - - - ); -}; - -export { NotFound }; diff --git a/src/app/app.css b/src/app/app.css index 24aac13..98bdcef 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -133,3 +133,94 @@ pf-v6-c-page__main-container.pf-m-fill { border: 1px solid var(--pf-t--global--border--color--default); } } + +.login-page { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--pf-t--global--spacer--md); + padding: var(--pf-t--global--spacer--md); + + @media screen and (max-width: 1025px) { + padding: var(--pf-t--global--spacer--md); + } + + .login-page__text { + max-width: 50rem; + text-align: center; + } + .login-page__button { + margin-top: var(--pf-t--global--spacer--md); + } +} + +.error-page { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: var(--pf-t--global--spacer--md); + + .error-page__image { + display: flex; + align-items: center; + justify-content: center; + + @media screen and (max-width: 1025px) { + align-items: initial; + justify-content: initial; + } + } + + .error-page__main-layout { + display: flex; + gap: var(--pf-t--global--spacer--xl); + + @media screen and (max-width: 1025px) { + flex-direction: column; + gap: var(--pf-t--global--spacer--sm); + } + } + + .error-page__text-and-buttons { + display: flex; + flex-direction: column; + gap: var(--pf-t--global--spacer--md); + border-left: 1px solid var(--pf-t--global--border--color--default); + padding: 0 var(--pf-t--global--spacer--xl) 0 var(--pf-t--global--spacer--xl); + + @media screen and (max-width: 1025px) { + border-left: 0; + padding: 0; + } + } + + .error-page__error-code { + color: var(--pf-t--global--text--color--subtle); + font-size: var(--pf-t--global--font--size--xs); + font-family: var(--pf-t--global--font--family--mono); + } + + .error-page__text { + max-width: 30rem; + h1 { + font-size: var(--pf-t--global--font--size--heading--h2); + margin-bottom: var(--pf-t--global--spacer--sm); + font-family: var(--pf-t--chatbot--heading--font-family); + } + p { + font-size: var(--pf-t--global--font--size--md); + font-family: var(--pf-t--global--font--family--body); + } + } + + .error-page__buttons { + display: flex; + gap: var(--pf-t--global--spacer--sm); + flex-wrap: wrap; + } +} diff --git a/src/app/routes.tsx b/src/app/routes.tsx index fec45b8..3234203 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import { RouterProvider, createBrowserRouter } from 'react-router-dom'; -import { NotFound } from '@app/NotFound/NotFound'; import { BaseChatbot } from '@app/BaseChatbot/BaseChatbot'; import { AppLayout } from '@app/AppLayout/AppLayout'; import { chatbotLoader } from '@app/utils/utils'; import { ComparePage } from './Compare/ComparePage'; +import { ErrorBoundary } from './ErrorPage/ErrorBoundary'; export interface IAppRoute { label?: string; // Excluding the label will exclude the route from the nav sidebar in AppLayout @@ -27,19 +27,17 @@ const routes: AppRouteConfig[] = [{ path: '/', label: 'Home', title: 'Red Hat Co const router = createBrowserRouter([ { element: , - errorElement: , + errorElement: , children: [ { path: '/', element: , loader: chatbotLoader, - errorElement: , }, { path: 'compare', element: , loader: chatbotLoader, - errorElement: , }, ], }, diff --git a/src/app/types/CannedChatbot.ts b/src/app/types/CannedChatbot.ts index 4b431e5..343744f 100644 --- a/src/app/types/CannedChatbot.ts +++ b/src/app/types/CannedChatbot.ts @@ -1,7 +1,7 @@ export interface CannedChatbot { displayName: string; - id: string; - llmConnection: { description: string; id: string; name: string }; + id?: string; + llmConnection?: { description: string; id: string; name: string }; name: string; - retrieverConnection: { id: string; name: string; description: string; index: string; metadataFields: string[] }; + retrieverConnection?: { id: string; name: string; description: string; index: string; metadataFields: string[] }; } diff --git a/src/app/types/ErrorObject.ts b/src/app/types/ErrorObject.ts new file mode 100644 index 0000000..872864e --- /dev/null +++ b/src/app/types/ErrorObject.ts @@ -0,0 +1,4 @@ +export interface ErrorObject { + title: string; + body: string; +} diff --git a/src/app/utils/utils.test.tsx b/src/app/utils/utils.test.tsx new file mode 100644 index 0000000..66d78e4 --- /dev/null +++ b/src/app/utils/utils.test.tsx @@ -0,0 +1,48 @@ +import '@testing-library/jest-dom'; +import { getChatbots, getId } from './utils'; + +const mockData = [{ name: 'Test' }]; +const mockFetch = (status: number, data, ok: boolean) => { + global.fetch = jest.fn(() => + Promise.resolve({ + status, + ok, + json: () => Promise.resolve(data), + } as Response), + ); +}; + +describe('getId', () => { + it('should generate ID', () => { + const id = getId(); + expect(typeof id).toBe('string'); + }); +}); + +describe('getChatbots', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('should return data if received from fetch', async () => { + mockFetch(200, mockData, true); + const chatbots = await getChatbots(); + expect(chatbots).toBe(mockData); + expect(global.fetch).toHaveBeenCalled(); + }); + it('should handle 401 correctly', async () => { + mockFetch(401, {}, false); + await expect(getChatbots).rejects.toHaveProperty('body', '{"status":401}'); + }); + it('should handle 403 correctly', async () => { + mockFetch(403, {}, false); + await expect(getChatbots).rejects.toHaveProperty('body', '{"status":403}'); + }); + it('should handle 500 correctly', async () => { + mockFetch(500, {}, false); + await expect(getChatbots).rejects.toHaveProperty('body', '{"status":500}'); + }); + it('should handle 503 correctly', async () => { + mockFetch(503, {}, false); + await expect(getChatbots).rejects.toHaveProperty('body', '{"status":503}'); + }); +}); diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts index 9b6cec2..8c0c7bc 100644 --- a/src/app/utils/utils.ts +++ b/src/app/utils/utils.ts @@ -1,5 +1,7 @@ import { CannedChatbot } from '@app/types/CannedChatbot'; +import { json } from 'react-router-dom'; +/** Used in chatbots */ export const ERROR_TITLE = { 'Error: 404': '404: Network error', 'Error: 500': 'Server error', @@ -11,15 +13,26 @@ export const getId = () => { return date.toString(); }; -export const getChatbots = () => { +export const getChatbots = async () => { const url = process.env.REACT_APP_INFO_URL ?? ''; return fetch(url) - .then((res) => res.json()) + .then((res) => { + if (res.ok) { + return res.json(); + } + switch (res.status) { + case 401: + throw json({ status: 401 }); + case 403: + throw json({ status: 403 }); + case 503: + throw json({ status: 503 }); + default: + throw json({ status: 500 }); + } + }) .then((data: CannedChatbot[]) => { return data; - }) - .catch((e) => { - throw new Response(e.message, { status: 404 }); }); }; From 98dbc8cc0aed3a963c48e622dcb4c78a36c188de Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Fri, 8 Nov 2024 17:21:57 -0500 Subject: [PATCH 2/2] Add unique error for when no env vars Env vars are used for API endpoints --- src/app/ErrorPage/ErrorBoundary.tsx | 32 +++++++++++++++++++---------- src/app/ErrorPage/ErrorLayout.tsx | 21 +++++++++++++------ src/app/utils/utils.ts | 3 +++ 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/app/ErrorPage/ErrorBoundary.tsx b/src/app/ErrorPage/ErrorBoundary.tsx index 6ca21ca..d5ef903 100644 --- a/src/app/ErrorPage/ErrorBoundary.tsx +++ b/src/app/ErrorPage/ErrorBoundary.tsx @@ -5,19 +5,8 @@ import { LoginPage } from '@app/LoginPage/LoginPage'; const ErrorBoundary: React.FunctionComponent = () => { const error = useRouteError(); - console.error(error); if (isRouteErrorResponse(error)) { - if (error.status === 404) { - return ( - - ); - } - if (error.status === 401) { return ; } @@ -32,6 +21,16 @@ const ErrorBoundary: React.FunctionComponent = () => { ); } + if (error.status === 404) { + return ( + + ); + } + if (error.status === 503) { return ( { } } + if (typeof error === 'object' && error !== null && 'data' in error) { + if ( + typeof error.data === 'object' && + error.data !== null && + 'status' in error.data && + error.data.status === 'Misconfigured' + ) { + return ; + } + } + return ( = ({ hasRetry, title, body, errorCode }) => { +const ErrorLayout: React.FunctionComponent = ({ + hasRetry, + title, + body, + errorCode, + hasHome = true, +}) => { const supportLink = process.env.SUPPORT_LINK ?? ''; const navigate = useNavigate(); @@ -31,7 +38,7 @@ const ErrorLayout: React.FunctionComponent = ({ hasRetry, titl
- Error Code {errorCode} + {errorCode && Error Code {errorCode}}

{title}

{body}

@@ -41,9 +48,11 @@ const ErrorLayout: React.FunctionComponent = ({ hasRetry, titl Retry )} - + {hasHome && ( + + )} {supportLink && (