Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 = () => (
<>
<Message
name="Bot"
role="bot"
avatar={patternflyAvatar}
content="This message contains multiple action groups, each with their own selection persistence: \n1. Feedback actions (thumbs up/down) with persistent selections \n2. Utility actions (copy, download) with non-persistent selections \n3. Listen action with persistent selection"
actions={[
{
actions: {
// eslint-disable-next-line no-console
positive: { onClick: () => 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
}
]}
/>
<Message
name="Bot"
role="bot"
avatar={patternflyAvatar}
content="This message contains multiple action groups, both of which persist selections."
actions={[
{
// eslint-disable-next-line no-console
positive: { onClick: () => 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}
/>
</>
);
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Message>` component. This object can contain the following customizations:
Expand Down
200 changes: 198 additions & 2 deletions packages/module/src/Message/Message.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Message
avatar="./img"
Expand All @@ -463,9 +463,204 @@ describe('Message', () => {
/>
);
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(
<Message
avatar="./img"
role="bot"
name="Bot"
content="Hi"
actions={[
{
// eslint-disable-next-line no-console
positive: { onClick: () => 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(
<Message
avatar="./img"
role="bot"
name="Bot"
content="Hi"
actions={[
{
actions: {
// eslint-disable-next-line no-console
positive: { onClick: () => 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(
<Message
avatar="./img"
role="bot"
name="Bot"
content="Test message"
persistActionSelection
actions={{
positive: { onClick: jest.fn() },
negative: { onClick: jest.fn() }
}}
/>
);
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(
<Message
avatar="./img"
role="bot"
name="Bot"
content="Test message"
persistActionSelection
actions={[
{
positive: { onClick: jest.fn() },
negative: { onClick: jest.fn() }
},
{
copy: { onClick: jest.fn() }
}
]}
/>
);
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(
<Message
avatar="./img"
role="bot"
name="Bot"
content="Test message"
actions={[
{
actions: {
positive: { onClick: jest.fn() },
negative: { onClick: jest.fn() }
},
persistActionSelection: true
},
{
actions: {
copy: { onClick: jest.fn() }
},
persistActionSelection: false
}
]}
/>
);
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(
<Message
Expand Down Expand Up @@ -527,6 +722,7 @@ describe('Message', () => {
expect(screen.queryByRole('button', { name: label })).toBeFalsy();
});
});

it('should render unordered lists correctly', () => {
render(<Message avatar="./img" role="user" name="User" content={UNORDERED_LIST} />);
expect(screen.getByText('Here is an unordered list:')).toBeTruthy();
Expand Down
41 changes: 35 additions & 6 deletions packages/module/src/Message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,27 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, '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;
Expand Down Expand Up @@ -506,7 +521,21 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
/>
)}
{!isLoading && !isEditable && actions && (
<ResponseActions actions={actions} persistActionSelection={persistActionSelection} />
<>
{Array.isArray(actions) ? (
<div className="pf-chatbot__response-actions-groups">
{actions.map((actionGroup, index) => (
<ResponseActions
key={index}
actions={actionGroup.actions || actionGroup}
persistActionSelection={persistActionSelection || actionGroup.persistActionSelection}
/>
))}
</div>
) : (
<ResponseActions actions={actions} persistActionSelection={persistActionSelection} />
)}
</>
)}
{userFeedbackForm && <UserFeedback {...userFeedbackForm} timestamp={dateString} isCompact={isCompact} />}
{userFeedbackComplete && (
Expand Down
11 changes: 11 additions & 0 deletions packages/module/src/ResponseActions/ResponseActions.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading