diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx new file mode 100644 index 000000000..8f44de8bb --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx @@ -0,0 +1,202 @@ +// From Cursor, with aid +import React, { FunctionComponent, useState, useRef, useEffect } from 'react'; +import { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'; +import ChatbotConversationHistoryNav, { + Conversation +} from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; +import { ChatbotModal } from '@patternfly/chatbot/dist/dynamic/ChatbotModal'; +import { + Checkbox, + DropdownItem, + DropdownList, + Button, + TextInput, + Form, + FormGroup, + ModalHeader, + ModalBody, + ModalFooter +} from '@patternfly/react-core'; + +export const ChatbotHeaderTitleDemo: FunctionComponent = () => { + const [isDrawerOpen, setIsDrawerOpen] = useState(true); + const displayMode = ChatbotDisplayMode.embedded; + + // Modal state + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingConversationId, setEditingConversationId] = useState(null); + const [editingText, setEditingText] = useState(''); + + // Ref for the text input + const textInputRef = useRef(null); + + // Focus the text input when modal opens + useEffect(() => { + if (isModalOpen && textInputRef.current) { + textInputRef.current.focus(); + // Move cursor to the end of the text + const length = textInputRef.current.value.length; + textInputRef.current.setSelectionRange(length, length); + } + }, [isModalOpen]); + + const findConversationAndGroup = (conversations: { [key: string]: Conversation[] }, itemId: string | number) => { + for (const [groupKey, conversationList] of Object.entries(conversations)) { + const conversationIndex = conversationList.findIndex((conv) => conv.id === itemId); + if (conversationIndex !== -1) { + return { groupKey, conversationIndex, conversation: conversationList[conversationIndex] }; + } + } + return null; + }; + + const onRenameClick = (itemId: string | number) => { + const result = findConversationAndGroup(conversations, itemId); + if (result) { + setEditingConversationId(itemId); + setEditingText(result.conversation.text); + setIsModalOpen(true); + } + }; + + const handleModalSave = () => { + if (editingConversationId) { + setConversations((prevConversations) => { + const result = findConversationAndGroup(prevConversations, editingConversationId); + if (!result) { + return prevConversations; + } + + const { groupKey, conversationIndex } = result; + const newConversations = { ...prevConversations }; + const newGroup = [...newConversations[groupKey]]; + + newGroup[conversationIndex] = { ...newGroup[conversationIndex], text: editingText }; + newConversations[groupKey] = newGroup; + + return newConversations; + }); + } + handleModalClose(); + }; + + const handleModalCancel = () => { + handleModalClose(); + }; + + const handleModalClose = () => { + setIsModalOpen(false); + setEditingConversationId(null); + setEditingText(''); + }; + + const handleTextInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleModalSave(); + } + }; + + const renderMenuItems = (itemId: string | number) => [ + + + Download + + onRenameClick(itemId)}> + Rename + + + Archive + + + Delete + + + ]; + + const initialConversations: { [key: string]: Conversation[] } = { + Today: [{ id: '1', text: 'Red Hat products and services' }], + 'This month': [ + { + id: '2', + text: 'Enterprise Linux installation and setup' + }, + { id: '3', text: 'Troubleshoot system crash' } + ], + March: [ + { id: '4', text: 'Ansible security and updates' }, + { id: '5', text: 'Red Hat certification' }, + { id: '6', text: 'Lightspeed user documentation' } + ], + February: [ + { id: '7', text: 'Crashing pod assistance' }, + { id: '8', text: 'OpenShift AI pipelines' }, + { id: '9', text: 'Updating subscription plan' }, + { id: '10', text: 'Red Hat licensing options' } + ], + January: [ + { id: '11', text: 'RHEL system performance' }, + { id: '12', text: 'Manage user accounts' } + ] + }; + + const [conversations, setConversations] = useState(initialConversations); + + // Create conversations with menu items dynamically + const conversationsWithMenuItems = () => { + const newConversations = { ...conversations }; + Object.keys(newConversations).forEach((groupKey) => { + newConversations[groupKey] = newConversations[groupKey].map((conv) => ({ + ...conv, + menuItems: renderMenuItems(conv.id) + })); + }); + return newConversations; + }; + + return ( + <> + setIsDrawerOpen(!isDrawerOpen)} + id="drawer-actions-visible" + name="drawer-actions-visible" + > + setIsDrawerOpen(!isDrawerOpen)} + isDrawerOpen={isDrawerOpen} + setIsDrawerOpen={setIsDrawerOpen} + conversations={conversationsWithMenuItems()} + drawerContent={
Drawer content
} + /> + + + + +
+ + setEditingText(value)} + onKeyDown={handleTextInputKeyDown} + id="conversation-name" + /> + +
+
+ + + + +
+ + ); +}; 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 35be739ee..1afae00ed 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 @@ -86,7 +86,7 @@ import userAvatar from '../Messages/user_avatar.svg'; import patternflyAvatar from '../Messages/patternfly_avatar.jpg'; import termsAndConditionsHeader from './PF-TermsAndConditionsHeader.svg'; import { CloseIcon, SearchIcon, OutlinedCommentsIcon } from '@patternfly/react-icons'; -import { FunctionComponent, FormEvent, useState, useRef, MouseEvent, isValidElement, cloneElement, Children, ReactNode, Ref, MouseEvent as ReactMouseEvent, CSSProperties} from 'react'; +import { FunctionComponent, FormEvent, useState, useRef, MouseEvent, isValidElement, cloneElement, Children, ReactNode, Ref, MouseEvent as ReactMouseEvent, CSSProperties, useEffect} from 'react'; ## Structure @@ -374,6 +374,19 @@ To help users track important conversations, add a "pin" option to the conversat ``` +### Renaming conversations in history drawer + +You can allow users to rename a conversation in the history drawer by implementing a modal that opens upon clicking a "Rename" (or similar) action. When doing so, you must ensure the following: + +- When the modal opens, focus is placed at the end of the text input. +- When the modal closes, focus goes back to the action toggle that was previously opened. +- Changes can be canceled via the **Escape** key or clicking a "Cancel" button. +- Changes can be saved via the **Enter** key or by clicking a "Save" button. + +```js file="./ChatbotConversationEditing.tsx" + +``` + ### Drawer with active conversation If you're showing a conversation that is already active, you can set the `activeItemId` prop on your `` to apply an active visual state. diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryDropdown.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryDropdown.test.tsx index 64c4cd7ac..13bc117ab 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryDropdown.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryDropdown.test.tsx @@ -14,13 +14,13 @@ describe('ChatbotConversationHistoryDropdown', () => { it('should render the dropdown', () => { render(); - expect(screen.queryByRole('menuitem', { name: /Conversation options/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Conversation options/i })).toBeInTheDocument(); }); it('should display the dropdown menuItems', () => { render(); - const toggle = screen.queryByRole('menuitem', { name: /Conversation options/i })!; + const toggle = screen.queryByRole('button', { name: /Conversation options/i })!; expect(toggle).toBeInTheDocument(); fireEvent.click(toggle); @@ -33,7 +33,7 @@ describe('ChatbotConversationHistoryDropdown', () => { it('should invoke onSelect callback when menuitem is clicked', () => { render(); - const toggle = screen.queryByRole('menuitem', { name: /Conversation options/i })!; + const toggle = screen.queryByRole('button', { name: /Conversation options/i })!; fireEvent.click(toggle); fireEvent.click(screen.getByText('Rename')); @@ -42,7 +42,7 @@ describe('ChatbotConversationHistoryDropdown', () => { it('should toggle the dropdown when menuitem is clicked', () => { render(); - const toggle = screen.queryByRole('menuitem', { name: /Conversation options/i })!; + const toggle = screen.queryByRole('button', { name: /Conversation options/i })!; fireEvent.click(toggle); fireEvent.click(screen.getByText('Delete')); @@ -53,7 +53,7 @@ describe('ChatbotConversationHistoryDropdown', () => { it('should close the dropdown when user clicks outside', () => { render(); - const toggle = screen.queryByRole('menuitem', { name: /Conversation options/i })!; + const toggle = screen.queryByRole('button', { name: /Conversation options/i })!; fireEvent.click(toggle); expect(screen.queryByText('Delete')).toBeInTheDocument(); @@ -64,7 +64,7 @@ describe('ChatbotConversationHistoryDropdown', () => { it('should show the tooltip when the user hovers over the toggle button', async () => { render(); - const toggle = screen.queryByRole('menuitem', { name: /Actions dropdown/i })!; + const toggle = screen.queryByRole('button', { name: /Actions dropdown/i })!; fireEvent( toggle, diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryDropdown.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryDropdown.tsx index 080ebc016..28a2bd046 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryDropdown.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryDropdown.tsx @@ -44,7 +44,6 @@ export const ChatbotConversationHistoryDropdown: FunctionComponent setIsOpen(!isOpen)} - role="menuitem" > diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss index 469b0b4b7..b9424aa26 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss @@ -6,10 +6,7 @@ position: absolute; border-radius: var(--pf-t--global--border--radius--medium); } - // Drawer input - // ---------------------------------------------------------------------------- - .pf-chatbot__input { - } + // Drawer title // ---------------------------------------------------------------------------- .pf-chatbot__title-container { @@ -27,50 +24,61 @@ } // Drawer menu // ---------------------------------------------------------------------------- - .pf-v6-c-menu { - --pf-v6-c-menu--PaddingBlockStart: 0; - --pf-v6-c-menu--BackgroundColor: var(--pf-t--global--background--color--floating--default); - overflow: initial; - position: relative; - } - .pf-v6-c-menu__item-main { - --pf-v6-c-menu__item-main--ColumnGap: var(--pf-t--global--spacer--md); + .pf-chatbot__conversation-list { + --pf-v6-c-list--Gap: var(--pf-t--global--spacer--xs); + + margin-block-start: var(--pf-t--global--spacer--md); + margin-block-end: var(--pf-t--global--spacer--md); } - .pf-chatbot__menu-item-header > .pf-v6-c-menu__group-title { + + .pf-chatbot__conversation-list-header { 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); + padding-inline-start: var(--pf-t--global--spacer--sm); + padding-inline-end: var(--pf-t--global--spacer--sm); position: -webkit-sticky; position: sticky; top: 0; background-color: var(--pf-t--global--background--color--floating--default); z-index: var(--pf-t--global--z-index--md); } - .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); - padding-block-start: var(--pf-t--global--spacer--xs); - padding-block-end: var(--pf-t--global--spacer--xs); - color: var(--pf-t--global--text--color--regular); - font-size: var(--pf-t--global--font--size--body--lg); - font-weight: var(--pf-t--global--font--weight--body--default); - border-radius: var(--pf-t--global--border--radius--small); - } - // allows focus state to have border radius - .pf-v6-c-menu__list-item.pf-chatbot__menu-item { - overflow: hidden; + .pf-chatbot__conversation-list-item { + & > span { + display: flex; + column-gap: var(--pf-t--global--spacer--sm); + } + + & .pf-chatbot__conversation-history-item { + --pf-v6-c-button--JustifyContent: flex-start; + --pf-v6-c-button--FontSize: var(--pf-t--global--font--size--body--lg); + --pf-v6-c-button--m-link--Color: var(--pf-t--global--text--color--regular); + --pf-v6-c-button--m-link__icon--Color: var(--pf-t--global--icon--color--regular); + --pf-v6-c-button--m-link--hover--Color: var(--pf-t--global--text--color--regular--hover); + --pf-v6-c-button--m-link--hover__icon--Color: var(--pf-t--global--icon--color--regular); + --pf-v6-c-button--m-link--m-clicked--Color: var(--pf-t--global--text--color--regular--clicked); + --pf-v6-c-button--m-link--m-clicked__icon--Color: var(--pf-t--global--icon--color--regular); + + column-gap: var(--pf-t--global--spacer--md); + flex-basis: 100%; + + & .pf-v6-c-button__text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } } + .pf-chatbot__history-actions { transform: rotate(90deg); } - .pf-chatbot__menu-item--active { + .pf-chatbot__conversation-list-item--active { background-color: var(--pf-t--global--background--color--action--plain--clicked); } - button.pf-chatbot__menu-item--active { + button.pf-chatbot__conversation-list-item--active { background-color: initial; } } @@ -233,8 +241,8 @@ } } - .pf-chatbot__menu-item { - font-size: var(--pf-t--global--font--size--body--md); + .pf-chatbot__conversation-history-item { + --pf-v6-c-button--FontSize: var(--pf-t--global--font--size--body--md); } .pf-v6-c-drawer__head { diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index 43796d188..5a3d01855 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -491,4 +491,74 @@ describe('ChatbotConversationHistoryNav', () => { const iconElement = container.querySelector('.pf-chatbot__title-icon'); expect(iconElement).toBeInTheDocument(); }); + + it('Passes titleProps to Title', () => { + render( + + ); + expect(screen.getByRole('heading', { name: /Today/i })).toHaveClass('test'); + }); + + it('Overrides Title heading level when titleProps.headingLevel is passed', () => { + render( + + ); + expect(screen.queryByRole('heading', { name: /Today/i, level: 4 })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Today/i, level: 2 })).toBeInTheDocument(); + }); + + it('Passes listProps to List when conversations is an array', () => { + render( + + ); + expect(screen.getByRole('list')).toHaveClass('test'); + }); + + it('Passes listProps to List when conversations is an object', () => { + render( + + ); + expect(screen.getByRole('list')).toHaveClass('test'); + }); + + it('Passes listItemProps to ListItem', () => { + render( + + ); + expect(screen.getByRole('listitem')).toHaveClass('test'); + }); }); diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx index 31aadd104..af464f1cf 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx @@ -8,6 +8,7 @@ import { useRef, Fragment } from 'react'; // Import PatternFly components import { Button, + ButtonProps, Drawer, DrawerPanelContent, DrawerContent, @@ -18,13 +19,10 @@ import { DrawerCloseButton, DrawerContentBody, SearchInput, - Menu, - MenuList, - MenuGroup, - MenuItem, - MenuContent, - MenuItemProps, - MenuProps, + List, + ListItem, + ListItemProps, + Title, DrawerPanelContentProps, DrawerContentProps, DrawerContentBodyProps, @@ -33,9 +31,10 @@ import { DrawerCloseButtonProps, DrawerPanelBodyProps, SkeletonProps, - Title, Icon, - ButtonProps + MenuProps, // Remove in next breaking change + TitleProps, + ListProps } from '@patternfly/react-core'; import { OutlinedClockIcon, OutlinedCommentAltIcon, PenToSquareIcon } from '@patternfly/react-icons'; @@ -61,8 +60,10 @@ export interface Conversation { label?: string; /** Callback for when user selects item. */ onSelect?: (event?: React.MouseEvent, value?: string | number) => void; - /** Additional props passed to conversation menu item */ - additionalProps?: MenuItemProps; + /** Additional props passed to conversation button item */ + additionalProps?: ButtonProps; + /** Additional props passed to conversation list item */ + listItemProps?: Omit; } export interface ChatbotConversationHistoryNavProps extends DrawerProps { /** Function called to toggle drawer */ @@ -79,6 +80,10 @@ export interface ChatbotConversationHistoryNavProps extends DrawerProps { conversations: Conversation[] | { [key: string]: Conversation[] }; /** Additional button props for new chat button. */ newChatButtonProps?: ButtonProps; + /** Additional props applied to all conversation list headers */ + titleProps?: Partial; + /** Additional props applied to conversation list. If conversations is an object, you should pass an object of ListProps for each group. */ + listProps?: ListProps | { [key: string]: ListProps }; /** Text shown in blue button */ newChatButtonText?: string; /** Callback function for when blue button is clicked. Omit to hide blue "new chat button" */ @@ -97,7 +102,7 @@ export interface ChatbotConversationHistoryNavProps extends DrawerProps { reverseButtonOrder?: boolean; /** Custom test id for the drawer actions */ drawerActionsTestId?: string; - /** Additional props applied to menu */ + /** @deprecated Additional props applied to list container */ menuProps?: MenuProps; /** Additional props applied to panel */ drawerPanelContentProps?: DrawerPanelContentProps; @@ -136,6 +141,8 @@ export const ChatbotConversationHistoryNav: FunctionComponent ( - })} - /* eslint-disable indent */ - {...(conversation.menuItems - ? { - actions: ( - - ) - } - : {})} - {...conversation.additionalProps} + {...conversation.listItemProps} /* eslint-enable indent */ > - {conversation.text} - + <> + + {conversation.menuItems && ( + + )} + + ); - const buildMenu = () => { + const buildConversations = () => { if (Array.isArray(conversations)) { - // Render for array of MenuItemObject return ( - + {conversations.map((conversation) => ( {getNavItem(conversation)} ))} - + ); } else { - // Render for object with NavItemObject arrays as values return ( - <> +
{Object.keys(conversations).map((navGroup) => ( - - +
+ + {navGroup} + + {conversations[navGroup].map((conversation) => ( {getNavItem(conversation)} ))} - - + +
))} - +
); } }; @@ -238,11 +248,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent; } - return ( - - {buildMenu()} - - ); + return <>{buildConversations()}; }; const renderDrawerContent = () => ( diff --git a/packages/module/src/ChatbotModal/ChatbotModal.scss b/packages/module/src/ChatbotModal/ChatbotModal.scss index da2f27616..25deec61e 100644 --- a/packages/module/src/ChatbotModal/ChatbotModal.scss +++ b/packages/module/src/ChatbotModal/ChatbotModal.scss @@ -20,7 +20,7 @@ padding-block-end: var(--pf-t--global--spacer--xl); } .pf-v6-c-modal-box__header { - padding-block-end: var(--pf-t--global--spacer--lg); + padding-block-end: var(--pf-t--global--spacer--sm); } }