diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithPersistedActions.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithPersistedActions.tsx new file mode 100644 index 000000000..e90ac8a58 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithPersistedActions.tsx @@ -0,0 +1,22 @@ +import { FunctionComponent } from 'react'; + +import Message from '@patternfly/chatbot/dist/dynamic/Message'; +import patternflyAvatar from './patternfly_avatar.jpg'; + +export const MessageWithPersistedActions: FunctionComponent = () => ( + console.log('Good response') }, + // eslint-disable-next-line no-console + negative: { onClick: () => console.log('Bad response') }, + // eslint-disable-next-line no-console + listen: { onClick: () => console.log('Listen') } + }} + persistActionSelection + /> +); diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md index 1391169c4..de03f71d9 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md @@ -108,6 +108,20 @@ Once the component has rendered, user interactions will take precedence over the ``` +### Message actions persistent selections + +By default, message actions will automatically deselect when you click outside the component or on a different action button. To persist the selection instead, set `persistActionSelection` to `true`. + +When `persistActionSelection` is `true`: + +- The selected action will remain selected even when you click outside the component. +- Clicking a different button will still switch the selection to that button. +- Clicking the same action button again will toggle the selection off, though you will have to move your focus elsewhere to see the visual state change. + +```js file="./MessageWithPersistedActions.tsx" + +``` + ### Custom message actions Beyond the standard message actions (good response, bad response, copy, share, or listen), you can add custom actions to a bot message by passing an `actions` object to the `` component. This object can contain the following customizations: diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index b51c8cc85..02d26c27a 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -108,6 +108,9 @@ export interface MessageProps extends Omit, 'role'> { actions?: { [key: string]: ActionProps; }; + /** When true, the selected action will persist even when clicking outside the component. + * When false (default), clicking outside or clicking another action will deselect the current selection. */ + persistActionSelection?: boolean; /** Sources for message */ sources?: SourcesCardProps; /** Label for the English word "AI," used to tag messages with role "bot" */ @@ -202,6 +205,7 @@ export const MessageBase: FunctionComponent = ({ timestamp, isLoading, actions, + persistActionSelection, sources, botWord = 'AI', loadingWord = 'Loading message', @@ -499,7 +503,9 @@ export const MessageBase: FunctionComponent = ({ isCompact={isCompact} /> )} - {!isLoading && !isEditable && actions && } + {!isLoading && !isEditable && actions && ( + + )} {userFeedbackForm && } {userFeedbackComplete && ( diff --git a/packages/module/src/ResponseActions/ResponseActions.test.tsx b/packages/module/src/ResponseActions/ResponseActions.test.tsx index 8ec94d92e..43f62ff04 100644 --- a/packages/module/src/ResponseActions/ResponseActions.test.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.test.tsx @@ -6,8 +6,8 @@ import { DownloadIcon, InfoCircleIcon, RedoIcon } from '@patternfly/react-icons' import Message from '../Message'; const ALL_ACTIONS = [ - { type: 'positive', label: 'Good response', clickedLabel: 'Response recorded' }, - { type: 'negative', label: 'Bad response', clickedLabel: 'Response recorded' }, + { type: 'positive', label: 'Good response', clickedLabel: 'Good response recorded' }, + { type: 'negative', label: 'Bad response', clickedLabel: 'Bad response recorded' }, { type: 'copy', label: 'Copy', clickedLabel: 'Copied' }, { type: 'edit', label: 'Edit', clickedLabel: 'Editing' }, { type: 'share', label: 'Share', clickedLabel: 'Shared' }, @@ -81,7 +81,7 @@ describe('ResponseActions', () => { expect(button).toBeTruthy(); }); await userEvent.click(goodBtn); - expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass( + expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass( 'pf-chatbot__button--response-action-clicked' ); let unclickedButtons = buttons.filter((button) => button !== goodBtn); @@ -89,7 +89,7 @@ describe('ResponseActions', () => { expect(button).not.toHaveClass('pf-chatbot__button--response-action-clicked'); }); await userEvent.click(badBtn); - expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass( + expect(screen.getByRole('button', { name: 'Bad response recorded' })).toHaveClass( 'pf-chatbot__button--response-action-clicked' ); unclickedButtons = buttons.filter((button) => button !== badBtn); @@ -117,13 +117,13 @@ describe('ResponseActions', () => { expect(badBtn).toBeTruthy(); await userEvent.click(goodBtn); - expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass( + expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass( 'pf-chatbot__button--response-action-clicked' ); expect(badBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); await userEvent.click(badBtn); - expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass( + expect(screen.getByRole('button', { name: 'Bad response recorded' })).toHaveClass( 'pf-chatbot__button--response-action-clicked' ); expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); @@ -238,30 +238,30 @@ describe('ResponseActions', () => { }); it('should be able to call onClick correctly', async () => { - ALL_ACTIONS.forEach(async ({ type, label }) => { + for (const { type, label } of ALL_ACTIONS) { const spy = jest.fn(); render(); await userEvent.click(screen.getByRole('button', { name: label })); expect(spy).toHaveBeenCalledTimes(1); - }); + } }); it('should swap clicked and non-clicked aria labels on click', async () => { - ALL_ACTIONS.forEach(async ({ type, label, clickedLabel }) => { + for (const { type, label, clickedLabel } of ALL_ACTIONS) { render(); expect(screen.getByRole('button', { name: label })).toBeTruthy(); await userEvent.click(screen.getByRole('button', { name: label })); expect(screen.getByRole('button', { name: clickedLabel })).toBeTruthy(); - }); + } }); it('should swap clicked and non-clicked tooltips on click', async () => { - ALL_ACTIONS.forEach(async ({ type, label, clickedLabel }) => { + for (const { type, label, clickedLabel } of ALL_ACTIONS) { render(); expect(screen.getByRole('button', { name: label })).toBeTruthy(); await userEvent.click(screen.getByRole('button', { name: label })); expect(screen.getByRole('tooltip', { name: clickedLabel })).toBeTruthy(); - }); + } }); it('should be able to change aria labels', () => { @@ -322,4 +322,103 @@ describe('ResponseActions', () => { expect(screen.getByTestId(action[key])).toBeTruthy(); }); }); + + // we are testing for the reverse case already above + it('should not deselect when clicking outside when persistActionSelection is true', async () => { + render( + + ); + const goodBtn = screen.getByRole('button', { name: 'Good response' }); + + await userEvent.click(goodBtn); + expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + + await userEvent.click(screen.getByText('Test content')); + + expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + }); + + it('should switch selection to another button when persistActionSelection is true', async () => { + render( + + ); + const goodBtn = screen.getByRole('button', { name: 'Good response' }); + const badBtn = screen.getByRole('button', { name: 'Bad response' }); + + await userEvent.click(goodBtn); + expect(goodBtn).toHaveClass('pf-chatbot__button--response-action-clicked'); + + await userEvent.click(badBtn); + expect(badBtn).toHaveClass('pf-chatbot__button--response-action-clicked'); + expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + }); + + it('should toggle off when clicking the same button when persistActionSelection is true', async () => { + render( + + ); + const goodBtn = screen.getByRole('button', { name: 'Good response' }); + + await userEvent.click(goodBtn); + expect(goodBtn).toHaveClass('pf-chatbot__button--response-action-clicked'); + + await userEvent.click(goodBtn); + expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + }); + + it('should work with custom actions when persistActionSelection is true', async () => { + const actions = { + positive: { 'data-testid': 'positive', onClick: jest.fn() }, + negative: { 'data-testid': 'negative', onClick: jest.fn() }, + custom: { + 'data-testid': 'custom', + onClick: jest.fn(), + ariaLabel: 'Custom', + tooltipContent: 'Custom action', + icon: + } + }; + render(); + + const customBtn = screen.getByTestId('custom'); + await userEvent.click(customBtn); + expect(customBtn).toHaveClass('pf-chatbot__button--response-action-clicked'); + + await userEvent.click(customBtn); + expect(customBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + }); }); diff --git a/packages/module/src/ResponseActions/ResponseActions.tsx b/packages/module/src/ResponseActions/ResponseActions.tsx index 5e816668f..7598f9227 100644 --- a/packages/module/src/ResponseActions/ResponseActions.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.tsx @@ -53,11 +53,20 @@ export interface ResponseActionProps { listen?: ActionProps; edit?: ActionProps; }; + /** When true, the selected action will persist even when clicking outside the component. + * When false (default), clicking outside or clicking another action will deselect the current selection. */ + persistActionSelection?: boolean; } -export const ResponseActions: FunctionComponent = ({ actions }) => { +export const ResponseActions: FunctionComponent = ({ + actions, + persistActionSelection = false +}) => { const [activeButton, setActiveButton] = useState(); const [clickStatePersisted, setClickStatePersisted] = useState(false); + + const { positive, negative, copy, edit, share, download, listen, ...additionalActions } = actions; + useEffect(() => { // Define the order of precedence for checking initial `isClicked` const actionPrecedence = ['positive', 'negative', 'copy', 'edit', 'share', 'download', 'listen']; @@ -82,13 +91,21 @@ export const ResponseActions: FunctionComponent = ({ action // Click state is explicitly controlled by consumer. setClickStatePersisted(true); } + // If persistActionSelection is true, all selections are persisted + if (persistActionSelection) { + setClickStatePersisted(true); + } setActiveButton(initialActive); - }, [actions]); + }, [actions, persistActionSelection]); - const { positive, negative, copy, edit, share, download, listen, ...additionalActions } = actions; const responseActions = useRef(null); useEffect(() => { + // Only add click outside listener if not persisting selection + if (persistActionSelection) { + return; + } + const handleClickOutside = (e) => { if (responseActions.current && !responseActions.current.contains(e.target) && !clickStatePersisted) { setActiveButton(undefined); @@ -99,15 +116,26 @@ export const ResponseActions: FunctionComponent = ({ action return () => { window.removeEventListener('click', handleClickOutside); }; - }, [clickStatePersisted]); + }, [clickStatePersisted, persistActionSelection]); const handleClick = ( e: MouseEvent | MouseEvent | KeyboardEvent, id: string, onClick?: (event: MouseEvent | MouseEvent | KeyboardEvent) => void ) => { - setClickStatePersisted(false); - setActiveButton(id); + if (persistActionSelection) { + if (activeButton === id) { + // Toggle off if clicking the same button + setActiveButton(undefined); + } else { + // Set new active button + setActiveButton(id); + } + setClickStatePersisted(true); + } else { + setClickStatePersisted(false); + setActiveButton(id); + } onClick && onClick(e); }; @@ -117,12 +145,12 @@ export const ResponseActions: FunctionComponent = ({ action handleClick(e, 'positive', positive.onClick)} className={positive.className} isDisabled={positive.isDisabled} tooltipContent={positive.tooltipContent ?? 'Good response'} - clickedTooltipContent={positive.clickedTooltipContent ?? 'Response recorded'} + clickedTooltipContent={positive.clickedTooltipContent ?? 'Good response recorded'} tooltipProps={positive.tooltipProps} icon={} isClicked={activeButton === 'positive'} @@ -135,12 +163,12 @@ export const ResponseActions: FunctionComponent = ({ action handleClick(e, 'negative', negative.onClick)} className={negative.className} isDisabled={negative.isDisabled} tooltipContent={negative.tooltipContent ?? 'Bad response'} - clickedTooltipContent={negative.clickedTooltipContent ?? 'Response recorded'} + clickedTooltipContent={negative.clickedTooltipContent ?? 'Bad response recorded'} tooltipProps={negative.tooltipProps} icon={} isClicked={activeButton === 'negative'}