From 01ae538231145c740cc2879d86a99ff53fbb4680 Mon Sep 17 00:00:00 2001 From: Debsmita Santra Date: Wed, 26 Nov 2025 18:24:08 +0530 Subject: [PATCH 1/2] expose partial drawer context for ApplicationDrawer --- .../src/components/FloatingButton.tsx | 6 +- .../lightspeed/packages/app/package.json | 1 + .../lightspeed/packages/app/src/App.tsx | 22 +- .../packages/app/src/components/Root/Root.tsx | 110 +++-- .../plugins/lightspeed/package.json | 1 + .../src/components/LightSpeedChat.tsx | 401 +++++++++++------- .../src/components/LightspeedChatBox.tsx | 12 +- .../components/LightspeedChatBoxHeader.tsx | 59 ++- .../components/LightspeedChatContainer.tsx | 173 ++++++++ .../components/LightspeedDrawerContext.tsx | 67 +++ .../components/LightspeedDrawerProvider.tsx | 209 +++++++++ .../src/components/LightspeedIcon.tsx | 20 + .../src/components/LightspeedPage.tsx | 155 +------ .../src/components/ResizableDrawer.tsx | 152 +++++++ .../src/hooks/useLightspeedDrawerContext.tsx | 33 ++ .../lightspeed/src/images/rounded-logo.svg | 22 + .../plugins/lightspeed/src/index.ts | 11 +- .../plugins/lightspeed/src/plugin.ts | 21 + .../plugins/lightspeed/src/translations/de.ts | 8 + .../plugins/lightspeed/src/translations/es.ts | 8 + .../plugins/lightspeed/src/translations/fr.ts | 8 + .../lightspeed/src/translations/ref.ts | 8 + .../quickstart/packages/app/package.json | 2 + .../src/components/Root/ApplicationDrawer.tsx | 176 ++++++++ .../src/components/Root/ResizableDrawer.tsx | 157 +++++++ .../packages/app/src/components/Root/Root.tsx | 120 ++++-- .../components/Root/TestDrawerSidebarItem.tsx | 26 ++ .../plugins/quickstart/dev/index.tsx | 5 + .../plugins/quickstart/package.json | 1 + ...ickstartDrawer.tsx => DrawerComponent.tsx} | 18 +- .../components/QuickstartDrawerContent.tsx | 69 +++ .../components/QuickstartDrawerContext.tsx | 1 + .../components/QuickstartDrawerProvider.tsx | 24 +- .../QuickstartDrawerStateExposer.tsx | 68 +++ .../plugins/quickstart/src/index.ts | 1 + .../plugins/quickstart/src/plugin.ts | 34 ++ .../quickstart/plugins/test-drawer/README.md | 79 ++++ .../plugins/test-drawer/dev/index.tsx | 84 ++++ .../plugins/test-drawer/package.json | 59 +++ .../src/components/DrawerComponent.tsx | 34 ++ .../src/components/TestDrawerButton.tsx | 78 ++++ .../src/components/TestDrawerContent.tsx | 121 ++++++ .../src/components/TestDrawerContext.tsx | 67 +++ .../src/components/TestDrawerProvider.tsx | 60 +++ .../src/components/TestDrawerStateExposer.tsx | 68 +++ .../test-drawer/src/components/index.ts | 23 + .../plugins/test-drawer/src/index.ts | 29 ++ .../plugins/test-drawer/src/plugin.ts | 93 ++++ workspaces/quickstart/yarn.lock | 46 ++ 49 files changed, 2625 insertions(+), 425 deletions(-) create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatContainer.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/ResizableDrawer.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawerContext.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/images/rounded-logo.svg create mode 100644 workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx create mode 100644 workspaces/quickstart/packages/app/src/components/Root/ResizableDrawer.tsx create mode 100644 workspaces/quickstart/packages/app/src/components/Root/TestDrawerSidebarItem.tsx rename workspaces/quickstart/plugins/quickstart/src/components/{QuickstartDrawer.tsx => DrawerComponent.tsx} (83%) create mode 100644 workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContent.tsx create mode 100644 workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerStateExposer.tsx create mode 100644 workspaces/quickstart/plugins/test-drawer/README.md create mode 100644 workspaces/quickstart/plugins/test-drawer/dev/index.tsx create mode 100644 workspaces/quickstart/plugins/test-drawer/package.json create mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/DrawerComponent.tsx create mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerButton.tsx create mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContent.tsx create mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContext.tsx create mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerProvider.tsx create mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerStateExposer.tsx create mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/index.ts create mode 100644 workspaces/quickstart/plugins/test-drawer/src/index.ts create mode 100644 workspaces/quickstart/plugins/test-drawer/src/plugin.ts 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/packages/app/package.json b/workspaces/lightspeed/packages/app/package.json index e304e5a0d8..f9494d08f5 100644 --- a/workspaces/lightspeed/packages/app/package.json +++ b/workspaces/lightspeed/packages/app/package.json @@ -46,6 +46,7 @@ "@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": "*", "react": "^18.0.2", "react-dom": "^18.0.2", diff --git a/workspaces/lightspeed/packages/app/src/App.tsx b/workspaces/lightspeed/packages/app/src/App.tsx index b20b60a23e..597bd4933a 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,19 @@ 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 { githubAuthApiRef } from '@backstage/core-plugin-api'; import { LightspeedPage } from '@red-hat-developer-hub/backstage-plugin-lightspeed'; +import { 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 +91,9 @@ const app = createApp({ }); }, components: { - SignInPage: props => , + SignInPage: props => ( + + ), }, }); @@ -117,6 +132,7 @@ const routes = ( } /> } /> } /> + } /> ); @@ -125,7 +141,9 @@ export default app.createRoot( - {routes} + + {routes} + , ); diff --git a/workspaces/lightspeed/packages/app/src/components/Root/Root.tsx b/workspaces/lightspeed/packages/app/src/components/Root/Root.tsx index 3737ed297e..eff3dccb9a 100644 --- a/workspaces/lightspeed/packages/app/src/components/Root/Root.tsx +++ b/workspaces/lightspeed/packages/app/src/components/Root/Root.tsx @@ -43,9 +43,11 @@ import MenuIcon from '@material-ui/icons/Menu'; 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 { GlobalFloatingActionButton } from '@red-hat-developer-hub/backstage-plugin-global-floating-action-button'; +import { + LightspeedFABIcon, + useLightspeedDrawerContext, +} from '@red-hat-developer-hub/backstage-plugin-lightspeed'; const useSidebarLogoStyles = makeStyles({ root: { @@ -75,46 +77,64 @@ const SidebarLogo = () => { ); }; -export const Root = ({ children }: PropsWithChildren<{}>) => ( - - - - } to="/search"> - - - - }> - {/* Global nav, not org-specific */} - - - - - - - ;{/* End global nav */} +export const Root = ({ children }: PropsWithChildren<{}>) => { + const { isChatbotActive, toggleChatbot } = useLightspeedDrawerContext(); + + return ( + + , + label: 'Lightspeed', + toolTip: isChatbotActive ? 'Close Lightspeed' : 'Open Lightspeed', + size: 'small', + isOpen: isChatbotActive, + displayCloseWhenOpen: true, + onClick: toggleChatbot, + disableHoverEffect: true, + }, + ]} + /> + + + } to="/search"> + + - - {/* Items in this group will be scrollable if they run out of space */} - - - - - } - to="/settings" - > - - - - {children} - -); + }> + {/* Global nav, not org-specific */} + + + + + + {/* 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/package.json b/workspaces/lightspeed/plugins/lightspeed/package.json index 07a2f2960c..2f08c43c0b 100644 --- a/workspaces/lightspeed/plugins/lightspeed/package.json +++ b/workspaces/lightspeed/plugins/lightspeed/package.json @@ -61,6 +61,7 @@ "@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" diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index d675b1db5c..9aa03c9daa 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -30,6 +30,7 @@ import { ChatbotHeaderMain, ChatbotHeaderMenu, ChatbotHeaderTitle, + ChatbotModal, FileDropZone, MessageBar, MessageProps, @@ -48,6 +49,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'; @@ -64,6 +66,7 @@ import FilePreview from './FilePreview'; import { LightspeedChatBox } from './LightspeedChatBox'; import { LightspeedChatBoxHeader } from './LightspeedChatBoxHeader'; import { RenameConversationModal } from './RenameConversationModal'; +import { ResizableDrawer } from './ResizableDrawer'; const useStyles = makeStyles(theme => ({ body: { @@ -130,7 +133,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 +145,14 @@ export const LightspeedChat = ({ const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); const { isReady, lastOpenedId, setLastOpenedId, clearLastOpenedId } = useLastOpenedConversation(user); - + const { + displayMode, + setDisplayMode, + drawerWidth, + setDrawerWidth, + currentConversationId: routeConversationId, + setCurrentConversationId, + } = useLightspeedDrawerContext(); const { uploadError, showAlert, @@ -165,6 +176,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 +218,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 +256,7 @@ export const LightspeedChat = ({ const onStart = (conv_id: string) => { setConversationId(conv_id); + setCurrentConversationId(conv_id); }; const onComplete = (message: string) => { @@ -254,14 +307,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 +486,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 +530,8 @@ export const LightspeedChat = ({ setFilterValue(value); }, []); - const onDrawerToggle = useCallback(() => { - setIsDrawerOpen(isOpen => !isOpen); + const onEmbeddedDrawerToggle = useCallback(() => { + setIsEmbeddedDrawerOpen(isOpen => !isOpen); }, []); const handleAttach = (data: File[], event: DropEvent) => { @@ -485,6 +550,185 @@ export const LightspeedChat = ({ }); }; + const chatbot = ( + + + + setIsEmbeddedDrawerOpen(!isEmbeddedDrawerOpen)} + className={classes.headerMenu} + tooltipContent={t('tooltip.chatHistoryMenu')} + aria-label={t('aria.chatHistoryMenu')} + /> + {displayMode === ChatbotDisplayMode.embedded && ( + + + {t('chatbox.header.title')} + + + )} + + + handleSelectedModel(item)} + models={models} + isPinningChatsEnabled={isPinningChatsEnabled} + onPinnedChatsToggle={setIsPinningChatsEnabled} + setDisplayMode={setDisplayMode} + displayMode={displayMode} + /> + + + , + }} + handleTextInputChange={handleFilter} + searchInputPlaceholder={t('chatbox.search.placeholder')} + searchInputAriaLabel={t('aria.search.placeholder')} + searchInputProps={{ + value: filterValue, + onClear: () => { + setFilterValue(''); + }, + }} + noResultsState={ + filterValue && + Object.keys(filterConversations(filterValue)).length === 0 + ? { + bodyText: t('chatbox.emptyState.noResults.body'), + titleText: t('chatbox.emptyState.noResults.title'), + icon: SearchIcon, + } + : undefined + } + drawerContent={ + handleAttach(data, e)} + displayMode={ChatbotDisplayMode.embedded} + infoText={t('chatbox.fileUpload.infoText')} + allowedFileTypes={supportedFileTypes} + onAttachRejected={onAttachRejected} + > + {showAlert && uploadError.message && ( +
+ setUploadError({ message: null })} + > + {uploadError.message} + +
+ )} + + + + + + + + + +
+ } + /> +
+ ); + + const getChatDisplay = () => { + if (displayMode === ChatbotDisplayMode.docked) { + return ( + + {chatbot} + + ); + } + if (displayMode === ChatbotDisplayMode.default) { + return ( + {}} + ouiaId="LightspeedChatbotModal" + aria-labelledby="lightspeed-chatpopup-modal" + > + {chatbot} + + ); + } + + return chatbot; + }; + return ( <> {isDeleteModalOpen && ( @@ -502,144 +746,7 @@ export const LightspeedChat = ({ conversationId={targetConversationId} /> )} - - - - setIsDrawerOpen(!isDrawerOpen)} - className={classes.headerMenu} - tooltipContent={t('tooltip.chatHistoryMenu')} - aria-label={t('aria.chatHistoryMenu')} - /> - - - {t('chatbox.header.title')} - - - - - handleSelectedModel(item)} - models={models} - isPinningChatsEnabled={isPinningChatsEnabled} - onPinnedChatsToggle={setIsPinningChatsEnabled} - /> - - - , - }} - handleTextInputChange={handleFilter} - searchInputPlaceholder={t('chatbox.search.placeholder')} - searchInputAriaLabel={t('aria.search.placeholder')} - searchInputProps={{ - value: filterValue, - onClear: () => { - setFilterValue(''); - }, - }} - noResultsState={ - filterValue && - Object.keys(filterConversations(filterValue)).length === 0 - ? { - bodyText: t('chatbox.emptyState.noResults.body'), - titleText: t('chatbox.emptyState.noResults.title'), - icon: SearchIcon, - } - : undefined - } - drawerContent={ - handleAttach(data, e)} - displayMode={ChatbotDisplayMode.embedded} - infoText={t('chatbox.fileUpload.infoText')} - allowedFileTypes={supportedFileTypes} - onAttachRejected={onAttachRejected} - > - {showAlert && uploadError.message && ( -
- setUploadError({ message: null })} - > - {uploadError.message} - -
- )} - - - - - - - - - -
- } - /> -
+ {getChatDisplay()} ); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBox.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBox.tsx index 9f090ca1fc..760fa3d46b 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,11 +153,12 @@ export const LightspeedChatBox = forwardRef( }, [autoScroll, cmessages, containerRef]); const messageBoxClasses = `${classes.container} ${classes.userMessageText}`; + const isEmbeddedMode = displayMode === ChatbotDisplayMode.embedded; 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..fe55189fbb --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx @@ -0,0 +1,209 @@ +/* + * 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, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; + +import { LightspeedChatContainer } from './LightspeedChatContainer'; +import { LightspeedDrawerContext } from './LightspeedDrawerContext'; + +/** + * @public + */ +export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { + const navigate = useNavigate(); + const location = useLocation(); + + const [displayMode, setDisplayModeState] = useState( + ChatbotDisplayMode.default, + ); + const [isOpen, setIsOpen] = useState(false); + const [drawerWidth, setDrawerWidth] = useState(400); + const [userKey, setUserKey] = useState('guest'); + const [currentConversationId, setCurrentConversationIdState] = useState< + string | undefined + >(undefined); + + const identityApi = useApi(identityApiRef); + + const isLightspeedRoute = location.pathname.startsWith('/lightspeed'); + + // Resolve the current user's identity to scope localStorage keys per user + useEffect(() => { + let cancelled = false; + (async () => { + try { + const identity = await identityApi.getBackstageIdentity(); + const ref = identity?.userEntityRef?.toLowerCase() || 'guest'; + if (!cancelled) setUserKey(ref); + } catch (e) { + if (!cancelled) setUserKey('guest'); + } + })(); + return () => { + cancelled = true; + }; + }, [identityApi]); + + useEffect(() => { + if (isLightspeedRoute) { + const match = location.pathname.match(/\/lightspeed\/conversation\/(.+)/); + if (match) { + setCurrentConversationIdState(match[1]); + } else { + setCurrentConversationIdState(undefined); + } + setDisplayModeState(ChatbotDisplayMode.embedded); + setIsOpen(true); + } + }, [isLightspeedRoute, location.pathname]); + + // Load drawer width from localStorage on mount + useEffect(() => { + if (!userKey) return; + + const drawerWidthKey = `lightspeed-drawer-width:${userKey}`; + const savedDrawerWidth = localStorage.getItem(drawerWidthKey); + + if (savedDrawerWidth) { + const width = parseInt(savedDrawerWidth, 10); + if (!Number.isNaN(width) && width > 0) { + setDrawerWidth(width); + } + } + }, [userKey]); + + // Save drawer width to localStorage + useEffect(() => { + if (!userKey) return; + + const drawerWidthKey = `lightspeed-drawer-width:${userKey}`; + localStorage.setItem(drawerWidthKey, drawerWidth.toString()); + }, [drawerWidth, userKey]); + + // Set CSS variables for drawer width when drawer is open in docked mode + useEffect(() => { + if (isOpen && displayMode === ChatbotDisplayMode.docked) { + document.body.classList.add('lightspeed-drawer-open'); + document.body.style.setProperty( + '--lightspeed-drawer-width', + `${drawerWidth}px`, + ); + } else { + document.body.classList.remove('lightspeed-drawer-open'); + document.body.style.removeProperty('--lightspeed-drawer-width'); + } + + return () => { + document.body.classList.remove('lightspeed-drawer-open'); + document.body.style.removeProperty('--lightspeed-drawer-width'); + }; + }, [isOpen, drawerWidth, displayMode]); + + // Open chatbot in overlay mode (no route change) + const openChatbot = useCallback(() => { + setDisplayModeState(ChatbotDisplayMode.default); + setIsOpen(true); + }, []); + + // Close chatbot + const closeChatbot = useCallback(() => { + // If in embedded mode on the lightspeed route, navigate back + if (displayMode === ChatbotDisplayMode.embedded && isLightspeedRoute) { + navigate(-1); + } + setIsOpen(false); + setDisplayModeState(ChatbotDisplayMode.default); + }, [displayMode, 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 (displayMode === ChatbotDisplayMode.embedded && isLightspeedRoute) { + const path = id ? `/lightspeed/conversation/${id}` : '/lightspeed'; + navigate(path, { replace: true }); + } + }, + [displayMode, 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 ?? currentConversationId; + const path = convId + ? `/lightspeed/conversation/${convId}` + : '/lightspeed'; + navigate(path); + setIsOpen(true); + } else if (mode === ChatbotDisplayMode.docked) { + // If we were on the lightspeed route, navigate back + if (isLightspeedRoute) { + navigate(-1); + } + setIsOpen(true); + } else { + // Default Overlay mode + // If we were on the lightspeed route, navigate back + if (isLightspeedRoute) { + navigate(-1); + } + setIsOpen(true); + } + }, + [navigate, isLightspeedRoute, currentConversationId], + ); + + // Only render for overlay and docked modes (embedded is handled by the route) + const shouldRenderChat = + isOpen && displayMode !== ChatbotDisplayMode.embedded && !isLightspeedRoute; + + return ( + + {children} + {shouldRenderChat && } + + ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx index 0af2b2abb5..004270c8f4 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,22 @@ 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/ResizableDrawer.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/ResizableDrawer.tsx new file mode 100644 index 0000000000..ba0704cff8 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/ResizableDrawer.tsx @@ -0,0 +1,152 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; + +import Box from '@mui/material/Box'; +import Drawer from '@mui/material/Drawer'; +import { styled } from '@mui/material/styles'; + +import { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme'; + +const Handle = styled('div')(({ theme }) => ({ + width: 6, + cursor: 'col-resize', + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + zIndex: 1201, + backgroundColor: theme.palette.divider, +})); + +type ResizableDrawerProps = { + children: React.ReactNode; + minWidth?: number; + maxWidth?: number; + initialWidth?: number; + isDrawerOpen: boolean; + drawerWidth?: number; + onWidthChange?: (width: number) => void; + isResizable?: boolean; + [key: string]: any; +}; + +export const ResizableDrawer = (props: ResizableDrawerProps) => { + const { + children, + minWidth = 400, + maxWidth = 800, + initialWidth = 400, + isDrawerOpen, + drawerWidth: externalDrawerWidth, + onWidthChange, + ...drawerProps + } = props; + + // Ensure width is never below minWidth + const clampedInitialWidth = Math.max( + externalDrawerWidth || initialWidth, + minWidth, + ); + const [width, setWidth] = useState(clampedInitialWidth); + const resizingRef = useRef(false); + + // Sync with external drawerWidth if provided, ensuring it's not below minWidth + useEffect(() => { + if (externalDrawerWidth !== undefined) { + const clampedWidth = Math.max(externalDrawerWidth, minWidth); + if (clampedWidth !== width) { + setWidth(clampedWidth); + // If the external width was below min, update the parent + if (externalDrawerWidth < minWidth && onWidthChange) { + onWidthChange(clampedWidth); + } + } + } + }, [externalDrawerWidth, width, minWidth, onWidthChange]); + + const onMouseDown = () => { + resizingRef.current = true; + }; + + const onMouseMove = useCallback( + (e: MouseEvent) => { + if (!resizingRef.current) return; + // For right-anchored drawer, calculate width from the right edge + const newWidth = window.innerWidth - e.clientX; + + if (newWidth >= minWidth && newWidth <= maxWidth) { + setWidth(newWidth); + if (onWidthChange) { + onWidthChange(newWidth); + } + } + }, + [maxWidth, minWidth, onWidthChange], + ); + + const onMouseUp = () => { + resizingRef.current = false; + }; + + useEffect(() => { + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + }, [onMouseMove]); + + // 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/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..f2f3b68c6d 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/index.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/index.ts @@ -14,5 +14,12 @@ * 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 { useLightspeedDrawerContext } from './hooks/useLightspeedDrawerContext'; +export { lightspeedApiRef } from './api/api'; +export { LightspeedApiClient } from './api/LightspeedApiClient'; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts b/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts index 0f96071688..7b5b2df48d 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,21 @@ 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, + ), + }, + }), + ); 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/quickstart/packages/app/package.json b/workspaces/quickstart/packages/app/package.json index f3584c70e6..0f1d93bdc4 100644 --- a/workspaces/quickstart/packages/app/package.json +++ b/workspaces/quickstart/packages/app/package.json @@ -49,8 +49,10 @@ "@material-ui/icons": "^4.9.1", "@mui/icons-material": "5.18.0", "@mui/material": "5.18.0", + "@red-hat-developer-hub/backstage-plugin-application-drawer": "workspace:^", "@red-hat-developer-hub/backstage-plugin-global-header": "^1.17.1", "@red-hat-developer-hub/backstage-plugin-quickstart": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-test-drawer": "workspace:^", "@red-hat-developer-hub/backstage-plugin-theme": "^0.11.0", "react": "^18.0.2", "react-dom": "^18.0.2", 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..91ef3aa54f --- /dev/null +++ b/workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx @@ -0,0 +1,176 @@ +/* + * 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 { ResizableDrawer } from './ResizableDrawer'; + +/** + * 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/ResizableDrawer.tsx b/workspaces/quickstart/packages/app/src/components/Root/ResizableDrawer.tsx new file mode 100644 index 0000000000..e67e837a44 --- /dev/null +++ b/workspaces/quickstart/packages/app/src/components/Root/ResizableDrawer.tsx @@ -0,0 +1,157 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; + +import Box from '@mui/material/Box'; +import Drawer from '@mui/material/Drawer'; +import { styled } from '@mui/material/styles'; + +import { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme'; + +const Handle = styled('div')(({ theme }) => ({ + width: 6, + cursor: 'col-resize', + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + zIndex: 1201, + backgroundColor: theme.palette.divider, +})); + +export type ResizableDrawerProps = { + children: React.ReactNode; + minWidth?: number; + maxWidth?: number; + initialWidth?: number; + isDrawerOpen: boolean; + drawerWidth?: number; + onWidthChange?: (width: number) => void; + isResizable?: boolean; + [key: string]: any; +}; + +export const ResizableDrawer = (props: ResizableDrawerProps) => { + const { + children, + minWidth = 400, + maxWidth = 800, + initialWidth = 400, + isDrawerOpen, + isResizable = false, + drawerWidth: externalDrawerWidth, + onWidthChange, + ...drawerProps + } = props; + + // Ensure width is never below minWidth + const clampedInitialWidth = Math.max( + externalDrawerWidth || initialWidth, + minWidth, + ); + + const [width, setWidth] = useState(clampedInitialWidth); + const resizingRef = useRef(false); + + // Sync with external drawerWidth when it changes + useEffect(() => { + if (externalDrawerWidth !== undefined) { + const clampedWidth = Math.max(externalDrawerWidth, minWidth); + if (clampedWidth !== width) { + setWidth(clampedWidth); + // If the external width was below min, update the parent + if (externalDrawerWidth < minWidth && onWidthChange && isResizable) { + onWidthChange(clampedWidth); + } + } + } + }, [externalDrawerWidth, width, minWidth, onWidthChange, isResizable]); + + const onMouseDown = () => { + resizingRef.current = true; + }; + + const onMouseMove = useCallback( + (e: MouseEvent) => { + if (!resizingRef.current) return; + // For right-anchored drawer, calculate width from the right edge + const newWidth = window.innerWidth - e.clientX; + + if (newWidth >= minWidth && newWidth <= maxWidth) { + setWidth(newWidth); + if (onWidthChange) { + onWidthChange(newWidth); + } + } + }, + [maxWidth, minWidth, onWidthChange], + ); + + const onMouseUp = () => { + resizingRef.current = false; + }; + + useEffect(() => { + if (isResizable) { + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + } + return () => {}; + }, [onMouseMove, isResizable]); + + // 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} + {isResizable && } + + + ); +}; \ No newline at end of file diff --git a/workspaces/quickstart/packages/app/src/components/Root/Root.tsx b/workspaces/quickstart/packages/app/src/components/Root/Root.tsx index 9731ec6cc9..6161d0d420 100644 --- a/workspaces/quickstart/packages/app/src/components/Root/Root.tsx +++ b/workspaces/quickstart/packages/app/src/components/Root/Root.tsx @@ -47,9 +47,20 @@ 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 { + TestDrawerContent, + TestDrawerProvider, + TestDrawerStateExposer, +} from '@red-hat-developer-hub/backstage-plugin-test-drawer'; +import { ApplicationDrawer } from './ApplicationDrawer'; +import { TestDrawerSidebarItem } from './TestDrawerSidebarItem'; const useSidebarLogoStyles = makeStyles({ root: { @@ -88,9 +99,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', }, }, @@ -101,46 +112,71 @@ export const Root = ({ children }: PropsWithChildren<{}>) => { globalHeaderMountPoints={defaultGlobalHeaderComponentsMountPoints} /> - - - } to="/search"> - - - - }> - {/* Global nav, not org-specific */} - - - - - - {/* End global nav */} + + + + } to="/search"> + + + + }> + {/* Global nav, not org-specific */} + + + + + + {/* End global nav */} + + + + {/* Items in this group will be scrollable if they run out of space */} + + + + + - - - {/* Items in this group will be scrollable if they run out of space */} - - - - - - } - to="/settings" - > - - - - {children} + } + to="/settings" + > + + + + {children} + + diff --git a/workspaces/quickstart/packages/app/src/components/Root/TestDrawerSidebarItem.tsx b/workspaces/quickstart/packages/app/src/components/Root/TestDrawerSidebarItem.tsx new file mode 100644 index 0000000000..0b47afcd90 --- /dev/null +++ b/workspaces/quickstart/packages/app/src/components/Root/TestDrawerSidebarItem.tsx @@ -0,0 +1,26 @@ +/* + * 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 { SidebarItem, StarIcon } from '@backstage/core-components'; +import { useTestDrawerContext } from '@red-hat-developer-hub/backstage-plugin-test-drawer'; + +export const TestDrawerSidebarItem = () => { + const { toggleDrawer } = useTestDrawerContext(); + + return ( + + ); +}; 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/package.json b/workspaces/quickstart/plugins/quickstart/package.json index aef5ae8a05..fbc3c13b55 100644 --- a/workspaces/quickstart/plugins/quickstart/package.json +++ b/workspaces/quickstart/plugins/quickstart/package.json @@ -37,6 +37,7 @@ "@backstage/theme": "^0.7.0", "@mui/icons-material": "5.18.0", "@mui/material": "5.18.0", + "@red-hat-developer-hub/backstage-plugin-application-drawer": "^0.1.0", "@red-hat-developer-hub/backstage-plugin-theme": "^0.11.0", "react-use": "^17.6.0" }, 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/QuickstartDrawerContext.tsx b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContext.tsx index c89741296b..2f9a360c4f 100644 --- a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContext.tsx +++ b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContext.tsx @@ -23,6 +23,7 @@ import { UserRole } from '../types'; * @public */ export interface QuickstartDrawerContextType { + id: string; /** * The prop to check if the drawer is open */ diff --git a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx index 1db449e068..825a023bf0 100644 --- a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx +++ b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx @@ -24,11 +24,12 @@ 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'; +const DRAWER_ID = 'quickstart'; + /** * Provider component for the Quickstart Drawer functionality * @public @@ -45,25 +46,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; @@ -190,6 +172,7 @@ export const QuickstartDrawerProvider = ({ children }: PropsWithChildren) => { return ( { }} > {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 { id, isDrawerOpen, drawerWidth, setDrawerWidth } = + useQuickstartDrawerContext(); + + useEffect(() => { + onStateChange({ + id, + isDrawerOpen, + drawerWidth, + setDrawerWidth, + }); + }, [id, 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..55413c0272 100644 --- a/workspaces/quickstart/plugins/quickstart/src/index.ts +++ b/workspaces/quickstart/plugins/quickstart/src/index.ts @@ -26,6 +26,7 @@ export * from './plugin'; export { useQuickstartDrawerContext } from './hooks/useQuickstartDrawerContext'; export type { QuickstartDrawerContextType } from './components/QuickstartDrawerContext'; +export { QuickstartDrawerStateExposer } 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, + ), + }, + }), +); diff --git a/workspaces/quickstart/plugins/test-drawer/README.md b/workspaces/quickstart/plugins/test-drawer/README.md new file mode 100644 index 0000000000..9d5d314af9 --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/README.md @@ -0,0 +1,79 @@ +# Test Drawer Plugin + +A test drawer plugin for Backstage that demonstrates how to create drawer components with context-based state management. + +## Getting Started + +This plugin can be accessed by running `yarn start` from this directory, and then navigating to [/test-drawer](http://localhost:3000/test-drawer). + +## Components + +### TestDrawerProvider + +Provider component that wraps your application and provides drawer context and the MUI Drawer component. + +```tsx +import { TestDrawerProvider } from '@red-hat-developer-hub/backstage-plugin-test-drawer'; + +export const App = () => ( + + {/* Your app content */} + +); +``` + +### TestDrawerContent + +The content component that renders inside the MUI Drawer. It includes a header with close button, main content area, and footer. + +### TestDrawerButton + +A button component that can be placed anywhere to toggle the drawer. + +```tsx +import { TestDrawerButton } from '@red-hat-developer-hub/backstage-plugin-test-drawer'; + +// Use in header or toolbar + +``` + +### useTestDrawerContext + +Hook to access the drawer context from any component within the provider. + +```tsx +import { useTestDrawerContext } from '@red-hat-developer-hub/backstage-plugin-test-drawer'; + +const MyComponent = () => { + const { isDrawerOpen, openDrawer, closeDrawer, toggleDrawer, drawerWidth, setDrawerWidth } = useTestDrawerContext(); + + return ( + + ); +}; +``` + +## Context API + +The `TestDrawerContextType` provides: + +| Property | Type | Description | +|----------|------|-------------| +| `isDrawerOpen` | `boolean` | Whether the drawer is currently open | +| `openDrawer` | `() => void` | Function to open the drawer | +| `closeDrawer` | `() => void` | Function to close the drawer | +| `toggleDrawer` | `() => void` | Function to toggle the drawer state | +| `drawerWidth` | `number` | Current drawer width in pixels | +| `setDrawerWidth` | `Dispatch>` | Function to set the drawer width | + +## CSS Variables + +When the drawer is open, the following CSS class and variable are set on `document.body`: + +- Class: `test-drawer-open` +- Variable: `--test-drawer-width` (e.g., `400px`) + +This allows you to adjust other UI elements when the drawer is open. + diff --git a/workspaces/quickstart/plugins/test-drawer/dev/index.tsx b/workspaces/quickstart/plugins/test-drawer/dev/index.tsx new file mode 100644 index 0000000000..2bcf8010de --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/dev/index.tsx @@ -0,0 +1,84 @@ +/* + * 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 { createDevApp } from '@backstage/dev-utils'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import { + TestDrawerContent, + testDrawerPlugin, + TestDrawerProvider, + useTestDrawerContext, +} from '../src'; +import { DrawerComponent } from '../src/components'; + +const TestPage = () => { + const { toggleDrawer, isDrawerOpen, drawerWidth } = useTestDrawerContext(); + + return ( + + + Test Drawer Plugin + + + + This page demonstrates the Test Drawer plugin functionality. + + + + + + + + Drawer State: + + Is Open: {isDrawerOpen ? 'Yes' : 'No'} + + + Width: {drawerWidth}px + + + + ); +}; + +const DevPage = () => ( + + + + + + +); + +createDevApp() + .registerPlugin(testDrawerPlugin) + .addPage({ + element: , + title: 'Test Drawer', + path: '/test-drawer', + }) + .render(); diff --git a/workspaces/quickstart/plugins/test-drawer/package.json b/workspaces/quickstart/plugins/test-drawer/package.json new file mode 100644 index 0000000000..caefcf7edc --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/package.json @@ -0,0 +1,59 @@ +{ + "name": "@red-hat-developer-hub/backstage-plugin-test-drawer", + "version": "0.1.0", + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/redhat-developer/rhdh-plugins", + "directory": "workspaces/quickstart/plugins/test-drawer" + }, + "backstage": { + "role": "frontend-plugin", + "pluginId": "test-drawer", + "pluginPackages": [ + "@red-hat-developer-hub/backstage-plugin-test-drawer" + ] + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-components": "^0.18.3", + "@backstage/core-plugin-api": "^1.12.0", + "@backstage/theme": "^0.7.0", + "@mui/icons-material": "5.18.0", + "@mui/material": "5.18.0", + "@red-hat-developer-hub/backstage-plugin-application-drawer": "^0.1.0", + "@red-hat-developer-hub/backstage-plugin-theme": "^0.11.0" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.34.5", + "@backstage/dev-utils": "^1.1.17", + "@backstage/test-utils": "^1.7.13", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + }, + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "files": [ + "dist" + ] +} diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/DrawerComponent.tsx b/workspaces/quickstart/plugins/test-drawer/src/components/DrawerComponent.tsx new file mode 100644 index 0000000000..43620f28a0 --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/src/components/DrawerComponent.tsx @@ -0,0 +1,34 @@ +/* + * 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 { useTestDrawerContext } from './TestDrawerContext'; +import { ResizableDrawer } from '../../../../packages/app/src/components/Root/ResizableDrawer'; + +export const DrawerComponent = ({ children }: PropsWithChildren) => { + const { isDrawerOpen, drawerWidth, setDrawerWidth } = useTestDrawerContext(); + + return ( + + {children} + + ); +}; diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerButton.tsx b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerButton.tsx new file mode 100644 index 0000000000..2fd840402b --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerButton.tsx @@ -0,0 +1,78 @@ +/* + * 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 MenuItem from '@mui/material/MenuItem'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useTheme } from '@mui/material/styles'; +import { useTestDrawerContext } from './TestDrawerContext'; +import { useCallback } from 'react'; + +/** + * Button component to toggle the Test Drawer + * + * Can be used in the global header help dropdown + * + * @public + */ +export const TestDrawerButton = ({ + onClick = () => {}, +}: { + onClick: () => void; +}) => { + const { toggleDrawer } = useTestDrawerContext(); + const theme = useTheme(); + + const handleClick = useCallback(() => { + toggleDrawer(); + onClick(); + }, [toggleDrawer, onClick]); + + return ( + + + + + + Test Drawer + + + + + + ); +}; diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContent.tsx b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContent.tsx new file mode 100644 index 0000000000..f0a8fabacb --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContent.tsx @@ -0,0 +1,121 @@ +/* + * 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 Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import CloseIcon from '@mui/icons-material/Close'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { useTestDrawerContext } from './TestDrawerContext'; + +/** + * Content to be rendered inside the Test Drawer + * + * @public + */ +export const TestDrawerContent = () => { + const { toggleDrawer } = useTestDrawerContext(); + + return ( + + + + Test Drawer + + + + + + + {/* Content */} + + + This is a test drawer component that demonstrates how drawer content + can be structured and rendered inside an MUI Drawer. + + + + Features: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Test Drawer Plugin v0.1.0 + + + + ); +}; + diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContext.tsx b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContext.tsx new file mode 100644 index 0000000000..46e8406500 --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContext.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, useContext } from 'react'; + +/** + * Type for TestDrawerContext + * + * @public + */ +export interface TestDrawerContextType { + id: string; + /** + * Whether the drawer is open + */ + isDrawerOpen: boolean; + /** + * Function to toggle the drawer state + */ + toggleDrawer: () => void; + /** + * Current drawer width in pixels + */ + drawerWidth: number; + /** + * Function to set the drawer width + */ + setDrawerWidth: React.Dispatch>; +} + +/** + * Context for the Test Drawer + * + * @public + */ +export const TestDrawerContext = createContext< + TestDrawerContextType | undefined +>(undefined); + +/** + * Hook to access the TestDrawerContext + * + * @public + */ +export const useTestDrawerContext = (): TestDrawerContextType => { + const context = useContext(TestDrawerContext); + if (!context) { + throw new Error( + 'useTestDrawerContext must be used within a TestDrawerProvider', + ); + } + return context; +}; + diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerProvider.tsx b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerProvider.tsx new file mode 100644 index 0000000000..2b8352c66a --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerProvider.tsx @@ -0,0 +1,60 @@ +/* + * 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, useState, useCallback } from 'react'; +import { TestDrawerContext } from './TestDrawerContext'; + +const DRAWER_ID = 'test-drawer'; +const DEFAULT_DRAWER_WIDTH = 400; +const MIN_DRAWER_WIDTH = 300; +const MAX_DRAWER_WIDTH = 800; + +/** + * Provider component for the Test Drawer functionality + * + * @public + */ +export const TestDrawerProvider = ({ children }: PropsWithChildren) => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [drawerWidth, setDrawerWidth] = useState(DEFAULT_DRAWER_WIDTH); + + const toggleDrawer = useCallback(() => { + setIsDrawerOpen(prev => !prev); + }, []); + + // Constrain drawer width to min/max bounds + const handleSetDrawerWidth: React.Dispatch> = + useCallback(value => { + setDrawerWidth(prev => { + const newWidth = typeof value === 'function' ? value(prev) : value; + return Math.min(MAX_DRAWER_WIDTH, Math.max(MIN_DRAWER_WIDTH, newWidth)); + }); + }, []); + + return ( + + {children} + + ); +}; diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerStateExposer.tsx b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerStateExposer.tsx new file mode 100644 index 0000000000..f25cdca0e9 --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerStateExposer.tsx @@ -0,0 +1,68 @@ +/* + * 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 { useTestDrawerContext } from './TestDrawerContext'; + +/** + * Partial Test drawer state exposed to the ApplicationDrawer + * + * @public + */ +export type DrawerState = { + id: string; + isDrawerOpen: boolean; + drawerWidth: number; + setDrawerWidth: (width: number) => void; +}; + +/** + * Props for drawer state exposer components + * + * @public + */ +export type DrawerStateExposerProps = { + /** + * Callback called whenever the drawer state changes + */ + onStateChange: (state: DrawerState) => void; +}; + +/** + * This exposes TestDrawer's partial context to the ApplicationDrawer + * + * It reads the TestDrawerContext and calls the onStateChange callback with the + * partial state (id, isDrawerOpen, drawerWidth, setDrawerWidth). + * + * @public + */ +export const TestDrawerStateExposer = ({ + onStateChange, +}: DrawerStateExposerProps) => { + const { id, isDrawerOpen, drawerWidth, setDrawerWidth } = + useTestDrawerContext(); + + useEffect(() => { + onStateChange({ + id, + isDrawerOpen, + drawerWidth, + setDrawerWidth, + }); + }, [id, isDrawerOpen, drawerWidth, onStateChange, setDrawerWidth]); + + return null; +}; diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/index.ts b/workspaces/quickstart/plugins/test-drawer/src/components/index.ts new file mode 100644 index 0000000000..bfda8c19fd --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/src/components/index.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +export { TestDrawerContext, useTestDrawerContext } from './TestDrawerContext'; +export type { TestDrawerContextType } from './TestDrawerContext'; +export { TestDrawerContent } from './TestDrawerContent'; +export { TestDrawerProvider } from './TestDrawerProvider'; +export { TestDrawerButton } from './TestDrawerButton'; +export { DrawerComponent } from './DrawerComponent'; +export { TestDrawerStateExposer } from './TestDrawerStateExposer'; diff --git a/workspaces/quickstart/plugins/test-drawer/src/index.ts b/workspaces/quickstart/plugins/test-drawer/src/index.ts new file mode 100644 index 0000000000..eec03a32e4 --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/src/index.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +export { + testDrawerPlugin, + TestDrawerProvider, + TestDrawerContent, + TestDrawerButton, +} from './plugin'; + +export { + TestDrawerContext, + useTestDrawerContext, + TestDrawerStateExposer, +} from './components'; +export type { TestDrawerContextType } from './components'; diff --git a/workspaces/quickstart/plugins/test-drawer/src/plugin.ts b/workspaces/quickstart/plugins/test-drawer/src/plugin.ts new file mode 100644 index 0000000000..6dd233a959 --- /dev/null +++ b/workspaces/quickstart/plugins/test-drawer/src/plugin.ts @@ -0,0 +1,93 @@ +/* + * 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 { + createPlugin, + createComponentExtension, +} from '@backstage/core-plugin-api'; + +/** + * Test Drawer Plugin + * + * @public + */ +export const testDrawerPlugin = createPlugin({ + id: 'test-drawer', +}); + +/** + * Test Drawer Provider component extension + * + * @public + */ +export const TestDrawerProvider = testDrawerPlugin.provide( + createComponentExtension({ + name: 'TestDrawerProvider', + component: { + lazy: () => + import('./components/TestDrawerProvider').then( + m => m.TestDrawerProvider, + ), + }, + }), +); + +/** + * Test Drawer Content component extension + * + * @public + */ +export const TestDrawerContent = testDrawerPlugin.provide( + createComponentExtension({ + name: 'TestDrawerContent', + component: { + lazy: () => + import('./components/TestDrawerContent').then(m => m.TestDrawerContent), + }, + }), +); + +/** + * Test Drawer Button component extension + * + * @public + */ +export const TestDrawerButton = testDrawerPlugin.provide( + createComponentExtension({ + name: 'TestDrawerButton', + component: { + lazy: () => + import('./components/TestDrawerButton').then(m => m.TestDrawerButton), + }, + }), +); + +/** + * Test Drawer State Exposer exposes its drawer state + * + * @public + */ +export const TestDrawerStateExposer = testDrawerPlugin.provide( + createComponentExtension({ + name: 'TestDrawerStateExposer', + component: { + lazy: () => + import('./components/TestDrawerStateExposer').then( + m => m.TestDrawerStateExposer, + ), + }, + }), +); diff --git a/workspaces/quickstart/yarn.lock b/workspaces/quickstart/yarn.lock index 7cc04947e2..bc220e164f 100644 --- a/workspaces/quickstart/yarn.lock +++ b/workspaces/quickstart/yarn.lock @@ -10996,6 +10996,27 @@ __metadata: languageName: node linkType: hard +"@red-hat-developer-hub/backstage-plugin-application-drawer@^0.1.0, @red-hat-developer-hub/backstage-plugin-application-drawer@workspace:^, @red-hat-developer-hub/backstage-plugin-application-drawer@workspace:plugins/application-drawer": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-application-drawer@workspace:plugins/application-drawer" + dependencies: + "@backstage/cli": ^0.34.5 + "@backstage/core-components": ^0.18.3 + "@backstage/core-plugin-api": ^1.12.0 + "@backstage/dev-utils": ^1.1.17 + "@backstage/test-utils": ^1.7.13 + "@backstage/theme": ^0.7.0 + "@mui/icons-material": 5.18.0 + "@mui/material": 5.18.0 + "@red-hat-developer-hub/backstage-plugin-theme": ^0.11.0 + "@testing-library/jest-dom": ^6.0.0 + "@testing-library/react": ^14.0.0 + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-global-header@npm:^1.17.1": version: 1.17.1 resolution: "@red-hat-developer-hub/backstage-plugin-global-header@npm:1.17.1" @@ -11044,6 +11065,7 @@ __metadata: "@backstage/theme": ^0.7.0 "@mui/icons-material": 5.18.0 "@mui/material": 5.18.0 + "@red-hat-developer-hub/backstage-plugin-application-drawer": ^0.1.0 "@red-hat-developer-hub/backstage-plugin-theme": ^0.11.0 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 @@ -11056,6 +11078,28 @@ __metadata: languageName: unknown linkType: soft +"@red-hat-developer-hub/backstage-plugin-test-drawer@workspace:^, @red-hat-developer-hub/backstage-plugin-test-drawer@workspace:plugins/test-drawer": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-test-drawer@workspace:plugins/test-drawer" + dependencies: + "@backstage/cli": ^0.34.5 + "@backstage/core-components": ^0.18.3 + "@backstage/core-plugin-api": ^1.12.0 + "@backstage/dev-utils": ^1.1.17 + "@backstage/test-utils": ^1.7.13 + "@backstage/theme": ^0.7.0 + "@mui/icons-material": 5.18.0 + "@mui/material": 5.18.0 + "@red-hat-developer-hub/backstage-plugin-application-drawer": ^0.1.0 + "@red-hat-developer-hub/backstage-plugin-theme": ^0.11.0 + "@testing-library/jest-dom": ^6.0.0 + "@testing-library/react": ^14.0.0 + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + languageName: unknown + linkType: soft + "@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" @@ -15318,8 +15362,10 @@ __metadata: "@mui/icons-material": 5.18.0 "@mui/material": 5.18.0 "@playwright/test": 1.57.0 + "@red-hat-developer-hub/backstage-plugin-application-drawer": "workspace:^" "@red-hat-developer-hub/backstage-plugin-global-header": ^1.17.1 "@red-hat-developer-hub/backstage-plugin-quickstart": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-test-drawer": "workspace:^" "@red-hat-developer-hub/backstage-plugin-theme": ^0.11.0 "@testing-library/dom": ^9.0.0 "@testing-library/jest-dom": ^6.0.0 From 8d944ef7a853345c9091daba5be6dacb833990c6 Mon Sep 17 00:00:00 2001 From: Debsmita Santra Date: Tue, 23 Dec 2025 23:07:15 +0530 Subject: [PATCH 2/2] feat(lightspeed): add chotbot display modes --- .../.changeset/giant-pears-exercise.md | 5 + .../.changeset/grumpy-jokes-juggle.md | 5 + .../lightspeed/packages/app/package.json | 1 + .../lightspeed/packages/app/src/App.tsx | 6 +- .../src/components/Root/ApplicationDrawer.tsx | 177 +++++++ .../app/src/components/Root/CustomDrawer.tsx | 81 +++ .../packages/app/src/components/Root/Root.tsx | 137 ++--- .../plugins/lightspeed/dev/index.tsx | 71 ++- .../plugins/lightspeed/package.json | 4 +- .../plugins/lightspeed/report-alpha.api.md | 6 + .../plugins/lightspeed/report.api.md | 193 +++++++ .../lightspeed/src/api/LightspeedApiClient.ts | 10 + .../api/__tests__/LightspeedApiClient.test.ts | 476 ++++++++++++++++++ .../plugins/lightspeed/src/api/api.ts | 10 + .../src/components/DrawerComponent.tsx | 19 +- .../src/components/LightSpeedChat.tsx | 341 ++++++------- .../src/components/LightspeedChatBox.tsx | 18 +- .../components/LightspeedDrawerProvider.tsx | 178 +++---- .../LightspeedDrawerStateExposer.tsx} | 33 +- .../src/components/LightspeedFAB.tsx | 79 +++ .../src/components/LightspeedIcon.tsx | 1 + .../src/components/ResizableDrawer.tsx | 152 ------ .../__tests__/LightspeedChat.test.tsx | 194 +++++++ .../LightspeedDrawerStateExposer.test.tsx | 219 ++++++++ .../__tests__/LightspeedFAB.test.tsx | 173 +++++++ .../__tests__/LightspeedIcon.test.tsx | 50 ++ .../useLightspeedDrawerContext.test.tsx | 220 ++++++++ .../plugins/lightspeed/src/index.ts | 22 + .../plugins/lightspeed/src/plugin.ts | 49 ++ .../plugins/lightspeed/src/types.ts | 43 ++ workspaces/lightspeed/yarn.lock | 356 ++++++++++--- .../.changeset/fancy-symbols-ask.md | 5 + .../quickstart/packages/app/package.json | 2 - .../src/components/Root/ApplicationDrawer.tsx | 12 +- .../app/src/components/Root/CustomDrawer.tsx | 79 +++ .../src/components/Root/ResizableDrawer.tsx | 157 ------ .../packages/app/src/components/Root/Root.tsx | 119 ++--- .../components/Root/TestDrawerSidebarItem.tsx | 26 - .../plugins/quickstart/package.json | 1 - .../plugins/quickstart/report.api.md | 21 + .../components/QuickstartDrawerContext.tsx | 1 - .../components/QuickstartDrawerProvider.tsx | 3 - .../QuickstartDrawerStateExposer.tsx | 6 +- .../plugins/quickstart/src/index.ts | 4 + .../quickstart/plugins/test-drawer/README.md | 79 --- .../plugins/test-drawer/dev/index.tsx | 84 ---- .../plugins/test-drawer/package.json | 59 --- .../src/components/TestDrawerButton.tsx | 78 --- .../src/components/TestDrawerContent.tsx | 121 ----- .../src/components/TestDrawerContext.tsx | 67 --- .../src/components/TestDrawerProvider.tsx | 60 --- .../test-drawer/src/components/index.ts | 23 - .../plugins/test-drawer/src/index.ts | 29 -- .../plugins/test-drawer/src/plugin.ts | 93 ---- workspaces/quickstart/yarn.lock | 46 -- 55 files changed, 2888 insertions(+), 1616 deletions(-) create mode 100644 workspaces/global-floating-action-button/.changeset/giant-pears-exercise.md create mode 100644 workspaces/lightspeed/.changeset/grumpy-jokes-juggle.md create mode 100644 workspaces/lightspeed/packages/app/src/components/Root/ApplicationDrawer.tsx create mode 100644 workspaces/lightspeed/packages/app/src/components/Root/CustomDrawer.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/api/__tests__/LightspeedApiClient.test.ts rename workspaces/{quickstart/plugins/test-drawer => lightspeed/plugins/lightspeed}/src/components/DrawerComponent.tsx (62%) rename workspaces/{quickstart/plugins/test-drawer/src/components/TestDrawerStateExposer.tsx => lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx} (64%) create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedFAB.tsx delete mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/ResizableDrawer.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedIcon.test.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx create mode 100644 workspaces/quickstart/.changeset/fancy-symbols-ask.md create mode 100644 workspaces/quickstart/packages/app/src/components/Root/CustomDrawer.tsx delete mode 100644 workspaces/quickstart/packages/app/src/components/Root/ResizableDrawer.tsx delete mode 100644 workspaces/quickstart/packages/app/src/components/Root/TestDrawerSidebarItem.tsx delete mode 100644 workspaces/quickstart/plugins/test-drawer/README.md delete mode 100644 workspaces/quickstart/plugins/test-drawer/dev/index.tsx delete mode 100644 workspaces/quickstart/plugins/test-drawer/package.json delete mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerButton.tsx delete mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContent.tsx delete mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContext.tsx delete mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerProvider.tsx delete mode 100644 workspaces/quickstart/plugins/test-drawer/src/components/index.ts delete mode 100644 workspaces/quickstart/plugins/test-drawer/src/index.ts delete mode 100644 workspaces/quickstart/plugins/test-drawer/src/plugin.ts 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/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 f9494d08f5..6a01a5d257 100644 --- a/workspaces/lightspeed/packages/app/package.json +++ b/workspaces/lightspeed/packages/app/package.json @@ -48,6 +48,7 @@ "@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 597bd4933a..b0c3296d1d 100644 --- a/workspaces/lightspeed/packages/app/src/App.tsx +++ b/workspaces/lightspeed/packages/app/src/App.tsx @@ -54,8 +54,10 @@ 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 { githubAuthApiRef } from '@backstage/core-plugin-api'; -import { LightspeedPage } from '@red-hat-developer-hub/backstage-plugin-lightspeed'; -import { LightspeedDrawerProvider } from '@red-hat-developer-hub/backstage-plugin-lightspeed'; +import { + LightspeedPage, + LightspeedDrawerProvider, +} from '@red-hat-developer-hub/backstage-plugin-lightspeed'; const identityProviders: IdentityProviders = [ 'guest', 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 eff3dccb9a..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,14 +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 { GlobalFloatingActionButton } from '@red-hat-developer-hub/backstage-plugin-global-floating-action-button'; import { - LightspeedFABIcon, - useLightspeedDrawerContext, + 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: { @@ -78,63 +80,78 @@ const SidebarLogo = () => { }; export const Root = ({ children }: PropsWithChildren<{}>) => { - const { isChatbotActive, toggleChatbot } = useLightspeedDrawerContext(); - return ( - - , - label: 'Lightspeed', - toolTip: isChatbotActive ? 'Close Lightspeed' : 'Open Lightspeed', - size: 'small', - isOpen: isChatbotActive, - displayCloseWhenOpen: true, - onClick: toggleChatbot, - disableHoverEffect: true, + 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 */} + }, + }} + > + + + + + } to="/search"> + + + + }> + {/* Global nav, not org-specific */} + + + + + + {/* End global nav */} + + + {/* Items in this group will be scrollable if they run out of space */} + + + - - {/* Items in this group will be scrollable if they run out of space */} - - - - - } - to="/settings" - > - - - - {children} - + } + 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 2f08c43c0b..6ab7bfc550 100644 --- a/workspaces/lightspeed/plugins/lightspeed/package.json +++ b/workspaces/lightspeed/plugins/lightspeed/package.json @@ -57,6 +57,7 @@ "@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", @@ -116,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/quickstart/plugins/test-drawer/src/components/DrawerComponent.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/DrawerComponent.tsx similarity index 62% rename from workspaces/quickstart/plugins/test-drawer/src/components/DrawerComponent.tsx rename to workspaces/lightspeed/plugins/lightspeed/src/components/DrawerComponent.tsx index 43620f28a0..097928701b 100644 --- a/workspaces/quickstart/plugins/test-drawer/src/components/DrawerComponent.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/DrawerComponent.tsx @@ -15,20 +15,23 @@ */ import { PropsWithChildren } from 'react'; -import { useTestDrawerContext } from './TestDrawerContext'; -import { ResizableDrawer } from '../../../../packages/app/src/components/Root/ResizableDrawer'; -export const DrawerComponent = ({ children }: PropsWithChildren) => { - const { isDrawerOpen, drawerWidth, setDrawerWidth } = useTestDrawerContext(); +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 9aa03c9daa..cda6f50fbd 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -30,7 +30,6 @@ import { ChatbotHeaderMain, ChatbotHeaderMenu, ChatbotHeaderTitle, - ChatbotModal, FileDropZone, MessageBar, MessageProps, @@ -66,7 +65,6 @@ import FilePreview from './FilePreview'; import { LightspeedChatBox } from './LightspeedChatBox'; import { LightspeedChatBoxHeader } from './LightspeedChatBoxHeader'; import { RenameConversationModal } from './RenameConversationModal'; -import { ResizableDrawer } from './ResizableDrawer'; const useStyles = makeStyles(theme => ({ body: { @@ -148,8 +146,6 @@ export const LightspeedChat = ({ const { displayMode, setDisplayMode, - drawerWidth, - setDrawerWidth, currentConversationId: routeConversationId, setCurrentConversationId, } = useLightspeedDrawerContext(); @@ -541,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'), @@ -550,185 +549,6 @@ export const LightspeedChat = ({ }); }; - const chatbot = ( - - - - setIsEmbeddedDrawerOpen(!isEmbeddedDrawerOpen)} - className={classes.headerMenu} - tooltipContent={t('tooltip.chatHistoryMenu')} - aria-label={t('aria.chatHistoryMenu')} - /> - {displayMode === ChatbotDisplayMode.embedded && ( - - - {t('chatbox.header.title')} - - - )} - - - handleSelectedModel(item)} - models={models} - isPinningChatsEnabled={isPinningChatsEnabled} - onPinnedChatsToggle={setIsPinningChatsEnabled} - setDisplayMode={setDisplayMode} - displayMode={displayMode} - /> - - - , - }} - handleTextInputChange={handleFilter} - searchInputPlaceholder={t('chatbox.search.placeholder')} - searchInputAriaLabel={t('aria.search.placeholder')} - searchInputProps={{ - value: filterValue, - onClear: () => { - setFilterValue(''); - }, - }} - noResultsState={ - filterValue && - Object.keys(filterConversations(filterValue)).length === 0 - ? { - bodyText: t('chatbox.emptyState.noResults.body'), - titleText: t('chatbox.emptyState.noResults.title'), - icon: SearchIcon, - } - : undefined - } - drawerContent={ - handleAttach(data, e)} - displayMode={ChatbotDisplayMode.embedded} - infoText={t('chatbox.fileUpload.infoText')} - allowedFileTypes={supportedFileTypes} - onAttachRejected={onAttachRejected} - > - {showAlert && uploadError.message && ( -
- setUploadError({ message: null })} - > - {uploadError.message} - -
- )} - - - - - - - - - -
- } - /> -
- ); - - const getChatDisplay = () => { - if (displayMode === ChatbotDisplayMode.docked) { - return ( - - {chatbot} - - ); - } - if (displayMode === ChatbotDisplayMode.default) { - return ( - {}} - ouiaId="LightspeedChatbotModal" - aria-labelledby="lightspeed-chatpopup-modal" - > - {chatbot} - - ); - } - - return chatbot; - }; - return ( <> {isDeleteModalOpen && ( @@ -746,7 +566,158 @@ export const LightspeedChat = ({ conversationId={targetConversationId} /> )} - {getChatDisplay()} + + + + + setIsEmbeddedDrawerOpen(!isEmbeddedDrawerOpen) + } + className={classes.headerMenu} + tooltipContent={t('tooltip.chatHistoryMenu')} + aria-label={t('aria.chatHistoryMenu')} + /> + {displayMode === ChatbotDisplayMode.embedded && ( + + + {t('chatbox.header.title')} + + + )} + + + handleSelectedModel(item)} + models={models} + isPinningChatsEnabled={isPinningChatsEnabled} + onPinnedChatsToggle={setIsPinningChatsEnabled} + setDisplayMode={setDisplayMode} + displayMode={displayMode} + /> + + + , + }} + handleTextInputChange={handleFilter} + searchInputPlaceholder={t('chatbox.search.placeholder')} + searchInputAriaLabel={t('aria.search.placeholder')} + searchInputProps={{ + value: filterValue, + onClear: () => { + setFilterValue(''); + }, + }} + noResultsState={ + filterValue && + Object.keys(filterConversations(filterValue)).length === 0 + ? { + bodyText: t('chatbox.emptyState.noResults.body'), + titleText: t('chatbox.emptyState.noResults.title'), + icon: SearchIcon, + } + : undefined + } + drawerContent={ + handleAttach(data, e)} + displayMode={ChatbotDisplayMode.embedded} + infoText={t('chatbox.fileUpload.infoText')} + allowedFileTypes={supportedFileTypes} + onAttachRejected={onAttachRejected} + > + {showAlert && uploadError.message && ( +
+ setUploadError({ message: null })} + > + {uploadError.message} + +
+ )} + + + + + + + + + +
+ } + /> +
); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBox.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBox.tsx index 760fa3d46b..328f7b680b 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBox.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBox.tsx @@ -154,13 +154,21 @@ export const LightspeedChatBox = forwardRef( 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 ( ({ + 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 [displayMode, setDisplayModeState] = useState( + const [displayModeState, setDisplayModeState] = useState( ChatbotDisplayMode.default, ); const [isOpen, setIsOpen] = useState(false); const [drawerWidth, setDrawerWidth] = useState(400); - const [userKey, setUserKey] = useState('guest'); - const [currentConversationId, setCurrentConversationIdState] = useState< + const [currentConversationIdState, setCurrentConversationIdState] = useState< string | undefined >(undefined); - const identityApi = useApi(identityApiRef); - const isLightspeedRoute = location.pathname.startsWith('/lightspeed'); - // Resolve the current user's identity to scope localStorage keys per user - useEffect(() => { - let cancelled = false; - (async () => { - try { - const identity = await identityApi.getBackstageIdentity(); - const ref = identity?.userEntityRef?.toLowerCase() || 'guest'; - if (!cancelled) setUserKey(ref); - } catch (e) { - if (!cancelled) setUserKey('guest'); - } - })(); - return () => { - cancelled = true; - }; - }, [identityApi]); - useEffect(() => { if (isLightspeedRoute) { const match = location.pathname.match(/\/lightspeed\/conversation\/(.+)/); @@ -72,52 +68,12 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { } setDisplayModeState(ChatbotDisplayMode.embedded); setIsOpen(true); + } else if (displayModeState === ChatbotDisplayMode.embedded) { + setDisplayModeState(ChatbotDisplayMode.default); } - }, [isLightspeedRoute, location.pathname]); - - // Load drawer width from localStorage on mount - useEffect(() => { - if (!userKey) return; - - const drawerWidthKey = `lightspeed-drawer-width:${userKey}`; - const savedDrawerWidth = localStorage.getItem(drawerWidthKey); - - if (savedDrawerWidth) { - const width = parseInt(savedDrawerWidth, 10); - if (!Number.isNaN(width) && width > 0) { - setDrawerWidth(width); - } - } - }, [userKey]); + }, [isLightspeedRoute, location.pathname, displayModeState]); - // Save drawer width to localStorage - useEffect(() => { - if (!userKey) return; - - const drawerWidthKey = `lightspeed-drawer-width:${userKey}`; - localStorage.setItem(drawerWidthKey, drawerWidth.toString()); - }, [drawerWidth, userKey]); - - // Set CSS variables for drawer width when drawer is open in docked mode - useEffect(() => { - if (isOpen && displayMode === ChatbotDisplayMode.docked) { - document.body.classList.add('lightspeed-drawer-open'); - document.body.style.setProperty( - '--lightspeed-drawer-width', - `${drawerWidth}px`, - ); - } else { - document.body.classList.remove('lightspeed-drawer-open'); - document.body.style.removeProperty('--lightspeed-drawer-width'); - } - - return () => { - document.body.classList.remove('lightspeed-drawer-open'); - document.body.style.removeProperty('--lightspeed-drawer-width'); - }; - }, [isOpen, drawerWidth, displayMode]); - - // Open chatbot in overlay mode (no route change) + // Open chatbot in overlay mode const openChatbot = useCallback(() => { setDisplayModeState(ChatbotDisplayMode.default); setIsOpen(true); @@ -126,12 +82,12 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { // Close chatbot const closeChatbot = useCallback(() => { // If in embedded mode on the lightspeed route, navigate back - if (displayMode === ChatbotDisplayMode.embedded && isLightspeedRoute) { + if (displayModeState === ChatbotDisplayMode.embedded && isLightspeedRoute) { navigate(-1); } setIsOpen(false); setDisplayModeState(ChatbotDisplayMode.default); - }, [displayMode, isLightspeedRoute, navigate]); + }, [displayModeState, isLightspeedRoute, navigate]); const toggleChatbot = useCallback(() => { if (isOpen) { @@ -146,12 +102,15 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { setCurrentConversationIdState(id); // Update route if in embedded mode - if (displayMode === ChatbotDisplayMode.embedded && isLightspeedRoute) { + if ( + displayModeState === ChatbotDisplayMode.embedded && + isLightspeedRoute + ) { const path = id ? `/lightspeed/conversation/${id}` : '/lightspeed'; navigate(path, { replace: true }); } }, - [displayMode, isLightspeedRoute, navigate], + [displayModeState, isLightspeedRoute, navigate], ); // Set display mode with route handling for embedded/fullscreen @@ -161,49 +120,68 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { // Navigate to fullscreen route with conversation ID if available if (mode === ChatbotDisplayMode.embedded) { - const convId = conversationId ?? currentConversationId; + const convId = conversationId ?? currentConversationIdState; const path = convId ? `/lightspeed/conversation/${convId}` : '/lightspeed'; navigate(path); setIsOpen(true); - } else if (mode === ChatbotDisplayMode.docked) { - // If we were on the lightspeed route, navigate back - if (isLightspeedRoute) { - navigate(-1); - } - setIsOpen(true); } else { - // Default Overlay mode - // If we were on the lightspeed route, navigate back if (isLightspeedRoute) { navigate(-1); } setIsOpen(true); } }, - [navigate, isLightspeedRoute, currentConversationId], + [navigate, isLightspeedRoute, currentConversationIdState], ); - // Only render for overlay and docked modes (embedded is handled by the route) - const shouldRenderChat = - isOpen && displayMode !== ChatbotDisplayMode.embedded && !isLightspeedRoute; + // 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} - {shouldRenderChat && } + {shouldRenderOverlayModal && ( + + + + )} ); }; diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerStateExposer.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx similarity index 64% rename from workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerStateExposer.tsx rename to workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx index f25cdca0e9..9154cd39de 100644 --- a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerStateExposer.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx @@ -15,12 +15,14 @@ */ import { useEffect } from 'react'; -import { useTestDrawerContext } from './TestDrawerContext'; + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; + +import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; /** - * Partial Test drawer state exposed to the ApplicationDrawer - * * @public + * Partial Lightspeed drawer state exposed to the ApplicationDrawer */ export type DrawerState = { id: string; @@ -30,9 +32,8 @@ export type DrawerState = { }; /** - * Props for drawer state exposer components - * * @public + * Props for drawer state exposer components */ export type DrawerStateExposerProps = { /** @@ -42,27 +43,25 @@ export type DrawerStateExposerProps = { }; /** - * This exposes TestDrawer's partial context to the ApplicationDrawer + * @public + * This exposes LightspeedDrawer's partial context to the ApplicationDrawer * - * It reads the TestDrawerContext and calls the onStateChange callback with the + * It reads the LightspeedDrawerContext and calls the onStateChange callback with the * partial state (id, isDrawerOpen, drawerWidth, setDrawerWidth). - * - * @public + */ -export const TestDrawerStateExposer = ({ +export const LightspeedDrawerStateExposer = ({ onStateChange, }: DrawerStateExposerProps) => { - const { id, isDrawerOpen, drawerWidth, setDrawerWidth } = - useTestDrawerContext(); - + const { displayMode, drawerWidth, setDrawerWidth } = + useLightspeedDrawerContext(); useEffect(() => { onStateChange({ - id, - isDrawerOpen, + id: 'lightspeed', + isDrawerOpen: displayMode === ChatbotDisplayMode.docked, drawerWidth, setDrawerWidth, }); - }, [id, isDrawerOpen, drawerWidth, onStateChange, 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 004270c8f4..40bbeb38de 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx @@ -42,6 +42,7 @@ export const LightspeedFABIcon = () => { return ( {t('icon.lightspeed.alt')} ({ - width: 6, - cursor: 'col-resize', - position: 'absolute', - top: 0, - left: 0, - bottom: 0, - zIndex: 1201, - backgroundColor: theme.palette.divider, -})); - -type ResizableDrawerProps = { - children: React.ReactNode; - minWidth?: number; - maxWidth?: number; - initialWidth?: number; - isDrawerOpen: boolean; - drawerWidth?: number; - onWidthChange?: (width: number) => void; - isResizable?: boolean; - [key: string]: any; -}; - -export const ResizableDrawer = (props: ResizableDrawerProps) => { - const { - children, - minWidth = 400, - maxWidth = 800, - initialWidth = 400, - isDrawerOpen, - drawerWidth: externalDrawerWidth, - onWidthChange, - ...drawerProps - } = props; - - // Ensure width is never below minWidth - const clampedInitialWidth = Math.max( - externalDrawerWidth || initialWidth, - minWidth, - ); - const [width, setWidth] = useState(clampedInitialWidth); - const resizingRef = useRef(false); - - // Sync with external drawerWidth if provided, ensuring it's not below minWidth - useEffect(() => { - if (externalDrawerWidth !== undefined) { - const clampedWidth = Math.max(externalDrawerWidth, minWidth); - if (clampedWidth !== width) { - setWidth(clampedWidth); - // If the external width was below min, update the parent - if (externalDrawerWidth < minWidth && onWidthChange) { - onWidthChange(clampedWidth); - } - } - } - }, [externalDrawerWidth, width, minWidth, onWidthChange]); - - const onMouseDown = () => { - resizingRef.current = true; - }; - - const onMouseMove = useCallback( - (e: MouseEvent) => { - if (!resizingRef.current) return; - // For right-anchored drawer, calculate width from the right edge - const newWidth = window.innerWidth - e.clientX; - - if (newWidth >= minWidth && newWidth <= maxWidth) { - setWidth(newWidth); - if (onWidthChange) { - onWidthChange(newWidth); - } - } - }, - [maxWidth, minWidth, onWidthChange], - ); - - const onMouseUp = () => { - resizingRef.current = false; - }; - - useEffect(() => { - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('mouseup', onMouseUp); - return () => { - window.removeEventListener('mousemove', onMouseMove); - window.removeEventListener('mouseup', onMouseUp); - }; - }, [onMouseMove]); - - // 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/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/index.ts b/workspaces/lightspeed/plugins/lightspeed/src/index.ts index f2f3b68c6d..676550f9b3 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/index.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/index.ts @@ -20,6 +20,28 @@ export { 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 7b5b2df48d..4a8ccf8a32 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts @@ -84,3 +84,52 @@ export const LightspeedDrawerProvider: React.ComponentType = }, }), ); + +/** + * 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/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/package.json b/workspaces/quickstart/packages/app/package.json index 0f1d93bdc4..f3584c70e6 100644 --- a/workspaces/quickstart/packages/app/package.json +++ b/workspaces/quickstart/packages/app/package.json @@ -49,10 +49,8 @@ "@material-ui/icons": "^4.9.1", "@mui/icons-material": "5.18.0", "@mui/material": "5.18.0", - "@red-hat-developer-hub/backstage-plugin-application-drawer": "workspace:^", "@red-hat-developer-hub/backstage-plugin-global-header": "^1.17.1", "@red-hat-developer-hub/backstage-plugin-quickstart": "workspace:^", - "@red-hat-developer-hub/backstage-plugin-test-drawer": "workspace:^", "@red-hat-developer-hub/backstage-plugin-theme": "^0.11.0", "react": "^18.0.2", "react-dom": "^18.0.2", diff --git a/workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx b/workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx index 91ef3aa54f..f1fad3f674 100644 --- a/workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx +++ b/workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx @@ -21,7 +21,7 @@ import { useMemo, useEffect, } from 'react'; -import { ResizableDrawer } from './ResizableDrawer'; +import { CustomDrawer } from './CustomDrawer'; /** * Partial drawer state exposed by drawer plugins @@ -157,19 +157,21 @@ export const ApplicationDrawer = ({ <> {/* 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/ResizableDrawer.tsx b/workspaces/quickstart/packages/app/src/components/Root/ResizableDrawer.tsx deleted file mode 100644 index e67e837a44..0000000000 --- a/workspaces/quickstart/packages/app/src/components/Root/ResizableDrawer.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/* - * 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 { useCallback, useEffect, useRef, useState } from 'react'; - -import Box from '@mui/material/Box'; -import Drawer from '@mui/material/Drawer'; -import { styled } from '@mui/material/styles'; - -import { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme'; - -const Handle = styled('div')(({ theme }) => ({ - width: 6, - cursor: 'col-resize', - position: 'absolute', - top: 0, - left: 0, - bottom: 0, - zIndex: 1201, - backgroundColor: theme.palette.divider, -})); - -export type ResizableDrawerProps = { - children: React.ReactNode; - minWidth?: number; - maxWidth?: number; - initialWidth?: number; - isDrawerOpen: boolean; - drawerWidth?: number; - onWidthChange?: (width: number) => void; - isResizable?: boolean; - [key: string]: any; -}; - -export const ResizableDrawer = (props: ResizableDrawerProps) => { - const { - children, - minWidth = 400, - maxWidth = 800, - initialWidth = 400, - isDrawerOpen, - isResizable = false, - drawerWidth: externalDrawerWidth, - onWidthChange, - ...drawerProps - } = props; - - // Ensure width is never below minWidth - const clampedInitialWidth = Math.max( - externalDrawerWidth || initialWidth, - minWidth, - ); - - const [width, setWidth] = useState(clampedInitialWidth); - const resizingRef = useRef(false); - - // Sync with external drawerWidth when it changes - useEffect(() => { - if (externalDrawerWidth !== undefined) { - const clampedWidth = Math.max(externalDrawerWidth, minWidth); - if (clampedWidth !== width) { - setWidth(clampedWidth); - // If the external width was below min, update the parent - if (externalDrawerWidth < minWidth && onWidthChange && isResizable) { - onWidthChange(clampedWidth); - } - } - } - }, [externalDrawerWidth, width, minWidth, onWidthChange, isResizable]); - - const onMouseDown = () => { - resizingRef.current = true; - }; - - const onMouseMove = useCallback( - (e: MouseEvent) => { - if (!resizingRef.current) return; - // For right-anchored drawer, calculate width from the right edge - const newWidth = window.innerWidth - e.clientX; - - if (newWidth >= minWidth && newWidth <= maxWidth) { - setWidth(newWidth); - if (onWidthChange) { - onWidthChange(newWidth); - } - } - }, - [maxWidth, minWidth, onWidthChange], - ); - - const onMouseUp = () => { - resizingRef.current = false; - }; - - useEffect(() => { - if (isResizable) { - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('mouseup', onMouseUp); - return () => { - window.removeEventListener('mousemove', onMouseMove); - window.removeEventListener('mouseup', onMouseUp); - }; - } - return () => {}; - }, [onMouseMove, isResizable]); - - // 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} - {isResizable && } - - - ); -}; \ No newline at end of file diff --git a/workspaces/quickstart/packages/app/src/components/Root/Root.tsx b/workspaces/quickstart/packages/app/src/components/Root/Root.tsx index 6161d0d420..9d8685f4eb 100644 --- a/workspaces/quickstart/packages/app/src/components/Root/Root.tsx +++ b/workspaces/quickstart/packages/app/src/components/Root/Root.tsx @@ -54,13 +54,7 @@ import { } from '@red-hat-developer-hub/backstage-plugin-quickstart'; import { QuickstartSidebarItem } from './QuickstartSidebarItem'; import { Administration } from '@backstage-community/plugin-rbac'; -import { - TestDrawerContent, - TestDrawerProvider, - TestDrawerStateExposer, -} from '@red-hat-developer-hub/backstage-plugin-test-drawer'; import { ApplicationDrawer } from './ApplicationDrawer'; -import { TestDrawerSidebarItem } from './TestDrawerSidebarItem'; const useSidebarLogoStyles = makeStyles({ root: { @@ -112,71 +106,56 @@ export const Root = ({ children }: PropsWithChildren<{}>) => { globalHeaderMountPoints={defaultGlobalHeaderComponentsMountPoints} /> - - - - } to="/search"> - - - - }> - {/* Global nav, not org-specific */} - - - - - - {/* End global nav */} - - - - {/* Items in this group will be scrollable if they run out of space */} - - - - - + + + } to="/search"> + + + + }> + {/* Global nav, not org-specific */} + + + + + + {/* End global nav */} - } - to="/settings" - > - - - - {children} - - + + + {/* Items in this group will be scrollable if they run out of space */} + + + + + + } + to="/settings" + > + + + + {children} + diff --git a/workspaces/quickstart/packages/app/src/components/Root/TestDrawerSidebarItem.tsx b/workspaces/quickstart/packages/app/src/components/Root/TestDrawerSidebarItem.tsx deleted file mode 100644 index 0b47afcd90..0000000000 --- a/workspaces/quickstart/packages/app/src/components/Root/TestDrawerSidebarItem.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 { SidebarItem, StarIcon } from '@backstage/core-components'; -import { useTestDrawerContext } from '@red-hat-developer-hub/backstage-plugin-test-drawer'; - -export const TestDrawerSidebarItem = () => { - const { toggleDrawer } = useTestDrawerContext(); - - return ( - - ); -}; diff --git a/workspaces/quickstart/plugins/quickstart/package.json b/workspaces/quickstart/plugins/quickstart/package.json index fbc3c13b55..aef5ae8a05 100644 --- a/workspaces/quickstart/plugins/quickstart/package.json +++ b/workspaces/quickstart/plugins/quickstart/package.json @@ -37,7 +37,6 @@ "@backstage/theme": "^0.7.0", "@mui/icons-material": "5.18.0", "@mui/material": "5.18.0", - "@red-hat-developer-hub/backstage-plugin-application-drawer": "^0.1.0", "@red-hat-developer-hub/backstage-plugin-theme": "^0.11.0", "react-use": "^17.6.0" }, 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/QuickstartDrawerContext.tsx b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContext.tsx index 2f9a360c4f..c89741296b 100644 --- a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContext.tsx +++ b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContext.tsx @@ -23,7 +23,6 @@ import { UserRole } from '../types'; * @public */ export interface QuickstartDrawerContextType { - id: string; /** * The prop to check if the drawer is open */ diff --git a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx index 825a023bf0..842f1edb34 100644 --- a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx +++ b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx @@ -28,8 +28,6 @@ import { QuickstartItemData } from '../types'; import { filterQuickstartItemsByRole } from '../utils'; import { useQuickstartRole } from '../hooks/useQuickstartRole'; -const DRAWER_ID = 'quickstart'; - /** * Provider component for the Quickstart Drawer functionality * @public @@ -172,7 +170,6 @@ export const QuickstartDrawerProvider = ({ children }: PropsWithChildren) => { return ( { - const { id, isDrawerOpen, drawerWidth, setDrawerWidth } = + const { isDrawerOpen, drawerWidth, setDrawerWidth } = useQuickstartDrawerContext(); useEffect(() => { onStateChange({ - id, + id: 'quickstart', isDrawerOpen, drawerWidth, setDrawerWidth, }); - }, [id, isDrawerOpen, drawerWidth, onStateChange, 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 55413c0272..8ea0dd80fb 100644 --- a/workspaces/quickstart/plugins/quickstart/src/index.ts +++ b/workspaces/quickstart/plugins/quickstart/src/index.ts @@ -27,6 +27,10 @@ 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/test-drawer/README.md b/workspaces/quickstart/plugins/test-drawer/README.md deleted file mode 100644 index 9d5d314af9..0000000000 --- a/workspaces/quickstart/plugins/test-drawer/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# Test Drawer Plugin - -A test drawer plugin for Backstage that demonstrates how to create drawer components with context-based state management. - -## Getting Started - -This plugin can be accessed by running `yarn start` from this directory, and then navigating to [/test-drawer](http://localhost:3000/test-drawer). - -## Components - -### TestDrawerProvider - -Provider component that wraps your application and provides drawer context and the MUI Drawer component. - -```tsx -import { TestDrawerProvider } from '@red-hat-developer-hub/backstage-plugin-test-drawer'; - -export const App = () => ( - - {/* Your app content */} - -); -``` - -### TestDrawerContent - -The content component that renders inside the MUI Drawer. It includes a header with close button, main content area, and footer. - -### TestDrawerButton - -A button component that can be placed anywhere to toggle the drawer. - -```tsx -import { TestDrawerButton } from '@red-hat-developer-hub/backstage-plugin-test-drawer'; - -// Use in header or toolbar - -``` - -### useTestDrawerContext - -Hook to access the drawer context from any component within the provider. - -```tsx -import { useTestDrawerContext } from '@red-hat-developer-hub/backstage-plugin-test-drawer'; - -const MyComponent = () => { - const { isDrawerOpen, openDrawer, closeDrawer, toggleDrawer, drawerWidth, setDrawerWidth } = useTestDrawerContext(); - - return ( - - ); -}; -``` - -## Context API - -The `TestDrawerContextType` provides: - -| Property | Type | Description | -|----------|------|-------------| -| `isDrawerOpen` | `boolean` | Whether the drawer is currently open | -| `openDrawer` | `() => void` | Function to open the drawer | -| `closeDrawer` | `() => void` | Function to close the drawer | -| `toggleDrawer` | `() => void` | Function to toggle the drawer state | -| `drawerWidth` | `number` | Current drawer width in pixels | -| `setDrawerWidth` | `Dispatch>` | Function to set the drawer width | - -## CSS Variables - -When the drawer is open, the following CSS class and variable are set on `document.body`: - -- Class: `test-drawer-open` -- Variable: `--test-drawer-width` (e.g., `400px`) - -This allows you to adjust other UI elements when the drawer is open. - diff --git a/workspaces/quickstart/plugins/test-drawer/dev/index.tsx b/workspaces/quickstart/plugins/test-drawer/dev/index.tsx deleted file mode 100644 index 2bcf8010de..0000000000 --- a/workspaces/quickstart/plugins/test-drawer/dev/index.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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 { createDevApp } from '@backstage/dev-utils'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Typography from '@mui/material/Typography'; -import { - TestDrawerContent, - testDrawerPlugin, - TestDrawerProvider, - useTestDrawerContext, -} from '../src'; -import { DrawerComponent } from '../src/components'; - -const TestPage = () => { - const { toggleDrawer, isDrawerOpen, drawerWidth } = useTestDrawerContext(); - - return ( - - - Test Drawer Plugin - - - - This page demonstrates the Test Drawer plugin functionality. - - - - - - - - Drawer State: - - Is Open: {isDrawerOpen ? 'Yes' : 'No'} - - - Width: {drawerWidth}px - - - - ); -}; - -const DevPage = () => ( - - - - - - -); - -createDevApp() - .registerPlugin(testDrawerPlugin) - .addPage({ - element: , - title: 'Test Drawer', - path: '/test-drawer', - }) - .render(); diff --git a/workspaces/quickstart/plugins/test-drawer/package.json b/workspaces/quickstart/plugins/test-drawer/package.json deleted file mode 100644 index caefcf7edc..0000000000 --- a/workspaces/quickstart/plugins/test-drawer/package.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "@red-hat-developer-hub/backstage-plugin-test-drawer", - "version": "0.1.0", - "license": "Apache-2.0", - "main": "src/index.ts", - "types": "src/index.ts", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "https://github.com/redhat-developer/rhdh-plugins", - "directory": "workspaces/quickstart/plugins/test-drawer" - }, - "backstage": { - "role": "frontend-plugin", - "pluginId": "test-drawer", - "pluginPackages": [ - "@red-hat-developer-hub/backstage-plugin-test-drawer" - ] - }, - "sideEffects": false, - "scripts": { - "start": "backstage-cli package start", - "build": "backstage-cli package build", - "lint": "backstage-cli package lint", - "test": "backstage-cli package test", - "clean": "backstage-cli package clean", - "prepack": "backstage-cli package prepack", - "postpack": "backstage-cli package postpack" - }, - "dependencies": { - "@backstage/core-components": "^0.18.3", - "@backstage/core-plugin-api": "^1.12.0", - "@backstage/theme": "^0.7.0", - "@mui/icons-material": "5.18.0", - "@mui/material": "5.18.0", - "@red-hat-developer-hub/backstage-plugin-application-drawer": "^0.1.0", - "@red-hat-developer-hub/backstage-plugin-theme": "^0.11.0" - }, - "peerDependencies": { - "react": "^16.13.1 || ^17.0.0 || ^18.0.0" - }, - "devDependencies": { - "@backstage/cli": "^0.34.5", - "@backstage/dev-utils": "^1.1.17", - "@backstage/test-utils": "^1.7.13", - "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^14.0.0", - "react": "^16.13.1 || ^17.0.0 || ^18.0.0" - }, - "exports": { - ".": "./src/index.ts", - "./package.json": "./package.json" - }, - "files": [ - "dist" - ] -} diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerButton.tsx b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerButton.tsx deleted file mode 100644 index 2fd840402b..0000000000 --- a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerButton.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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 MenuItem from '@mui/material/MenuItem'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import { useTheme } from '@mui/material/styles'; -import { useTestDrawerContext } from './TestDrawerContext'; -import { useCallback } from 'react'; - -/** - * Button component to toggle the Test Drawer - * - * Can be used in the global header help dropdown - * - * @public - */ -export const TestDrawerButton = ({ - onClick = () => {}, -}: { - onClick: () => void; -}) => { - const { toggleDrawer } = useTestDrawerContext(); - const theme = useTheme(); - - const handleClick = useCallback(() => { - toggleDrawer(); - onClick(); - }, [toggleDrawer, onClick]); - - return ( - - - - - - Test Drawer - - - - - - ); -}; diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContent.tsx b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContent.tsx deleted file mode 100644 index f0a8fabacb..0000000000 --- a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContent.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/* - * 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 Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import ListItemText from '@mui/material/ListItemText'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import CloseIcon from '@mui/icons-material/Close'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import { useTestDrawerContext } from './TestDrawerContext'; - -/** - * Content to be rendered inside the Test Drawer - * - * @public - */ -export const TestDrawerContent = () => { - const { toggleDrawer } = useTestDrawerContext(); - - return ( - - - - Test Drawer - - - - - - - {/* Content */} - - - This is a test drawer component that demonstrates how drawer content - can be structured and rendered inside an MUI Drawer. - - - - Features: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Test Drawer Plugin v0.1.0 - - - - ); -}; - diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContext.tsx b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContext.tsx deleted file mode 100644 index 46e8406500..0000000000 --- a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerContext.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 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, useContext } from 'react'; - -/** - * Type for TestDrawerContext - * - * @public - */ -export interface TestDrawerContextType { - id: string; - /** - * Whether the drawer is open - */ - isDrawerOpen: boolean; - /** - * Function to toggle the drawer state - */ - toggleDrawer: () => void; - /** - * Current drawer width in pixels - */ - drawerWidth: number; - /** - * Function to set the drawer width - */ - setDrawerWidth: React.Dispatch>; -} - -/** - * Context for the Test Drawer - * - * @public - */ -export const TestDrawerContext = createContext< - TestDrawerContextType | undefined ->(undefined); - -/** - * Hook to access the TestDrawerContext - * - * @public - */ -export const useTestDrawerContext = (): TestDrawerContextType => { - const context = useContext(TestDrawerContext); - if (!context) { - throw new Error( - 'useTestDrawerContext must be used within a TestDrawerProvider', - ); - } - return context; -}; - diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerProvider.tsx b/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerProvider.tsx deleted file mode 100644 index 2b8352c66a..0000000000 --- a/workspaces/quickstart/plugins/test-drawer/src/components/TestDrawerProvider.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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, useState, useCallback } from 'react'; -import { TestDrawerContext } from './TestDrawerContext'; - -const DRAWER_ID = 'test-drawer'; -const DEFAULT_DRAWER_WIDTH = 400; -const MIN_DRAWER_WIDTH = 300; -const MAX_DRAWER_WIDTH = 800; - -/** - * Provider component for the Test Drawer functionality - * - * @public - */ -export const TestDrawerProvider = ({ children }: PropsWithChildren) => { - const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const [drawerWidth, setDrawerWidth] = useState(DEFAULT_DRAWER_WIDTH); - - const toggleDrawer = useCallback(() => { - setIsDrawerOpen(prev => !prev); - }, []); - - // Constrain drawer width to min/max bounds - const handleSetDrawerWidth: React.Dispatch> = - useCallback(value => { - setDrawerWidth(prev => { - const newWidth = typeof value === 'function' ? value(prev) : value; - return Math.min(MAX_DRAWER_WIDTH, Math.max(MIN_DRAWER_WIDTH, newWidth)); - }); - }, []); - - return ( - - {children} - - ); -}; diff --git a/workspaces/quickstart/plugins/test-drawer/src/components/index.ts b/workspaces/quickstart/plugins/test-drawer/src/components/index.ts deleted file mode 100644 index bfda8c19fd..0000000000 --- a/workspaces/quickstart/plugins/test-drawer/src/components/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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. - */ - -export { TestDrawerContext, useTestDrawerContext } from './TestDrawerContext'; -export type { TestDrawerContextType } from './TestDrawerContext'; -export { TestDrawerContent } from './TestDrawerContent'; -export { TestDrawerProvider } from './TestDrawerProvider'; -export { TestDrawerButton } from './TestDrawerButton'; -export { DrawerComponent } from './DrawerComponent'; -export { TestDrawerStateExposer } from './TestDrawerStateExposer'; diff --git a/workspaces/quickstart/plugins/test-drawer/src/index.ts b/workspaces/quickstart/plugins/test-drawer/src/index.ts deleted file mode 100644 index eec03a32e4..0000000000 --- a/workspaces/quickstart/plugins/test-drawer/src/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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. - */ - -export { - testDrawerPlugin, - TestDrawerProvider, - TestDrawerContent, - TestDrawerButton, -} from './plugin'; - -export { - TestDrawerContext, - useTestDrawerContext, - TestDrawerStateExposer, -} from './components'; -export type { TestDrawerContextType } from './components'; diff --git a/workspaces/quickstart/plugins/test-drawer/src/plugin.ts b/workspaces/quickstart/plugins/test-drawer/src/plugin.ts deleted file mode 100644 index 6dd233a959..0000000000 --- a/workspaces/quickstart/plugins/test-drawer/src/plugin.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * 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 { - createPlugin, - createComponentExtension, -} from '@backstage/core-plugin-api'; - -/** - * Test Drawer Plugin - * - * @public - */ -export const testDrawerPlugin = createPlugin({ - id: 'test-drawer', -}); - -/** - * Test Drawer Provider component extension - * - * @public - */ -export const TestDrawerProvider = testDrawerPlugin.provide( - createComponentExtension({ - name: 'TestDrawerProvider', - component: { - lazy: () => - import('./components/TestDrawerProvider').then( - m => m.TestDrawerProvider, - ), - }, - }), -); - -/** - * Test Drawer Content component extension - * - * @public - */ -export const TestDrawerContent = testDrawerPlugin.provide( - createComponentExtension({ - name: 'TestDrawerContent', - component: { - lazy: () => - import('./components/TestDrawerContent').then(m => m.TestDrawerContent), - }, - }), -); - -/** - * Test Drawer Button component extension - * - * @public - */ -export const TestDrawerButton = testDrawerPlugin.provide( - createComponentExtension({ - name: 'TestDrawerButton', - component: { - lazy: () => - import('./components/TestDrawerButton').then(m => m.TestDrawerButton), - }, - }), -); - -/** - * Test Drawer State Exposer exposes its drawer state - * - * @public - */ -export const TestDrawerStateExposer = testDrawerPlugin.provide( - createComponentExtension({ - name: 'TestDrawerStateExposer', - component: { - lazy: () => - import('./components/TestDrawerStateExposer').then( - m => m.TestDrawerStateExposer, - ), - }, - }), -); diff --git a/workspaces/quickstart/yarn.lock b/workspaces/quickstart/yarn.lock index bc220e164f..7cc04947e2 100644 --- a/workspaces/quickstart/yarn.lock +++ b/workspaces/quickstart/yarn.lock @@ -10996,27 +10996,6 @@ __metadata: languageName: node linkType: hard -"@red-hat-developer-hub/backstage-plugin-application-drawer@^0.1.0, @red-hat-developer-hub/backstage-plugin-application-drawer@workspace:^, @red-hat-developer-hub/backstage-plugin-application-drawer@workspace:plugins/application-drawer": - version: 0.0.0-use.local - resolution: "@red-hat-developer-hub/backstage-plugin-application-drawer@workspace:plugins/application-drawer" - dependencies: - "@backstage/cli": ^0.34.5 - "@backstage/core-components": ^0.18.3 - "@backstage/core-plugin-api": ^1.12.0 - "@backstage/dev-utils": ^1.1.17 - "@backstage/test-utils": ^1.7.13 - "@backstage/theme": ^0.7.0 - "@mui/icons-material": 5.18.0 - "@mui/material": 5.18.0 - "@red-hat-developer-hub/backstage-plugin-theme": ^0.11.0 - "@testing-library/jest-dom": ^6.0.0 - "@testing-library/react": ^14.0.0 - react: ^16.13.1 || ^17.0.0 || ^18.0.0 - peerDependencies: - react: ^16.13.1 || ^17.0.0 || ^18.0.0 - languageName: unknown - linkType: soft - "@red-hat-developer-hub/backstage-plugin-global-header@npm:^1.17.1": version: 1.17.1 resolution: "@red-hat-developer-hub/backstage-plugin-global-header@npm:1.17.1" @@ -11065,7 +11044,6 @@ __metadata: "@backstage/theme": ^0.7.0 "@mui/icons-material": 5.18.0 "@mui/material": 5.18.0 - "@red-hat-developer-hub/backstage-plugin-application-drawer": ^0.1.0 "@red-hat-developer-hub/backstage-plugin-theme": ^0.11.0 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 @@ -11078,28 +11056,6 @@ __metadata: languageName: unknown linkType: soft -"@red-hat-developer-hub/backstage-plugin-test-drawer@workspace:^, @red-hat-developer-hub/backstage-plugin-test-drawer@workspace:plugins/test-drawer": - version: 0.0.0-use.local - resolution: "@red-hat-developer-hub/backstage-plugin-test-drawer@workspace:plugins/test-drawer" - dependencies: - "@backstage/cli": ^0.34.5 - "@backstage/core-components": ^0.18.3 - "@backstage/core-plugin-api": ^1.12.0 - "@backstage/dev-utils": ^1.1.17 - "@backstage/test-utils": ^1.7.13 - "@backstage/theme": ^0.7.0 - "@mui/icons-material": 5.18.0 - "@mui/material": 5.18.0 - "@red-hat-developer-hub/backstage-plugin-application-drawer": ^0.1.0 - "@red-hat-developer-hub/backstage-plugin-theme": ^0.11.0 - "@testing-library/jest-dom": ^6.0.0 - "@testing-library/react": ^14.0.0 - react: ^16.13.1 || ^17.0.0 || ^18.0.0 - peerDependencies: - react: ^16.13.1 || ^17.0.0 || ^18.0.0 - languageName: unknown - linkType: soft - "@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" @@ -15362,10 +15318,8 @@ __metadata: "@mui/icons-material": 5.18.0 "@mui/material": 5.18.0 "@playwright/test": 1.57.0 - "@red-hat-developer-hub/backstage-plugin-application-drawer": "workspace:^" "@red-hat-developer-hub/backstage-plugin-global-header": ^1.17.1 "@red-hat-developer-hub/backstage-plugin-quickstart": "workspace:^" - "@red-hat-developer-hub/backstage-plugin-test-drawer": "workspace:^" "@red-hat-developer-hub/backstage-plugin-theme": ^0.11.0 "@testing-library/dom": ^9.0.0 "@testing-library/jest-dom": ^6.0.0