// ============================================================================ // Chatbot Header - Chatbot Conversation History Nav // ============================================================================ import type { KeyboardEvent, FunctionComponent } from 'react'; import { useRef, Fragment } from 'react'; // Import PatternFly components import { Button, ButtonProps, Divider, Drawer, DrawerPanelContent, DrawerContent, DrawerPanelBody, DrawerProps, DrawerHead, DrawerActions, DrawerCloseButton, DrawerContentBody, InputGroup, InputGroupItem, SearchInput, Title, DrawerPanelContentProps, DrawerContentProps, DrawerContentBodyProps, DrawerHeadProps, DrawerActionsProps, DrawerCloseButtonProps, DrawerPanelBodyProps, SkeletonProps, Icon, TitleProps, SearchInputProps, MenuProps, MenuListProps, MenuList, MenuGroup, MenuItem, Menu, MenuContent, MenuItemProps, MenuGroupProps, MenuContentProps } from '@patternfly/react-core'; import { OutlinedClockIcon, OutlinedCommentAltIcon, PenToSquareIcon } from '@patternfly/react-icons'; import { ChatbotDisplayMode } from '../Chatbot/Chatbot'; import ConversationHistoryDropdown from './ChatbotConversationHistoryDropdown'; import LoadingState from './LoadingState'; import HistoryEmptyState, { HistoryEmptyStateProps } from './EmptyState'; export interface Conversation { /** Conversation id */ id: string; /** Conversation icon */ icon?: React.ReactNode; /** Flag for no icon */ noIcon?: boolean; /** Conversation */ text: string; /** Dropdown items rendered in conversation settings dropdown */ menuItems?: React.ReactNode; /** Optional classname applied to conversation settings dropdown */ menuClassName?: string; /** Tooltip content and aria-label applied to conversation settings dropdown */ label?: string; /** Callback for when user selects item. */ onSelect?: (event?: React.MouseEvent, value?: string | number) => void; /** Additional props passed to menu item */ additionalProps?: MenuItemProps; /** Custom dropdown ID to ensure uniqueness across demo instances */ dropdownId?: string; } export interface ChatbotConversationHistoryNavProps extends DrawerProps { /** Function called to toggle drawer */ onDrawerToggle: (event: React.KeyboardEvent | React.MouseEvent | React.TransitionEvent) => void; /** Flag to indicate whether drawer is open */ isDrawerOpen: boolean; /** Function called to close drawer */ setIsDrawerOpen: (bool: boolean) => void; /* itemId of the currently active item. */ activeItemId?: string | number; /** Callback function for when an item is selected */ onSelectActiveItem?: (event?: React.MouseEvent, itemId?: string | number) => void; /** Items shown in conversation history */ conversations: Conversation[] | { [key: string]: Conversation[] }; /** Additional button props for new chat button. */ newChatButtonProps?: ButtonProps; /** Additional props applied to conversation menu group. If conversations is an object, you should pass an object of MenuGroupProps for each group. */ menuGroupProps?: MenuGroupProps | { [key: string]: MenuGroupProps }; /** Additional props applied to conversation list. If conversations is an object, you should pass an object of MenuListProps for each group. */ menuListProps?: Omit | { [key: string]: Omit }; /** Text shown in blue button */ newChatButtonText?: string; /** Callback function for when blue button is clicked. Omit to hide blue "new chat button" */ onNewChat?: () => void; /** Content wrapped by conversation history nav */ drawerContent?: React.ReactNode; /** Placeholder for search input */ searchInputPlaceholder?: string; /** Aria label for search input */ searchInputAriaLabel?: string; /** Additional props passed to search input */ searchInputProps?: SearchInputProps; /** A callback for when the input value changes. Omit to hide input field */ handleTextInputChange?: (value: string) => void; /** Display mode of chatbot */ displayMode: ChatbotDisplayMode; /** Reverses the order of the drawer action buttons */ reverseButtonOrder?: boolean; /** Custom test id for the drawer actions */ drawerActionsTestId?: string; /** Additional props applied to menu */ menuProps?: MenuProps; /** Additional props applied to panel */ drawerPanelContentProps?: DrawerPanelContentProps; /** Additional props applied to drawer content */ drawerContentProps?: Omit; /** Additional props applied to drawer content body */ drawerContentBodyProps?: DrawerContentBodyProps; /** Additional props applied to drawer head */ drawerHeadProps?: DrawerHeadProps; /** Additional props applied to drawer actions */ drawerActionsProps?: DrawerActionsProps; /** Additional props applied to drawer close button */ drawerCloseButtonProps?: DrawerCloseButtonProps; /** Additional props appleid to drawer panel body */ drawerPanelBodyProps?: DrawerPanelBodyProps; /** Flag indicating whether a divider should render between the drawer head and title. */ hasDrawerHeadDivider?: boolean; /** Whether to show drawer loading state */ isLoading?: boolean; /** Additional props for loading state */ loadingState?: SkeletonProps; /** Content to show in error state. Error state will appear once content is passed in. */ errorState?: HistoryEmptyStateProps; /** Content to show in empty state. Empty state will appear once content is passed in. */ emptyState?: HistoryEmptyStateProps; /** Content to show in no results state. No results state will appear once content is passed in. */ noResultsState?: HistoryEmptyStateProps; /** Sets drawer to compact styling. */ isCompact?: boolean; /** Display title */ title?: string; /** Icon displayed in title */ navTitleIcon?: React.ReactNode; /** Title header level */ navTitleProps?: Partial; /** Visually hidden text that gets announced by assistive technologies. Should be used to convey the result count when the search input value changes. */ searchInputScreenReaderText?: string; /** Custom action rendered before the search input. */ searchActionStart?: React.ReactNode; /** Custom action rendered after the search input. */ searchActionEnd?: React.ReactNode; /** A custom search toolbar to render below the title. This will override the default search actions and/or search input. */ searchToolbar?: React.ReactNode; /** Additional props passed to MenuContent */ menuContentProps?: Omit; } export const ChatbotConversationHistoryNav: FunctionComponent = ({ onDrawerToggle, isDrawerOpen, setIsDrawerOpen, activeItemId, onSelectActiveItem, conversations, menuListProps, newChatButtonText = 'New chat', drawerContent, onNewChat, newChatButtonProps, searchInputPlaceholder = 'Search previous conversations...', searchInputAriaLabel = 'Search previous conversations', searchInputProps, handleTextInputChange, displayMode, reverseButtonOrder = false, drawerActionsTestId = 'chatbot-nav-drawer-actions', drawerPanelContentProps, drawerContentProps, drawerContentBodyProps, drawerHeadProps, drawerActionsProps, drawerCloseButtonProps, drawerPanelBodyProps, hasDrawerHeadDivider, isLoading, loadingState, errorState, emptyState, noResultsState, isCompact, title = 'Chat history', navTitleProps, navTitleIcon = , searchInputScreenReaderText, searchActionStart, searchActionEnd, searchToolbar, menuProps, menuGroupProps, menuContentProps, ...props }: ChatbotConversationHistoryNavProps) => { const drawerRef = useRef(null); const onExpand = () => { drawerRef.current && drawerRef.current.focus(); }; const isConversation = (item: any): item is Conversation => item && typeof item === 'object' && 'id' in item && 'text' in item; const getNavItem = (conversation: Conversation) => ( })} /* eslint-disable indent */ {...(conversation.menuItems ? { actions: ( ) } : {})} {...conversation.additionalProps} > {conversation.text} ); const buildConversations = () => { if (Array.isArray(conversations)) { return ( {conversations.map((conversation) => { if (isConversation(conversation)) { return {getNavItem(conversation)}; } else { return conversation; } })} ); } else { return ( <> {Object.keys(conversations).map((navGroup) => ( {conversations[navGroup].map((conversation: Conversation) => ( {getNavItem(conversation)} ))} ))} ); } }; // Menu Content // - Consumers should pass an array to of the list of conversations // - Groups could be optional, but items need to be ordered by date const renderMenuContent = () => { if (errorState) { return ; } if (emptyState) { return ; } if (noResultsState) { return ; } return ( {buildConversations()} ); }; const renderDrawerContent = () => ( <> {renderMenuContent()} ); const searchInputContainer = handleTextInputChange && (
handleTextInputChange(value)} placeholder={searchInputPlaceholder} {...searchInputProps} /> {searchInputScreenReaderText && (
{searchInputScreenReaderText}
)}
); const renderSearchAndActions = () => { if (searchToolbar) { return searchToolbar; } return searchActionStart || searchActionEnd ? (
{searchActionStart && {searchActionStart}} {searchInputContainer && {searchInputContainer}} {searchActionEnd && {searchActionEnd}}
) : ( searchInputContainer ); }; const renderPanelContent = () => { const drawer = ( <> {onNewChat && ( )} {hasDrawerHeadDivider && }
{navTitleIcon} {title}
{!isLoading && renderSearchAndActions()}
{isLoading ? : renderDrawerContent()} ); return ( {drawer} ); }; // An onKeyDown property must be passed to the Drawer component to handle closing // the drawer panel and deactivating the focus trap via the Escape key. const onEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { // prevents using escape key on menu buttons from closing the panel, but I'm not sure if this is allowed if (event.target instanceof HTMLInputElement && event.target.type !== 'button') { setIsDrawerOpen(false); } } }; return ( <>
{drawerContent}
); }; export default ChatbotConversationHistoryNav;