From ed64845a1d653d82cf96e3ab5ef1135bc8873edb Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Mon, 20 Jul 2026 16:12:55 -0400 Subject: [PATCH] feat(History): Add expandable sections Assisted-by: Cursor --- ...atbotHeaderDrawerWithCollapsibleGroups.tsx | 129 +++++++++++++ .../extensions/chatbot/examples/UI/UI.md | 13 +- .../ChatbotConversationHistoryNav.scss | 54 +++++- .../ChatbotConversationHistoryNav.test.tsx | 145 +++++++++++++- .../ChatbotConversationHistoryNav.tsx | 182 ++++++++++++++---- 5 files changed, 480 insertions(+), 43 deletions(-) create mode 100644 packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx new file mode 100644 index 000000000..82d4f6ee6 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotHeaderDrawerWithCollapsibleGroups.tsx @@ -0,0 +1,129 @@ +import { useState, FunctionComponent } from 'react'; +import { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'; +import ChatbotConversationHistoryNav, { + Conversation, + ConversationGroup +} from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; +import { Checkbox, MenuItem } from '@patternfly/react-core'; + +const pinnedChats: Conversation[] = [ + { + id: '1', + text: 'Red Hat products and services' + } +]; + +const recentChats: Conversation[] = [ + { + id: '2', + text: 'Enterprise Linux installation and setup' + }, + { + id: '3', + text: 'Troubleshoot system crash' + }, + { + id: '4', + text: 'Ansible security and updates' + }, + { + id: '5', + text: 'Red Hat certification' + }, + { + id: '6', + text: 'Lightspeed user documentation' + } +]; + +const savedPrompts: Conversation[] = [ + { + id: '7', + text: 'Summarize this document' + }, + { + id: '8', + text: 'Draft a release announcement' + } +]; + +const VISIBLE_CHAT_COUNT = 3; + +export const ChatbotHeaderDrawerWithCollapsibleGroupsDemo: FunctionComponent = () => { + const [isOpen, setIsOpen] = useState(true); + const [isSavedPromptsExpanded, setIsSavedPromptsExpanded] = useState(false); + const [isShowingAllChats, setIsShowingAllChats] = useState(false); + + const visibleChats = isShowingAllChats ? recentChats : recentChats.slice(0, VISIBLE_CHAT_COUNT); + const hiddenChatCount = recentChats.length - VISIBLE_CHAT_COUNT; + + const renderExpandButton = () => { + if (isShowingAllChats) { + return [ + setIsShowingAllChats(false)} + > + Show less + + ]; + } + if (hiddenChatCount > 0 && !isShowingAllChats) { + return [ + setIsShowingAllChats(true)} + > + {`Show all (${recentChats.length})`} + + ]; + } + return []; + }; + + const conversations: ConversationGroup[] = [ + { + id: 'pinned', + label: 'Pinned chats', + items: pinnedChats + }, + { + id: 'chats', + label: 'Chats', + items: [...visibleChats, ...renderExpandButton()] + }, + { + id: 'saved-prompts', + label: 'Saved prompts', + collapsible: { + isExpanded: isSavedPromptsExpanded, + onToggle: setIsSavedPromptsExpanded + }, + items: savedPrompts + } + ]; + + return ( + <> + setIsOpen(!isOpen)} + id="collapsible-groups-drawer-visible" + name="collapsible-groups-drawer-visible" + /> + setIsOpen(!isOpen)} + isDrawerOpen={isOpen} + setIsDrawerOpen={setIsOpen} + conversations={conversations} + drawerContent={
Drawer content
} + /> + + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md index 03eb020a7..5e314f4d2 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md @@ -35,7 +35,8 @@ propComponents: 'MessageBarWithAttachMenuProps', 'SourceDetailsMenuItem', 'ChatbotConversationHistoryNav', - 'Conversation' + 'Conversation', + 'ConversationGroup' ] sortValue: 2 --- @@ -401,6 +402,16 @@ To help users track important conversations, add a "pin" option to the conversat ``` +### Grouped and collapsible conversation history + +For grouped chat history, pass a `ConversationGroup[]` to describe each section's label, items, and behavior. Each group's `items` array accepts `Conversation` objects or custom menu content such as a **Show all** action. Use `collapsible` on a group to make that section expandable, and `footer` for content rendered after the group's menu list. + +The object form `{ [groupLabel: string]: Conversation[] }` remains supported as shorthand for simple static groups. + +```js file="./ChatbotHeaderDrawerWithCollapsibleGroups.tsx" + +``` + ### Renaming conversations in chat history drawer You can allow users to rename a conversation in the chat history drawer by implementing a modal that opens upon clicking a "Rename" (or similar) action. When doing so, you must ensure the following: diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss index 8b2fa2ecc..b8d04b6ca 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss @@ -60,12 +60,8 @@ --pf-v6-c-menu__item-main--ColumnGap: var(--pf-t--global--spacer--md); } - .pf-chatbot__menu-item-header > .pf-v6-c-menu__group-title { - color: var(--pf-t--global--text--color--subtle); - font-weight: var(--pf-t--global--font--weight--body--bold); - font-size: var(--pf-t--global--icon--size--font--sm); - --pf-v6-c-menu__group-title--PaddingInlineStart: var(--pf-t--global--spacer--sm); - --pf-v6-c-menu__group-title--PaddingInlineEnd: var(--pf-t--global--spacer--sm); + .pf-chatbot__menu-item-header > .pf-v6-c-menu__group-title, + .pf-chatbot__menu-item-header--collapsible .pf-chatbot__menu-group-toggle { position: -webkit-sticky; position: sticky; top: 0; @@ -73,6 +69,47 @@ z-index: var(--pf-t--global--z-index--md); } + .pf-chatbot__menu-item-header > .pf-v6-c-menu__group-title, + .pf-chatbot__menu-item-header--collapsible .pf-chatbot__menu-group-toggle > .pf-v6-c-expandable-section__toggle { + color: var(--pf-t--global--text--color--subtle); + font-weight: var(--pf-t--global--font--weight--body--bold); + font-size: var(--pf-t--global--icon--size--font--sm); + } + + .pf-chatbot__menu-group-toggle { + padding-block-start: var(--pf-t--global--spacer--sm); + padding-block-end: var(--pf-t--global--spacer--sm); + } + + .pf-chatbot__menu-item-header > .pf-v6-c-menu__group-title { + --pf-v6-c-menu__group-title--PaddingInlineStart: var(--pf-t--global--spacer--sm); + --pf-v6-c-menu__group-title--PaddingInlineEnd: var(--pf-t--global--spacer--sm); + } + + .pf-chatbot__menu-item-header--collapsible .pf-chatbot__menu-group-toggle { + width: 100%; + + .pf-v6-c-expandable-section__toggle { + margin: 0; + } + + .pf-v6-c-button.pf-m-link { + --pf-v6-c-button--PaddingBlockStart: 0; + --pf-v6-c-button--PaddingBlockEnd: 0; + --pf-v6-c-button--PaddingInlineStart: var(--pf-t--global--spacer--sm); + --pf-v6-c-button--PaddingInlineEnd: var(--pf-t--global--spacer--sm); + color: inherit; + font-weight: inherit; + font-size: inherit; + justify-content: flex-start; + width: 100%; + } + + .pf-v6-c-expandable-section__toggle-icon { + color: var(--pf-t--global--text--color--subtle); + } + } + .pf-chatbot__menu-item { --pf-v6-c-menu__item--PaddingInlineStart: var(--pf-t--global--spacer--sm); --pf-v6-c-menu__item--PaddingInlineEnd: var(--pf-t--global--spacer--sm); @@ -86,6 +123,11 @@ border-radius: var(--pf-t--global--border--radius--small); } + .pf-chatbot__menu-item--show-button { + color: var(--pf-t--global--text--color--link--default); + font-size: var(--pf-t--global--font--size--body--default); + } + li.pf-chatbot__menu-item:hover::after { position: absolute; inset: 0; diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index e030dcff3..a9f2ee11b 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -2,8 +2,8 @@ import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ChatbotDisplayMode } from '../Chatbot/Chatbot'; -import ChatbotConversationHistoryNav, { Conversation } from './ChatbotConversationHistoryNav'; -import { EmptyStateStatus, Spinner } from '@patternfly/react-core'; +import ChatbotConversationHistoryNav, { Conversation, ConversationGroup } from './ChatbotConversationHistoryNav'; +import { EmptyStateStatus, Spinner, MenuItem } from '@patternfly/react-core'; import { BellIcon, OutlinedCommentsIcon, SearchIcon } from '@patternfly/react-icons'; import { ComponentType } from 'react'; @@ -715,4 +715,145 @@ describe('ChatbotConversationHistoryNav', () => { ); expect(screen.getByTestId('bell')).toBeInTheDocument(); }); + + it('renders static and collapsible groups from ConversationGroup[]', () => { + const groups: ConversationGroup[] = [ + { + id: 'pinned', + label: 'Pinned chats', + items: initialConversations + }, + { + id: 'chats', + label: 'Chats', + collapsible: { + isExpanded: true, + onToggle: jest.fn() + }, + items: [{ id: '2', text: 'Chatbot extension' }] + } + ]; + + render( + + ); + + expect(screen.getByRole('heading', { name: 'Pinned chats', level: 3 })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Pinned chats' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Chats' })).toBeInTheDocument(); + }); + + it('collapses and expands a group when collapsible.onToggle is called', async () => { + const onToggle = jest.fn(); + const groups: ConversationGroup[] = [ + { + id: 'chats', + label: 'Chats', + collapsible: { + isExpanded: true, + onToggle + }, + items: [{ id: '2', text: 'Chatbot extension' }] + } + ]; + + const { rerender } = render( + + ); + + expect(screen.getByRole('menuitem', { name: /Chatbot extension/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Chats' })); + expect(onToggle).toHaveBeenCalledWith(false); + + rerender( + + ); + + await waitFor(() => { + expect(screen.queryByRole('menuitem', { name: /Chatbot extension/i })).not.toBeInTheDocument(); + }); + }); + + it('renders custom menu items and footers supplied in ConversationGroup', () => { + const groups: ConversationGroup[] = [ + { + id: 'chats', + label: 'Chats', + items: [ + initialConversations[0], + + Show all + + ], + footer:
Footer content
+ } + ]; + + render( + + ); + + expect(screen.getByRole('menuitem', { name: 'Show all' })).toBeInTheDocument(); + expect(screen.getByTestId('group-footer')).toBeInTheDocument(); + }); + + it('passes collapsible expandableSectionProps from ConversationGroup', () => { + const groups: ConversationGroup[] = [ + { + id: 'saved', + label: 'Saved prompts', + collapsible: { + isExpanded: true, + onToggle: jest.fn(), + expandableSectionProps: { className: 'test-expandable-section' } + }, + items: initialConversations + } + ]; + + render( + + ); + + expect(document.querySelector('.test-expandable-section')).toBeInTheDocument(); + }); }); diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx index bf21815f8..38be3a7c8 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx @@ -1,7 +1,7 @@ // ============================================================================ // Chatbot Header - Chatbot Conversation History Nav // ============================================================================ -import type { KeyboardEvent, FunctionComponent } from 'react'; +import type { KeyboardEvent, FunctionComponent, ReactNode } from 'react'; import { useRef, Fragment } from 'react'; // Import PatternFly components @@ -42,7 +42,11 @@ import { MenuContent, MenuItemProps, MenuGroupProps, - MenuContentProps + MenuContentProps, + ExpandableSection, + ExpandableSectionToggle, + ExpandableSectionProps, + ExpandableSectionToggleProps } from '@patternfly/react-core'; import { RhUiClockIcon, RhUiCommentIcon, RhUiEditFillIcon } from '@patternfly/react-icons'; @@ -73,6 +77,42 @@ export interface Conversation { /** Custom dropdown ID to ensure uniqueness across demo instances */ dropdownId?: string; } + +export interface ConversationGroupCollapsible { + /** Whether the group content is expanded */ + isExpanded: boolean; + /** Callback when the group is toggled */ + onToggle: (isExpanded: boolean) => void; + /** Additional props applied to ExpandableSection */ + expandableSectionProps?: Omit; + /** Additional props applied to ExpandableSectionToggle */ + expandableSectionToggleProps?: ExpandableSectionToggleProps; +} + +export interface ConversationGroup { + /** Unique group id */ + id: string; + /** Group label rendered as a MenuGroup heading or collapsible toggle label */ + label: ReactNode; + /** Conversation items or custom menu content such as a "Show all" action */ + items: (Conversation | ReactNode)[]; + /** Content rendered after the group's menu list */ + footer?: ReactNode; + /** Custom group header that replaces the default label or collapsible toggle */ + header?: ReactNode; + /** When set, the group renders as a collapsible section */ + collapsible?: ConversationGroupCollapsible; + /** Additional props applied to the conversation menu group */ + menuGroupProps?: MenuGroupProps; + /** Additional props applied to the conversation list */ + menuListProps?: Omit; +} + +export type Conversations = + | (Conversation | ReactNode)[] + | ConversationGroup[] + | { [key: string]: (Conversation | ReactNode)[] }; + export interface ChatbotConversationHistoryNavProps extends DrawerProps { /** Function called to toggle drawer */ onDrawerToggle: (event: React.KeyboardEvent | React.MouseEvent | React.TransitionEvent) => void; @@ -85,7 +125,7 @@ export interface ChatbotConversationHistoryNavProps extends DrawerProps { /** Callback function for when an item is selected */ onSelectActiveItem?: (event?: React.MouseEvent, itemId?: string | number) => void; /** Items shown in chat history */ - conversations: Conversation[] | { [key: string]: Conversation[] }; + conversations: Conversations; /** 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. */ @@ -160,6 +200,15 @@ export interface ChatbotConversationHistoryNavProps extends DrawerProps { menuContentProps?: Omit; } +const isConversation = (item: unknown): item is Conversation => + Boolean(item && typeof item === 'object' && 'id' in item && 'text' in item && !('items' in item)); + +const isConversationGroup = (item: unknown): item is ConversationGroup => + Boolean(item && typeof item === 'object' && 'id' in item && 'label' in item && 'items' in item); + +const isConversationGroupArray = (items: unknown[]): items is ConversationGroup[] => + items.length > 0 && isConversationGroup(items[0]); + export const ChatbotConversationHistoryNav: FunctionComponent = ({ onDrawerToggle, isDrawerOpen, @@ -211,9 +260,6 @@ export const ChatbotConversationHistoryNav: FunctionComponent - item && typeof item === 'object' && 'id' in item && 'text' in item; - const getNavItem = (conversation: Conversation) => ( ); + const renderConversationItems = (items: (Conversation | ReactNode)[], keyPrefix = '') => + items.map((item, index) => { + if (isConversation(item)) { + return {getNavItem(item)}; + } + + return {item}; + }); + + const renderGroupBody = (group: ConversationGroup) => ( + <> + {renderConversationItems(group.items, group.id)} + {group.footer} + + ); + + const renderConversationGroup = (group: ConversationGroup) => { + if (group.header) { + return ( +
+ {group.header} + {renderGroupBody(group)} +
+ ); + } + + if (group.collapsible) { + const toggleId = `chatbot-nav-group-${group.id}-toggle`; + const contentId = `chatbot-nav-group-${group.id}-content`; + const { isExpanded, onToggle, expandableSectionProps, expandableSectionToggleProps } = group.collapsible; + + return ( +
+ + {group.label} + + + {renderGroupBody(group)} + +
+ ); + } + + return ( + + {renderGroupBody(group)} + + ); + }; + + const normalizeObjectGroups = (groupedConversations: { [key: string]: (Conversation | ReactNode)[] }) => + Object.keys(groupedConversations).map((groupKey) => ({ + id: groupKey, + label: groupKey, + items: groupedConversations[groupKey], + menuGroupProps: + menuGroupProps && typeof menuGroupProps === 'object' && groupKey in menuGroupProps + ? (menuGroupProps as { [key: string]: MenuGroupProps })[groupKey] + : undefined, + menuListProps: + menuListProps && typeof menuListProps === 'object' && groupKey in menuListProps + ? (menuListProps as { [key: string]: Omit })[groupKey] + : undefined + })); + const buildConversations = () => { if (Array.isArray(conversations)) { + if (isConversationGroupArray(conversations)) { + return <>{conversations.map(renderConversationGroup)}; + } + return ( - - {conversations.map((conversation) => { - if (isConversation(conversation)) { - return {getNavItem(conversation)}; - } else { - return conversation; - } - })} + )}> + {renderConversationItems(conversations)} ); - } else { - return ( - <> - {Object.keys(conversations).map((navGroup) => ( - - - {conversations[navGroup].map((conversation: Conversation) => ( - {getNavItem(conversation)} - ))} - - - ))} - - ); } + + return <>{normalizeObjectGroups(conversations).map(renderConversationGroup)}; }; // Menu Content