diff --git a/src/app/AppLayout/AppLayout.tsx b/src/app/AppLayout/AppLayout.tsx index 04a85b6..259335a 100644 --- a/src/app/AppLayout/AppLayout.tsx +++ b/src/app/AppLayout/AppLayout.tsx @@ -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, @@ -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 = ( @@ -50,48 +59,7 @@ const AppLayout: React.FunctionComponent = () => { ); - const location = useLocation(); - - const renderNavItem = (route: IAppRoute, index: number) => ( - - - [isPending ? 'pending' : '', isActive ? 'active' : '', isTransitioning ? 'transitioning' : ''].join(' ') - } - > - {route.label} - - - ); - - const renderNavGroup = (group: IAppRouteGroup, groupIndex: number) => ( - route.path === location.pathname)} - > - {group.routes.map((route, idx) => route.label && renderNavItem(route, idx))} - - ); - - const Navigation = ( - - - {routes.map( - (route, idx) => route.label && (!route.routes ? renderNavItem(route, idx) : renderNavGroup(route, idx)), - )} - - - ); - - const Sidebar = ( - - {Navigation} - - ); + const Sidebar = ; const pageId = 'primary-app-container'; diff --git a/src/app/BaseChatbot/BaseChatbot.test.tsx b/src/app/BaseChatbot/BaseChatbot.test.tsx index 26ffff1..0711bbe 100644 --- a/src/app/BaseChatbot/BaseChatbot.test.tsx +++ b/src/app/BaseChatbot/BaseChatbot.test.tsx @@ -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: , + }, + ], + { + 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: , - }, - ], - { - initialEntries: ['/'], - }, - ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render correctly', () => { render(); 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(); + 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(); + 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(); + 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); + }); }); diff --git a/src/app/FlyoutHeader.tsx/FlyoutHeader.tsx b/src/app/FlyoutHeader.tsx/FlyoutHeader.tsx new file mode 100644 index 0000000..ff51dc2 --- /dev/null +++ b/src/app/FlyoutHeader.tsx/FlyoutHeader.tsx @@ -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 = ({ title, hideFlyout }: FlyoutHeaderProps) => { + return ( + + {title} + } /> + + ); +}; diff --git a/src/app/FlyoutStartScreen.tsx/FlyoutStartScreen.tsx b/src/app/FlyoutStartScreen.tsx/FlyoutStartScreen.tsx new file mode 100644 index 0000000..ae7f2e2 --- /dev/null +++ b/src/app/FlyoutStartScreen.tsx/FlyoutStartScreen.tsx @@ -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 = ({ + image, + imageAlt, + subtitle, + title, + primaryButtonText, + secondaryButtonText, +}: FlyoutStartScreenProps) => { + return ( + + {image && } + + {title} + {subtitle && {subtitle}} + + + {primaryButtonText && {primaryButtonText}} + {secondaryButtonText && ( + <> + or + {secondaryButtonText} + > + )} + + + ); +}; diff --git a/src/app/Home/Home.tsx b/src/app/Home/Home.tsx deleted file mode 100644 index 587c055..0000000 --- a/src/app/Home/Home.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import * as React from 'react'; -import { Brand, Bullseye } from '@patternfly/react-core'; -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 Home: React.FunctionComponent = () => { - React.useEffect(() => { - document.title = `Red Hat Composer AI Studio | Home`; - }, []); - - return ( - - - - - - - - - ); -}; - -export { Home }; diff --git a/src/app/SidebarWithFlyout/FlyoutMenu.tsx b/src/app/SidebarWithFlyout/FlyoutMenu.tsx new file mode 100644 index 0000000..ea381ee --- /dev/null +++ b/src/app/SidebarWithFlyout/FlyoutMenu.tsx @@ -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 = ({ + id, + height, + children, + hideFlyout, +}: FlyoutMenuProps) => { + const previouslyFocusedElement = React.useRef(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 ( + + {children} + + ); +}; diff --git a/src/app/SidebarWithFlyout/SidebarWithFlyout.tsx b/src/app/SidebarWithFlyout/SidebarWithFlyout.tsx new file mode 100644 index 0000000..ebc56e8 --- /dev/null +++ b/src/app/SidebarWithFlyout/SidebarWithFlyout.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Brand, Nav, NavItem, NavList, PageSidebar } from '@patternfly/react-core'; +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 { FlyoutHeader } from '@app/FlyoutHeader.tsx/FlyoutHeader'; +import { FlyoutStartScreen } from '@app/FlyoutStartScreen.tsx/FlyoutStartScreen'; +import { FlyoutMenu } from './FlyoutMenu'; +import { NavLink } from 'react-router-dom'; + +export const SidebarWithFlyout: React.FunctionComponent = () => { + const [sidebarHeight, setSidebarHeight] = useState(0); + const [visibleFlyout, setVisibleFlyout] = useState(null); + const sidebarRef = useRef(null); + const flyoutMenuRef = useRef(null); + + // Capture sidebar height initially and whenever it changes. + // We use this to control the flyout height. + useEffect(() => { + const updateHeight = () => { + if (sidebarRef.current) { + setSidebarHeight(sidebarRef.current.offsetHeight); + } + }; + + // Set initial height and add event listeners for window resize + updateHeight(); + window.addEventListener('resize', updateHeight); + + return () => { + window.removeEventListener('resize', updateHeight); + }; + }, []); + + // Adjust flyout height to match the sidebar height when flyout is visible + useEffect(() => { + if (visibleFlyout && sidebarRef.current && flyoutMenuRef.current) { + const sidebarHeight = sidebarRef.current.offsetHeight; + flyoutMenuRef.current.style.height = `${sidebarHeight}px`; + } + }, [visibleFlyout]); + + /*const toggleFlyout = (e) => { + if (visibleFlyout === e.target.innerText) { + setVisibleFlyout(null); + } else { + setVisibleFlyout(e.target.innerText); + } + };*/ + + return ( + + + + + + + + + + + + + + setVisibleFlyout(null)}> + Home + + {/* + Chats + + + Assistants + */} + + + {/* Flyout menu */} + {visibleFlyout && ( + setVisibleFlyout(null)} + > + setVisibleFlyout(null)} /> + + + )} + + + ); +}; diff --git a/src/app/app.css b/src/app/app.css index 98bdcef..08dffff 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -21,8 +21,21 @@ pf-v6-c-page__main-container.pf-m-fill { .assistant-selector-menu { max-width: 60vw; - max-height: 80vh; + max-height: 19rem; overflow-y: auto; + --pf-v6-c-menu--PaddingBlockStart: 0; + + .pf-v6-c-menu__content { + position: relative; + } + + .pf-v6-c-menu__search { + padding-block-start: var(--pf-t--global--spacer--sm); + position: sticky; + top: 0; + background-color: var(--pf-t--global--background--color--floating--default); + z-index: var(--pf-t--global--z-index--md); + } } .compare { @@ -77,6 +90,7 @@ pf-v6-c-page__main-container.pf-m-fill { } } } + .compare-item-hidden { display: block; @@ -128,6 +142,127 @@ pf-v6-c-page__main-container.pf-m-fill { } } +.sidebar-masthead { + padding: var(--pf-t--global--spacer--md); + @media screen and (max-width: 1199px) { + display: none; + } +} + +#page-sidebar { + overflow: visible; + @media screen and (max-width: 900px) { + width: 100%; + } +} + +.flyout-header { + font-weight: 500; + display: flex; + justify-content: space-between; +} + +.flyout-menu { + padding: var(--pf-t--global--spacer--md); + overflow: hidden; + overflow-y: auto; + position: absolute; + top: 0; + left: 100%; + width: 25vw; + background: var(--pf-t--global--background--color--floating--default); + box-shadow: var(--pf-t--global--box-shadow--md); + display: flex; + flex-direction: column; + + @media screen and (max-width: 900px) { + width: 100%; + left: 0; + } +} + +.start-screen { + display: flex; + flex-direction: column; + gap: var(--pf-t--global--spacer--md); + justify-content: center; + align-items: center; + flex: 1; + + h1 { + font-weight: 500; + font-size: var(--pf-t--global--font--size--heading--h3); + } +} + +.start-screen-text { + text-align: center; +} + +.start-screen-actions { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: var(--pf-t--global--spacer--sm); +} + +.pf-v6-c-masthead { + @media screen and (min-width: 1200px) { + display: none; + } +} + +.compare-container { + display: flex; + flex-direction: column; + position: relative; + height: 100%; + + .pf-chatbot__footer { + position: sticky; + bottom: 0; + } +} + +.compare-toggle { + width: 100%; + + .pf-v6-c-toggle-group__button { + width: 100%; + display: flex; + justify-content: center; + } +} + +.chatbot-ui-page { + .pf-v6-c-page__main { + overflow: hidden; + } + @media screen and (max-width: 900px) { + --pf-v6-c-page__main-container--MaxHeight: 100%; + } + .pf-v6-c-page__main-container { + @media screen and (min-width: 1024px) { + border-top: 0px; + } + @media screen and (max-width: 900px) { + --pf-v6-c-page__main-container--MarginInlineStart: 0; + --pf-v6-c-page__main-container--MarginInlineEnd: 0; + --pf-v6-c-page__main-container--BorderWidth: 0; + --pf-v6-c-page__main-container--BorderColor: initial; + --pf-v6-c-page__main-container--BorderRadius: none; + } + } +} + +.sidebar-masthead { + padding: var(--pf-t--global--spacer--md); + @media screen and (max-width: 1199px) { + display: none; + } +} + .pf-chatbot__message.pf-chatbot__message--user { img { border: 1px solid var(--pf-t--global--border--color--default); @@ -224,3 +359,9 @@ pf-v6-c-page__main-container.pf-m-fill { flex-wrap: wrap; } } + +.sidebar-nav { + @media screen and (min-width: 1200px) { + --pf-v6-c-nav--PaddingInlineStart: 0.8rem; + } +}
{subtitle}
or