diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithMultipleActionGroups.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithMultipleActionGroups.tsx new file mode 100644 index 000000000..4f4d6269b --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithMultipleActionGroups.tsx @@ -0,0 +1,61 @@ +import { FunctionComponent } from 'react'; + +import Message from '@patternfly/chatbot/dist/dynamic/Message'; +import patternflyAvatar from './patternfly_avatar.jpg'; + +export const MessageWithMultipleActionGroups: FunctionComponent = () => ( + <> + console.log('Good response') }, + // eslint-disable-next-line no-console + negative: { onClick: () => console.log('Bad response') } + }, + persistActionSelection: true + }, + { + actions: { + // eslint-disable-next-line no-console + copy: { onClick: () => console.log('Copy') }, + // eslint-disable-next-line no-console + download: { onClick: () => console.log('Download') } + }, + persistActionSelection: false + }, + { + actions: { + // eslint-disable-next-line no-console + listen: { onClick: () => console.log('Listen') } + }, + persistActionSelection: true + } + ]} + /> + 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={true} + /> + +); 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 a79efe236..101a8ff73 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 @@ -122,6 +122,24 @@ When `persistActionSelection` is `true`: ``` +### 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. + +To provide flexibility for your use case, there are 2 approaches you can take to pass an array of objects to `actions`: + +1. Pass an array of objects, where each object contains: + + - `actions`: An `action` object containing the actions for that group (the same format as a single `action` object) + + - `persistActionSelection` (optional): A boolean to control whether selections persists for this specific group + +2. Pass an array of `action` objects (the same format as a single `action` object) and (optionally) a value for the `persistActionSelection` property that will apply to all groups. + +```js file="./MessageWithMultipleActionGroups.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.test.tsx b/packages/module/src/Message/Message.test.tsx index 351242fdd..01b7703ad 100644 --- a/packages/module/src/Message/Message.test.tsx +++ b/packages/module/src/Message/Message.test.tsx @@ -437,7 +437,7 @@ describe('Message', () => { expect(screen.queryByRole('button', { name: /No/i })).toBeFalsy(); expect(screen.getByRole('button', { name: /1 more/i })); }); - it('should be able to show actions', async () => { + it('Renders response actions when a single actions object is passed', async () => { render( { /> ); ALL_ACTIONS.forEach(({ label }) => { - expect(screen.getByRole('button', { name: label })).toBeTruthy(); + expect(screen.getByRole('button', { name: label })).toBeVisible(); }); }); + it('Renders response actions when an array of actions objects is passed', async () => { + render( + 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('Copy') }, + // eslint-disable-next-line no-console + edit: { onClick: () => console.log('Edit') }, + // eslint-disable-next-line no-console + share: { onClick: () => console.log('Share') }, + // eslint-disable-next-line no-console + download: { onClick: () => console.log('Download') } + }, + { + // eslint-disable-next-line no-console + listen: { onClick: () => console.log('Listen') } + } + ]} + /> + ); + ALL_ACTIONS.forEach(({ label }) => { + expect(screen.getByRole('button', { name: label })).toBeVisible(); + }); + }); + it('Renders response actions when an array of objects containing actions objects is passed', async () => { + render( + console.log('Good response') }, + // eslint-disable-next-line no-console + negative: { onClick: () => console.log('Bad response') } + } + }, + { + actions: { + // eslint-disable-next-line no-console + copy: { onClick: () => console.log('Copy') }, + // eslint-disable-next-line no-console + edit: { onClick: () => console.log('Edit') }, + // eslint-disable-next-line no-console + share: { onClick: () => console.log('Share') }, + // eslint-disable-next-line no-console + download: { onClick: () => console.log('Download') } + } + }, + { + actions: { + // eslint-disable-next-line no-console + listen: { onClick: () => console.log('Listen') } + } + } + ]} + /> + ); + ALL_ACTIONS.forEach(({ label }) => { + expect(screen.getByRole('button', { name: label })).toBeVisible(); + }); + }); + + it('should handle persistActionSelection correctly when a single actions object is passed', async () => { + render( + + ); + const goodBtn = screen.getByRole('button', { name: /Good response/i }); + const badBtn = screen.getByRole('button', { name: /Bad response/i }); + + await userEvent.click(goodBtn); + expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + + await userEvent.click(screen.getByText('Test message')); + expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + + await userEvent.click(badBtn); + expect(screen.getByRole('button', { name: /Bad response recorded/i })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + }); + + it('should handle persistActionSelection correctly when an array of actions objects is passed', async () => { + render( + + ); + const goodBtn = screen.getByRole('button', { name: /Good response/i }); + const copyBtn = screen.getByRole('button', { name: /Copy/i }); + + await userEvent.click(goodBtn); + expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + + await userEvent.click(screen.getByText('Test message')); + expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + + await userEvent.click(copyBtn); + expect(screen.getByRole('button', { name: /Copied/i })).toHaveClass('pf-chatbot__button--response-action-clicked'); + + await userEvent.click(screen.getByText('Test message')); + expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + expect(screen.getByRole('button', { name: /Copied/i })).toHaveClass('pf-chatbot__button--response-action-clicked'); + }); + + it('should handle persistActionSelection correctly when an array of objects containing actions objects is passed', async () => { + render( + + ); + const goodBtn = screen.getByRole('button', { name: /Good response/i }); + const copyBtn = screen.getByRole('button', { name: /Copy/i }); + + await userEvent.click(goodBtn); + expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + + await userEvent.click(copyBtn); + expect(screen.getByRole('button', { name: /Copied/i })).toHaveClass('pf-chatbot__button--response-action-clicked'); + + await userEvent.click(screen.getByText('Test message')); + expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + expect(copyBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + }); + it('should not show actions if loading', async () => { render( { expect(screen.queryByRole('button', { name: label })).toBeFalsy(); }); }); + it('should render unordered lists correctly', () => { render(); expect(screen.getByText('Here is an unordered list:')).toBeTruthy(); diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index 4cbc8fc12..c487c59b2 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -104,12 +104,27 @@ export interface MessageProps extends Omit, 'role'> { isLoading?: boolean; /** Array of attachments attached to a message */ attachments?: MessageAttachment[]; - /** Props for message actions, such as feedback (positive or negative), copy button, edit message, share, and listen */ - actions?: { - [key: string]: ActionProps; - }; + /** Props for message actions, such as feedback (positive or negative), copy button, edit message, share, and listen. + * Can be a single actions object or an array of action group objects. When passing an array, you can pass an object of actions or + * an object that contains an actions property for finer control of selection persistence. + */ + actions?: + | { + [key: string]: ActionProps; + } + | { + [key: string]: ActionProps; + }[] + | { + actions: { + [key: string]: ActionProps; + }; + persistActionSelection?: boolean; + }[]; /** 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. */ + * When false (default), clicking outside or clicking another action will deselect the current selection. + * For finer control of multiple action groups, use persistActionSelection on each group. + */ persistActionSelection?: boolean; /** Sources for message */ sources?: SourcesCardProps; @@ -506,7 +521,21 @@ export const MessageBase: FunctionComponent = ({ /> )} {!isLoading && !isEditable && actions && ( - + <> + {Array.isArray(actions) ? ( +
+ {actions.map((actionGroup, index) => ( + + ))} +
+ ) : ( + + )} + )} {userFeedbackForm && } {userFeedbackComplete && ( diff --git a/packages/module/src/ResponseActions/ResponseActions.scss b/packages/module/src/ResponseActions/ResponseActions.scss index cf8b740dd..baa6f63b2 100644 --- a/packages/module/src/ResponseActions/ResponseActions.scss +++ b/packages/module/src/ResponseActions/ResponseActions.scss @@ -22,6 +22,17 @@ } } +.pf-chatbot__response-actions-groups { + display: grid; + grid-auto-flow: column; + grid-auto-columns: max-content; + gap: var(--pf-t--global--spacer--xs); + + .pf-chatbot__response-actions { + display: flex; + } +} + .pf-v6-c-button.pf-chatbot__button--response-action-clicked.pf-v6-c-button.pf-m-plain.pf-m-small { --pf-v6-c-button--m-plain--BackgroundColor: var(--pf-t--global--background--color--action--plain--alt--clicked); --pf-v6-c-button__icon--Color: var(--pf-t--global--icon--color--regular);