Skip to content

Commit

Permalink
Merge pull request #6 from rebeccaalpert/panel
Browse files Browse the repository at this point in the history
Start investigating panel
  • Loading branch information
rebeccaalpert authored Nov 11, 2024
2 parents 7fc6d73 + 0512ebb commit 9785760
Show file tree
Hide file tree
Showing 8 changed files with 457 additions and 90 deletions.
68 changes: 18 additions & 50 deletions src/app/AppLayout/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { Outlet } from 'react-router-dom';
import {
Brand,
Button,
Expand All @@ -8,23 +8,32 @@ import {
MastheadLogo,
MastheadMain,
MastheadToggle,
Nav,
NavExpandable,
NavItem,
NavList,
Page,
PageSidebar,
PageSidebarBody,
SkipToContent,
} from '@patternfly/react-core';
import { IAppRoute, IAppRouteGroup, routes } from '@app/routes';
import { BarsIcon } from '@patternfly/react-icons';
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';
import { SidebarWithFlyout } from '@app/SidebarWithFlyout/SidebarWithFlyout';

const AppLayout: React.FunctionComponent = () => {
const [sidebarOpen, setSidebarOpen] = React.useState(true);

// If you close the sidebar on mobile and go back to desktop, you lose it forever (or at least until reload)
// This forces it to reopen if that happens.
React.useEffect(() => {
const updateSidebar = () => {
if (window.innerWidth >= 1200) {
setSidebarOpen(true);
}
};
window.addEventListener('resize', updateSidebar);

return () => {
window.removeEventListener('resize', updateSidebar);
};
}, []);

const masthead = (
<Masthead display={{ default: 'inline' }}>
<MastheadMain>
Expand All @@ -50,48 +59,7 @@ const AppLayout: React.FunctionComponent = () => {
</Masthead>
);

const location = useLocation();

const renderNavItem = (route: IAppRoute, index: number) => (
<NavItem key={`${route.label}-${index}`} id={`${route.label}-${index}`} isActive={route.path === location.pathname}>
<NavLink
to={route.path}
reloadDocument
className={({ isActive, isPending, isTransitioning }) =>
[isPending ? 'pending' : '', isActive ? 'active' : '', isTransitioning ? 'transitioning' : ''].join(' ')
}
>
{route.label}
</NavLink>
</NavItem>
);

const renderNavGroup = (group: IAppRouteGroup, groupIndex: number) => (
<NavExpandable
key={`${group.label}-${groupIndex}`}
id={`${group.label}-${groupIndex}`}
title={group.label}
isActive={group.routes.some((route) => route.path === location.pathname)}
>
{group.routes.map((route, idx) => route.label && renderNavItem(route, idx))}
</NavExpandable>
);

const Navigation = (
<Nav id="nav-primary-simple">
<NavList id="nav-list-simple">
{routes.map(
(route, idx) => route.label && (!route.routes ? renderNavItem(route, idx) : renderNavGroup(route, idx)),
)}
</NavList>
</Nav>
);

const Sidebar = (
<PageSidebar>
<PageSidebarBody>{Navigation}</PageSidebarBody>
</PageSidebar>
);
const Sidebar = <SidebarWithFlyout />;

const pageId = 'primary-app-container';

Expand Down
87 changes: 71 additions & 16 deletions src/app/BaseChatbot/BaseChatbot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,92 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BaseChatbot } from './BaseChatbot';
import { RouterProvider, createMemoryRouter, useLoaderData as useLoaderDataOriginal } from 'react-router-dom';
import userEvent from '@testing-library/user-event';

window.HTMLElement.prototype.scrollIntoView = jest.fn();

// fixme our version of node should have this
if (typeof global.structuredClone === 'undefined') {
global.structuredClone = (obj) => JSON.parse(JSON.stringify(obj));
}

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLoaderData: jest.fn(),
}));

window.HTMLElement.prototype.scrollIntoView = jest.fn();

// fixes type problem
const useLoaderData = useLoaderDataOriginal as jest.Mock;

const router = createMemoryRouter(
[
{
path: '/',
element: <BaseChatbot />,
},
],
{
initialEntries: ['/'],
},
);

