diff --git a/workspaces/global-floating-action-button/.changeset/giant-pears-exercise.md b/workspaces/global-floating-action-button/.changeset/giant-pears-exercise.md new file mode 100644 index 0000000000..61f5afe6b8 --- /dev/null +++ b/workspaces/global-floating-action-button/.changeset/giant-pears-exercise.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-global-floating-action-button': patch +--- + +updated drawer classname diff --git a/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FloatingButton.tsx b/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FloatingButton.tsx index 0a85787cd1..443a481802 100644 --- a/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FloatingButton.tsx +++ b/workspaces/global-floating-action-button/plugins/global-floating-action-button/src/components/FloatingButton.tsx @@ -31,10 +31,10 @@ const useStyles = makeStyles(theme => ({ right: `calc(${theme?.spacing?.(2) ?? '16px'} + 1.5em)`, alignItems: 'end', - // When quickstart drawer is open, adjust margin - '.quickstart-drawer-open &': { + // When drawer is docked, adjust margin + '.docked-drawer-open &': { transition: 'margin-right 0.3s ease', - marginRight: 'var(--quickstart-drawer-width, 500px) ', + marginRight: 'var(--docked-drawer-width, 500px) ', }, }, 'bottom-left': { diff --git a/workspaces/lightspeed/.changeset/grumpy-jokes-juggle.md b/workspaces/lightspeed/.changeset/grumpy-jokes-juggle.md new file mode 100644 index 0000000000..14b5f38e13 --- /dev/null +++ b/workspaces/lightspeed/.changeset/grumpy-jokes-juggle.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed': patch +--- + +adds lightspeed chatbot popup diff --git a/workspaces/lightspeed/packages/app/package.json b/workspaces/lightspeed/packages/app/package.json index e304e5a0d8..6a01a5d257 100644 --- a/workspaces/lightspeed/packages/app/package.json +++ b/workspaces/lightspeed/packages/app/package.json @@ -46,7 +46,9 @@ "@backstage/ui": "^0.8.2", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", + "@red-hat-developer-hub/backstage-plugin-global-floating-action-button": "^1.6.1", "@red-hat-developer-hub/backstage-plugin-lightspeed": "*", + "@red-hat-developer-hub/backstage-plugin-theme": "^0.11.0", "react": "^18.0.2", "react-dom": "^18.0.2", "react-router": "^6.3.0", diff --git a/workspaces/lightspeed/packages/app/src/App.tsx b/workspaces/lightspeed/packages/app/src/App.tsx index b20b60a23e..b0c3296d1d 100644 --- a/workspaces/lightspeed/packages/app/src/App.tsx +++ b/workspaces/lightspeed/packages/app/src/App.tsx @@ -43,6 +43,7 @@ import { Root } from './components/Root'; import { AlertDisplay, + IdentityProviders, OAuthRequestDialog, SignInPage, } from '@backstage/core-components'; @@ -52,7 +53,21 @@ import { CatalogGraphPage } from '@backstage/plugin-catalog-graph'; import { RequirePermission } from '@backstage/plugin-permission-react'; import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha'; import { lightspeedTranslations } from '@red-hat-developer-hub/backstage-plugin-lightspeed/alpha'; -import { LightspeedPage } from '@red-hat-developer-hub/backstage-plugin-lightspeed'; +import { githubAuthApiRef } from '@backstage/core-plugin-api'; +import { + LightspeedPage, + LightspeedDrawerProvider, +} from '@red-hat-developer-hub/backstage-plugin-lightspeed'; + +const identityProviders: IdentityProviders = [ + 'guest', + { + id: 'github-auth-provider', + title: 'GitHub', + message: 'Sign in using GitHub', + apiRef: githubAuthApiRef, + }, +]; const app = createApp({ apis, @@ -78,7 +93,9 @@ const app = createApp({ }); }, components: { - SignInPage: props => , + SignInPage: props => ( + + ), }, }); @@ -117,6 +134,7 @@ const routes = ( } /> } /> } /> + } /> ); @@ -125,7 +143,9 @@ export default app.createRoot( - {routes} + + {routes} + , ); diff --git a/workspaces/lightspeed/packages/app/src/components/Root/ApplicationDrawer.tsx b/workspaces/lightspeed/packages/app/src/components/Root/ApplicationDrawer.tsx new file mode 100644 index 0000000000..b125ac370f --- /dev/null +++ b/workspaces/lightspeed/packages/app/src/components/Root/ApplicationDrawer.tsx @@ -0,0 +1,177 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ComponentType, + useState, + useCallback, + useMemo, + useEffect, +} from 'react'; +import { CustomDrawer } from './CustomDrawer'; + +/** + * Partial drawer state exposed by drawer plugins + * + * @public + */ +export interface DrawerPartialState { + id: string; + isDrawerOpen: boolean; + drawerWidth: number; + setDrawerWidth: (width: number) => void; +} + +/** + * Props for drawer state exposer components + * + * @public + */ +export interface DrawerStateExposerProps { + onStateChange: (state: DrawerPartialState) => void; + onUnmount?: (id: string) => void; +} + +/** + * Drawer content configuration + */ +type DrawerContentType = { + id: string; + Component: ComponentType; + priority?: number; +}; + +/** + * State exposer component type + */ +type StateExposerType = { + Component: ComponentType; +}; + +export interface ApplicationDrawerProps { + /** + * Array of drawer content configurations + * Maps drawer IDs to their content components + */ + drawerContents: DrawerContentType[]; + /** + * Array of state exposer components from drawer plugins + * These are typically mounted via `application/drawer-state` mount point + * + * In RHDH dynamic plugins, this would come from: + * ```yaml + * mountPoints: + * - mountPoint: application/drawer-state + * importName: TestDrawerStateExposer + * ``` + */ + stateExposers?: StateExposerType[]; +} + +export const ApplicationDrawer = ({ + drawerContents, + stateExposers = [], +}: ApplicationDrawerProps) => { + // Collect drawer states from all state exposers + const [drawerStates, setDrawerStates] = useState< + Record + >({}); + + // Callback for state exposers to report their state + const handleStateChange = useCallback((state: DrawerPartialState) => { + setDrawerStates(prev => { + // Only update if something actually changed + const existing = prev[state.id]; + if ( + existing && + existing.isDrawerOpen === state.isDrawerOpen && + existing.drawerWidth === state.drawerWidth && + existing.setDrawerWidth === state.setDrawerWidth + ) { + return prev; + } + return { ...prev, [state.id]: state }; + }); + }, []); + + // Convert states record to array + const statesArray = useMemo( + () => Object.values(drawerStates), + [drawerStates], + ); + + // Get active drawer - find the open drawer with highest priority + const activeDrawer = useMemo(() => { + return statesArray + .filter(state => state.isDrawerOpen) + .map(state => { + const content = drawerContents.find(c => c.id === state.id); + if (!content) return null; + return { ...state, ...content }; + }) + .filter(Boolean) + .sort((a, b) => (b?.priority ?? -1) - (a?.priority ?? -1))[0]; + }, [statesArray, drawerContents]); + + // Manage CSS classes and variables for layout adjustments + useEffect(() => { + if (activeDrawer) { + const className = `docked-drawer-open`; + const cssVar = `--docked-drawer-width`; + + document.body.classList.add(className); + document.body.style.setProperty(cssVar, `${activeDrawer.drawerWidth}px`); + + return () => { + document.body.classList.remove(className); + document.body.style.removeProperty(cssVar); + }; + } + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeDrawer?.id, activeDrawer?.drawerWidth]); + + // Wrapper to handle the width change callback type + const handleWidthChange = useCallback( + (width: number) => { + activeDrawer?.setDrawerWidth(width); + }, + [activeDrawer], + ); + + return ( + <> + {/* Render all state exposers - they return null but report their state */} + {stateExposers.map(({ Component }, index) => ( + + ))} + + {/* Render the active drawer */} + {activeDrawer && ( + + + + )} + + ); +}; diff --git a/workspaces/lightspeed/packages/app/src/components/Root/CustomDrawer.tsx b/workspaces/lightspeed/packages/app/src/components/Root/CustomDrawer.tsx new file mode 100644 index 0000000000..695153e642 --- /dev/null +++ b/workspaces/lightspeed/packages/app/src/components/Root/CustomDrawer.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable no-restricted-imports */ +import Box from '@mui/material/Box'; +import Drawer from '@mui/material/Drawer'; +/* eslint-enable no-restricted-imports */ + +import { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme'; + +export type CustomDrawerProps = { + children: React.ReactNode; + minWidth?: number; + maxWidth?: number; + initialWidth?: number; + isDrawerOpen: boolean; + drawerWidth?: number; + onWidthChange?: (width: number) => void; + [key: string]: any; +}; + +export const CustomDrawer = (props: CustomDrawerProps) => { + const { + children, + minWidth = 400, + maxWidth = 800, + initialWidth = 400, + isDrawerOpen, + drawerWidth, + onWidthChange, + ...drawerProps + } = props; + + // Ensure anchor is always 'right' and not overridden by drawerProps + const { anchor: _, ...restDrawerProps } = drawerProps; + + return ( + { + const themeConfig = theme as ThemeConfig; + return ( + themeConfig.palette?.rhdh?.general?.sidebarBackgroundColor || + theme.palette.background.paper + ); + }, + justifyContent: 'space-between', + }, + // Only apply header offset when global header exists + 'body:has(#global-header) &': { + '& .v5-MuiDrawer-paper': { + top: '64px !important', + height: 'calc(100vh - 64px) !important', + }, + }, + }} + variant="persistent" + open={isDrawerOpen} + > + {children} + + ); +}; diff --git a/workspaces/lightspeed/packages/app/src/components/Root/Root.tsx b/workspaces/lightspeed/packages/app/src/components/Root/Root.tsx index 3737ed297e..9feaf80ddb 100644 --- a/workspaces/lightspeed/packages/app/src/components/Root/Root.tsx +++ b/workspaces/lightspeed/packages/app/src/components/Root/Root.tsx @@ -20,8 +20,6 @@ import HomeIcon from '@material-ui/icons/Home'; import ExtensionIcon from '@material-ui/icons/Extension'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import LogoFull from './LogoFull'; -import LogoIcon from './LogoIcon'; import { Settings as SidebarSettings, UserSettingsSignInAvatar, @@ -40,12 +38,18 @@ import { Link, } from '@backstage/core-components'; import MenuIcon from '@material-ui/icons/Menu'; +import Box from '@mui/material/Box'; import SearchIcon from '@material-ui/icons/Search'; import { MyGroupsSidebarItem } from '@backstage/plugin-org'; import GroupIcon from '@material-ui/icons/People'; - -import { LightspeedIcon } from '@red-hat-developer-hub/backstage-plugin-lightspeed'; -import { IconComponent } from '@backstage/core-plugin-api'; +import { + LightspeedChatContainer, + LightspeedDrawerStateExposer, + LightspeedFAB, +} from '@red-hat-developer-hub/backstage-plugin-lightspeed'; +import { ApplicationDrawer } from './ApplicationDrawer'; +import LogoFull from './LogoFull'; +import LogoIcon from './LogoIcon'; const useSidebarLogoStyles = makeStyles({ root: { @@ -75,46 +79,79 @@ const SidebarLogo = () => { ); }; -export const Root = ({ children }: PropsWithChildren<{}>) => ( - - - - } to="/search"> - - - - }> - {/* Global nav, not org-specific */} - - - - - - ) => { + return ( + div > main[class*='BackstagePage-root']": { + marginRight: 'calc(var(--docked-drawer-width, 500px) + 1.5em)', + transition: 'margin-right 0.3s ease', + }, + }, + }} + > + + + + + } to="/search"> + + + + }> + {/* Global nav, not org-specific */} + + + + + + {/* End global nav */} + + + {/* Items in this group will be scrollable if they run out of space */} + + + + + } + to="/settings" + > + + + + {children} + - ;{/* End global nav */} - - - {/* Items in this group will be scrollable if they run out of space */} - - - - - } - to="/settings" - > - - - - {children} - -); + + + ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/dev/index.tsx b/workspaces/lightspeed/plugins/lightspeed/dev/index.tsx index d85546d0d2..cba4c92800 100644 --- a/workspaces/lightspeed/plugins/lightspeed/dev/index.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/dev/index.tsx @@ -16,12 +16,79 @@ import { createDevApp } from '@backstage/dev-utils'; -import { LightspeedPage, lightspeedPlugin } from '../src/plugin'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +import { DrawerComponent } from '../src/components/DrawerComponent'; +import { + LightspeedChatContainer, + LightspeedDrawerProvider, + LightspeedFAB, + LightspeedPage, + lightspeedPlugin, +} from '../src/plugin'; + +const TestPage = () => { + return ( + + + Lightspeed Display Modes Test + + + Click the Lightspeed FAB button (bottom right) to open the chatbot. + + + Display Modes: + +
    +
  • + Overlay (default): Opens as a modal overlay +
  • +
  • + Docked: Opens as a drawer on the right +
  • +
  • + Fullscreen: Navigate to /lightspeed route +
  • +
+ + Use the settings dropdown in the chatbot header to switch between + display modes. + +
+ ); +}; createDevApp() .registerPlugin(lightspeedPlugin) .addPage({ - element: , + element: ( + + + + + + + + ), + title: 'Test Page', + path: '/', + }) + .addPage({ + element: ( + + + + ), title: 'Lightspeed Page', path: '/lightspeed', }) diff --git a/workspaces/lightspeed/plugins/lightspeed/package.json b/workspaces/lightspeed/plugins/lightspeed/package.json index 07a2f2960c..6ab7bfc550 100644 --- a/workspaces/lightspeed/plugins/lightspeed/package.json +++ b/workspaces/lightspeed/plugins/lightspeed/package.json @@ -57,10 +57,12 @@ "@material-ui/lab": "^4.0.0-alpha.61", "@mui/icons-material": "^6.1.8", "@mui/material": "^5.12.2", + "@mui/styles": "5.18.0", "@patternfly/chatbot": "6.4.1", "@patternfly/react-core": "6.4.0", "@patternfly/react-icons": "^6.3.1", "@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-theme": "^0.10.0", "@tanstack/react-query": "^5.59.15", "react-markdown": "^9.0.1", "react-use": "^17.2.4" @@ -115,7 +117,8 @@ "bugs": "https://github.com/redhat-developer/rhdh-plugins/issues", "maintainers": [ "@karthikjeeyar", - "@rohitkrai03" + "@rohitkrai03", + "@debsmita1" ], "author": "Red Hat" } diff --git a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md index 6108a96832..e341876a0b 100644 --- a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md +++ b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md @@ -80,6 +80,7 @@ readonly "aria.options.label": string; readonly "aria.scroll.down": string; readonly "aria.scroll.up": string; readonly "aria.settings.label": string; +readonly "aria.close": string; readonly "modal.edit": string; readonly "modal.save": string; readonly "modal.close": string; @@ -101,6 +102,7 @@ readonly "tooltip.responseRecorded": string; readonly "tooltip.backToTop": string; readonly "tooltip.backToBottom": string; readonly "tooltip.settings": string; +readonly "tooltip.close": string; readonly "modal.title.preview": string; readonly "modal.title.edit": string; readonly "icon.lightspeed.alt": string; @@ -135,6 +137,10 @@ readonly "settings.pinned.enable": string; readonly "settings.pinned.disable": string; readonly "settings.pinned.enabled.description": string; readonly "settings.pinned.disabled.description": string; +readonly "settings.displayMode.label": string; +readonly "settings.displayMode.overlay": string; +readonly "settings.displayMode.docked": string; +readonly "settings.displayMode.fullscreen": string; }>; // @alpha diff --git a/workspaces/lightspeed/plugins/lightspeed/report.api.md b/workspaces/lightspeed/plugins/lightspeed/report.api.md index 90168d0697..121429e465 100644 --- a/workspaces/lightspeed/plugins/lightspeed/report.api.md +++ b/workspaces/lightspeed/plugins/lightspeed/report.api.md @@ -4,9 +4,183 @@ ```ts +/// + +import { AlertProps } from '@patternfly/react-core'; +import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { ChatbotDisplayMode } from '@patternfly/chatbot'; +import { ConfigApi } from '@backstage/core-plugin-api'; +import { FetchApi } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react/jsx-runtime'; +import { PropsWithChildren } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; +import { SourcesCardProps } from '@patternfly/chatbot'; + +// @public +export type Attachment = { + attachment_type: string; + content_type: string; + content: string; +}; + +// @public +export interface BaseMessage { + // (undocumented) + content: string; + // (undocumented) + error?: AlertProps; + // (undocumented) + id: number; + // (undocumented) + model: string; + // (undocumented) + name: string; + // (undocumented) + referenced_documents?: ReferencedDocuments; + // (undocumented) + sources?: SourcesCardProps; + // (undocumented) + timestamp: string; + // (undocumented) + type: string; +} + +// @public +export type CaptureFeedback = { + conversation_id: string; + user_question: string; + llm_response: string; + user_feedback: string; + sentiment: number; +}; + +// @public +export type ConversationList = ConversationSummary[]; + +// @public +export type ConversationSummary = { + conversation_id: string; + last_message_timestamp: number; + topic_summary: string; +}; + +// @public +export type DrawerState = { + id: string; + isDrawerOpen: boolean; + drawerWidth: number; + setDrawerWidth: (width: number) => void; +}; + +// @public +export type DrawerStateExposerProps = { + onStateChange: (state: DrawerState) => void; +}; + +// @public +export interface LCSModel { + // (undocumented) + api_model_type: LCSModelApiModelType; + // (undocumented) + identifier: string; + // (undocumented) + metadata: { + embedding_dimension: number; + }; + // (undocumented) + model_type: LCSModelType; + // (undocumented) + provider_id: string; + // (undocumented) + provider_resource_id: string; + // (undocumented) + type: 'model'; +} + +// @public +export type LCSModelApiModelType = 'embedding' | 'llm'; + +// @public +export type LCSModelType = 'embedding' | 'llm'; + +// @public +export type LightspeedAPI = { + getAllModels: () => Promise; + getConversationMessages: (conversation_id: string) => Promise; + createMessage: (prompt: string, selectedModel: string, selectedProvider: string, conversation_id: string, attachments: Attachment[]) => Promise; + deleteConversation: (conversation_id: string) => Promise<{ + success: boolean; + }>; + renameConversation: (conversation_id: string, newName: string) => Promise<{ + success: boolean; + }>; + getConversations: () => Promise; + getFeedbackStatus: () => Promise; + captureFeedback: (payload: CaptureFeedback) => Promise<{ + response: string; + }>; + isTopicRestrictionEnabled: () => Promise; +}; + +// @public +export class LightspeedApiClient implements LightspeedAPI { + constructor(options: Options); + // (undocumented) + captureFeedback: (payload: CaptureFeedback) => Promise; + // (undocumented) + createMessage(prompt: string, selectedModel: string, selectedProvider: string, conversation_id: string, attachments: Attachment[]): Promise>; + // (undocumented) + deleteConversation(conversation_id: string): Promise<{ + success: boolean; + }>; + // (undocumented) + getAllModels(): Promise; + // (undocumented) + getBaseUrl(): Promise; + // (undocumented) + getConversationMessages(conversation_id: string): Promise; + // (undocumented) + getConversations(): Promise; + // (undocumented) + getFeedbackStatus: () => Promise; + // (undocumented) + isTopicRestrictionEnabled(): Promise; + // (undocumented) + renameConversation(conversation_id: string, newName: string): Promise<{ + success: boolean; + }>; +} + +// @public +export const lightspeedApiRef: ApiRef; + +// @public (undocumented) +export const LightspeedChatContainer: () => JSX_2.Element; + +// @public +export interface LightspeedDrawerContextType { + currentConversationId?: string; + displayMode: ChatbotDisplayMode; + drawerWidth: number; + isChatbotActive: boolean; + setCurrentConversationId: (id: string | undefined) => void; + setDisplayMode: (mode: ChatbotDisplayMode) => void; + setDrawerWidth: React.Dispatch>; + toggleChatbot: () => void; +} + +// @public +export const LightspeedDrawerProvider: React.ComponentType; + +// @public +export const LightspeedDrawerStateExposer: ({ onStateChange, }: DrawerStateExposerProps) => null; + +// @public +export const LightspeedFAB: () => JSX_2.Element | null; + +// @public +export const LightspeedFABIcon: () => JSX_2.Element; // @public export const LightspeedIcon: () => JSX_2.Element; @@ -19,6 +193,25 @@ export const lightspeedPlugin: BackstagePlugin< { root: RouteRef; }, {}, {}>; +// @public +export type Options = { + configApi: ConfigApi; + fetchApi: FetchApi; +}; + +// @public +export type ReferencedDocument = { + doc_title: string; + doc_url: string; + doc_description?: string; +}; + +// @public +export type ReferencedDocuments = ReferencedDocument[]; + +// @public +export const useLightspeedDrawerContext: () => LightspeedDrawerContextType; + // (No @packageDocumentation comment for this package) ``` diff --git a/workspaces/lightspeed/plugins/lightspeed/src/api/LightspeedApiClient.ts b/workspaces/lightspeed/plugins/lightspeed/src/api/LightspeedApiClient.ts index bf685d36a3..40a3d222ab 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/api/LightspeedApiClient.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/api/LightspeedApiClient.ts @@ -23,11 +23,21 @@ import { import { Attachment, CaptureFeedback } from '../types'; import { LightspeedAPI } from './api'; +/** + * @public + * Lightspeed API client options + */ + export type Options = { configApi: ConfigApi; fetchApi: FetchApi; }; +/** + * @public + * Lightspeed API client implementation + */ + export class LightspeedApiClient implements LightspeedAPI { private readonly configApi: ConfigApi; private readonly fetchApi: FetchApi; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/api/__tests__/LightspeedApiClient.test.ts b/workspaces/lightspeed/plugins/lightspeed/src/api/__tests__/LightspeedApiClient.test.ts new file mode 100644 index 0000000000..0bd63ce59a --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/api/__tests__/LightspeedApiClient.test.ts @@ -0,0 +1,476 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigApi, FetchApi } from '@backstage/core-plugin-api'; + +import { TEMP_CONVERSATION_ID } from '../../const'; +import { LightspeedApiClient } from '../LightspeedApiClient'; + +describe('LightspeedApiClient', () => { + let mockConfigApi: jest.Mocked; + let mockFetchApi: jest.Mocked; + let client: LightspeedApiClient; + + beforeEach(() => { + mockConfigApi = { + getString: jest.fn().mockReturnValue('http://localhost:7007'), + } as unknown as jest.Mocked; + + mockFetchApi = { + fetch: jest.fn(), + } as unknown as jest.Mocked; + + client = new LightspeedApiClient({ + configApi: mockConfigApi, + fetchApi: mockFetchApi, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getBaseUrl', () => { + it('should return the correct base URL', async () => { + const baseUrl = await client.getBaseUrl(); + expect(baseUrl).toBe('http://localhost:7007/api/lightspeed'); + expect(mockConfigApi.getString).toHaveBeenCalledWith('backend.baseUrl'); + }); + }); + + describe('getAllModels', () => { + it('should return models when API call succeeds', async () => { + const mockModels = [ + { identifier: 'model1', type: 'model' }, + { identifier: 'model2', type: 'model' }, + ]; + + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ models: mockModels }), + } as unknown as Response); + + const result = await client.getAllModels(); + + expect(result).toEqual(mockModels); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/lightspeed/v1/models', + expect.objectContaining({ + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + + it('should return empty array when models is undefined', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response); + + const result = await client.getAllModels(); + expect(result).toEqual([]); + }); + + it('should throw error when API call fails', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + } as unknown as Response); + + await expect(client.getAllModels()).rejects.toThrow( + 'failed to fetch data, status 500: Internal Server Error', + ); + }); + }); + + describe('getConversations', () => { + it('should return conversations when API call succeeds', async () => { + const mockConversations = [ + { conversation_id: 'conv1', topic_summary: 'Test' }, + ]; + + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ conversations: mockConversations }), + } as unknown as Response); + + const result = await client.getConversations(); + + expect(result).toEqual(mockConversations); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/lightspeed/v2/conversations', + expect.any(Object), + ); + }); + + it('should return empty array when conversations is undefined', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response); + + const result = await client.getConversations(); + expect(result).toEqual([]); + }); + }); + + describe('getConversationMessages', () => { + it('should return empty array for temp conversation ID', async () => { + const result = await client.getConversationMessages(TEMP_CONVERSATION_ID); + expect(result).toEqual([]); + expect(mockFetchApi.fetch).not.toHaveBeenCalled(); + }); + + it('should return messages when API call succeeds', async () => { + const mockMessages = [{ id: 1, content: 'Hello' }]; + + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ chat_history: mockMessages }), + } as unknown as Response); + + const result = await client.getConversationMessages('conv-123'); + + expect(result).toEqual(mockMessages); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/lightspeed/v2/conversations/conv-123', + expect.any(Object), + ); + }); + + it('should return empty array when chat_history is undefined', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response); + + const result = await client.getConversationMessages('conv-123'); + expect(result).toEqual([]); + }); + }); + + describe('deleteConversation', () => { + it('should return success when delete succeeds', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + } as unknown as Response); + + const result = await client.deleteConversation('conv-123'); + + expect(result).toEqual({ success: true }); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/lightspeed/v2/conversations/conv-123', + expect.objectContaining({ method: 'DELETE' }), + ); + }); + + it('should throw error when delete fails', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + } as unknown as Response); + + await expect(client.deleteConversation('conv-123')).rejects.toThrow( + 'failed to delete conversation, status 404: Not Found', + ); + }); + }); + + describe('renameConversation', () => { + it('should return success when rename succeeds', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + } as unknown as Response); + + const result = await client.renameConversation('conv-123', 'New Name'); + + expect(result).toEqual({ success: true }); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/lightspeed/v2/conversations/conv-123', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ topic_summary: 'New Name' }), + }), + ); + }); + + it('should throw error when rename fails', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + } as unknown as Response); + + await expect( + client.renameConversation('conv-123', 'New Name'), + ).rejects.toThrow( + 'failed to rename conversation, status 400: Bad Request', + ); + }); + }); + + describe('getFeedbackStatus', () => { + it('should return true when feedback is enabled', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ status: { enabled: true } }), + } as unknown as Response); + + const result = await client.getFeedbackStatus(); + + expect(result).toBe(true); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/lightspeed/v1/feedback/status', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('should return false when feedback is disabled', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ status: { enabled: false } }), + } as unknown as Response); + + const result = await client.getFeedbackStatus(); + expect(result).toBe(false); + }); + + it('should return false when status is undefined', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response); + + const result = await client.getFeedbackStatus(); + expect(result).toBe(false); + }); + + it('should throw error when API call fails', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + } as unknown as Response); + + await expect(client.getFeedbackStatus()).rejects.toThrow( + 'failed to GET feedback status, status 500: Internal Server Error', + ); + }); + }); + + describe('captureFeedback', () => { + it('should return response when feedback is captured', async () => { + const mockPayload = { + conversation_id: 'conv-123', + user_question: 'test question', + llm_response: 'test response', + user_feedback: 'Great!', + sentiment: 1, + }; + + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ response: 'success' }), + } as unknown as Response); + + const result = await client.captureFeedback(mockPayload); + + expect(result).toEqual({ response: 'success' }); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/lightspeed/v1/feedback', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(mockPayload), + }), + ); + }); + + it('should throw error when capture fails', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + } as unknown as Response); + + await expect( + client.captureFeedback({ + conversation_id: 'conv-123', + user_question: 'test', + llm_response: 'test', + user_feedback: 'test', + sentiment: 1, + }), + ).rejects.toThrow('failed to capture feedback, status 400: Bad Request'); + }); + }); + + describe('isTopicRestrictionEnabled', () => { + it('should return true when valid shield is present', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + shields: [ + { + provider_resource_id: 'lightspeed_question_validity-shield', + }, + ], + }), + } as unknown as Response); + + const result = await client.isTopicRestrictionEnabled(); + expect(result).toBe(true); + }); + + it('should return false when no valid shield is present', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + shields: [{ provider_resource_id: 'other-shield' }], + }), + } as unknown as Response); + + const result = await client.isTopicRestrictionEnabled(); + expect(result).toBe(false); + }); + + it('should return false when shields array is empty', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ shields: [] }), + } as unknown as Response); + + const result = await client.isTopicRestrictionEnabled(); + expect(result).toBe(false); + }); + + it('should return false when shields is not an array', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response); + + const result = await client.isTopicRestrictionEnabled(); + expect(result).toBe(false); + }); + }); + + describe('createMessage', () => { + it('should return readable stream reader when message is created', async () => { + const mockReader = { + read: jest.fn(), + }; + const mockBody = { + getReader: jest.fn().mockReturnValue(mockReader), + }; + + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + body: mockBody, + } as unknown as Response); + + const result = await client.createMessage( + 'Hello', + 'granite', + 'openai', + 'conv-123', + [], + ); + + expect(result).toBe(mockReader); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/lightspeed/v1/query', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + conversation_id: 'conv-123', + model: 'granite', + provider: 'openai', + query: 'Hello', + attachments: [], + }), + }), + ); + }); + + it('should send undefined conversation_id for temp conversation', async () => { + const mockReader = { read: jest.fn() }; + const mockBody = { getReader: jest.fn().mockReturnValue(mockReader) }; + + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + body: mockBody, + } as unknown as Response); + + await client.createMessage( + 'Hello', + 'granite', + 'openai', + TEMP_CONVERSATION_ID, + [], + ); + + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + conversation_id: undefined, + model: 'granite', + provider: 'openai', + query: 'Hello', + attachments: [], + }), + }), + ); + }); + + it('should throw error when response has no body', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + body: null, + } as unknown as Response); + + await expect( + client.createMessage('Hello', 'granite', 'openai', 'conv-123', []), + ).rejects.toThrow( + 'Readable stream is not supported or there is no body.', + ); + }); + + it('should throw error with message from response when not ok', async () => { + const errorMessage = { error: 'Invalid request' }; + const mockReader = { + read: jest.fn().mockResolvedValue({ + done: false, + value: new TextEncoder().encode(JSON.stringify(errorMessage)), + }), + }; + const mockBody = { + getReader: jest.fn().mockReturnValue(mockReader), + }; + + mockFetchApi.fetch.mockResolvedValue({ + ok: false, + body: mockBody, + } as unknown as Response); + + await expect( + client.createMessage('Hello', 'granite', 'openai', 'conv-123', []), + ).rejects.toThrow('failed to create message: Invalid request'); + }); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/api/api.ts b/workspaces/lightspeed/plugins/lightspeed/src/api/api.ts index 27547f6dbb..bf88232182 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/api/api.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/api/api.ts @@ -24,6 +24,11 @@ import { LCSModel, } from '../types'; +/** + * @public + * Lightspeed API + */ + export type LightspeedAPI = { getAllModels: () => Promise; getConversationMessages: (conversation_id: string) => Promise; @@ -47,6 +52,11 @@ export type LightspeedAPI = { isTopicRestrictionEnabled: () => Promise; }; +/** + * @public + * Lightspeed API interface + */ + export const lightspeedApiRef = createApiRef({ id: 'plugin.lightspeed.service', }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/DrawerComponent.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/DrawerComponent.tsx new file mode 100644 index 0000000000..097928701b --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/DrawerComponent.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PropsWithChildren } from 'react'; + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; + +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { CustomDrawer } from '../../../../packages/app/src/components/Root/CustomDrawer'; +import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; + +export const DrawerComponent = ({ children }: PropsWithChildren) => { + const { displayMode, drawerWidth, setDrawerWidth } = + useLightspeedDrawerContext(); + return ( + + {children} + + ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index d675b1db5c..cda6f50fbd 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -48,6 +48,7 @@ import { useLastOpenedConversation, useLightspeedDeletePermission, } from '../hooks'; +import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; import { useLightspeedUpdatePermission } from '../hooks/useLightspeedUpdatePermission'; import { useTranslation } from '../hooks/useTranslation'; import { useWelcomePrompts } from '../hooks/useWelcomePrompts'; @@ -130,7 +131,8 @@ export const LightspeedChat = ({ const [filterValue, setFilterValue] = useState(''); const [announcement, setAnnouncement] = useState(''); const [conversationId, setConversationId] = useState(''); - const [isDrawerOpen, setIsDrawerOpen] = useState(!isMobile); + const [isEmbeddedDrawerOpen, setIsEmbeddedDrawerOpen] = + useState(!isMobile); const [newChatCreated, setNewChatCreated] = useState(false); const [isSendButtonDisabled, setIsSendButtonDisabled] = useState(false); @@ -141,7 +143,12 @@ export const LightspeedChat = ({ const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); const { isReady, lastOpenedId, setLastOpenedId, clearLastOpenedId } = useLastOpenedConversation(user); - + const { + displayMode, + setDisplayMode, + currentConversationId: routeConversationId, + setCurrentConversationId, + } = useLightspeedDrawerContext(); const { uploadError, showAlert, @@ -165,6 +172,18 @@ export const LightspeedChat = ({ } }, [isPinningChatsEnabled]); + useEffect(() => { + if (displayMode === ChatbotDisplayMode.embedded) { + setIsEmbeddedDrawerOpen(true); + } else if ( + displayMode === ChatbotDisplayMode.docked || + displayMode === ChatbotDisplayMode.default + ) { + setIsEmbeddedDrawerOpen(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [displayMode]); + const queryClient = useQueryClient(); const { @@ -195,6 +214,35 @@ export const LightspeedChat = ({ } }, [isLoading, isRefetching, conversations, lastOpenedId, clearLastOpenedId]); + useEffect(() => { + if ( + !isLoading && + !isRefetching && + routeConversationId && + displayMode === ChatbotDisplayMode.embedded + ) { + const conversationExists = conversations.some( + (c: ConversationSummary) => c.conversation_id === routeConversationId, + ); + if (!conversationExists) { + // Conversation from route doesn't exist, start a new chat + setConversationId(TEMP_CONVERSATION_ID); + setCurrentConversationId(undefined); + setNewChatCreated(true); + } else if (conversationId !== routeConversationId) { + setConversationId(routeConversationId); + } + } + }, [ + isLoading, + isRefetching, + routeConversationId, + conversations, + displayMode, + conversationId, + setCurrentConversationId, + ]); + useEffect(() => { // Update last opened conversation whenever `conversationId` changes if (conversationId) { @@ -204,6 +252,7 @@ export const LightspeedChat = ({ const onStart = (conv_id: string) => { setConversationId(conv_id); + setCurrentConversationId(conv_id); }; const onComplete = (message: string) => { @@ -254,14 +303,18 @@ export const LightspeedChat = ({ setUploadError({ message: null }); setConversationId(TEMP_CONVERSATION_ID); setNewChatCreated(true); + setCurrentConversationId(undefined); + if (displayMode !== ChatbotDisplayMode.embedded) { + setIsEmbeddedDrawerOpen(false); + } } })(); }, [ conversationId, - setConversationId, - setMessages, - setUploadError, setFileContents, + setUploadError, + displayMode, + setCurrentConversationId, ]); const openDeleteModal = (conversation_id: string) => { @@ -429,17 +482,25 @@ export const LightspeedChat = ({ const onSelectActiveItem = useCallback( (_: MouseEvent | undefined, selectedItem: string | number | undefined) => { setNewChatCreated(false); + const newConvId = String(selectedItem); setConversationId((c_id: string) => { if (c_id !== selectedItem) { - return String(selectedItem); + return newConvId; } return c_id; }); + setCurrentConversationId(newConvId); setFileContents([]); setUploadError({ message: null }); scrollToBottomRef.current?.scrollToBottom(); }, - [setConversationId, setUploadError, setFileContents, scrollToBottomRef], + [ + setConversationId, + setUploadError, + setFileContents, + scrollToBottomRef, + setCurrentConversationId, + ], ); const conversationFound = !!conversations.find( @@ -465,8 +526,8 @@ export const LightspeedChat = ({ setFilterValue(value); }, []); - const onDrawerToggle = useCallback(() => { - setIsDrawerOpen(isOpen => !isOpen); + const onEmbeddedDrawerToggle = useCallback(() => { + setIsEmbeddedDrawerOpen(isOpen => !isOpen); }, []); const handleAttach = (data: File[], event: DropEvent) => { @@ -476,7 +537,10 @@ export const LightspeedChat = ({ const onAttachRejected = (data: FileRejection[]) => { data.forEach(attachment => { - if (!!attachment.errors.find(e => e.code === 'file-invalid-type')) { + const hasInvalidTypeError = attachment.errors.some( + e => e.code === 'file-invalid-type', + ); + if (hasInvalidTypeError) { setShowAlert(true); setUploadError({ message: t('file.upload.error.unsupportedType'), @@ -509,17 +573,21 @@ export const LightspeedChat = ({ setIsDrawerOpen(!isDrawerOpen)} + aria-expanded={isEmbeddedDrawerOpen} + onMenuToggle={() => + setIsEmbeddedDrawerOpen(!isEmbeddedDrawerOpen) + } className={classes.headerMenu} tooltipContent={t('tooltip.chatHistoryMenu')} aria-label={t('aria.chatHistoryMenu')} /> - - - {t('chatbox.header.title')} - - + {displayMode === ChatbotDisplayMode.embedded && ( + + + {t('chatbox.header.title')} + + + )} diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBox.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBox.tsx index 9f090ca1fc..328f7b680b 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBox.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBox.tsx @@ -25,6 +25,7 @@ import { import { makeStyles } from '@material-ui/core'; import { + ChatbotDisplayMode, ChatbotWelcomePrompt, Message, MessageBox, @@ -49,6 +50,12 @@ const useStyles = makeStyles(theme => ({ alert: { background: 'unset !important', }, + promptSuggestions: { + '& div.pf-chatbot__prompt-suggestions': { + 'flex-direction': 'column !important', + }, + }, + userMessageText: { '& div.pf-chatbot__message--user': { '& div.pf-chatbot__message-text': { @@ -69,6 +76,7 @@ type LightspeedChatBoxProps = { welcomePrompts: WelcomePrompt[]; conversationId: string; isStreaming: boolean; + displayMode?: ChatbotDisplayMode; }; export interface ScrollContainerHandle { @@ -86,6 +94,7 @@ export const LightspeedChatBox = forwardRef( welcomePrompts, isStreaming, topicRestrictionEnabled, + displayMode, }: LightspeedChatBoxProps, ref: ForwardedRef, ) => { @@ -144,13 +153,22 @@ export const LightspeedChatBox = forwardRef( }, [autoScroll, cmessages, containerRef]); const messageBoxClasses = `${classes.container} ${classes.userMessageText}`; + const isEmbeddedMode = displayMode === ChatbotDisplayMode.embedded; + + const getMessageBoxClassName = () => { + if (!welcomePrompts.length) { + return messageBoxClasses; + } + const baseClasses = `${messageBoxClasses} ${classes.prompt}`; + if (isEmbeddedMode) { + return baseClasses; + } + return `${baseClasses} ${classes.promptSuggestions}`; + }; + return ( void; models: { label: string; value: string; provider: string }[]; isPinningChatsEnabled: boolean; onPinnedChatsToggle: (state: boolean) => void; + setDisplayMode: (mode: ChatbotDisplayMode) => void; }; const useStyles = makeStyles(() => @@ -64,10 +72,12 @@ const useStyles = makeStyles(() => export const LightspeedChatBoxHeader = ({ selectedModel, + displayMode, handleSelectedModel, models, isPinningChatsEnabled, onPinnedChatsToggle, + setDisplayMode, }: LightspeedChatBoxHeaderProps) => { const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false); const { t } = useTranslation(); @@ -107,6 +117,18 @@ export const LightspeedChatBoxHeader = ({ onPinnedChatsToggle(state); }; + const handleDockedToWindow = () => { + setDisplayMode(ChatbotDisplayMode.docked); + }; + + const handleFullscreen = () => { + setDisplayMode(ChatbotDisplayMode.embedded); + }; + + const handleOverlay = () => { + setDisplayMode(ChatbotDisplayMode.default); + }; + return ( + + + + {t('settings.displayMode.label')} + + } + onClick={handleOverlay} + isSelected={displayMode === ChatbotDisplayMode.default} + > + {t('settings.displayMode.overlay')} + + } + onClick={handleDockedToWindow} + isSelected={displayMode === ChatbotDisplayMode.docked} + > + {t('settings.displayMode.docked')} + + } + onClick={handleFullscreen} + isSelected={displayMode === ChatbotDisplayMode.embedded} + > + {t('settings.displayMode.fullscreen')} + + + + {isPinningChatsEnabled ? ( diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatContainer.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatContainer.tsx new file mode 100644 index 0000000000..6fc82eb0a3 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatContainer.tsx @@ -0,0 +1,173 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { useAsync } from 'react-use'; + +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; + +import { useTheme } from '@material-ui/core/styles'; +import { QueryClientProvider } from '@tanstack/react-query'; + +import { useAllModels } from '../hooks/useAllModels'; +import { useLightspeedViewPermission } from '../hooks/useLightspeedViewPermission'; +import { useTopicRestrictionStatus } from '../hooks/useQuestionValidation'; +import queryClient from '../utils/queryClient'; +import FileAttachmentContextProvider from './AttachmentContext'; +import { LightspeedChat } from './LightSpeedChat'; +import PermissionRequiredState from './PermissionRequiredState'; + +const THEME_DARK = 'dark'; +const THEME_DARK_CLASS = 'pf-v6-theme-dark'; +const LAST_SELECTED_MODEL_KEY = 'lastSelectedModel'; + +/** + * Inner component that contains all the Lightspeed chat rendering logic + */ +const LightspeedChatContainerInner = () => { + const { + palette: { type }, + } = useTheme(); + + const identityApi = useApi(identityApiRef); + + const { data: models } = useAllModels(); + + const { allowed: hasViewAccess, loading } = useLightspeedViewPermission(); + + const { value: profile, loading: profileLoading } = useAsync( + async () => await identityApi.getProfileInfo(), + ); + + const [selectedModel, setSelectedModel] = useState(''); + const [selectedProvider, setSelectedProvider] = useState(''); + + const { data: topicRestrictionEnabled } = useTopicRestrictionStatus(); + + const modelsItems = useMemo( + () => + models + ? models + .filter(model => model.model_type === 'llm') + .map(m => ({ + label: m.provider_resource_id, + value: m.provider_resource_id, + provider: m.provider_id, + })) + : [], + [models], + ); + + // Handle dark theme class on document + useEffect(() => { + const htmlTagElement = document.documentElement; + if (type === THEME_DARK) { + htmlTagElement.classList.add(THEME_DARK_CLASS); + } else { + htmlTagElement.classList.remove(THEME_DARK_CLASS); + } + }, [type]); + + // Load last selected model from localStorage + useEffect(() => { + if (modelsItems.length > 0) { + try { + const storedData = localStorage.getItem(LAST_SELECTED_MODEL_KEY); + const parsedData = storedData ? JSON.parse(storedData) : null; + + const storedModel = parsedData?.model + ? modelsItems.find(m => m.value === parsedData.model) + : null; + + if (storedModel) { + setSelectedModel(storedModel.value); + setSelectedProvider(storedModel.provider); + } else { + setSelectedModel(modelsItems[0].value); + setSelectedProvider(modelsItems[0].provider); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error( + 'Error loading last selected model from localStorage:', + error, + ); + setSelectedModel(modelsItems[0].value); + setSelectedProvider(modelsItems[0].provider); + } + } + }, [modelsItems]); + + // Save selected model to localStorage + useEffect(() => { + if (selectedModel && selectedProvider) { + try { + localStorage.setItem( + LAST_SELECTED_MODEL_KEY, + JSON.stringify({ + model: selectedModel, + provider: selectedProvider, + }), + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + 'Error saving last selected model to localStorage:', + error, + ); + } + } + }, [selectedModel, selectedProvider]); + + if (loading) { + return null; + } + + if (!hasViewAccess) { + return ; + } + + return ( + + { + setSelectedModel(item); + setSelectedProvider( + modelsItems.find((m: any) => m.value === item)?.provider || '', + ); + }} + models={modelsItems} + userName={profile?.displayName} + avatar={profile?.picture} + profileLoading={profileLoading} + /> + + ); +}; + +/** + * @public + */ +export const LightspeedChatContainer = () => { + return ( + + + + ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx new file mode 100644 index 0000000000..88aef928e3 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext } from 'react'; + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; + +/** + * Type for LightspeedDrawerContext + * + * @public + */ +export interface LightspeedDrawerContextType { + /** + * Whether the chatbot is active + */ + isChatbotActive: boolean; + /** + * Toggle the chatbot open/closed + */ + toggleChatbot: () => void; + /** + * The current display mode + */ + displayMode: ChatbotDisplayMode; + /** + * Set the display mode (overlay, docked, or fullscreen/embedded) + */ + setDisplayMode: (mode: ChatbotDisplayMode) => void; + /** + * The drawer width (for docked mode) + */ + drawerWidth: number; + /** + * The function for setting the drawer width + */ + setDrawerWidth: React.Dispatch>; + /** + * The current conversation ID + */ + currentConversationId?: string; + /** + * Set the current conversation ID and update the route if in embedded mode + * Pass undefined to clear the conversation (example: for new chat) + */ + setCurrentConversationId: (id: string | undefined) => void; +} + +/** + * @public + */ +export const LightspeedDrawerContext = createContext< + LightspeedDrawerContextType | undefined +>(undefined); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx new file mode 100644 index 0000000000..87fef0fd0a --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx @@ -0,0 +1,187 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + PropsWithChildren, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { makeStyles } from '@mui/styles'; +import { ChatbotDisplayMode, ChatbotModal } from '@patternfly/chatbot'; + +import { LightspeedChatContainer } from './LightspeedChatContainer'; +import { LightspeedDrawerContext } from './LightspeedDrawerContext'; + +const useStyles = makeStyles(() => ({ + chatbotModal: { + // When docked drawer is open, adjust modal position + 'body.docked-drawer-open &': { + transition: 'margin-right 0.3s ease', + marginRight: 'var(--docked-drawer-width, 500px)', + }, + }, +})); + +/** + * @public + */ +export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { + const classes = useStyles(); + const navigate = useNavigate(); + const location = useLocation(); + + const [displayModeState, setDisplayModeState] = useState( + ChatbotDisplayMode.default, + ); + const [isOpen, setIsOpen] = useState(false); + const [drawerWidth, setDrawerWidth] = useState(400); + const [currentConversationIdState, setCurrentConversationIdState] = useState< + string | undefined + >(undefined); + + const isLightspeedRoute = location.pathname.startsWith('/lightspeed'); + + useEffect(() => { + if (isLightspeedRoute) { + const match = location.pathname.match(/\/lightspeed\/conversation\/(.+)/); + if (match) { + setCurrentConversationIdState(match[1]); + } else { + setCurrentConversationIdState(undefined); + } + setDisplayModeState(ChatbotDisplayMode.embedded); + setIsOpen(true); + } else if (displayModeState === ChatbotDisplayMode.embedded) { + setDisplayModeState(ChatbotDisplayMode.default); + } + }, [isLightspeedRoute, location.pathname, displayModeState]); + + // Open chatbot in overlay mode + const openChatbot = useCallback(() => { + setDisplayModeState(ChatbotDisplayMode.default); + setIsOpen(true); + }, []); + + // Close chatbot + const closeChatbot = useCallback(() => { + // If in embedded mode on the lightspeed route, navigate back + if (displayModeState === ChatbotDisplayMode.embedded && isLightspeedRoute) { + navigate(-1); + } + setIsOpen(false); + setDisplayModeState(ChatbotDisplayMode.default); + }, [displayModeState, isLightspeedRoute, navigate]); + + const toggleChatbot = useCallback(() => { + if (isOpen) { + closeChatbot(); + } else { + openChatbot(); + } + }, [isOpen, openChatbot, closeChatbot]); + + const setCurrentConversationId = useCallback( + (id: string | undefined) => { + setCurrentConversationIdState(id); + + // Update route if in embedded mode + if ( + displayModeState === ChatbotDisplayMode.embedded && + isLightspeedRoute + ) { + const path = id ? `/lightspeed/conversation/${id}` : '/lightspeed'; + navigate(path, { replace: true }); + } + }, + [displayModeState, isLightspeedRoute, navigate], + ); + + // Set display mode with route handling for embedded/fullscreen + const setDisplayMode = useCallback( + (mode: ChatbotDisplayMode, conversationId?: string) => { + setDisplayModeState(mode); + + // Navigate to fullscreen route with conversation ID if available + if (mode === ChatbotDisplayMode.embedded) { + const convId = conversationId ?? currentConversationIdState; + const path = convId + ? `/lightspeed/conversation/${convId}` + : '/lightspeed'; + navigate(path); + setIsOpen(true); + } else { + if (isLightspeedRoute) { + navigate(-1); + } + setIsOpen(true); + } + }, + [navigate, isLightspeedRoute, currentConversationIdState], + ); + + // Only render ChatbotModal for overlay mode + // Docked mode is handled by ApplicationDrawer in Root + // Embedded mode is handled by LightspeedPage route + const shouldRenderOverlayModal = + isOpen && + displayModeState === ChatbotDisplayMode.default && + !isLightspeedRoute; + + const contextValue = useMemo( + () => ({ + isChatbotActive: isOpen, + toggleChatbot, + displayMode: displayModeState, + setDisplayMode, + drawerWidth, + setDrawerWidth, + currentConversationId: currentConversationIdState, + setCurrentConversationId, + }), + [ + isOpen, + toggleChatbot, + displayModeState, + setDisplayMode, + drawerWidth, + setDrawerWidth, + currentConversationIdState, + setCurrentConversationId, + ], + ); + + return ( + + {children} + {shouldRenderOverlayModal && ( + + + + )} + + ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx new file mode 100644 index 0000000000..9154cd39de --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect } from 'react'; + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; + +import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; + +/** + * @public + * Partial Lightspeed drawer state exposed to the ApplicationDrawer + */ +export type DrawerState = { + id: string; + isDrawerOpen: boolean; + drawerWidth: number; + setDrawerWidth: (width: number) => void; +}; + +/** + * @public + * Props for drawer state exposer components + */ +export type DrawerStateExposerProps = { + /** + * Callback called whenever the drawer state changes + */ + onStateChange: (state: DrawerState) => void; +}; + +/** + * @public + * This exposes LightspeedDrawer's partial context to the ApplicationDrawer + * + * It reads the LightspeedDrawerContext and calls the onStateChange callback with the + * partial state (id, isDrawerOpen, drawerWidth, setDrawerWidth). + + */ +export const LightspeedDrawerStateExposer = ({ + onStateChange, +}: DrawerStateExposerProps) => { + const { displayMode, drawerWidth, setDrawerWidth } = + useLightspeedDrawerContext(); + useEffect(() => { + onStateChange({ + id: 'lightspeed', + isDrawerOpen: displayMode === ChatbotDisplayMode.docked, + drawerWidth, + setDrawerWidth, + }); + }, [displayMode, drawerWidth, onStateChange, setDrawerWidth]); + return null; +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedFAB.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedFAB.tsx new file mode 100644 index 0000000000..b4778ed7bd --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedFAB.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Close from '@mui/icons-material/Close'; +import Fab from '@mui/material/Fab'; +import Tooltip from '@mui/material/Tooltip'; +import { makeStyles } from '@mui/styles'; +import { ChatbotDisplayMode } from '@patternfly/chatbot'; + +import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; +import { LightspeedFABIcon } from './LightspeedIcon'; + +const useStyles = makeStyles(theme => ({ + 'fab-button': { + bottom: `calc(${theme?.spacing?.(2) ?? '16px'} + 1.5em)`, + right: `calc(${theme?.spacing?.(2) ?? '16px'} + 1.5em)`, + alignItems: 'end', + zIndex: 200, + display: 'flex', + position: 'fixed', + + // When drawer is docked, adjust margin + '.docked-drawer-open &': { + transition: 'margin-right 0.3s ease', + marginRight: 'var(--docked-drawer-width, 500px) ', + }, + }, +})); + +/** + * @public + * Lightspeed Floating action button to open/close the lightspeed chatbot + */ + +export const LightspeedFAB = () => { + const { isChatbotActive, toggleChatbot, displayMode } = + useLightspeedDrawerContext(); + const fabButton = useStyles(); + + if (displayMode === ChatbotDisplayMode.embedded) { + return null; + } + + return ( +
+ + + {isChatbotActive ? : } + + +
+ ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx index 0af2b2abb5..40bbeb38de 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx @@ -16,6 +16,7 @@ import { useTranslation } from '../hooks/useTranslation'; import logo from '../images/logo-white.svg'; +import roundedLogo from '../images/rounded-logo.svg'; /** * @public @@ -32,3 +33,23 @@ export const LightspeedIcon = () => { /> ); }; + +/** + * @public + * Lightspeed FAB Icon */ +export const LightspeedFABIcon = () => { + const { t } = useTranslation(); + + return ( + {t('icon.lightspeed.alt')} + ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedPage.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedPage.tsx index b7ef8b4765..aec6f2cde6 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedPage.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedPage.tsx @@ -14,23 +14,12 @@ * limitations under the License. */ -import { useEffect, useMemo, useState } from 'react'; -import { useAsync } from 'react-use'; - import { Content, Header, Page } from '@backstage/core-components'; -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; -import { createStyles, makeStyles, useTheme } from '@material-ui/core/styles'; -import { QueryClientProvider } from '@tanstack/react-query'; +import { createStyles, makeStyles } from '@material-ui/core/styles'; -import { useAllModels } from '../hooks/useAllModels'; -import { useLightspeedViewPermission } from '../hooks/useLightspeedViewPermission'; -import { useTopicRestrictionStatus } from '../hooks/useQuestionValidation'; import { useTranslation } from '../hooks/useTranslation'; -import queryClient from '../utils/queryClient'; -import FileAttachmentContextProvider from './AttachmentContext'; -import { LightspeedChat } from './LightSpeedChat'; -import PermissionRequiredState from './PermissionRequiredState'; +import { LightspeedChatContainer } from './LightspeedChatContainer'; const useStyles = makeStyles(() => createStyles({ @@ -40,112 +29,13 @@ const useStyles = makeStyles(() => }), ); -const THEME_DARK = 'dark'; -const THEME_DARK_CLASS = 'pf-v6-theme-dark'; -const LAST_SELECTED_MODEL_KEY = 'lastSelectedModel'; - -const LightspeedPageInner = () => { +/** + * Lightspeed Page - Routable fullscreen/embedded mode + * @public + */ +export const LightspeedPage = () => { const classes = useStyles(); const { t } = useTranslation(); - const { - palette: { type }, - } = useTheme(); - - const identityApi = useApi(identityApiRef); - - const { data: models } = useAllModels(); - - const { allowed: hasViewAccess, loading } = useLightspeedViewPermission(); - - const { value: profile, loading: profileLoading } = useAsync( - async () => await identityApi.getProfileInfo(), - ); - - const [selectedModel, setSelectedModel] = useState(''); - const [selectedProvider, setSelectedProvider] = useState(''); - - const { data: topicRestrictionEnabled } = useTopicRestrictionStatus(); - - const modelsItems = useMemo( - () => - models - ? models - .filter(model => model.model_type === 'llm') - .map(m => ({ - label: m.provider_resource_id, - value: m.provider_resource_id, - provider: m.provider_id, - })) - : [], - [models], - ); - - useEffect(() => { - const htmlTagElement = document.documentElement; - if (type === THEME_DARK) { - htmlTagElement.classList.add(THEME_DARK_CLASS); - } else { - htmlTagElement.classList.remove(THEME_DARK_CLASS); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [type]); - - useEffect(() => { - if (modelsItems.length > 0) { - try { - const storedData = localStorage.getItem(LAST_SELECTED_MODEL_KEY); - const parsedData = storedData ? JSON.parse(storedData) : null; - - // Check if stored model exists in available models - const storedModel = parsedData?.model - ? modelsItems.find(m => m.value === parsedData.model) - : null; - - if (storedModel) { - setSelectedModel(storedModel.value); - setSelectedProvider(storedModel.provider); - } else { - // Fallback to first model if stored model is not available - setSelectedModel(modelsItems[0].value); - setSelectedProvider(modelsItems[0].provider); - } - } catch (error) { - // eslint-disable-next-line no-console - console.error( - 'Error loading last selected model from localStorage:', - error, - ); - // Fallback to first model on error - setSelectedModel(modelsItems[0].value); - setSelectedProvider(modelsItems[0].provider); - } - } - }, [modelsItems]); - - // Save to localStorage whenever model or provider changes - useEffect(() => { - if (selectedModel && selectedProvider) { - try { - localStorage.setItem( - LAST_SELECTED_MODEL_KEY, - JSON.stringify({ - model: selectedModel, - provider: selectedProvider, - }), - ); - } catch (error) { - // eslint-disable-next-line no-console - console.error( - 'Error saving last selected model to localStorage:', - error, - ); - } - } - }, [selectedModel, selectedProvider]); - - if (loading) { - return null; - } return ( @@ -155,37 +45,8 @@ const LightspeedPageInner = () => { pageTitleOverride={t('page.title')} /> - {!hasViewAccess ? ( - - ) : ( - - { - setSelectedModel(item); - setSelectedProvider( - modelsItems.find((m: any) => m.value === item)?.provider || - '', - ); - }} - models={modelsItems} - userName={profile?.displayName} - avatar={profile?.picture} - profileLoading={profileLoading} - /> - - )} + ); }; - -export const LightspeedPage = () => { - return ( - - - - ); -}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx index aaf24c1fa2..1918d1cdb4 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx @@ -21,6 +21,7 @@ import { import { usePermission } from '@backstage/plugin-permission-react'; import { mockApis, TestApiProvider } from '@backstage/test-utils'; +import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, @@ -33,6 +34,7 @@ import userEvent from '@testing-library/user-event'; import { lightspeedApiRef } from '../../api/api'; import { useConversations } from '../../hooks'; +import { useLightspeedDrawerContext } from '../../hooks/useLightspeedDrawerContext'; import { mockUseTranslation } from '../../test-utils/mockTranslations'; import FileAttachmentContextProvider from '../AttachmentContext'; import { LightspeedChat } from '../LightSpeedChat'; @@ -88,6 +90,10 @@ jest.mock('../../hooks/useTranslation', () => ({ useTranslation: jest.fn(() => mockUseTranslation()), })); +jest.mock('../../hooks/useLightspeedDrawerContext', () => ({ + useLightspeedDrawerContext: jest.fn(), +})); + jest.mock('@patternfly/chatbot', () => { const actual = jest.requireActual('@patternfly/chatbot'); return { @@ -100,6 +106,10 @@ const mockUseConversations = useConversations as jest.Mock; const mockUsePermission = usePermission as jest.MockedFunction< typeof usePermission >; +const mockUseLightspeedDrawerContext = + useLightspeedDrawerContext as jest.MockedFunction< + typeof useLightspeedDrawerContext + >; const configAPi = mockApis.config({}); @@ -140,6 +150,9 @@ const setupLightspeedChat = () => ( ); describe('LightspeedChat', () => { + const mockSetDisplayMode = jest.fn(); + const mockSetCurrentConversationId = jest.fn(); + beforeEach(() => { mockUsePermission.mockReturnValue({ loading: true, allowed: true }); mockUseConversations.mockReturnValue({ @@ -147,9 +160,25 @@ describe('LightspeedChat', () => { isRefetching: false, isLoading: false, }); + mockUseLightspeedDrawerContext.mockReturnValue({ + isChatbotActive: false, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.embedded, + setDisplayMode: mockSetDisplayMode, + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: undefined, + setCurrentConversationId: mockSetCurrentConversationId, + }); localStorage.clear(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); }); + const localStorageKey = 'lastOpenedConversation'; const mockUser = 'user:test'; @@ -361,4 +390,169 @@ describe('LightspeedChat', () => { expect(searchInput).toHaveValue('xyz123nonexistent'); }); }); + + describe('displayMode selection from settings dropdown', () => { + it('should open settings dropdown when clicking the settings button', async () => { + render(setupLightspeedChat()); + + const settingsButton = screen.getByLabelText('Chatbot options'); + expect(settingsButton).toBeInTheDocument(); + + await userEvent.click(settingsButton); + + // Verify dropdown is open with display mode options + await waitFor(() => { + expect(screen.getByText('Display mode')).toBeInTheDocument(); + }); + }); + + it('should show all display mode options in the dropdown', async () => { + render(setupLightspeedChat()); + + const settingsButton = screen.getByLabelText('Chatbot options'); + await userEvent.click(settingsButton); + + await waitFor(() => { + expect(screen.getByText('Display mode')).toBeInTheDocument(); + expect(screen.getByText('Overlay')).toBeInTheDocument(); + expect(screen.getByText('Dock to window')).toBeInTheDocument(); + expect(screen.getByText('Fullscreen')).toBeInTheDocument(); + }); + }); + + it('should call setDisplayMode with default when clicking Overlay option', async () => { + render(setupLightspeedChat()); + + const settingsButton = screen.getByLabelText('Chatbot options'); + await userEvent.click(settingsButton); + + await waitFor(() => { + expect(screen.getByText('Overlay')).toBeInTheDocument(); + }); + + const overlayOption = screen.getByText('Overlay'); + await userEvent.click(overlayOption); + + expect(mockSetDisplayMode).toHaveBeenCalledWith( + ChatbotDisplayMode.default, + ); + }); + + it('should call setDisplayMode with docked when clicking Dock to window option', async () => { + render(setupLightspeedChat()); + + const settingsButton = screen.getByLabelText('Chatbot options'); + await userEvent.click(settingsButton); + + await waitFor(() => { + expect(screen.getByText('Dock to window')).toBeInTheDocument(); + }); + + const dockedOption = screen.getByText('Dock to window'); + await userEvent.click(dockedOption); + + expect(mockSetDisplayMode).toHaveBeenCalledWith( + ChatbotDisplayMode.docked, + ); + }); + + it('should call setDisplayMode with embedded when clicking Fullscreen option', async () => { + render(setupLightspeedChat()); + + const settingsButton = screen.getByLabelText('Chatbot options'); + await userEvent.click(settingsButton); + + await waitFor(() => { + expect(screen.getByText('Fullscreen')).toBeInTheDocument(); + }); + + const fullscreenOption = screen.getByText('Fullscreen'); + await userEvent.click(fullscreenOption); + + expect(mockSetDisplayMode).toHaveBeenCalledWith( + ChatbotDisplayMode.embedded, + ); + }); + + it('should show current display mode as selected in full-screen mode', async () => { + mockUseLightspeedDrawerContext.mockReturnValue({ + isChatbotActive: false, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.embedded, + setDisplayMode: mockSetDisplayMode, + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: undefined, + setCurrentConversationId: mockSetCurrentConversationId, + }); + + render(setupLightspeedChat()); + + const settingsButton = screen.getByLabelText('Chatbot options'); + await userEvent.click(settingsButton); + + await waitFor(() => { + expect(screen.getByText('Fullscreen')).toBeInTheDocument(); + }); + + const fullscreenOption = screen + .getByText('Fullscreen') + .closest('button, li, [role="menuitem"]'); + expect(fullscreenOption).toHaveClass('pf-m-selected'); + }); + + it('should show current display mode as selected in docked mode', async () => { + mockUseLightspeedDrawerContext.mockReturnValue({ + isChatbotActive: true, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.docked, + setDisplayMode: mockSetDisplayMode, + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: undefined, + setCurrentConversationId: mockSetCurrentConversationId, + }); + + render(setupLightspeedChat()); + + const settingsButton = screen.getByLabelText('Chatbot options'); + await userEvent.click(settingsButton); + + await waitFor(() => { + expect(screen.getByText('Dock to window')).toBeInTheDocument(); + }); + + const dockedOption = screen + .getByText('Dock to window') + .closest('button, li, [role="menuitem"]'); + expect(dockedOption).toHaveClass('pf-m-selected'); + }); + + it('should show current display mode as selected in overlay mode', async () => { + mockUseLightspeedDrawerContext.mockReturnValue({ + isChatbotActive: false, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.default, + setDisplayMode: mockSetDisplayMode, + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: undefined, + setCurrentConversationId: mockSetCurrentConversationId, + }); + + render(setupLightspeedChat()); + + const settingsButton = screen.getByLabelText('Chatbot options'); + await userEvent.click(settingsButton); + + await waitFor(() => { + expect(screen.getByText('Overlay')).toBeInTheDocument(); + }); + + const overlayOption = screen + .getByText('Overlay') + .closest('button, li, [role="menuitem"]'); + expect(overlayOption).toHaveClass('pf-m-selected'); + }); + }); }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx new file mode 100644 index 0000000000..45fc04d6ee --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx @@ -0,0 +1,219 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; +import { render, waitFor } from '@testing-library/react'; + +import { LightspeedDrawerContext } from '../LightspeedDrawerContext'; +import { + DrawerState, + LightspeedDrawerStateExposer, +} from '../LightspeedDrawerStateExposer'; + +describe('LightspeedDrawerStateExposer', () => { + const mockSetDrawerWidth = jest.fn(); + const mockOnStateChange = jest.fn(); + + const createContextValue = (overrides = {}) => ({ + isChatbotActive: false, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.default, + setDisplayMode: jest.fn(), + drawerWidth: 500, + setDrawerWidth: mockSetDrawerWidth, + currentConversationId: undefined, + setCurrentConversationId: jest.fn(), + ...overrides, + }); + + const renderWithContext = ( + contextValue: ReturnType, + ) => { + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call onStateChange with initial state', async () => { + renderWithContext(createContextValue()); + + await waitFor(() => { + expect(mockOnStateChange).toHaveBeenCalledWith({ + id: 'lightspeed', + isDrawerOpen: false, + drawerWidth: 500, + setDrawerWidth: mockSetDrawerWidth, + }); + }); + }); + + it('should set isDrawerOpen to true when displayMode is docked', async () => { + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.docked, + }), + ); + + await waitFor(() => { + expect(mockOnStateChange).toHaveBeenCalledWith( + expect.objectContaining({ + isDrawerOpen: true, + }), + ); + }); + }); + + it('should set isDrawerOpen to false when displayMode is overlay', async () => { + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.default, + }), + ); + + await waitFor(() => { + expect(mockOnStateChange).toHaveBeenCalledWith( + expect.objectContaining({ + isDrawerOpen: false, + }), + ); + }); + }); + + it('should set isDrawerOpen to false when displayMode is fullscreen', async () => { + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.fullscreen, + }), + ); + + await waitFor(() => { + expect(mockOnStateChange).toHaveBeenCalledWith( + expect.objectContaining({ + isDrawerOpen: false, + }), + ); + }); + }); + + it('should include the correct drawerWidth in state', async () => { + renderWithContext( + createContextValue({ + drawerWidth: 600, + }), + ); + + await waitFor(() => { + expect(mockOnStateChange).toHaveBeenCalledWith( + expect.objectContaining({ + drawerWidth: 600, + }), + ); + }); + }); + + it('should call onStateChange when drawerWidth changes', async () => { + const { rerender } = renderWithContext( + createContextValue({ + drawerWidth: 500, + }), + ); + + await waitFor(() => { + expect(mockOnStateChange).toHaveBeenCalledTimes(1); + }); + + rerender( + + + , + ); + + await waitFor(() => { + expect(mockOnStateChange).toHaveBeenCalledWith( + expect.objectContaining({ + drawerWidth: 700, + }), + ); + }); + }); + + it('should call onStateChange when displayMode changes', async () => { + const { rerender } = renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.default, + }), + ); + + await waitFor(() => { + expect(mockOnStateChange).toHaveBeenCalledWith( + expect.objectContaining({ + isDrawerOpen: false, + }), + ); + }); + + rerender( + + + , + ); + + await waitFor(() => { + expect(mockOnStateChange).toHaveBeenCalledWith( + expect.objectContaining({ + isDrawerOpen: true, + }), + ); + }); + }); + + it('should render null', () => { + const { container } = renderWithContext(createContextValue()); + expect(container.firstChild).toBeNull(); + }); + + it('should always use lightspeed as the id', async () => { + renderWithContext(createContextValue()); + + await waitFor(() => { + const callArg = mockOnStateChange.mock.calls[0][0] as DrawerState; + expect(callArg.id).toBe('lightspeed'); + }); + }); + + it('should pass setDrawerWidth function in state', async () => { + renderWithContext(createContextValue()); + + await waitFor(() => { + const callArg = mockOnStateChange.mock.calls[0][0] as DrawerState; + expect(callArg.setDrawerWidth).toBe(mockSetDrawerWidth); + }); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx new file mode 100644 index 0000000000..2fb4f50f42 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx @@ -0,0 +1,173 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { mockUseTranslation } from '../../test-utils/mockTranslations'; +import { LightspeedDrawerContext } from '../LightspeedDrawerContext'; +import { LightspeedFAB } from '../LightspeedFAB'; + +jest.mock('../../hooks/useTranslation', () => ({ + useTranslation: jest.fn(() => mockUseTranslation()), +})); + +describe('LightspeedFAB', () => { + const mockToggleChatbot = jest.fn(); + + const createContextValue = (overrides = {}) => ({ + isChatbotActive: false, + toggleChatbot: mockToggleChatbot, + displayMode: ChatbotDisplayMode.default, + setDisplayMode: jest.fn(), + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: undefined, + setCurrentConversationId: jest.fn(), + ...overrides, + }); + + const renderWithContext = ( + contextValue: ReturnType, + ) => { + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render FAB button when displayMode is overlay', () => { + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.default, + }), + ); + + expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument(); + expect(screen.getByLabelText('lightspeed-fab')).toBeInTheDocument(); + }); + + it('should render FAB button when displayMode is docked', () => { + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.docked, + }), + ); + + expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument(); + }); + + it('should not render FAB button when displayMode is embedded', () => { + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.embedded, + }), + ); + + expect(screen.queryByTestId('lightspeed-fab')).not.toBeInTheDocument(); + }); + + it('should call toggleChatbot when FAB button is clicked', () => { + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.default, + }), + ); + + const fabButton = screen.getByLabelText('lightspeed-fab'); + fireEvent.click(fabButton); + + expect(mockToggleChatbot).toHaveBeenCalledTimes(1); + }); + + it('should show close icon when chatbot is active', () => { + renderWithContext( + createContextValue({ + isChatbotActive: true, + displayMode: ChatbotDisplayMode.default, + }), + ); + + // Close icon should be rendered (MUI Close icon) + expect(screen.getByTestId('CloseIcon')).toBeInTheDocument(); + }); + + it('should show LightspeedFABIcon when chatbot is not active', () => { + renderWithContext( + createContextValue({ + isChatbotActive: false, + displayMode: ChatbotDisplayMode.default, + }), + ); + + // The icon should be an image with the lightspeed alt text + expect(screen.getByTestId('lightspeed-fab-icon')).toBeInTheDocument(); + }); + + it('should have correct tooltip when chatbot is inactive', async () => { + renderWithContext( + createContextValue({ + isChatbotActive: false, + displayMode: ChatbotDisplayMode.default, + }), + ); + + // Tooltip should show "Open Lightspeed" + const fabButton = screen.getByLabelText('lightspeed-fab'); + expect(fabButton).toBeInTheDocument(); + }); + + it('should have correct tooltip when chatbot is active', async () => { + renderWithContext( + createContextValue({ + isChatbotActive: true, + displayMode: ChatbotDisplayMode.default, + }), + ); + + // Tooltip should show "Close Lightspeed" + const fabButton = screen.getByLabelText('lightspeed-fab'); + expect(fabButton).toBeInTheDocument(); + }); + + it('should render with correct id attribute', () => { + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.default, + }), + ); + + const fabContainer = screen.getByTestId('lightspeed-fab'); + expect(fabContainer).toHaveAttribute('id', 'lightspeed-fab'); + }); + + it('should not render when displayMode is fullscreen', () => { + // Fullscreen is essentially the same as embedded in behavior + renderWithContext( + createContextValue({ + displayMode: ChatbotDisplayMode.fullscreen, + }), + ); + + // Fullscreen mode should still show the FAB (it's not embedded) + expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument(); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedIcon.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedIcon.test.tsx new file mode 100644 index 0000000000..ca7ae126bc --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedIcon.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; + +import { mockUseTranslation } from '../../test-utils/mockTranslations'; +import { LightspeedFABIcon, LightspeedIcon } from '../LightspeedIcon'; + +jest.mock('../../hooks/useTranslation', () => ({ + useTranslation: jest.fn(() => mockUseTranslation()), +})); + +describe('LightspeedIcon', () => { + it('should render the lightspeed icon with correct alt text', () => { + render(); + + const img = screen.getByRole('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('alt', 'lightspeed icon'); + expect(img).toHaveStyle({ height: '25px' }); + }); +}); + +describe('LightspeedFABIcon', () => { + it('should render the FAB icon with correct alt text', () => { + render(); + + const img = screen.getByRole('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('data-testid', 'lightspeed-fab-icon'); + expect(img).toHaveStyle({ + width: '100%', + height: '100%', + display: 'block', + }); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx new file mode 100644 index 0000000000..75140d5bf7 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx @@ -0,0 +1,220 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; +import { renderHook } from '@testing-library/react'; + +import { LightspeedDrawerContext } from '../../components/LightspeedDrawerContext'; +import { useLightspeedDrawerContext } from '../useLightspeedDrawerContext'; + +describe('useLightspeedDrawerContext', () => { + const mockContextValue = { + isChatbotActive: true, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.default, + setDisplayMode: jest.fn(), + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: 'test-conv-id', + setCurrentConversationId: jest.fn(), + }; + + it('should return context value when used within provider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + expect(result.current).toEqual(mockContextValue); + }); + + it('should throw error when used outside of provider', () => { + // Suppress console.error for this test as React will log the error + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => { + renderHook(() => useLightspeedDrawerContext()); + }).toThrow( + 'useLightspeedDrawerContext must be used within a LightspeedDrawerProvider', + ); + + consoleSpy.mockRestore(); + }); + + it('should return isChatbotActive from context', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + expect(result.current.isChatbotActive).toBe(false); + }); + + it('should return displayMode from context', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + expect(result.current.displayMode).toBe(ChatbotDisplayMode.docked); + }); + + it('should return drawerWidth from context', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + expect(result.current.drawerWidth).toBe(600); + }); + + it('should return currentConversationId from context', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + expect(result.current.currentConversationId).toBe('my-conv-123'); + }); + + it('should return undefined currentConversationId when not set', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + expect(result.current.currentConversationId).toBeUndefined(); + }); + + it('should provide working toggleChatbot function', () => { + const mockToggle = jest.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + result.current.toggleChatbot(); + expect(mockToggle).toHaveBeenCalledTimes(1); + }); + + it('should provide working setDisplayMode function', () => { + const mockSetDisplayMode = jest.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + result.current.setDisplayMode(ChatbotDisplayMode.fullscreen); + expect(mockSetDisplayMode).toHaveBeenCalledWith( + ChatbotDisplayMode.fullscreen, + ); + }); + + it('should provide working setDrawerWidth function', () => { + const mockSetDrawerWidth = jest.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + result.current.setDrawerWidth(800); + expect(mockSetDrawerWidth).toHaveBeenCalledWith(800); + }); + + it('should provide working setCurrentConversationId function', () => { + const mockSetConversationId = jest.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + result.current.setCurrentConversationId('new-conv-id'); + expect(mockSetConversationId).toHaveBeenCalledWith('new-conv-id'); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawerContext.tsx b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawerContext.tsx new file mode 100644 index 0000000000..ec15a3bdf8 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawerContext.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useContext } from 'react'; + +import { LightspeedDrawerContext } from '../components/LightspeedDrawerContext'; + +/** + * Hook to access the LightspeedDrawerContext + * @public + */ +export const useLightspeedDrawerContext = () => { + const context = useContext(LightspeedDrawerContext); + if (context === undefined) { + throw new Error( + 'useLightspeedDrawerContext must be used within a LightspeedDrawerProvider', + ); + } + return context; +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/images/rounded-logo.svg b/workspaces/lightspeed/plugins/lightspeed/src/images/rounded-logo.svg new file mode 100644 index 0000000000..0ffe452a80 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/images/rounded-logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/workspaces/lightspeed/plugins/lightspeed/src/index.ts b/workspaces/lightspeed/plugins/lightspeed/src/index.ts index caab458393..676550f9b3 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/index.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/index.ts @@ -14,5 +14,34 @@ * limitations under the License. */ -export { lightspeedPlugin, LightspeedPage } from './plugin'; -export { LightspeedIcon } from './components/LightspeedIcon'; +export { + lightspeedPlugin, + LightspeedPage, + LightspeedDrawerProvider, +} from './plugin'; +export { LightspeedIcon, LightspeedFABIcon } from './components/LightspeedIcon'; +export { LightspeedFAB } from './components/LightspeedFAB'; +export { LightspeedChatContainer } from './components/LightspeedChatContainer'; +export { LightspeedDrawerStateExposer } from './components/LightspeedDrawerStateExposer'; +export { useLightspeedDrawerContext } from './hooks/useLightspeedDrawerContext'; +export { lightspeedApiRef } from './api/api'; +export { LightspeedApiClient } from './api/LightspeedApiClient'; +export type { LightspeedDrawerContextType } from './components/LightspeedDrawerContext'; +export type { + DrawerStateExposerProps, + DrawerState, +} from './components/LightspeedDrawerStateExposer'; +export type { LightspeedAPI } from './api/api'; +export type { Options } from './api/LightspeedApiClient'; +export type { + LCSModel, + LCSModelType, + LCSModelApiModelType, + BaseMessage, + Attachment, + ConversationList, + ConversationSummary, + CaptureFeedback, + ReferencedDocuments, + ReferencedDocument, +} from './types'; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts b/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts index 0f96071688..4a8ccf8a32 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts @@ -17,9 +17,12 @@ import '@patternfly/react-core/dist/styles/base-no-reset.css'; import '@patternfly/chatbot/dist/css/main.css'; +import { PropsWithChildren } from 'react'; + import { configApiRef, createApiFactory, + createComponentExtension, createPlugin, createRoutableExtension, fetchApiRef, @@ -63,3 +66,70 @@ export const LightspeedPage = lightspeedPlugin.provide( mountPoint: rootRouteRef, }), ); + +/** + * Lightspeed Drawer Provider + * + * @public + */ +export const LightspeedDrawerProvider: React.ComponentType = + lightspeedPlugin.provide( + createComponentExtension({ + name: 'LightspeedDrawerProvider', + component: { + lazy: () => + import('./components/LightspeedDrawerProvider').then( + m => m.LightspeedDrawerProvider, + ), + }, + }), + ); + +/** + * Lightspeed FAB for global floating action button fot LightspeedAI + * + * @public + */ +export const LightspeedFAB: React.ComponentType = lightspeedPlugin.provide( + createComponentExtension({ + name: 'LightspeedFAB', + component: { + lazy: () => + import('./components/LightspeedFAB').then(m => m.LightspeedFAB), + }, + }), +); + +/** + * Lightspeed Drawer State Exposer exposes its drawer state + * + * @public + */ +export const LightspeedDrawerStateExposer = lightspeedPlugin.provide( + createComponentExtension({ + name: 'LightspeedDrawerStateExposer', + component: { + lazy: () => + import('./components/LightspeedDrawerStateExposer').then( + m => m.LightspeedDrawerStateExposer, + ), + }, + }), +); + +/** + * Lightspeed Chat Container component extension + * + * @public + */ +export const LightspeedChatContainer = lightspeedPlugin.provide( + createComponentExtension({ + name: 'LightspeedChatContainer', + component: { + lazy: () => + import('./components/LightspeedChatContainer').then( + m => m.LightspeedChatContainer, + ), + }, + }), +); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts index e3a9106c1e..a5487c7611 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts @@ -138,6 +138,7 @@ const lightspeedTranslationDe = createTranslationMessages({ 'aria.scroll.down': 'Nach unten', 'aria.scroll.up': 'Nach oben', 'aria.settings.label': 'Chatbot-Optionen', + 'aria.close': 'Chatbot schließen', // Modal actions 'modal.edit': 'Bearbeiten', @@ -168,6 +169,7 @@ const lightspeedTranslationDe = createTranslationMessages({ 'tooltip.backToTop': 'Nach oben', 'tooltip.backToBottom': 'Nach unten', 'tooltip.settings': 'Chatbot-Optionen', + 'tooltip.close': 'Schließen', // Modal titles 'modal.title.preview': 'Anhang-Vorschau', @@ -227,6 +229,12 @@ const lightspeedTranslationDe = createTranslationMessages({ 'Angeheftete Chats sind derzeit aktiviert', 'settings.pinned.disabled.description': 'Angeheftete Chats sind derzeit deaktiviert', + + // Display modes + 'settings.displayMode.label': 'Anzeigemodus', + 'settings.displayMode.overlay': 'Überlagerung', + 'settings.displayMode.docked': 'An Fenster andocken', + 'settings.displayMode.fullscreen': 'Vollbild', }, }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts index 13a13a2abc..de0a1f7e5c 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts @@ -142,6 +142,7 @@ const lightspeedTranslationEs = createTranslationMessages({ 'aria.scroll.down': 'Volver abajo', 'aria.scroll.up': 'Volver arriba', 'aria.settings.label': 'Opciones del chatbot', + 'aria.close': 'Cerrar chatbot', // Modal actions 'modal.edit': 'Editar', @@ -172,6 +173,7 @@ const lightspeedTranslationEs = createTranslationMessages({ 'tooltip.backToTop': 'Volver arriba', 'tooltip.backToBottom': 'Volver abajo', 'tooltip.settings': 'Opciones del chatbot', + 'tooltip.close': 'Cerrar', // Modal titles 'modal.title.preview': 'Vista previa del adjunto', @@ -230,6 +232,12 @@ const lightspeedTranslationEs = createTranslationMessages({ 'Los chats fijados están actualmente habilitados', 'settings.pinned.disabled.description': 'Los chats fijados están actualmente deshabilitados', + + // Display modes + 'settings.displayMode.label': 'Modo de visualización', + 'settings.displayMode.overlay': 'Superposición', + 'settings.displayMode.docked': 'Acoplar a ventana', + 'settings.displayMode.fullscreen': 'Pantalla completa', }, }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts index d2a15643aa..386bd04ce6 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts @@ -141,6 +141,7 @@ const lightspeedTranslationFr = createTranslationMessages({ 'aria.scroll.down': 'Retour en bas', 'aria.scroll.up': 'Retour en haut', 'aria.settings.label': 'Options du chatbot', + 'aria.close': 'Fermer le chatbot', // Modal actions 'modal.edit': 'Modifier', @@ -171,6 +172,7 @@ const lightspeedTranslationFr = createTranslationMessages({ 'tooltip.backToTop': 'Retour en haut', 'tooltip.backToBottom': 'Retour en bas', 'tooltip.settings': 'Options du chatbot', + 'tooltip.close': 'Fermer', // Modal titles 'modal.title.preview': 'Aperçu de la pièce jointe', @@ -231,6 +233,12 @@ const lightspeedTranslationFr = createTranslationMessages({ 'Les chats épinglés sont actuellement activés', 'settings.pinned.disabled.description': 'Les chats épinglés sont actuellement désactivés', + + // Display modes + 'settings.displayMode.label': "Mode d'affichage", + 'settings.displayMode.overlay': 'Superposition', + 'settings.displayMode.docked': 'Ancrer à la fenêtre', + 'settings.displayMode.fullscreen': 'Plein écran', }, }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts index 486e8cdf1d..aa6802a55c 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts @@ -134,6 +134,7 @@ export const lightspeedMessages = { 'aria.scroll.down': 'Back to bottom', 'aria.scroll.up': 'Back to top', 'aria.settings.label': 'Chatbot options', + 'aria.close': 'Close chatbot', // Modal actions 'modal.edit': 'Edit', @@ -164,6 +165,7 @@ export const lightspeedMessages = { 'tooltip.backToTop': 'Back to top', 'tooltip.backToBottom': 'Back to bottom', 'tooltip.settings': 'Chatbot options', + 'tooltip.close': 'Close', // Modal titles 'modal.title.preview': 'Preview attachment', @@ -218,6 +220,12 @@ export const lightspeedMessages = { 'settings.pinned.disable': 'Disable pinned chats', 'settings.pinned.enabled.description': 'Pinned chats are currently enabled', 'settings.pinned.disabled.description': 'Pinned chats are currently disabled', + + // Display modes + 'settings.displayMode.label': 'Display mode', + 'settings.displayMode.overlay': 'Overlay', + 'settings.displayMode.docked': 'Dock to window', + 'settings.displayMode.fullscreen': 'Fullscreen', }; /** diff --git a/workspaces/lightspeed/plugins/lightspeed/src/types.ts b/workspaces/lightspeed/plugins/lightspeed/src/types.ts index 7cf98576f8..9ead725755 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/types.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/types.ts @@ -29,17 +29,38 @@ export type Conversations = { }; }; +/** + * @public + * Referenced document metadata + */ export type ReferencedDocument = { doc_title: string; doc_url: string; doc_description?: string; }; +/** + * @public + * List of referenced documents + */ export type ReferencedDocuments = ReferencedDocument[]; +/** + * @public + * LCS model type - embedding or llm + */ export type LCSModelType = 'embedding' | 'llm'; + +/** + * @public + * LCS model API model type + */ export type LCSModelApiModelType = 'embedding' | 'llm'; +/** + * @public + * LCS Model interface + */ export interface LCSModel { identifier: string; metadata: { @@ -67,6 +88,11 @@ export interface LCSShield { params: {}; provider_resource_id: string; } + +/** + * @public + * Base message interface for chat messages + */ export interface BaseMessage { name: string; type: string; @@ -78,6 +104,11 @@ export interface BaseMessage { referenced_documents?: ReferencedDocuments; error?: AlertProps; } + +/** + * @public + * Conversation summary type + */ export type ConversationSummary = { conversation_id: string; last_message_timestamp: number; @@ -95,12 +126,20 @@ export interface FileContent { name: string; } +/** + * @public + * Attachment type for file attachments in chat messages + */ export type Attachment = { attachment_type: string; content_type: string; content: string; }; +/** + * @public + * List of conversation summaries + */ export type ConversationList = ConversationSummary[]; export type SamplePrompt = @@ -115,6 +154,10 @@ export type SamplePrompt = export type SamplePrompts = SamplePrompt[]; +/** + * @public + * Feedback capture payload for user feedback on AI responses + */ export type CaptureFeedback = { conversation_id: string; user_question: string; diff --git a/workspaces/lightspeed/yarn.lock b/workspaces/lightspeed/yarn.lock index bba71afed5..18ab8bcdce 100644 --- a/workspaces/lightspeed/yarn.lock +++ b/workspaces/lightspeed/yarn.lock @@ -4955,16 +4955,16 @@ __metadata: languageName: node linkType: hard -"@emotion/cache@npm:^11.11.0, @emotion/cache@npm:^11.13.0": - version: 11.13.1 - resolution: "@emotion/cache@npm:11.13.1" +"@emotion/cache@npm:^11.13.0, @emotion/cache@npm:^11.13.5": + version: 11.14.0 + resolution: "@emotion/cache@npm:11.14.0" dependencies: "@emotion/memoize": ^0.9.0 "@emotion/sheet": ^1.4.0 - "@emotion/utils": ^1.4.0 + "@emotion/utils": ^1.4.2 "@emotion/weak-memoize": ^0.4.0 stylis: 4.2.0 - checksum: 94b161786a03a08a1e30257478fad9a9be1ac8585ddca0c6410d7411fd474fc8b0d6d1167d7d15bdb012d1fd8a1220ac2bbc79501ad9b292b83c17da0874d7de + checksum: 0a81591541ea43bc7851742e6444b7800d72e98006f94e775ae6ea0806662d14e0a86ff940f5f19d33b4bb2c427c882aa65d417e7322a6e0d5f20fe65ed920c9 languageName: node linkType: hard @@ -4975,7 +4975,7 @@ __metadata: languageName: node linkType: hard -"@emotion/hash@npm:^0.9.2": +"@emotion/hash@npm:^0.9.1, @emotion/hash@npm:^0.9.2": version: 0.9.2 resolution: "@emotion/hash@npm:0.9.2" checksum: 379bde2830ccb0328c2617ec009642321c0e009a46aa383dfbe75b679c6aea977ca698c832d225a893901f29d7b3eef0e38cf341f560f6b2b56f1ff23c172387 @@ -5035,16 +5035,16 @@ __metadata: languageName: node linkType: hard -"@emotion/serialize@npm:^1.2.0, @emotion/serialize@npm:^1.3.0, @emotion/serialize@npm:^1.3.1": - version: 1.3.2 - resolution: "@emotion/serialize@npm:1.3.2" +"@emotion/serialize@npm:^1.2.0, @emotion/serialize@npm:^1.3.0, @emotion/serialize@npm:^1.3.1, @emotion/serialize@npm:^1.3.3": + version: 1.3.3 + resolution: "@emotion/serialize@npm:1.3.3" dependencies: "@emotion/hash": ^0.9.2 "@emotion/memoize": ^0.9.0 "@emotion/unitless": ^0.10.0 - "@emotion/utils": ^1.4.1 + "@emotion/utils": ^1.4.2 csstype: ^3.0.2 - checksum: 8051bafe32459e1aecf716cdb66a22b090060806104cca89d4e664893b56878d3e9bb94a4657df9b7b3fd183700a9be72f7144c959ddcbd3cf7b330206919237 + checksum: 510331233767ae4e09e925287ca2c7269b320fa1d737ea86db5b3c861a734483ea832394c0c1fe5b21468fe335624a75e72818831d303ba38125f54f44ba02e7 languageName: node linkType: hard @@ -5091,10 +5091,10 @@ __metadata: languageName: node linkType: hard -"@emotion/utils@npm:^1.4.0, @emotion/utils@npm:^1.4.1": - version: 1.4.1 - resolution: "@emotion/utils@npm:1.4.1" - checksum: 088f6844c735981f53c84a76101cf261422301e7895cb37fea6a47e7950247ffa8ca174ca2a15d9b29a47f0fa831b432017ca7683bccbb5cfd78dda82743d856 +"@emotion/utils@npm:^1.4.0, @emotion/utils@npm:^1.4.2": + version: 1.4.2 + resolution: "@emotion/utils@npm:1.4.2" + checksum: 04cf76849c6401205c058b82689fd0ec5bf501aed6974880fe9681a1d61543efb97e848f4c38664ac4a9068c7ad2d1cb84f73bde6cf95f1208aa3c28e0190321 languageName: node linkType: hard @@ -7571,10 +7571,26 @@ __metadata: languageName: node linkType: hard -"@mui/core-downloads-tracker@npm:^5.16.7": - version: 5.16.7 - resolution: "@mui/core-downloads-tracker@npm:5.16.7" - checksum: b65c48ba2bf6bba6435ba9f2d6c33db0c8a85b3ff7599136a9682b72205bec76470ab5ed5e6e625d5bd012ed9bcbc641ed677548be80d217c9fb5d0435567062 +"@mui/core-downloads-tracker@npm:^5.18.0": + version: 5.18.0 + resolution: "@mui/core-downloads-tracker@npm:5.18.0" + checksum: 065b46739d2bd84b880ad2f6a0a2062d60e3a296ce18ff380cad22ab5b2cb3de396755f322f4bea3a422ffffe1a9244536fc3c9623056ff3873c996e6664b1b9 + languageName: node + linkType: hard + +"@mui/icons-material@npm:^5.15.17, @mui/icons-material@npm:^5.17.1": + version: 5.18.0 + resolution: "@mui/icons-material@npm:5.18.0" + dependencies: + "@babel/runtime": ^7.23.9 + peerDependencies: + "@mui/material": ^5.0.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 1ee4ee2817278c72095860c81e742270ee01fd320e48f865d371abfeacf83e43a7c6edbfd060ee66ef2a4f02c6cf79de0aca1e73a4d5f6320112bbfcac8176c3 languageName: node linkType: hard @@ -7594,28 +7610,28 @@ __metadata: languageName: node linkType: hard -"@mui/material@npm:^5.12.2": - version: 5.16.7 - resolution: "@mui/material@npm:5.16.7" +"@mui/material@npm:^5.12.2, @mui/material@npm:^5.15.17": + version: 5.18.0 + resolution: "@mui/material@npm:5.18.0" dependencies: "@babel/runtime": ^7.23.9 - "@mui/core-downloads-tracker": ^5.16.7 - "@mui/system": ^5.16.7 - "@mui/types": ^7.2.15 - "@mui/utils": ^5.16.6 + "@mui/core-downloads-tracker": ^5.18.0 + "@mui/system": ^5.18.0 + "@mui/types": ~7.2.15 + "@mui/utils": ^5.17.1 "@popperjs/core": ^2.11.8 "@types/react-transition-group": ^4.4.10 clsx: ^2.1.0 csstype: ^3.1.3 prop-types: ^15.8.1 - react-is: ^18.3.1 + react-is: ^19.0.0 react-transition-group: ^4.4.5 peerDependencies: "@emotion/react": ^11.5.0 "@emotion/styled": ^11.3.0 - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@emotion/react": optional: true @@ -7623,65 +7639,97 @@ __metadata: optional: true "@types/react": optional: true - checksum: 5057b48c3ce554247de9a8f675bda9bbda079bc83a696c500525f3ebbd63315a44f1c2a7c83c2025dbd02d2722892e397a0af10c1219d45f6534e41d91a43cc0 + checksum: b9d6cf638774e65924adf6f58ad50639c55050b33dc6223aa74686f733e137d173b4594b28be70e8ea3baf413a7fcd3bee40d35e3af12a42b7ba03def71ea217 languageName: node linkType: hard -"@mui/private-theming@npm:^5.16.6": - version: 5.16.6 - resolution: "@mui/private-theming@npm:5.16.6" +"@mui/private-theming@npm:^5.17.1": + version: 5.17.1 + resolution: "@mui/private-theming@npm:5.17.1" dependencies: "@babel/runtime": ^7.23.9 - "@mui/utils": ^5.16.6 + "@mui/utils": ^5.17.1 prop-types: ^15.8.1 peerDependencies: - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 314ba598ab17cd425a36e4cab677ed26fe0939b23e53120da77cfbc3be6dada5428fa8e2a55cb697417599a4e3abfee6d4711de0a7318b9fb2c3a822b2d5b5a8 + checksum: ab755e96c8adb6c12f2c7e567154a8d873d56f1e35d0fb897b568123dfc0e9d1d770ca0992410433c728f9f4403f556f3c1e1b5b57b001ef1948ddfc8c1717bd languageName: node linkType: hard -"@mui/styled-engine@npm:^5.16.6": - version: 5.16.6 - resolution: "@mui/styled-engine@npm:5.16.6" +"@mui/styled-engine@npm:^5.18.0": + version: 5.18.0 + resolution: "@mui/styled-engine@npm:5.18.0" dependencies: "@babel/runtime": ^7.23.9 - "@emotion/cache": ^11.11.0 + "@emotion/cache": ^11.13.5 + "@emotion/serialize": ^1.3.3 csstype: ^3.1.3 prop-types: ^15.8.1 peerDependencies: "@emotion/react": ^11.4.1 "@emotion/styled": ^11.3.0 - react: ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@emotion/react": optional: true "@emotion/styled": optional: true - checksum: 604f83b91801945336db211a8273061132668d01e9f456c30bb811a3b49cc5786b8b7dd8e0b5b89de15f6209abc900d9e679d3ae7a4651a6df45e323b6ed95c5 + checksum: ab2d260ad5eea94993bc7706b164ae4ec11bd37dd7be36b93755d18da5b7859d39ad44173adced0e111e8b1b7ef65c0e369df3f2908144237c5e78f793eebb5a languageName: node linkType: hard -"@mui/system@npm:^5.16.7": - version: 5.16.7 - resolution: "@mui/system@npm:5.16.7" +"@mui/styles@npm:5.18.0": + version: 5.18.0 + resolution: "@mui/styles@npm:5.18.0" dependencies: "@babel/runtime": ^7.23.9 - "@mui/private-theming": ^5.16.6 - "@mui/styled-engine": ^5.16.6 - "@mui/types": ^7.2.15 - "@mui/utils": ^5.16.6 + "@emotion/hash": ^0.9.1 + "@mui/private-theming": ^5.17.1 + "@mui/types": ~7.2.15 + "@mui/utils": ^5.17.1 + clsx: ^2.1.0 + csstype: ^3.1.3 + hoist-non-react-statics: ^3.3.2 + jss: ^10.10.0 + jss-plugin-camel-case: ^10.10.0 + jss-plugin-default-unit: ^10.10.0 + jss-plugin-global: ^10.10.0 + jss-plugin-nested: ^10.10.0 + jss-plugin-props-sort: ^10.10.0 + jss-plugin-rule-value-function: ^10.10.0 + jss-plugin-vendor-prefixer: ^10.10.0 + prop-types: ^15.8.1 + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 290cd7b7d738277c7024ac3f0840b2fd9f973008bd18a67294a8e1f401ffbca992311101c9af5cc1b799abd8c3ec469a0096c5d8cccc6b540218c433b0040f28 + languageName: node + linkType: hard + +"@mui/system@npm:^5.18.0": + version: 5.18.0 + resolution: "@mui/system@npm:5.18.0" + dependencies: + "@babel/runtime": ^7.23.9 + "@mui/private-theming": ^5.17.1 + "@mui/styled-engine": ^5.18.0 + "@mui/types": ~7.2.15 + "@mui/utils": ^5.17.1 clsx: ^2.1.0 csstype: ^3.1.3 prop-types: ^15.8.1 peerDependencies: "@emotion/react": ^11.5.0 "@emotion/styled": ^11.3.0 - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@emotion/react": optional: true @@ -7689,39 +7737,39 @@ __metadata: optional: true "@types/react": optional: true - checksum: 86cc11d062645b6742328178ca3a9e2aa2c6d064a559e4fb8c6c6bb8251794959b9dad385f9508fdcab2ae2764503c80f7c3d4f6eb1e0e8aa649f28d4f59133b + checksum: 451f43889c2638da7c52b898e6174eafcdbcbcaaf3a79f954fdd58d9a043786d76fa4fca902cfdd6ab1aa250f5b1f932ef93d789c5a15987d893c0741bf7a1ad languageName: node linkType: hard -"@mui/types@npm:^7.2.15": - version: 7.2.19 - resolution: "@mui/types@npm:7.2.19" +"@mui/types@npm:~7.2.15": + version: 7.2.24 + resolution: "@mui/types@npm:7.2.24" peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: c3b5723e6f0861d47df834c57878f19347aefecdaf948cf9a25a64b73fbc75791430693d0f540b2bdc01bdfc605dc32bf4ba738113ec415aa9eaf002ce38f064 + checksum: 3a7367f9503e90fc3cce78885b57b54a00f7a04108be8af957fdc882c1bc0af68390920ea3d6aef855e704651ffd4a530e36ccbec4d0f421a176a2c3c432bb95 languageName: node linkType: hard -"@mui/utils@npm:^5.14.15, @mui/utils@npm:^5.16.6": - version: 5.16.6 - resolution: "@mui/utils@npm:5.16.6" +"@mui/utils@npm:^5.14.15, @mui/utils@npm:^5.17.1": + version: 5.17.1 + resolution: "@mui/utils@npm:5.17.1" dependencies: "@babel/runtime": ^7.23.9 - "@mui/types": ^7.2.15 + "@mui/types": ~7.2.15 "@types/prop-types": ^15.7.12 clsx: ^2.1.1 prop-types: ^15.8.1 - react-is: ^18.3.1 + react-is: ^19.0.0 peerDependencies: - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 6f8068f07f60a842fcb2e2540eecbd9c5f04df695bcc427184720e8ae138ae689fefd3c20147ab7c76e809ede6e10f5e08d1c34cd3a8b09bd22d2020a666a96f + checksum: 06f9da7025b9291f1052c0af012fd0f00ff1539bc99880e15f483a85c27bff0e9c3d047511dc5c3177d546c21978227ece2e6f7a7bd91903c72b48b89c45a677 languageName: node linkType: hard @@ -8555,6 +8603,20 @@ __metadata: languageName: node linkType: hard +"@openshift/dynamic-plugin-sdk@npm:^5.0.1": + version: 5.0.1 + resolution: "@openshift/dynamic-plugin-sdk@npm:5.0.1" + dependencies: + lodash: ^4.17.21 + semver: ^7.3.7 + uuid: ^8.3.2 + yup: ^0.32.11 + peerDependencies: + react: ^17 || ^18 + checksum: 66e54691ac257cee70b83e627d8fa13c1b0951cc322da0b7c0e2511b9467a9adcf59290ce9250a8ed1de9f98870d6100195afe5dce49ad9b88cf4fc3c991ad4e + languageName: node + linkType: hard + "@opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.4.0, @opentelemetry/api@npm:^1.9.0": version: 1.9.0 resolution: "@opentelemetry/api@npm:1.9.0" @@ -11005,6 +11067,27 @@ __metadata: languageName: node linkType: hard +"@red-hat-developer-hub/backstage-plugin-global-floating-action-button@npm:^1.6.1": + version: 1.7.0 + resolution: "@red-hat-developer-hub/backstage-plugin-global-floating-action-button@npm:1.7.0" + dependencies: + "@backstage/core-components": ^0.18.3 + "@backstage/core-plugin-api": ^1.12.0 + "@backstage/theme": ^0.7.0 + "@mui/icons-material": ^5.15.17 + "@mui/material": ^5.15.17 + "@mui/styles": 5.18.0 + "@scalprum/react-core": 0.9.5 + classnames: ^2.5.1 + react-use: ^17.2.4 + peerDependencies: + react: 16.13.1 || ^17.0.0 || ^18.0.0 + react-dom: 16.13.1 || ^17.0.0 || ^18.0.0 + react-router-dom: ^6.0.0 + checksum: 7787420c0ec93941fe47f650982071402ad6ef2560c2d6a9376b721d492959080e76ebdd08858128c134c662af9d7036da149127f5156fd48d6bf13c149c7e5c + languageName: node + linkType: hard + "@red-hat-developer-hub/backstage-plugin-lightspeed-backend@workspace:^, @red-hat-developer-hub/backstage-plugin-lightspeed-backend@workspace:plugins/lightspeed-backend": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-lightspeed-backend@workspace:plugins/lightspeed-backend" @@ -11065,10 +11148,12 @@ __metadata: "@material-ui/lab": ^4.0.0-alpha.61 "@mui/icons-material": ^6.1.8 "@mui/material": ^5.12.2 + "@mui/styles": 5.18.0 "@patternfly/chatbot": 6.4.1 "@patternfly/react-core": 6.4.0 "@patternfly/react-icons": ^6.3.1 "@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-theme": ^0.10.0 "@spotify/prettier-config": ^15.0.0 "@tanstack/react-query": ^5.59.15 "@testing-library/dom": ^10.0.0 @@ -11088,6 +11173,38 @@ __metadata: languageName: unknown linkType: soft +"@red-hat-developer-hub/backstage-plugin-theme@npm:^0.10.0": + version: 0.10.2 + resolution: "@red-hat-developer-hub/backstage-plugin-theme@npm:0.10.2" + dependencies: + "@mui/icons-material": ^5.17.1 + peerDependencies: + "@backstage/core-plugin-api": ^1.10.9 + "@backstage/theme": ^0.6.8 + "@material-ui/icons": ^4.11.3 + "@mui/icons-material": ^5.17.1 + "@mui/material": ^5.0.0 + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + checksum: 02b73685dbbb14f82d274e2cc45bb924a887d3492345101835800592f0ac752342595e5866c23661f103a7052c6b21803a5f525d22090391aa7107d3f9f607ea + languageName: node + linkType: hard + +"@red-hat-developer-hub/backstage-plugin-theme@npm:^0.11.0": + version: 0.11.0 + resolution: "@red-hat-developer-hub/backstage-plugin-theme@npm:0.11.0" + dependencies: + "@mui/icons-material": ^5.17.1 + peerDependencies: + "@backstage/core-plugin-api": ^1.11.1 + "@backstage/theme": ^0.7.0 + "@material-ui/icons": ^4.11.3 + "@mui/icons-material": ^5.17.1 + "@mui/material": ^5.0.0 + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + checksum: 70368eb43351805dbea8a6f9b1a7e5e0c50ac6edc2ab7639c2fe48084d783b0d3f7a629225ae0e6ae3fd47b3fccdf2eecc4ad9a913ddec32e948f94fb6ecbd92 + languageName: node + linkType: hard + "@redis/bloom@npm:1.2.0": version: 1.2.0 resolution: "@redis/bloom@npm:1.2.0" @@ -11688,6 +11805,30 @@ __metadata: languageName: node linkType: hard +"@scalprum/core@npm:^0.8.3": + version: 0.8.3 + resolution: "@scalprum/core@npm:0.8.3" + dependencies: + "@openshift/dynamic-plugin-sdk": ^5.0.1 + tslib: ^2.6.2 + checksum: 31d1d376c795d47cfc7c4cf84d262989f7a62e3c536aedff03cfa6e23bf969713f70f0e3c9035fe46b280a5133c85c416f1cd2b46c98f9822080ac9d7c752819 + languageName: node + linkType: hard + +"@scalprum/react-core@npm:0.9.5": + version: 0.9.5 + resolution: "@scalprum/react-core@npm:0.9.5" + dependencies: + "@openshift/dynamic-plugin-sdk": ^5.0.1 + "@scalprum/core": ^0.8.3 + lodash: ^4.17.0 + peerDependencies: + react: ">=16.8.0 || >=17.0.0 || ^18.0.0" + react-dom: ">=16.8.0 || >=17.0.0 || ^18.0.0" + checksum: 824cc4c53437a3d0da9a760fefa31a6aba436caef7b2cad69838fb43bd19b6301f90d60e33e823628596ef5e7174a208ba0e67ab129688cf122771fc101b0598 + languageName: node + linkType: hard + "@scarf/scarf@npm:=1.4.0": version: 1.4.0 resolution: "@scarf/scarf@npm:1.4.0" @@ -14204,6 +14345,13 @@ __metadata: languageName: node linkType: hard +"@types/lodash@npm:^4.14.175": + version: 4.17.21 + resolution: "@types/lodash@npm:4.17.21" + checksum: e09e3eaf29b18b6c8e130fcbafd8e3c4ecc2110f35255079245e7d1bd310a5a8f93e4e70266533dce672f253bba899c721bc6870097e0a8c5448e0b628136d39 + languageName: node + linkType: hard + "@types/long@npm:^4.0.0": version: 4.0.2 resolution: "@types/long@npm:4.0.2" @@ -15492,7 +15640,9 @@ __metadata: "@material-ui/core": ^4.12.2 "@material-ui/icons": ^4.9.1 "@playwright/test": 1.57.0 + "@red-hat-developer-hub/backstage-plugin-global-floating-action-button": ^1.6.1 "@red-hat-developer-hub/backstage-plugin-lightspeed": "*" + "@red-hat-developer-hub/backstage-plugin-theme": ^0.11.0 "@testing-library/dom": ^9.0.0 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 @@ -24751,7 +24901,7 @@ __metadata: languageName: node linkType: hard -"jss-plugin-camel-case@npm:^10.5.1": +"jss-plugin-camel-case@npm:^10.10.0, jss-plugin-camel-case@npm:^10.5.1": version: 10.10.0 resolution: "jss-plugin-camel-case@npm:10.10.0" dependencies: @@ -24762,7 +24912,7 @@ __metadata: languageName: node linkType: hard -"jss-plugin-default-unit@npm:^10.5.1": +"jss-plugin-default-unit@npm:^10.10.0, jss-plugin-default-unit@npm:^10.5.1": version: 10.10.0 resolution: "jss-plugin-default-unit@npm:10.10.0" dependencies: @@ -24772,7 +24922,7 @@ __metadata: languageName: node linkType: hard -"jss-plugin-global@npm:^10.5.1": +"jss-plugin-global@npm:^10.10.0, jss-plugin-global@npm:^10.5.1": version: 10.10.0 resolution: "jss-plugin-global@npm:10.10.0" dependencies: @@ -24782,7 +24932,7 @@ __metadata: languageName: node linkType: hard -"jss-plugin-nested@npm:^10.5.1": +"jss-plugin-nested@npm:^10.10.0, jss-plugin-nested@npm:^10.5.1": version: 10.10.0 resolution: "jss-plugin-nested@npm:10.10.0" dependencies: @@ -24793,7 +24943,7 @@ __metadata: languageName: node linkType: hard -"jss-plugin-props-sort@npm:^10.5.1": +"jss-plugin-props-sort@npm:^10.10.0, jss-plugin-props-sort@npm:^10.5.1": version: 10.10.0 resolution: "jss-plugin-props-sort@npm:10.10.0" dependencies: @@ -24803,7 +24953,7 @@ __metadata: languageName: node linkType: hard -"jss-plugin-rule-value-function@npm:^10.5.1": +"jss-plugin-rule-value-function@npm:^10.10.0, jss-plugin-rule-value-function@npm:^10.5.1": version: 10.10.0 resolution: "jss-plugin-rule-value-function@npm:10.10.0" dependencies: @@ -24814,7 +24964,7 @@ __metadata: languageName: node linkType: hard -"jss-plugin-vendor-prefixer@npm:^10.5.1": +"jss-plugin-vendor-prefixer@npm:^10.10.0, jss-plugin-vendor-prefixer@npm:^10.5.1": version: 10.10.0 resolution: "jss-plugin-vendor-prefixer@npm:10.10.0" dependencies: @@ -24825,7 +24975,7 @@ __metadata: languageName: node linkType: hard -"jss@npm:10.10.0, jss@npm:^10.5.1, jss@npm:~10.10.0": +"jss@npm:10.10.0, jss@npm:^10.10.0, jss@npm:^10.5.1, jss@npm:~10.10.0": version: 10.10.0 resolution: "jss@npm:10.10.0" dependencies: @@ -25491,7 +25641,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:4.17.21, lodash@npm:^4.15.0, lodash@npm:^4.16.4, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:~4.17.15, lodash@npm:~4.17.21": +"lodash@npm:4.17.21, lodash@npm:^4.15.0, lodash@npm:^4.16.4, lodash@npm:^4.17.0, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:~4.17.15, lodash@npm:~4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 @@ -27638,6 +27788,13 @@ __metadata: languageName: node linkType: hard +"nanoclone@npm:^0.2.1": + version: 0.2.1 + resolution: "nanoclone@npm:0.2.1" + checksum: 96b2954e22f70561f41e20d69856266c65583c2a441dae108f1dc71b716785d2c8038dac5f1d5e92b117aed3825f526b53139e2e5d6e6db8a77cfa35b3b8bf40 + languageName: node + linkType: hard + "nanoid@npm:^3.3.7": version: 3.3.7 resolution: "nanoid@npm:3.3.7" @@ -30073,6 +30230,13 @@ __metadata: languageName: node linkType: hard +"property-expr@npm:^2.0.4": + version: 2.0.6 + resolution: "property-expr@npm:2.0.6" + checksum: 89977f4bb230736c1876f460dd7ca9328034502fd92e738deb40516d16564b850c0bbc4e052c3df88b5b8cd58e51c93b46a94bea049a3f23f4a022c038864cab + languageName: node + linkType: hard + "property-information@npm:^5.0.0": version: 5.6.0 resolution: "property-information@npm:5.6.0" @@ -30788,13 +30952,20 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0, react-is@npm:^18.2.0, react-is@npm:^18.3.1": +"react-is@npm:^18.0.0, react-is@npm:^18.2.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 languageName: node linkType: hard +"react-is@npm:^19.0.0": + version: 19.2.3 + resolution: "react-is@npm:19.2.3" + checksum: 3bb317292dc574632ec33093d38b8ff97abb6dc400e7b0375baef9429f148cf5ae0307e37de97358f3fad07edd159cda8fcb9d28aaaf0dcd8d408ee320638b83 + languageName: node + linkType: hard + "react-markdown@npm:^8.0.0": version: 8.0.7 resolution: "react-markdown@npm:8.0.7" @@ -32281,7 +32452,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.6.3, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3": +"semver@npm:7.6.3": version: 7.6.3 resolution: "semver@npm:7.6.3" bin: @@ -32308,6 +32479,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3": + version: 7.7.3 + resolution: "semver@npm:7.7.3" + bin: + semver: bin/semver.js + checksum: f013a3ee4607857bcd3503b6ac1d80165f7f8ea94f5d55e2d3e33df82fce487aa3313b987abf9b39e0793c83c9fc67b76c36c067625141a9f6f704ae0ea18db2 + languageName: node + linkType: hard + "semver@npm:~7.5.4": version: 7.5.4 resolution: "semver@npm:7.5.4" @@ -34152,6 +34332,13 @@ __metadata: languageName: node linkType: hard +"toposort@npm:^2.0.2": + version: 2.0.2 + resolution: "toposort@npm:2.0.2" + checksum: d64c74b570391c9432873f48e231b439ee56bc49f7cb9780b505cfdf5cb832f808d0bae072515d93834dd6bceca5bb34448b5b4b408335e4d4716eaf68195dcb + languageName: node + linkType: hard + "tosource@npm:^2.0.0-alpha.3": version: 2.0.0-alpha.3 resolution: "tosource@npm:2.0.0-alpha.3" @@ -36263,6 +36450,21 @@ __metadata: languageName: node linkType: hard +"yup@npm:^0.32.11": + version: 0.32.11 + resolution: "yup@npm:0.32.11" + dependencies: + "@babel/runtime": ^7.15.4 + "@types/lodash": ^4.14.175 + lodash: ^4.17.21 + lodash-es: ^4.17.21 + nanoclone: ^0.2.1 + property-expr: ^2.0.4 + toposort: ^2.0.2 + checksum: 43a16786b47cc910fed4891cebdd89df6d6e31702e9462e8f969c73eac88551ce750732608012201ea6b93802c8847cb0aa27b5d57370640f4ecf30f9f97d4b0 + languageName: node + linkType: hard + "zen-observable@npm:^0.10.0": version: 0.10.0 resolution: "zen-observable@npm:0.10.0" diff --git a/workspaces/quickstart/.changeset/fancy-symbols-ask.md b/workspaces/quickstart/.changeset/fancy-symbols-ask.md new file mode 100644 index 0000000000..1e9513805f --- /dev/null +++ b/workspaces/quickstart/.changeset/fancy-symbols-ask.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-quickstart': patch +--- + +separate quickstart content from the drawer container diff --git a/workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx b/workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx new file mode 100644 index 0000000000..f1fad3f674 --- /dev/null +++ b/workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx @@ -0,0 +1,178 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ComponentType, + useState, + useCallback, + useMemo, + useEffect, +} from 'react'; +import { CustomDrawer } from './CustomDrawer'; + +/** + * Partial drawer state exposed by drawer plugins + * + * @public + */ +export interface DrawerPartialState { + id: string; + isDrawerOpen: boolean; + drawerWidth: number; + setDrawerWidth: (width: number) => void; +} + +/** + * Props for drawer state exposer components + * + * @public + */ +export interface DrawerStateExposerProps { + onStateChange: (state: DrawerPartialState) => void; + onUnmount?: (id: string) => void; +} + +/** + * Drawer content configuration + */ +type DrawerContentType = { + id: string; + Component: ComponentType; + priority?: number; + resizable?: boolean; +}; + +/** + * State exposer component type + */ +type StateExposerType = { + Component: ComponentType; +}; + +export interface ApplicationDrawerProps { + /** + * Array of drawer content configurations + * Maps drawer IDs to their content components + */ + drawerContents: DrawerContentType[]; + /** + * Array of state exposer components from drawer plugins + * These are typically mounted via `application/drawer-state` mount point + * + * In RHDH dynamic plugins, this would come from: + * ```yaml + * mountPoints: + * - mountPoint: application/drawer-state + * importName: TestDrawerStateExposer + * ``` + */ + stateExposers?: StateExposerType[]; +} + +export const ApplicationDrawer = ({ + drawerContents, + stateExposers = [], +}: ApplicationDrawerProps) => { + // Collect drawer states from all state exposers + const [drawerStates, setDrawerStates] = useState< + Record + >({}); + + // Callback for state exposers to report their state + const handleStateChange = useCallback((state: DrawerPartialState) => { + setDrawerStates(prev => { + // Only update if something actually changed + const existing = prev[state.id]; + if ( + existing && + existing.isDrawerOpen === state.isDrawerOpen && + existing.drawerWidth === state.drawerWidth && + existing.setDrawerWidth === state.setDrawerWidth + ) { + return prev; + } + return { ...prev, [state.id]: state }; + }); + }, []); + + // Convert states record to array + const statesArray = useMemo( + () => Object.values(drawerStates), + [drawerStates], + ); + + // Get active drawer - find the open drawer with highest priority + const activeDrawer = useMemo(() => { + return statesArray + .filter(state => state.isDrawerOpen) + .map(state => { + const content = drawerContents.find(c => c.id === state.id); + if (!content) return null; + return { ...state, ...content }; + }) + .filter(Boolean) + .sort((a, b) => (b?.priority ?? -1) - (a?.priority ?? -1))[0]; + }, [statesArray, drawerContents]); + + // Manage CSS classes and variables for layout adjustments + useEffect(() => { + if (activeDrawer) { + const className = `docked-drawer-open`; + const cssVar = `--docked-drawer-width`; + + document.body.classList.add(className); + document.body.style.setProperty(cssVar, `${activeDrawer.drawerWidth}px`); + + return () => { + document.body.classList.remove(className); + document.body.style.removeProperty(cssVar); + }; + } + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeDrawer?.id, activeDrawer?.drawerWidth]); + + // Wrapper to handle the width change callback type + const handleWidthChange = useCallback( + (width: number) => { + activeDrawer?.setDrawerWidth(width); + }, + [activeDrawer], + ); + + return ( + <> + {/* Render all state exposers - they return null but report their state */} + {stateExposers.map(({ Component }, index) => ( + + ))} + + {/* Render the active drawer */} + {activeDrawer && ( + + + + )} + + ); +}; diff --git a/workspaces/quickstart/packages/app/src/components/Root/CustomDrawer.tsx b/workspaces/quickstart/packages/app/src/components/Root/CustomDrawer.tsx new file mode 100644 index 0000000000..f5149db4b3 --- /dev/null +++ b/workspaces/quickstart/packages/app/src/components/Root/CustomDrawer.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Box from '@mui/material/Box'; +import Drawer from '@mui/material/Drawer'; + +import { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme'; + +export type CustomDrawerProps = { + children: React.ReactNode; + minWidth?: number; + maxWidth?: number; + initialWidth?: number; + isDrawerOpen: boolean; + drawerWidth?: number; + onWidthChange?: (width: number) => void; + [key: string]: any; +}; + +export const CustomDrawer = (props: CustomDrawerProps) => { + const { + children, + minWidth = 400, + maxWidth = 800, + initialWidth = 400, + isDrawerOpen, + drawerWidth, + onWidthChange, + ...drawerProps + } = props; + + // Ensure anchor is always 'right' and not overridden by drawerProps + const { anchor: _, ...restDrawerProps } = drawerProps; + + return ( + { + const themeConfig = theme as ThemeConfig; + return ( + themeConfig.palette?.rhdh?.general?.sidebarBackgroundColor || + theme.palette.background.paper + ); + }, + justifyContent: 'space-between', + }, + // Only apply header offset when global header exists + 'body:has(#global-header) &': { + '& .v5-MuiDrawer-paper': { + top: '64px !important', + height: 'calc(100vh - 64px) !important', + }, + }, + }} + variant="persistent" + open={isDrawerOpen} + > + {children} + + ); +}; diff --git a/workspaces/quickstart/packages/app/src/components/Root/Root.tsx b/workspaces/quickstart/packages/app/src/components/Root/Root.tsx index 9731ec6cc9..9d8685f4eb 100644 --- a/workspaces/quickstart/packages/app/src/components/Root/Root.tsx +++ b/workspaces/quickstart/packages/app/src/components/Root/Root.tsx @@ -47,9 +47,14 @@ import { GlobalHeaderComponent, } from '@red-hat-developer-hub/backstage-plugin-global-header'; import Box from '@mui/material/Box'; -import { QuickstartDrawerProvider } from '@red-hat-developer-hub/backstage-plugin-quickstart'; +import { + QuickstartDrawerProvider, + QuickstartDrawerContent, + QuickstartDrawerStateExposer, +} from '@red-hat-developer-hub/backstage-plugin-quickstart'; import { QuickstartSidebarItem } from './QuickstartSidebarItem'; import { Administration } from '@backstage-community/plugin-rbac'; +import { ApplicationDrawer } from './ApplicationDrawer'; const useSidebarLogoStyles = makeStyles({ root: { @@ -88,9 +93,9 @@ export const Root = ({ children }: PropsWithChildren<{}>) => { // This code exists similarly in RHDH: // https://github.com/redhat-developer/rhdh/blob/main/packages/app/src/components/Root/Root.tsx#L159-L165 // https://github.com/redhat-developer/rhdh/blob/main/packages/app/src/components/ErrorPages/ErrorPage.tsx#L54-L59 - 'body.quickstart-drawer-open #sidebar&': { + 'body.docked-drawer-open #sidebar&': { "> div > main[class*='BackstagePage-root']": { - marginRight: 'calc(var(--quickstart-drawer-width, 500px) + 1.5em)', + marginRight: 'calc(var(--docked-drawer-width, 500px) + 1.5em)', transition: 'margin-right 0.3s ease', }, }, @@ -141,6 +146,16 @@ export const Root = ({ children }: PropsWithChildren<{}>) => { {children} + diff --git a/workspaces/quickstart/plugins/quickstart/dev/index.tsx b/workspaces/quickstart/plugins/quickstart/dev/index.tsx index 1b36d1335f..6e4f575190 100644 --- a/workspaces/quickstart/plugins/quickstart/dev/index.tsx +++ b/workspaces/quickstart/plugins/quickstart/dev/index.tsx @@ -25,6 +25,8 @@ import Card from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; import Button from '@mui/material/Button'; import { getAllThemes } from '@red-hat-developer-hub/backstage-plugin-theme'; +import { QuickstartDrawerContent } from '../src/components/QuickstartDrawerContent'; +import { DrawerComponent } from '../src/components/DrawerComponent'; const QuickstartTestPageContent = () => { const { openDrawer, closeDrawer, isDrawerOpen } = @@ -99,6 +101,9 @@ const QuickstartTestPageContent = () => { const QuickstartTestPage = () => ( + + + ); diff --git a/workspaces/quickstart/plugins/quickstart/report.api.md b/workspaces/quickstart/plugins/quickstart/report.api.md index 90eab2665a..7d224a2ff7 100644 --- a/workspaces/quickstart/plugins/quickstart/report.api.md +++ b/workspaces/quickstart/plugins/quickstart/report.api.md @@ -12,6 +12,19 @@ import { PropsWithChildren } from 'react'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; import { TranslationResource } from '@backstage/core-plugin-api/alpha'; +// @public +export type DrawerPartialState = { + id: string; + isDrawerOpen: boolean; + drawerWidth: number; + setDrawerWidth: (width: number) => void; +}; + +// @public +export type DrawerStateExposerProps = { + onStateChange: (state: DrawerPartialState) => void; +}; + // @public export const filterQuickstartItemsByRole: ( items: QuickstartItemData[], @@ -32,6 +45,9 @@ export interface QuickstartButtonProps { title?: string; } +// @public +export const QuickstartDrawerContent: () => JSX_2.Element | null; + // @public export interface QuickstartDrawerContextType { closeDrawer: () => void; @@ -49,6 +65,11 @@ export const QuickstartDrawerProvider: ({ children, }: PropsWithChildren) => JSX_2.Element; +// @public +export const QuickstartDrawerStateExposer: ({ + onStateChange, +}: DrawerStateExposerProps) => null; + // @public export interface QuickstartItemCtaData { link: string; diff --git a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawer.tsx b/workspaces/quickstart/plugins/quickstart/src/components/DrawerComponent.tsx similarity index 83% rename from workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawer.tsx rename to workspaces/quickstart/plugins/quickstart/src/components/DrawerComponent.tsx index 76856b34ee..c14c9bcf47 100644 --- a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawer.tsx +++ b/workspaces/quickstart/plugins/quickstart/src/components/DrawerComponent.tsx @@ -14,18 +14,17 @@ * limitations under the License. */ +import { PropsWithChildren, useMemo } from 'react'; import Drawer from '@mui/material/Drawer'; import { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme'; import { configApiRef, useApiHolder } from '@backstage/core-plugin-api'; -import { Quickstart } from './Quickstart'; import { useQuickstartDrawerContext } from '../hooks/useQuickstartDrawerContext'; import { QuickstartItemData } from '../types'; import { filterQuickstartItemsByRole } from '../utils'; // Role is now provided through context to avoid re-fetching on drawer open/close -import { useMemo } from 'react'; -export const QuickstartDrawer = () => { - const { isDrawerOpen, closeDrawer, drawerWidth, userRole, roleLoading } = +export const DrawerComponent = ({ children }: PropsWithChildren) => { + const { isDrawerOpen, drawerWidth, userRole, roleLoading } = useQuickstartDrawerContext(); const apiHolder = useApiHolder(); @@ -43,11 +42,6 @@ export const QuickstartDrawer = () => { : []; }, [roleLoading, userRole, quickstartItems]); - // Only expose items to the body when drawer is open to avoid re-renders during close - const filteredItems = useMemo(() => { - return isDrawerOpen ? eligibleItems : []; - }, [isDrawerOpen, eligibleItems]); - // No auto-open logic here; the provider initializes per user (visited/open) // If no quickstart items are configured at all, don't render the drawer to avoid reserving space @@ -87,11 +81,7 @@ export const QuickstartDrawer = () => { anchor="right" open={isDrawerOpen} > - + {children} ); }; diff --git a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContent.tsx b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContent.tsx new file mode 100644 index 0000000000..c5268acec0 --- /dev/null +++ b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContent.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useMemo } from 'react'; +import { configApiRef, useApiHolder } from '@backstage/core-plugin-api'; +import { Quickstart } from './Quickstart'; +import { useQuickstartDrawerContext } from '../hooks/useQuickstartDrawerContext'; +import { QuickstartItemData } from '../types'; +import { filterQuickstartItemsByRole } from '../utils'; + +export const QuickstartDrawerContent = () => { + const { isDrawerOpen, closeDrawer, userRole, roleLoading } = + useQuickstartDrawerContext(); + + const apiHolder = useApiHolder(); + const config = apiHolder.get(configApiRef); + const quickstartItems: QuickstartItemData[] = useMemo(() => { + return config?.has('app.quickstart') + ? (config.get('app.quickstart') as QuickstartItemData[]) + : []; + }, [config]); + + // Items available to the user based on role from context + const eligibleItems = useMemo(() => { + return !roleLoading && userRole + ? filterQuickstartItemsByRole(quickstartItems, userRole) + : []; + }, [roleLoading, userRole, quickstartItems]); + + // Only expose items to the body when drawer is open to avoid re-renders during close + const filteredItems = useMemo(() => { + return isDrawerOpen ? eligibleItems : []; + }, [isDrawerOpen, eligibleItems]); + + // No auto-open logic here; the provider initializes per user (visited/open) + + // If no quickstart items are configured at all, don't render the drawer to avoid reserving space + if (quickstartItems.length === 0) { + return null; + } + + // If there are no items for the user, hide the drawer entirely + if (!roleLoading && eligibleItems.length === 0) { + return null; + } + + // No role-fetching or filtering here when the drawer is closed + + return ( + + ); +}; diff --git a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx index 1db449e068..842f1edb34 100644 --- a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx +++ b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx @@ -24,7 +24,6 @@ import Snackbar from '@mui/material/Snackbar'; import CloseIcon from '@mui/icons-material/Close'; import IconButton from '@mui/material/IconButton'; import { QuickstartDrawerContext } from './QuickstartDrawerContext'; -import { QuickstartDrawer } from './QuickstartDrawer'; import { QuickstartItemData } from '../types'; import { filterQuickstartItemsByRole } from '../utils'; import { useQuickstartRole } from '../hooks/useQuickstartRole'; @@ -45,25 +44,6 @@ export const QuickstartDrawerProvider = ({ children }: PropsWithChildren) => { // Determine role once at provider level to avoid re-fetching on drawer open/close const { isLoading: roleLoading, userRole } = useQuickstartRole(); - // Single useEffect - sets class on document.body - useEffect(() => { - if (isDrawerOpen) { - document.body.classList.add('quickstart-drawer-open'); - document.body.style.setProperty( - '--quickstart-drawer-width', - `${drawerWidth}px`, - ); - } else { - document.body.classList.remove('quickstart-drawer-open'); - document.body.style.removeProperty('--quickstart-drawer-width'); - } - - return () => { - document.body.classList.remove('quickstart-drawer-open'); - document.body.style.removeProperty('--quickstart-drawer-width'); - }; - }, [isDrawerOpen, drawerWidth]); - // Resolve the current user's identity to scope localStorage keys per user useEffect(() => { let cancelled = false; @@ -201,7 +181,6 @@ export const QuickstartDrawerProvider = ({ children }: PropsWithChildren) => { }} > {children} - void; +}; + +/** + * Props for drawer state exposer components + * + * @public + */ +export type DrawerStateExposerProps = { + /** + * Callback called whenever the drawer state changes + */ + onStateChange: (state: DrawerPartialState) => void; +}; + +/** + * This exposes Quickstart Drawer's partial context to the ApplicationDrawer + * + * It reads the QuickstartDrawerContext and calls the onStateChange callback with the + * partial state (id, isDrawerOpen, drawerWidth, setDrawerWidth). + * + * @public + */ +export const QuickstartDrawerStateExposer = ({ + onStateChange, +}: DrawerStateExposerProps) => { + const { isDrawerOpen, drawerWidth, setDrawerWidth } = + useQuickstartDrawerContext(); + + useEffect(() => { + onStateChange({ + id: 'quickstart', + isDrawerOpen, + drawerWidth, + setDrawerWidth, + }); + }, [isDrawerOpen, drawerWidth, onStateChange, setDrawerWidth]); + + return null; +}; diff --git a/workspaces/quickstart/plugins/quickstart/src/index.ts b/workspaces/quickstart/plugins/quickstart/src/index.ts index a9e3cfb234..8ea0dd80fb 100644 --- a/workspaces/quickstart/plugins/quickstart/src/index.ts +++ b/workspaces/quickstart/plugins/quickstart/src/index.ts @@ -26,6 +26,11 @@ export * from './plugin'; export { useQuickstartDrawerContext } from './hooks/useQuickstartDrawerContext'; export type { QuickstartDrawerContextType } from './components/QuickstartDrawerContext'; +export { QuickstartDrawerStateExposer } from './components/QuickstartDrawerStateExposer'; +export type { + DrawerStateExposerProps, + DrawerPartialState, +} from './components/QuickstartDrawerStateExposer'; /** * @public */ diff --git a/workspaces/quickstart/plugins/quickstart/src/plugin.ts b/workspaces/quickstart/plugins/quickstart/src/plugin.ts index 5fda53cc00..2459bf7b2d 100644 --- a/workspaces/quickstart/plugins/quickstart/src/plugin.ts +++ b/workspaces/quickstart/plugins/quickstart/src/plugin.ts @@ -55,6 +55,23 @@ export const QuickstartDrawerProvider: React.ComponentType = }), ); +/** + * Quickstart Drawer Content component extension + * + * @public + */ +export const QuickstartDrawerContent = quickstartPlugin.provide( + createComponentExtension({ + name: 'QuickstartDrawerContent', + component: { + lazy: () => + import('./components/QuickstartDrawerContent').then( + m => m.QuickstartDrawerContent, + ), + }, + }), +); + /** * Quick start button for global header help dropdown * @@ -72,3 +89,20 @@ export const QuickstartButton: React.ComponentType = }, }), ); + +/** + * Quickstart Drawer State Exposer exposes its drawer state + * + * @public + */ +export const QuickstartDrawerStateExposer = quickstartPlugin.provide( + createComponentExtension({ + name: 'QuickstartDrawerStateExposer', + component: { + lazy: () => + import('./components/QuickstartDrawerStateExposer').then( + m => m.QuickstartDrawerStateExposer, + ), + }, + }), +);