Skip to content

Commit

Permalink
Merge pull request #15 from rebeccaalpert/error-states
Browse files Browse the repository at this point in the history
Add error states and some tests
  • Loading branch information
rebeccaalpert authored Nov 11, 2024
2 parents 22f842a + 98dbc8c commit 7fc6d73
Show file tree
Hide file tree
Showing 15 changed files with 524 additions and 40 deletions.
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ module.exports = {
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,

collectCoverageFrom: ['<rootDir>/src/app/**/*.tsx'],
collectCoverageFrom: ['<rootDir>/src/app/**/*.tsx', '<rootDir>/src/app/utils/utils.ts'],

// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
Expand Down Expand Up @@ -37,4 +37,5 @@ module.exports = {
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
setupFilesAfterEnv: ['./setupTests.ts'],
};
6 changes: 6 additions & 0 deletions setupTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
global.Response = class {
constructor(public body) {}
json() {
return Promise.resolve(this.body);
}
} as unknown as typeof Response;
100 changes: 100 additions & 0 deletions src/app/ErrorPage/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ErrorBoundary />);
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(<ErrorBoundary />);
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(<ErrorBoundary />);
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(<ErrorBoundary />);
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(<ErrorBoundary />);
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();
});
});
67 changes: 67 additions & 0 deletions src/app/ErrorPage/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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();

if (isRouteErrorResponse(error)) {
if (error.status === 401) {
return <LoginPage />;
}

if (error.status === 403) {
return (
<ErrorLayout
errorCode="403"
title="Access denied"
body="You don't have permission to access this page. Please check your credentials or contact an administrator for assistance."
/>
);
}

if (error.status === 404) {
return (
<ErrorLayout
errorCode="404"
title="Page not found"
body="The page you're looking for doesn't exit or may have been moved. Let's get you back on track!"
/>
);
}

if (error.status === 503) {
return (
<ErrorLayout
hasRetry
errorCode="503"
title="Service unavailable"
body="Our servers are currently down for maintenance or experiencing high demand. Please check back later."
/>
);
}
}

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 <ErrorLayout title="App not configured" body="Please update your environment variables" hasHome={false} />;
}
}

return (
<ErrorLayout
hasRetry
errorCode="500"
title="Something went wrong"
body="We're experiencing a server issue. Please try again later or contact support if the problem persists."
/>
);
};

export { ErrorBoundary };
26 changes: 26 additions & 0 deletions src/app/ErrorPage/ErrorLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ErrorLayout errorCode="500" body="Body" title="Title" />);
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(<ErrorLayout errorCode="500" body="Body" title="Title" hasRetry />);
const button = screen.getByRole('button', { name: /Retry/i });
expect(button).toBeTruthy();
});
});
68 changes: 68 additions & 0 deletions src/app/ErrorPage/ErrorLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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;
hasHome?: boolean;
}
const ErrorLayout: React.FunctionComponent<ErrorLayoutProps> = ({
hasRetry,
title,
body,
errorCode,
hasHome = true,
}) => {
const supportLink = process.env.SUPPORT_LINK ?? '';
const navigate = useNavigate();

const handleReload = () => {
navigate(0);
};

return (
<div className="error-page">
<div className="error-page__main-layout">
<div className="error-page__image">
<div className="show-light">
<Brand src={logo} alt="Red Hat Composer AI Studio" heights={{ default: '36px' }} />
</div>
<div className="show-dark">
<Brand src={logoDark} alt="Red Hat Composer AI Studio" heights={{ default: '36px' }} />
</div>
</div>
<div className="error-page__text-and-buttons">
<div className="error-page__text">
{errorCode && <span className="error-page__error-code">Error Code {errorCode}</span>}
<h1>{title}</h1>
<p>{body}</p>
</div>
<div className="error-page__buttons">
{hasRetry && (
<Button onClick={handleReload} size="sm">
Retry
</Button>
)}
{hasHome && (
<Button component="a" href="/" size="sm" variant="secondary">
Go back home
</Button>
)}
{supportLink && (
<Button component="a" href={supportLink} size="sm" variant="secondary">
Contact support
</Button>
)}
</div>
</div>
</div>
</div>
);
};

export { ErrorLayout };
59 changes: 59 additions & 0 deletions src/app/HeaderDropdown/HeaderDropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<HeaderDropdown chatbots={MOCK_CHATBOTS} onSelect={jest.fn} />);
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(<HeaderDropdown chatbots={MOCK_CHATBOTS} onSelect={spy} />);
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(<HeaderDropdown chatbots={MOCK_CHATBOTS} onSelect={jest.fn} />);
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(<HeaderDropdown chatbots={MOCK_CHATBOTS} onSelect={jest.fn} />);
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(<HeaderDropdown chatbots={MOCK_CHATBOTS} onSelect={jest.fn} />);
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();
});
});
27 changes: 27 additions & 0 deletions src/app/LoginPage/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="login-page">
<div className="show-light">
<Brand src={logo} alt="Red Hat Composer AI Studio" heights={{ default: '36px' }} />
</div>
<div className="show-dark">
<Brand src={logoDark} alt="Red Hat Composer AI Studio" heights={{ default: '36px' }} />
</div>
<div className="login-page__text">
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.
</div>
<div className="login-page__button">
<Button>Sign in</Button>
</div>
</div>
);
};

export { LoginPage };
Loading

0 comments on commit 7fc6d73

Please sign in to comment.