describe('Base chatbot', () => {
it('should render correctly', () => {
beforeEach(() => {
global.fetch = jest.fn();
useLoaderData.mockReturnValue({
chatbots: [{ displayName: 'Test assistant' }],
chatbots: [{ displayName: 'Test', name: 'test' }],
});
const router = createMemoryRouter(
[
{
path: '/',
element: <BaseChatbot />,
},
],
{
initialEntries: ['/'],
},
);
});

afterEach(() => {
jest.clearAllMocks();
});

it('should render correctly', () => {
render(<RouterProvider router={router} />);
screen.getByText('Red Hat AI Assistant');
screen.getByText('Hello, Chatbot User');
screen.getByText('How may I help you today?');
screen.getByText('Verify all information from this tool. LLMs make mistakes.');
});
it('should show alert when there is an unspecified error', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
json: async () => ({ data: 'Hello World' }),
});
render(<RouterProvider router={router} />);
const input = screen.getByRole('textbox', { name: /Send a message.../i });
await userEvent.type(input, 'test{enter}');
expect(global.fetch).toHaveBeenCalledTimes(1);
screen.getByRole('heading', { name: /Danger alert: Error/i });
expect(
screen.getAllByText(
/Test has encountered an error and is unable to answer your question. Use a different assistant or try again later./i,
),
).toHaveLength(2);
screen.getByRole('button', { name: /Close Danger alert: alert: Error/i });
});
it('can dismiss alert', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
json: async () => ({ data: 'Hello World' }),
});
render(<RouterProvider router={router} />);
const input = screen.getByRole('textbox', { name: /Send a message.../i });
await userEvent.type(input, 'test{enter}');
screen.getByRole('heading', { name: /Danger alert: Error/i });
const button = screen.getByRole('button', { name: /Close Danger alert: alert: Error/i });
await userEvent.click(button);
expect(screen.queryByRole('heading', { name: /Danger alert: Error/i })).toBeFalsy();
});
it('should handle sending messages', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
json: async () => ({ body: 'Hello world', status: 200, statusText: 'OK' }),
});
render(<RouterProvider router={router} />);
const input = screen.getByRole('textbox', { name: /Send a message.../i });
const date = new Date();
await userEvent.type(input, 'test{enter}');
screen.getByRole('region', { name: /Message from user/i });
screen.getByText(`${date.toLocaleDateString()} ${date.toLocaleTimeString()}`);
screen.getByText('test');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
16 changes: 16 additions & 0 deletions src/app/FlyoutHeader.tsx/FlyoutHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Button } from '@patternfly/react-core';
import { TimesIcon } from '@patternfly/react-icons';
import * as React from 'react';

interface FlyoutHeaderProps {
title: string;
hideFlyout: () => void;
}
export const FlyoutHeader: React.FunctionComponent<FlyoutHeaderProps> = ({ title, hideFlyout }: FlyoutHeaderProps) => {
return (
<div className="flyout-header">
{title}
<Button onClick={hideFlyout} variant="plain" icon={<TimesIcon />} />
</div>
);
};
39 changes: 39 additions & 0 deletions src/app/FlyoutStartScreen.tsx/FlyoutStartScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Button } from '@patternfly/react-core';
import * as React from 'react';

interface FlyoutStartScreenProps {
image?: string;
imageAlt?: string;
title: string;
subtitle?: string;
primaryButtonText?: string;
secondaryButtonText?: string;
}

export const FlyoutStartScreen: React.FunctionComponent<FlyoutStartScreenProps> = ({
image,
imageAlt,
subtitle,
title,
primaryButtonText,
secondaryButtonText,
}: FlyoutStartScreenProps) => {
return (
<div className="start-screen">
{image && <img src={image} alt={imageAlt} />}
<div className="start-screen-text">
<h1>{title}</h1>
{subtitle && <p>{subtitle}</p>}
</div>
<div className="start-screen-actions">
{primaryButtonText && <Button>{primaryButtonText}</Button>}
{secondaryButtonText && (
<>
<p>or</p>
<Button variant="secondary">{secondaryButtonText}</Button>
</>
)}
</div>
</div>
);
};
23 changes: 0 additions & 23 deletions src/app/Home/Home.tsx

This file was deleted.

67 changes: 67 additions & 0 deletions src/app/SidebarWithFlyout/FlyoutMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { FocusTrap } from '@patternfly/react-core';
import React from 'react';

interface FlyoutMenuProps {
id: string;
height: number;
children: React.ReactNode;
hideFlyout: () => void;
}
export const FlyoutMenu: React.FunctionComponent<FlyoutMenuProps> = ({
id,
height,
children,
hideFlyout,
}: FlyoutMenuProps) => {
const previouslyFocusedElement = React.useRef<HTMLElement>(null);

const handleFlyout = (event) => {
const key = event.key;
if (key === 'Escape') {
event.stopPropagation();
event.preventDefault();
hideFlyout();
}
};

const focusTrapProps = {
tabIndex: -1,
'aria-modal': true,
role: 'dialog',
active: true,
'aria-labelledby': id,
focusTrapOptions: {
onActivate: () => {
if (previouslyFocusedElement.current !== document.activeElement) {
//@ts-expect-error can't assign to current
previouslyFocusedElement.current = document.activeElement;
}
},
onDeactivate: () => {
previouslyFocusedElement.current &&
previouslyFocusedElement.current.focus &&
previouslyFocusedElement.current.focus();
},
clickOutsideDeactivates: true,
returnFocusOnDeactivate: false,
// FocusTrap's initialFocus can accept false as a value to prevent initial focus.
// We want to prevent this in case false is ever passed in.
initialFocus: undefined,
escapeDeactivates: false,
},
};

return (
<FocusTrap
id={id}
className="flyout-menu"
style={{
height: `${height}px`,
}}
onKeyDown={handleFlyout}
{...focusTrapProps}
>
{children}
</FocusTrap>
);
};
Loading

0 comments on commit 9785760

Please sign in to comment.