diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithIconSwapping.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithIconSwapping.tsx new file mode 100644 index 00000000..7d9cb9b1 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithIconSwapping.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 IconSwappingExample: FunctionComponent = () => ( + console.log('Good response') }, + // eslint-disable-next-line no-console + negative: { onClick: () => console.log('Bad response') }, + // eslint-disable-next-line no-console + copy: { onClick: () => console.log('Copied') } + }} + useFilledIconsOnClick + /> +); 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 c6e16796..faf94aab 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 @@ -138,6 +138,17 @@ When `persistActionSelection` is `true`: ``` +### Message actions that fill + +To provide enhanced visual feedback when users interact with response actions, you can enable icon swapping by setting `useFilledIconsOnClick` to `true`. When enabled, the predefined "positive" and "negative" actions will automatically swap to their filled icon counterparts when clicked, replacing the original outlined icon variants. + +This is especially useful for actions that are intended to persist (such as the "positive" and "negative" responses), so that a user's selection is more clear and emphasized. + + +```js file="./MessageWithIconSwapping.tsx" + +``` + ### Multiple messsage action groups To maintain finer control over message action selection behavior, you can create groups of actions by passing an array of objects to the `actions` prop. This allows you to separate actions into conceptually or functionally different groups and implement different behavior for each group as needed. For example, you could separate feedback actions (thumbs up/down) form utility actions (copy and download), and have different selection behaviors for each group. diff --git a/packages/module/src/Message/Message.test.tsx b/packages/module/src/Message/Message.test.tsx index 3e7fe1ad..754910ca 100644 --- a/packages/module/src/Message/Message.test.tsx +++ b/packages/module/src/Message/Message.test.tsx @@ -9,6 +9,23 @@ import rehypeExternalLinks from '../__mocks__/rehype-external-links'; import { AlertActionLink, Button, CodeBlockAction } from '@patternfly/react-core'; import { DeepThinkingProps } from '../DeepThinking'; +// Mock the icon components +jest.mock('@patternfly/react-icons', () => ({ + OutlinedThumbsUpIcon: () =>
OutlinedThumbsUpIcon
, + ThumbsUpIcon: () =>
ThumbsUpIcon
, + OutlinedThumbsDownIcon: () =>
OutlinedThumbsDownIcon
, + ThumbsDownIcon: () =>
ThumbsDownIcon
, + OutlinedCopyIcon: () =>
OutlinedCopyIcon
, + DownloadIcon: () =>
DownloadIcon
, + ExternalLinkAltIcon: () =>
ExternalLinkAltIcon
, + VolumeUpIcon: () =>
VolumeUpIcon
, + PencilAltIcon: () =>
PencilAltIcon
, + CheckIcon: () =>
CheckIcon
, + CloseIcon: () =>
CloseIcon
, + ExternalLinkSquareAltIcon: () =>
ExternalLinkSquareAltIcon
, + TimesIcon: () =>
TimesIcon
+})); + const ALL_ACTIONS = [ { label: /Good response/i }, { label: /Bad response/i }, @@ -1351,4 +1368,51 @@ describe('Message', () => { render(); expect(screen.getByRole('region')).toHaveClass('pf-m-end'); }); + + // We're just testing the positive action here to ensure logic passes through as needed, the other actions are + // tested in ResponseActions.test.tsx along with other aspects of this functionality + it('should not swap icons when useFilledIconsOnClick is omitted', async () => { + const user = userEvent.setup(); + + render( + + ); + + expect(screen.getByText('OutlinedThumbsUpIcon')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Good response/i })); + + expect(screen.getByText('OutlinedThumbsUpIcon')).toBeInTheDocument(); + expect(screen.queryByText('ThumbsUpIcon')).not.toBeInTheDocument(); + }); + + it('should swap icons when useFilledIconsOnClick is true', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: /Good response/i })); + + expect(screen.getByText('ThumbsUpIcon')).toBeInTheDocument(); + expect(screen.queryByText('OutlinedThumbsUpIcon')).not.toBeInTheDocument(); + }); }); diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index 47bc6953..918a6499 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -197,6 +197,8 @@ export interface MessageProps extends Omit, 'role'> { hasNoImagesInUserMessages?: boolean; /** Sets background colors to be appropriate on primary chatbot background */ isPrimary?: boolean; + /** When true, automatically swaps to filled icon variants when predefined actions are clicked. */ + useFilledIconsOnClick?: boolean; } export const MessageBase: FunctionComponent = ({ @@ -249,6 +251,7 @@ export const MessageBase: FunctionComponent = ({ toolCall, hasNoImagesInUserMessages = true, isPrimary, + useFilledIconsOnClick, ...props }: MessageProps) => { const [messageText, setMessageText] = useState(content); @@ -385,11 +388,16 @@ export const MessageBase: FunctionComponent = ({ key={index} actions={actionGroup.actions || actionGroup} persistActionSelection={persistActionSelection || actionGroup.persistActionSelection} + useFilledIconsOnClick={useFilledIconsOnClick} /> ))} ) : ( - + )} )} diff --git a/packages/module/src/ResponseActions/ResponseActions.test.tsx b/packages/module/src/ResponseActions/ResponseActions.test.tsx index 43f62ff0..c3fba126 100644 --- a/packages/module/src/ResponseActions/ResponseActions.test.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.test.tsx @@ -5,6 +5,21 @@ import userEvent from '@testing-library/user-event'; import { DownloadIcon, InfoCircleIcon, RedoIcon } from '@patternfly/react-icons'; import Message from '../Message'; +// Mock the icon components +jest.mock('@patternfly/react-icons', () => ({ + OutlinedThumbsUpIcon: () =>
OutlinedThumbsUpIcon
, + ThumbsUpIcon: () =>
ThumbsUpIcon
, + OutlinedThumbsDownIcon: () =>
OutlinedThumbsDownIcon
, + ThumbsDownIcon: () =>
ThumbsDownIcon
, + OutlinedCopyIcon: () =>
OutlinedCopyIcon
, + DownloadIcon: () =>
DownloadIcon
, + InfoCircleIcon: () =>
InfoCircleIcon
, + RedoIcon: () =>
RedoIcon
, + ExternalLinkAltIcon: () =>
ExternalLinkAltIcon
, + VolumeUpIcon: () =>
VolumeUpIcon
, + PencilAltIcon: () =>
PencilAltIcon
+})); + const ALL_ACTIONS = [ { type: 'positive', label: 'Good response', clickedLabel: 'Good response recorded' }, { type: 'negative', label: 'Bad response', clickedLabel: 'Bad response recorded' }, @@ -421,4 +436,189 @@ describe('ResponseActions', () => { await userEvent.click(customBtn); expect(customBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); }); + + describe('icon swapping with useFilledIconsOnClick', () => { + it('should render outline icons by default', () => { + render( + + ); + + expect(screen.getByText('OutlinedThumbsUpIcon')).toBeInTheDocument(); + expect(screen.getByText('OutlinedThumbsDownIcon')).toBeInTheDocument(); + + expect(screen.queryByText('ThumbsUpIcon')).not.toBeInTheDocument(); + expect(screen.queryByText('ThumbsDownIcon')).not.toBeInTheDocument(); + }); + + describe('positive actions', () => { + it('should not swap positive icon when clicked and useFilledIconsOnClick is false', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: 'Good response' })); + + expect(screen.getByText('OutlinedThumbsUpIcon')).toBeInTheDocument(); + expect(screen.queryByText('ThumbsUpIcon')).not.toBeInTheDocument(); + }); + + it('should swap positive icon from outline to filled when clicked with useFilledIconsOnClick', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: 'Good response' })); + + expect(screen.getByText('ThumbsUpIcon')).toBeInTheDocument(); + expect(screen.queryByText('OutlinedThumbsUpIcon')).not.toBeInTheDocument(); + }); + + it('should revert positive icon to outline icon when clicking outside', async () => { + const user = userEvent.setup(); + + render( +
+ +
Outside
+
+ ); + + await user.click(screen.getByRole('button', { name: 'Good response' })); + expect(screen.getByText('ThumbsUpIcon')).toBeInTheDocument(); + + await user.click(screen.getByTestId('outside')); + expect(screen.getByText('OutlinedThumbsUpIcon')).toBeInTheDocument(); + }); + + it('should not revert positive icon to outline icon when clicking outside if persistActionSelection is true', async () => { + const user = userEvent.setup(); + + render( +
+ +
Outside
+
+ ); + + await user.click(screen.getByRole('button', { name: 'Good response' })); + expect(screen.getByText('ThumbsUpIcon')).toBeInTheDocument(); + + await user.click(screen.getByTestId('outside')); + expect(screen.getByText('ThumbsUpIcon')).toBeInTheDocument(); + }); + + describe('negative actions', () => { + it('should not swap negative icon when clicked and useFilledIconsOnClick is false', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: 'Bad response' })); + + expect(screen.getByText('OutlinedThumbsDownIcon')).toBeInTheDocument(); + expect(screen.queryByText('ThumbsDownIcon')).not.toBeInTheDocument(); + }); + + it('should swap negative icon from outline to filled when clicked with useFilledIconsOnClick', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: 'Bad response' })); + + expect(screen.getByText('ThumbsDownIcon')).toBeInTheDocument(); + expect(screen.queryByText('OutlinedThumbsDownIcon')).not.toBeInTheDocument(); + }); + + it('should revert negative icon to outline when clicking outside', async () => { + const user = userEvent.setup(); + + render( +
+ +
Outside
+
+ ); + + await user.click(screen.getByRole('button', { name: 'Bad response' })); + expect(screen.getByText('ThumbsDownIcon')).toBeInTheDocument(); + + await user.click(screen.getByTestId('outside')); + expect(screen.getByText('OutlinedThumbsDownIcon')).toBeInTheDocument(); + }); + + it('should not revert negative icon to outline icon when clicking outside if persistActionSelection is true', async () => { + const user = userEvent.setup(); + + render( +
+ +
Outside
+
+ ); + + await user.click(screen.getByRole('button', { name: 'Bad response' })); + expect(screen.getByText('ThumbsDownIcon')).toBeInTheDocument(); + + await user.click(screen.getByTestId('outside')); + expect(screen.getByText('ThumbsDownIcon')).toBeInTheDocument(); + }); + }); + }); + }); }); diff --git a/packages/module/src/ResponseActions/ResponseActions.tsx b/packages/module/src/ResponseActions/ResponseActions.tsx index c1cfbcff..630b3ad2 100644 --- a/packages/module/src/ResponseActions/ResponseActions.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.tsx @@ -4,7 +4,9 @@ import { ExternalLinkAltIcon, VolumeUpIcon, OutlinedThumbsUpIcon, + ThumbsUpIcon, OutlinedThumbsDownIcon, + ThumbsDownIcon, OutlinedCopyIcon, DownloadIcon, PencilAltIcon @@ -62,11 +64,15 @@ export interface ResponseActionProps { /** 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; + /** When true, automatically swaps to filled icon variants when predefined actions are clicked. + * Predefined actions will use filled variants (e.g., ThumbsUpIcon) when clicked and outline variants (e.g., OutlinedThumbsUpIcon) when not clicked. */ + useFilledIconsOnClick?: boolean; } export const ResponseActions: FunctionComponent = ({ actions, - persistActionSelection = false + persistActionSelection = false, + useFilledIconsOnClick = false }) => { const [activeButton, setActiveButton] = useState(); const [clickStatePersisted, setClickStatePersisted] = useState(false); @@ -129,6 +135,7 @@ export const ResponseActions: FunctionComponent = ({ id: string, onClick?: (event: MouseEvent | MouseEvent | KeyboardEvent) => void ) => { + e.stopPropagation(); if (persistActionSelection) { if (activeButton === id) { // Toggle off if clicking the same button @@ -145,6 +152,27 @@ export const ResponseActions: FunctionComponent = ({ onClick && onClick(e); }; + const iconMap = { + positive: { + filled: , + outlined: + }, + negative: { + filled: , + outlined: + } + }; + + const getIcon = (actionName: string) => { + const isClicked = activeButton === actionName; + + if (isClicked && useFilledIconsOnClick) { + return iconMap[actionName].filled; + } + + return iconMap[actionName].outlined; + }; + return (
{positive && ( @@ -158,7 +186,7 @@ export const ResponseActions: FunctionComponent = ({ tooltipContent={positive.tooltipContent ?? 'Good response'} clickedTooltipContent={positive.clickedTooltipContent ?? 'Good response recorded'} tooltipProps={positive.tooltipProps} - icon={} + icon={getIcon('positive')} isClicked={activeButton === 'positive'} ref={positive.ref} aria-expanded={positive['aria-expanded']} @@ -176,7 +204,7 @@ export const ResponseActions: FunctionComponent = ({ tooltipContent={negative.tooltipContent ?? 'Bad response'} clickedTooltipContent={negative.clickedTooltipContent ?? 'Bad response recorded'} tooltipProps={negative.tooltipProps} - icon={} + icon={getIcon('negative')} isClicked={activeButton === 'negative'} ref={negative.ref} aria-expanded={negative['aria-expanded']}