From 0855085e15924d49d007b18ebb080fb1a7c62280 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 25 Jul 2025 14:45:47 -0400 Subject: [PATCH 01/14] feat(ConversationHistory): added ability to edit convo history names --- .../UI/ChatbotConversationEditing.tsx | 181 +++++++++++++++++ .../extensions/chatbot/examples/UI/UI.md | 8 + .../ChatbotConversationHistoryNav.scss | 67 +++--- .../ChatbotConversationHistoryNav.test.tsx | 190 ++++++++++++++++++ .../ChatbotConversationHistoryNav.tsx | 125 +++++++----- 5 files changed, 490 insertions(+), 81 deletions(-) create mode 100644 packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx 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..ba3d7ebba --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx @@ -0,0 +1,181 @@ +// From Cursor, with aid +import { FunctionComponent, useState, useRef } from 'react'; +import { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'; +import ChatbotConversationHistoryNav, { + Conversation +} from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; +import { Checkbox, DropdownItem, DropdownList } from '@patternfly/react-core'; + +export const ChatbotHeaderTitleDemo: FunctionComponent = () => { + const [isDrawerOpen, setIsDrawerOpen] = useState(true); + const displayMode = ChatbotDisplayMode.embedded; + + const originalTextRef = useRef({}); + + const onRenameClick = (itemId: string | number) => { + setConversations((prevConversations) => { + const newConversations = { ...prevConversations }; + Object.keys(newConversations).forEach((groupKey) => { + newConversations[groupKey] = newConversations[groupKey].map((conv) => { + if (conv.id === itemId) { + originalTextRef.current[conv.id] = conv.text; + return { ...conv, isEditing: true }; + } + return conv; + }); + }); + return newConversations; + }); + + setTimeout(() => { + const input = document.getElementById(`conversation-${itemId}-input`); + if (input) { + input.focus(); + } + }, 100); + }; + + const handleInputChange = (itemId: string | number, event: React.FormEvent, value: string) => { + setConversations((prevConversations) => { + const newConversations = { ...prevConversations }; + Object.keys(newConversations).forEach((groupKey) => { + newConversations[groupKey] = newConversations[groupKey].map((conv) => + conv.id === itemId ? { ...conv, text: value } : conv + ); + }); + return newConversations; + }); + }; + + const handleInputBlur = (itemId: string | number, event: React.FocusEvent) => { + const newValue = event.target.value; + setConversations((prevConversations) => { + const newConversations = { ...prevConversations }; + Object.keys(newConversations).forEach((groupKey) => { + newConversations[groupKey] = newConversations[groupKey].map((conv) => + conv.id === itemId ? { ...conv, text: newValue, isEditing: false } : conv + ); + }); + return newConversations; + }); + + delete originalTextRef.current[itemId]; + }; + + const handleInputKeyDown = (itemId: string | number, event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + const newValue = event.currentTarget.value; + setConversations((prevConversations) => { + const newConversations = { ...prevConversations }; + Object.keys(newConversations).forEach((groupKey) => { + newConversations[groupKey] = newConversations[groupKey].map((conv) => + conv.id === itemId ? { ...conv, text: newValue, isEditing: false } : conv + ); + }); + return newConversations; + }); + // Clean up the stored original text + delete originalTextRef.current[itemId]; + } else if (event.key === 'Escape') { + event.stopPropagation(); + event.preventDefault(); + // Revert to the original text + const originalText = originalTextRef.current[itemId] || ''; + setConversations((prevConversations) => { + const newConversations = { ...prevConversations }; + Object.keys(newConversations).forEach((groupKey) => { + newConversations[groupKey] = newConversations[groupKey].map((conv) => + conv.id === itemId ? { ...conv, text: originalText, isEditing: false } : conv + ); + }); + return newConversations; + }); + // Clean up the stored original text + delete originalTextRef.current[itemId]; + } + }; + + 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', menuItems: renderMenuItems('1'), isEditing: false }], + 'This month': [ + { + id: '2', + text: 'Enterprise Linux installation and setup', + menuItems: renderMenuItems('2'), + isEditing: false + }, + { id: '3', text: 'Troubleshoot system crash', menuItems: renderMenuItems('3'), isEditing: false } + ], + March: [ + { id: '4', text: 'Ansible security and updates', menuItems: renderMenuItems('4'), isEditing: false }, + { id: '5', text: 'Red Hat certification', menuItems: renderMenuItems('5'), isEditing: false }, + { id: '6', text: 'Lightspeed user documentation', menuItems: renderMenuItems('6'), isEditing: false } + ], + February: [ + { id: '7', text: 'Crashing pod assistance', menuItems: renderMenuItems('7'), isEditing: false }, + { id: '8', text: 'OpenShift AI pipelines', menuItems: renderMenuItems('8'), isEditing: false }, + { id: '9', text: 'Updating subscription plan', menuItems: renderMenuItems('9'), isEditing: false }, + { id: '10', text: 'Red Hat licensing options', menuItems: renderMenuItems('10'), isEditing: false } + ], + January: [ + { id: '11', text: 'RHEL system performance', menuItems: renderMenuItems('11'), isEditing: false }, + { id: '12', text: 'Manage user accounts', menuItems: renderMenuItems('12'), isEditing: false } + ] + }; + + const [conversations, setConversations] = useState(initialConversations); + + const createConversationItems = () => { + const newConversations = { ...conversations }; + + Object.keys(newConversations).forEach((groupKey) => { + newConversations[groupKey] = newConversations[groupKey].map((conv) => ({ + ...conv, + inputAriaLabel: `Edit conversation name: ${originalTextRef.current[conv.id] ?? conv.text}`, + onChange: (event: React.FormEvent, value: string) => handleInputChange(conv.id, event, value), + onBlur: (event: React.FocusEvent) => handleInputBlur(conv.id, event), + onKeyDown: (event: React.KeyboardEvent) => handleInputKeyDown(conv.id, event) + })); + }); + + return newConversations; + }; + + return ( + <> + setIsDrawerOpen(!isDrawerOpen)} + id="drawer-actions-visible" + name="drawer-actions-visible" + > + setIsDrawerOpen(!isDrawerOpen)} + isDrawerOpen={isDrawerOpen} + setIsDrawerOpen={setIsDrawerOpen} + conversations={createConversationItems()} + 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 35be739ee..cfe7af760 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 @@ -374,6 +374,14 @@ To help users track important conversations, add a "pin" option to the conversat ``` +### Drawer with editable conversations + +Sample text. + +```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/ChatbotConversationHistoryNav.scss b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss index 469b0b4b7..699ab7654 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss @@ -6,10 +6,6 @@ position: absolute; border-radius: var(--pf-t--global--border--radius--medium); } - // Drawer input - // ---------------------------------------------------------------------------- - .pf-chatbot__input { - } // Drawer title // ---------------------------------------------------------------------------- .pf-chatbot__title-container { @@ -27,50 +23,57 @@ } // 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); + + 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 +236,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..29c6b5914 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -1,5 +1,6 @@ import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ChatbotDisplayMode } from '../Chatbot/Chatbot'; import ChatbotConversationHistoryNav, { Conversation } from './ChatbotConversationHistoryNav'; @@ -491,4 +492,193 @@ describe('ChatbotConversationHistoryNav', () => { const iconElement = container.querySelector('.pf-chatbot__title-icon'); expect(iconElement).toBeInTheDocument(); }); + + describe('Editable conversations', () => { + const editableConversations: Conversation = { + id: '1', + text: 'ChatBot documentation', + isEditing: true, + listItemProps: { + className: 'test' + }, + inputProps: { + id: 'test' + } + }; + it('Passes listItemProps to ListItem', () => { + render( + + ); + expect(screen.getByRole('listitem')).toHaveClass('test'); + }); + + it('Renders conversation as button when isEditing is false', () => { + render( + + ); + expect(screen.getByRole('button', { name: /ChatBot documentation/i })).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('Renders conversation as text input when isEditing is true', () => { + render( + + ); + expect( + screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }) + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /ChatBot documentation/i })).not.toBeInTheDocument(); + }); + + it('Passes inputProps to TextInput', () => { + render( + + ); + expect( + screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }) + ).toHaveAttribute('id', 'test'); + }); + + it('Renders conversation input with custom aria-label when inputAriaLabel is passed', () => { + render( + + ); + expect(screen.getByDisplayValue('ChatBot documentation')).toHaveAccessibleName('Edit name for guidelines'); + }); + + it('Does not call onChange on input by default', async () => { + const onChange = jest.fn(); + render( + <> + + + + ); + const input = screen.getByRole('textbox', { name: /Other input/i }); + await userEvent.type(input, 'New value'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('Calls onChange when input is changed', async () => { + const onChange = jest.fn(); + render( + + ); + const input = screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }); + await userEvent.type(input, 'New value'); + expect(onChange).toHaveBeenCalled(); + }); + + it('Does not call onBlur on input by default', async () => { + const onBlur = jest.fn(); + render( + + ); + const input = screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }); + await userEvent.click(input); + expect(onBlur).not.toHaveBeenCalled(); + }); + + it('Calls onBlur when input is blurred', async () => { + const onBlur = jest.fn(); + render( + + ); + const input = screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }); + await userEvent.click(input); + await userEvent.tab(); + expect(onBlur).toHaveBeenCalled(); + }); + + it('Does not call onKeyDown on input by default', async () => { + const onKeyDown = jest.fn(); + render( + + ); + const input = screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }); + expect(onKeyDown).not.toHaveBeenCalled(); + }); + + it('Calls onKeyDown when input is focused and key is pressed', async () => { + const onKeyDown = jest.fn(); + render( + + ); + const input = screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }); + + await userEvent.type(input, 'Enter'); + expect(onKeyDown).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx index 31aadd104..2c537e5d4 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 + TextInput, + TextInputProps, + MenuProps } from '@patternfly/react-core'; import { OutlinedClockIcon, OutlinedCommentAltIcon, PenToSquareIcon } from '@patternfly/react-icons'; @@ -53,6 +52,20 @@ export interface Conversation { noIcon?: boolean; /** Conversation */ text: string; + /** Flag to indicate if the conversation name is being edited. */ + isEditing?: boolean; + /** Ref for the text input that renders when isEditing is true. */ + inputRef?: React.RefObject; + /** The accessible name for the text input that renders when isEditing is true. */ + inputAriaLabel?: string; + /** Additional props passed to the text input that renders when isEditing is true. */ + inputProps?: TextInputProps; + /** Callback for when the conversation text input value changes during editing. */ + onChange?: (event: React.FormEvent, value: string) => void; + /** Callback for when the conversation text input value is blurred during editing. */ + onBlur?: (event: React.FocusEvent) => void; + /** Callback for when a keydown event is truggered during editing. This must include logic to submit or cancel an edit. */ + onKeyDown?: (event: React.KeyboardEvent) => void; /** Dropdown items rendered in conversation settings dropdown */ menuItems?: React.ReactNode; /** Optional classname applied to conversation settings dropdown */ @@ -61,8 +74,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 */ @@ -97,7 +112,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; @@ -146,7 +161,6 @@ export const ChatbotConversationHistoryNav: FunctionComponent ( - })} - /* eslint-disable indent */ - {...(conversation.menuItems - ? { - actions: ( - - ) - } - : {})} - {...conversation.additionalProps} + {...conversation.listItemProps} /* eslint-enable indent */ > - {conversation.text} - + {conversation.isEditing ? ( + + ) : ( + <> + + {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 +269,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent; } - return ( - - {buildMenu()} - - ); + return <>{buildConversations()}; }; const renderDrawerContent = () => ( From 0914f553aa28d8dee3cccaf023cda7c713fd8fec Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 25 Jul 2025 15:04:23 -0400 Subject: [PATCH 02/14] Added additional spread props --- .../ChatbotConversationHistoryNav.test.tsx | 58 +++++++++++++++++++ .../ChatbotConversationHistoryNav.tsx | 16 +++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index 29c6b5914..746918d24 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -505,6 +505,64 @@ describe('ChatbotConversationHistoryNav', () => { id: 'test' } }; + + 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( ; + /** 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" */ @@ -151,6 +157,8 @@ export const ChatbotConversationHistoryNav: FunctionComponent { if (Array.isArray(conversations)) { return ( - + {conversations.map((conversation) => ( {getNavItem(conversation)} ))} @@ -239,10 +247,10 @@ export const ChatbotConversationHistoryNav: FunctionComponent {Object.keys(conversations).map((navGroup) => (
- + <Title headingLevel="h4" className="pf-chatbot__conversation-list-header" {...titleProps}> {navGroup} - + {conversations[navGroup].map((conversation) => ( {getNavItem(conversation)} ))} From 301c3e6c50697aff3d50319af7314a80d0ea2f97 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 25 Jul 2025 15:08:39 -0400 Subject: [PATCH 03/14] Removed unused var in test suite --- .../ChatbotConversationHistoryNav.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index 746918d24..56fb07b7c 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -718,7 +718,6 @@ describe('ChatbotConversationHistoryNav', () => { conversations={[{ ...editableConversations, onKeyDown }]} /> ); - const input = screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }); expect(onKeyDown).not.toHaveBeenCalled(); }); From 0da67eb1e998039f866b85859258fc439a564c4b Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Tue, 29 Jul 2025 09:34:40 -0400 Subject: [PATCH 04/14] Feedback from Rebecca --- .../UI/ChatbotConversationEditing.tsx | 86 ++++++++++++------- .../extensions/chatbot/examples/UI/UI.md | 6 +- .../ChatbotConversationHistoryDropdown.tsx | 1 - .../ChatbotConversationHistoryNav.scss | 5 ++ 4 files changed, 66 insertions(+), 32 deletions(-) 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 index ba3d7ebba..f3f0e8eab 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx @@ -12,18 +12,29 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { const originalTextRef = useRef({}); + 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) => { setConversations((prevConversations) => { + const result = findConversationAndGroup(prevConversations, itemId); + if (!result) return prevConversations; + + const { groupKey, conversationIndex } = result; const newConversations = { ...prevConversations }; - Object.keys(newConversations).forEach((groupKey) => { - newConversations[groupKey] = newConversations[groupKey].map((conv) => { - if (conv.id === itemId) { - originalTextRef.current[conv.id] = conv.text; - return { ...conv, isEditing: true }; - } - return conv; - }); - }); + const newGroup = [...newConversations[groupKey]]; + + originalTextRef.current[itemId] = newGroup[conversationIndex].text; + newGroup[conversationIndex] = { ...newGroup[conversationIndex], isEditing: true }; + newConversations[groupKey] = newGroup; + return newConversations; }); @@ -37,12 +48,15 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { const handleInputChange = (itemId: string | number, event: React.FormEvent, value: string) => { setConversations((prevConversations) => { + const result = findConversationAndGroup(prevConversations, itemId); + if (!result) return prevConversations; + const { groupKey, conversationIndex } = result; const newConversations = { ...prevConversations }; - Object.keys(newConversations).forEach((groupKey) => { - newConversations[groupKey] = newConversations[groupKey].map((conv) => - conv.id === itemId ? { ...conv, text: value } : conv - ); - }); + const newGroup = [...newConversations[groupKey]]; + + newGroup[conversationIndex] = { ...newGroup[conversationIndex], text: value }; + newConversations[groupKey] = newGroup; + return newConversations; }); }; @@ -50,12 +64,16 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { const handleInputBlur = (itemId: string | number, event: React.FocusEvent) => { const newValue = event.target.value; setConversations((prevConversations) => { + const result = findConversationAndGroup(prevConversations, itemId); + if (!result) return prevConversations; + + const { groupKey, conversationIndex } = result; const newConversations = { ...prevConversations }; - Object.keys(newConversations).forEach((groupKey) => { - newConversations[groupKey] = newConversations[groupKey].map((conv) => - conv.id === itemId ? { ...conv, text: newValue, isEditing: false } : conv - ); - }); + const newGroup = [...newConversations[groupKey]]; + + newGroup[conversationIndex] = { ...newGroup[conversationIndex], text: newValue, isEditing: false }; + newConversations[groupKey] = newGroup; + return newConversations; }); @@ -67,12 +85,16 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { event.preventDefault(); const newValue = event.currentTarget.value; setConversations((prevConversations) => { + const result = findConversationAndGroup(prevConversations, itemId); + if (!result) return prevConversations; + + const { groupKey, conversationIndex } = result; const newConversations = { ...prevConversations }; - Object.keys(newConversations).forEach((groupKey) => { - newConversations[groupKey] = newConversations[groupKey].map((conv) => - conv.id === itemId ? { ...conv, text: newValue, isEditing: false } : conv - ); - }); + const newGroup = [...newConversations[groupKey]]; + + newGroup[conversationIndex] = { ...newGroup[conversationIndex], text: newValue, isEditing: false }; + newConversations[groupKey] = newGroup; + return newConversations; }); // Clean up the stored original text @@ -83,12 +105,16 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { // Revert to the original text const originalText = originalTextRef.current[itemId] || ''; setConversations((prevConversations) => { + const result = findConversationAndGroup(prevConversations, itemId); + if (!result) return prevConversations; + + const { groupKey, conversationIndex } = result; const newConversations = { ...prevConversations }; - Object.keys(newConversations).forEach((groupKey) => { - newConversations[groupKey] = newConversations[groupKey].map((conv) => - conv.id === itemId ? { ...conv, text: originalText, isEditing: false } : conv - ); - }); + const newGroup = [...newConversations[groupKey]]; + + newGroup[conversationIndex] = { ...newGroup[conversationIndex], text: originalText, isEditing: false }; + newConversations[groupKey] = newGroup; + return newConversations; }); // Clean up the stored original text @@ -97,7 +123,7 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { }; const renderMenuItems = (itemId: string | number) => [ - + Download 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 cfe7af760..c5dd297e4 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 @@ -376,7 +376,11 @@ To help users track important conversations, add a "pin" option to the conversat ### Drawer with editable conversations -Sample text. +To make conversation names in the history drawer editable, pass the `isEditable` property to each conversation item that is intended or expected to be editable. When a conversation item is editable, you must ensure the following: + +- Each text input has a unique accessible name - this is handled automatically, but can be customized via the `inputAriaLabel` property on the conversation item. +- The `onBlur` and `onKeyDown` callback handlers are passed in, typically to "exit" editing mode on blur and to handle the Enter or Escape keys on key down - in this example, `onBlur` and the Enter key via `onKeyDown` save the text input content, while Escape via `onKeyDown` cancels the edit and reverts to the original text. +- The `onChange` callback handler is passed in to handle updating the text input value and any conversation state. ```js file="./ChatbotConversationEditing.tsx" 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 699ab7654..b9424aa26 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss @@ -6,6 +6,7 @@ position: absolute; border-radius: var(--pf-t--global--border--radius--medium); } + // Drawer title // ---------------------------------------------------------------------------- .pf-chatbot__title-container { @@ -53,6 +54,10 @@ --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%; From 41be3a59539ad88b22b059bea9d1029ec3772612 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Tue, 29 Jul 2025 09:41:52 -0400 Subject: [PATCH 05/14] Added focus handling verbiage in example docs --- .../patternfly-docs/content/extensions/chatbot/examples/UI/UI.md | 1 + 1 file changed, 1 insertion(+) 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 c5dd297e4..cea8f9528 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 @@ -381,6 +381,7 @@ To make conversation names in the history drawer editable, pass the `isEditable` - Each text input has a unique accessible name - this is handled automatically, but can be customized via the `inputAriaLabel` property on the conversation item. - The `onBlur` and `onKeyDown` callback handlers are passed in, typically to "exit" editing mode on blur and to handle the Enter or Escape keys on key down - in this example, `onBlur` and the Enter key via `onKeyDown` save the text input content, while Escape via `onKeyDown` cancels the edit and reverts to the original text. - The `onChange` callback handler is passed in to handle updating the text input value and any conversation state. +- Focus is handled correctly when enabling and disabled editing mode - when `isEditing` becomes true, focus should be moved to the text input, and when the Enter or Escape keys are pressed and `isEditing` becomes false focus should be moved to the conversation actions dropdown toggle. ```js file="./ChatbotConversationEditing.tsx" From e2874abdbb3bd0a5a5ca1e7bc41f16e9513559ba Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Tue, 29 Jul 2025 09:45:51 -0400 Subject: [PATCH 06/14] Fixed lint errors --- .../UI/ChatbotConversationEditing.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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 index f3f0e8eab..26ed72195 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx @@ -25,7 +25,9 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { const onRenameClick = (itemId: string | number) => { setConversations((prevConversations) => { const result = findConversationAndGroup(prevConversations, itemId); - if (!result) return prevConversations; + if (!result) { + return prevConversations; + } const { groupKey, conversationIndex } = result; const newConversations = { ...prevConversations }; @@ -49,7 +51,9 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { const handleInputChange = (itemId: string | number, event: React.FormEvent, value: string) => { setConversations((prevConversations) => { const result = findConversationAndGroup(prevConversations, itemId); - if (!result) return prevConversations; + if (!result) { + return prevConversations; + } const { groupKey, conversationIndex } = result; const newConversations = { ...prevConversations }; const newGroup = [...newConversations[groupKey]]; @@ -65,7 +69,9 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { const newValue = event.target.value; setConversations((prevConversations) => { const result = findConversationAndGroup(prevConversations, itemId); - if (!result) return prevConversations; + if (!result) { + return prevConversations; + } const { groupKey, conversationIndex } = result; const newConversations = { ...prevConversations }; @@ -86,7 +92,9 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { const newValue = event.currentTarget.value; setConversations((prevConversations) => { const result = findConversationAndGroup(prevConversations, itemId); - if (!result) return prevConversations; + if (!result) { + return prevConversations; + } const { groupKey, conversationIndex } = result; const newConversations = { ...prevConversations }; @@ -106,7 +114,9 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { const originalText = originalTextRef.current[itemId] || ''; setConversations((prevConversations) => { const result = findConversationAndGroup(prevConversations, itemId); - if (!result) return prevConversations; + if (!result) { + return prevConversations; + } const { groupKey, conversationIndex } = result; const newConversations = { ...prevConversations }; From 23a46edd44deee7273ca47de40b8ff8647bb3677 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Tue, 29 Jul 2025 09:51:48 -0400 Subject: [PATCH 07/14] Updated menuitem role to button in tests --- .../ChatbotConversationHistoryDropdown.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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, From c42e9c49e1ac76c2c4c10ac9205363881d3a1e59 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Thu, 31 Jul 2025 13:13:41 -0400 Subject: [PATCH 08/14] Refactored to modal approach --- .../UI/ChatbotConversationEditing.tsx | 215 ++++++------- .../extensions/chatbot/examples/UI/UI.md | 2 +- .../ChatbotConversationHistoryNav.test.tsx | 304 ++++-------------- .../ChatbotConversationHistoryNav.tsx | 67 ++-- 4 files changed, 186 insertions(+), 402 deletions(-) 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 index 26ed72195..7b1ffb1cf 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx @@ -1,16 +1,46 @@ // From Cursor, with aid -import { FunctionComponent, useState, useRef } from 'react'; +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 { Checkbox, DropdownItem, DropdownList } from '@patternfly/react-core'; +import { + Checkbox, + DropdownItem, + DropdownList, + Modal, + ModalVariant, + Button, + TextInput, + Form, + FormGroup, + ModalHeader, + ModalBody, + ModalFooter +} from '@patternfly/react-core'; export const ChatbotHeaderTitleDemo: FunctionComponent = () => { const [isDrawerOpen, setIsDrawerOpen] = useState(true); const displayMode = ChatbotDisplayMode.embedded; - const originalTextRef = useRef({}); + // Modal state + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingConversationId, setEditingConversationId] = useState(null); + const [editingText, setEditingText] = useState(''); + const [originalText, setOriginalText] = 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)) { @@ -23,75 +53,19 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { }; const onRenameClick = (itemId: string | number) => { - setConversations((prevConversations) => { - const result = findConversationAndGroup(prevConversations, itemId); - if (!result) { - return prevConversations; - } - - const { groupKey, conversationIndex } = result; - const newConversations = { ...prevConversations }; - const newGroup = [...newConversations[groupKey]]; - - originalTextRef.current[itemId] = newGroup[conversationIndex].text; - newGroup[conversationIndex] = { ...newGroup[conversationIndex], isEditing: true }; - newConversations[groupKey] = newGroup; - - return newConversations; - }); - - setTimeout(() => { - const input = document.getElementById(`conversation-${itemId}-input`); - if (input) { - input.focus(); - } - }, 100); - }; - - const handleInputChange = (itemId: string | number, event: React.FormEvent, value: string) => { - setConversations((prevConversations) => { - const result = findConversationAndGroup(prevConversations, itemId); - if (!result) { - return prevConversations; - } - const { groupKey, conversationIndex } = result; - const newConversations = { ...prevConversations }; - const newGroup = [...newConversations[groupKey]]; - - newGroup[conversationIndex] = { ...newGroup[conversationIndex], text: value }; - newConversations[groupKey] = newGroup; - - return newConversations; - }); - }; - - const handleInputBlur = (itemId: string | number, event: React.FocusEvent) => { - const newValue = event.target.value; - setConversations((prevConversations) => { - const result = findConversationAndGroup(prevConversations, itemId); - if (!result) { - return prevConversations; - } - - const { groupKey, conversationIndex } = result; - const newConversations = { ...prevConversations }; - const newGroup = [...newConversations[groupKey]]; - - newGroup[conversationIndex] = { ...newGroup[conversationIndex], text: newValue, isEditing: false }; - newConversations[groupKey] = newGroup; - - return newConversations; - }); - - delete originalTextRef.current[itemId]; + const result = findConversationAndGroup(conversations, itemId); + if (result) { + setEditingConversationId(itemId); + setEditingText(result.conversation.text); + setOriginalText(result.conversation.text); + setIsModalOpen(true); + } }; - const handleInputKeyDown = (itemId: string | number, event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - const newValue = event.currentTarget.value; + const handleModalSave = () => { + if (editingConversationId) { setConversations((prevConversations) => { - const result = findConversationAndGroup(prevConversations, itemId); + const result = findConversationAndGroup(prevConversations, editingConversationId); if (!result) { return prevConversations; } @@ -100,35 +74,30 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { const newConversations = { ...prevConversations }; const newGroup = [...newConversations[groupKey]]; - newGroup[conversationIndex] = { ...newGroup[conversationIndex], text: newValue, isEditing: false }; + newGroup[conversationIndex] = { ...newGroup[conversationIndex], text: editingText }; newConversations[groupKey] = newGroup; return newConversations; }); - // Clean up the stored original text - delete originalTextRef.current[itemId]; - } else if (event.key === 'Escape') { - event.stopPropagation(); - event.preventDefault(); - // Revert to the original text - const originalText = originalTextRef.current[itemId] || ''; - setConversations((prevConversations) => { - const result = findConversationAndGroup(prevConversations, itemId); - if (!result) { - return prevConversations; - } + } + handleModalClose(); + }; - const { groupKey, conversationIndex } = result; - const newConversations = { ...prevConversations }; - const newGroup = [...newConversations[groupKey]]; + const handleModalCancel = () => { + handleModalClose(); + }; - newGroup[conversationIndex] = { ...newGroup[conversationIndex], text: originalText, isEditing: false }; - newConversations[groupKey] = newGroup; + const handleModalClose = () => { + setIsModalOpen(false); + setEditingConversationId(null); + setEditingText(''); + setOriginalText(''); + }; - return newConversations; - }); - // Clean up the stored original text - delete originalTextRef.current[itemId]; + const handleTextInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleModalSave(); } }; @@ -150,48 +119,42 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { ]; const initialConversations: { [key: string]: Conversation[] } = { - Today: [{ id: '1', text: 'Red Hat products and services', menuItems: renderMenuItems('1'), isEditing: false }], + Today: [{ id: '1', text: 'Red Hat products and services' }], 'This month': [ { id: '2', - text: 'Enterprise Linux installation and setup', - menuItems: renderMenuItems('2'), - isEditing: false + text: 'Enterprise Linux installation and setup' }, - { id: '3', text: 'Troubleshoot system crash', menuItems: renderMenuItems('3'), isEditing: false } + { id: '3', text: 'Troubleshoot system crash' } ], March: [ - { id: '4', text: 'Ansible security and updates', menuItems: renderMenuItems('4'), isEditing: false }, - { id: '5', text: 'Red Hat certification', menuItems: renderMenuItems('5'), isEditing: false }, - { id: '6', text: 'Lightspeed user documentation', menuItems: renderMenuItems('6'), isEditing: false } + { 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', menuItems: renderMenuItems('7'), isEditing: false }, - { id: '8', text: 'OpenShift AI pipelines', menuItems: renderMenuItems('8'), isEditing: false }, - { id: '9', text: 'Updating subscription plan', menuItems: renderMenuItems('9'), isEditing: false }, - { id: '10', text: 'Red Hat licensing options', menuItems: renderMenuItems('10'), isEditing: false } + { 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', menuItems: renderMenuItems('11'), isEditing: false }, - { id: '12', text: 'Manage user accounts', menuItems: renderMenuItems('12'), isEditing: false } + { id: '11', text: 'RHEL system performance' }, + { id: '12', text: 'Manage user accounts' } ] }; const [conversations, setConversations] = useState(initialConversations); - const createConversationItems = () => { + // Create conversations with menu items dynamically + const conversationsWithMenuItems = () => { const newConversations = { ...conversations }; - Object.keys(newConversations).forEach((groupKey) => { newConversations[groupKey] = newConversations[groupKey].map((conv) => ({ ...conv, - inputAriaLabel: `Edit conversation name: ${originalTextRef.current[conv.id] ?? conv.text}`, - onChange: (event: React.FormEvent, value: string) => handleInputChange(conv.id, event, value), - onBlur: (event: React.FocusEvent) => handleInputBlur(conv.id, event), - onKeyDown: (event: React.KeyboardEvent) => handleInputKeyDown(conv.id, event) + menuItems: renderMenuItems(conv.id) })); }); - return newConversations; }; @@ -209,9 +172,35 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { onDrawerToggle={() => setIsDrawerOpen(!isDrawerOpen)} isDrawerOpen={isDrawerOpen} setIsDrawerOpen={setIsDrawerOpen} - conversations={createConversationItems()} + 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 cea8f9528..1c5807ae3 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 diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index 56fb07b7c..9397fdc35 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -493,249 +493,73 @@ describe('ChatbotConversationHistoryNav', () => { expect(iconElement).toBeInTheDocument(); }); - describe('Editable conversations', () => { - const editableConversations: Conversation = { - id: '1', - text: 'ChatBot documentation', - isEditing: true, - listItemProps: { - className: 'test' - }, - inputProps: { - id: 'test' - } - }; - - 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'); - }); - - it('Renders conversation as button when isEditing is false', () => { - render( - - ); - expect(screen.getByRole('button', { name: /ChatBot documentation/i })).toBeInTheDocument(); - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - }); - - it('Renders conversation as text input when isEditing is true', () => { - render( - - ); - expect( - screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }) - ).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /ChatBot documentation/i })).not.toBeInTheDocument(); - }); - - it('Passes inputProps to TextInput', () => { - render( - - ); - expect( - screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }) - ).toHaveAttribute('id', 'test'); - }); - - it('Renders conversation input with custom aria-label when inputAriaLabel is passed', () => { - render( - - ); - expect(screen.getByDisplayValue('ChatBot documentation')).toHaveAccessibleName('Edit name for guidelines'); - }); - - it('Does not call onChange on input by default', async () => { - const onChange = jest.fn(); - render( - <> - - - - ); - const input = screen.getByRole('textbox', { name: /Other input/i }); - await userEvent.type(input, 'New value'); - expect(onChange).not.toHaveBeenCalled(); - }); - - it('Calls onChange when input is changed', async () => { - const onChange = jest.fn(); - render( - - ); - const input = screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }); - await userEvent.type(input, 'New value'); - expect(onChange).toHaveBeenCalled(); - }); + it('Passes titleProps to Title', () => { + render( + + ); + expect(screen.getByRole('heading', { name: /Today/i })).toHaveClass('test'); + }); - it('Does not call onBlur on input by default', async () => { - const onBlur = jest.fn(); - render( - - ); - const input = screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }); - await userEvent.click(input); - expect(onBlur).not.toHaveBeenCalled(); - }); + 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('Calls onBlur when input is blurred', async () => { - const onBlur = jest.fn(); - render( - - ); - const input = screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }); - await userEvent.click(input); - await userEvent.tab(); - expect(onBlur).toHaveBeenCalled(); - }); + it('Passes listProps to List when conversations is an array', () => { + render( + + ); + expect(screen.getByRole('list')).toHaveClass('test'); + }); - it('Does not call onKeyDown on input by default', async () => { - const onKeyDown = jest.fn(); - render( - - ); - expect(onKeyDown).not.toHaveBeenCalled(); - }); + it('Passes listProps to List when conversations is an object', () => { + render( + + ); + expect(screen.getByRole('list')).toHaveClass('test'); + }); - it('Calls onKeyDown when input is focused and key is pressed', async () => { - const onKeyDown = jest.fn(); - render( - - ); - const input = screen.getByRole('textbox', { name: /Edit conversation name for ChatBot documentation/i }); - - await userEvent.type(input, 'Enter'); - expect(onKeyDown).toHaveBeenCalled(); - }); + 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 93470e35e..af464f1cf 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx @@ -32,8 +32,6 @@ import { DrawerPanelBodyProps, SkeletonProps, Icon, - TextInput, - TextInputProps, MenuProps, // Remove in next breaking change TitleProps, ListProps @@ -54,20 +52,6 @@ export interface Conversation { noIcon?: boolean; /** Conversation */ text: string; - /** Flag to indicate if the conversation name is being edited. */ - isEditing?: boolean; - /** Ref for the text input that renders when isEditing is true. */ - inputRef?: React.RefObject; - /** The accessible name for the text input that renders when isEditing is true. */ - inputAriaLabel?: string; - /** Additional props passed to the text input that renders when isEditing is true. */ - inputProps?: TextInputProps; - /** Callback for when the conversation text input value changes during editing. */ - onChange?: (event: React.FormEvent, value: string) => void; - /** Callback for when the conversation text input value is blurred during editing. */ - onBlur?: (event: React.FocusEvent) => void; - /** Callback for when a keydown event is truggered during editing. This must include logic to submit or cancel an edit. */ - onKeyDown?: (event: React.KeyboardEvent) => void; /** Dropdown items rendered in conversation settings dropdown */ menuItems?: React.ReactNode; /** Optional classname applied to conversation settings dropdown */ @@ -198,38 +182,25 @@ export const ChatbotConversationHistoryNav: FunctionComponent - {conversation.isEditing ? ( - - ) : ( - <> - - {conversation.menuItems && ( - - )} - - )} + <> + + {conversation.menuItems && ( + + )} + ); From 180c7675281a8f7d8b6e9a528b6e3bc33bd2d6ad Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Thu, 31 Jul 2025 13:37:12 -0400 Subject: [PATCH 09/14] Fixed unused imports and test failures --- .../chatbot/examples/UI/ChatbotConversationEditing.tsx | 3 --- .../content/extensions/chatbot/examples/UI/UI.md | 10 +++++----- .../ChatbotConversationHistoryNav.test.tsx | 3 +-- 3 files changed, 6 insertions(+), 10 deletions(-) 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 index 7b1ffb1cf..b54b0df13 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx @@ -27,7 +27,6 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [editingConversationId, setEditingConversationId] = useState(null); const [editingText, setEditingText] = useState(''); - const [originalText, setOriginalText] = useState(''); // Ref for the text input const textInputRef = useRef(null); @@ -57,7 +56,6 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { if (result) { setEditingConversationId(itemId); setEditingText(result.conversation.text); - setOriginalText(result.conversation.text); setIsModalOpen(true); } }; @@ -91,7 +89,6 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { setIsModalOpen(false); setEditingConversationId(null); setEditingText(''); - setOriginalText(''); }; const handleTextInputKeyDown = (event: React.KeyboardEvent) => { 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 1c5807ae3..a651008f9 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 @@ -376,12 +376,12 @@ To help users track important conversations, add a "pin" option to the conversat ### Drawer with editable conversations -To make conversation names in the history drawer editable, pass the `isEditable` property to each conversation item that is intended or expected to be editable. When a conversation item is editable, you must ensure the following: +You can allow conversation items in the history drawer editable by implementing a modal that opens upon clicking a "Rename" (or similar) action. When doing so, you must ensure the following: -- Each text input has a unique accessible name - this is handled automatically, but can be customized via the `inputAriaLabel` property on the conversation item. -- The `onBlur` and `onKeyDown` callback handlers are passed in, typically to "exit" editing mode on blur and to handle the Enter or Escape keys on key down - in this example, `onBlur` and the Enter key via `onKeyDown` save the text input content, while Escape via `onKeyDown` cancels the edit and reverts to the original text. -- The `onChange` callback handler is passed in to handle updating the text input value and any conversation state. -- Focus is handled correctly when enabling and disabled editing mode - when `isEditing` becomes true, focus should be moved to the text input, and when the Enter or Escape keys are pressed and `isEditing` becomes false focus should be moved to the conversation actions dropdown toggle. +- When the modal opens, place focus 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 clicking a "save" button. ```js file="./ChatbotConversationEditing.tsx" diff --git a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx index 9397fdc35..5a3d01855 100644 --- a/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx +++ b/packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx @@ -1,6 +1,5 @@ import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { ChatbotDisplayMode } from '../Chatbot/Chatbot'; import ChatbotConversationHistoryNav, { Conversation } from './ChatbotConversationHistoryNav'; @@ -557,7 +556,7 @@ describe('ChatbotConversationHistoryNav', () => { isDrawerOpen={true} displayMode={ChatbotDisplayMode.fullscreen} setIsDrawerOpen={jest.fn()} - conversations={initialConversations} + conversations={[{ id: '1', text: 'ChatBot documentation', listItemProps: { className: 'test' } }]} /> ); expect(screen.getByRole('listitem')).toHaveClass('test'); From 57931ef2706d88eebcb12154aba93e54fe8c18b9 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 1 Aug 2025 14:01:42 -0400 Subject: [PATCH 10/14] Used ChatbotModal over PF Modal --- .../chatbot/examples/UI/ChatbotConversationEditing.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index b54b0df13..26a82b4ca 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx @@ -4,11 +4,11 @@ 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, - Modal, ModalVariant, Button, TextInput, @@ -173,7 +173,7 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { drawerContent={
Drawer content
} /> - +
@@ -197,7 +197,7 @@ export const ChatbotHeaderTitleDemo: FunctionComponent = () => { Cancel - + ); }; From 74a2d50a292ce4c4dfc1326b36352dfc4fd7d644 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 1 Aug 2025 14:20:54 -0400 Subject: [PATCH 11/14] Verbiage and linting update --- .../chatbot/examples/UI/ChatbotConversationEditing.tsx | 1 - .../content/extensions/chatbot/examples/UI/UI.md | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) 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 index 26a82b4ca..8f44de8bb 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotConversationEditing.tsx @@ -9,7 +9,6 @@ import { Checkbox, DropdownItem, DropdownList, - ModalVariant, Button, TextInput, Form, 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 a651008f9..73fbb86ca 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 @@ -376,12 +376,12 @@ To help users track important conversations, add a "pin" option to the conversat ### Drawer with editable conversations -You can allow conversation items in the history drawer editable by implementing a modal that opens upon clicking a "Rename" (or similar) action. When doing so, you must ensure the following: +You can allow users to edit conversation names 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, place focus at the end of the text input. +- 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 clicking a "save" button. +- 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" From 85932032c5622e1d3d6380e862ea27e3bbdfa092 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 1 Aug 2025 14:39:18 -0400 Subject: [PATCH 12/14] Example title update --- .../content/extensions/chatbot/examples/UI/UI.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 73fbb86ca..dd8dd40b7 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 @@ -374,9 +374,9 @@ To help users track important conversations, add a "pin" option to the conversat ``` -### Drawer with editable conversations +### Renaming conversations in history drawer -You can allow users to edit conversation names 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: +You can allow users to rename 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. From d0c1350d6a1549766060792ffdf6293a3a57a651 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 1 Aug 2025 14:44:15 -0400 Subject: [PATCH 13/14] Dang a --- .../content/extensions/chatbot/examples/UI/UI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dd8dd40b7..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 @@ -376,7 +376,7 @@ To help users track important conversations, add a "pin" option to the conversat ### Renaming conversations in history drawer -You can allow users to rename 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: +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. From 8c516503432e0b183a24b12da655359b7a7dc64c Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Fri, 1 Aug 2025 16:54:35 -0400 Subject: [PATCH 14/14] Updated padding inline end for modal header --- packages/module/src/ChatbotModal/ChatbotModal.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } }