Skip to content

Commit e9a3ac3

Browse files
Merge pull request #764 from thatblindgeye/iss581
feat(ResponseActions): allowed grouped actions for multiple persistence
2 parents 5774d77 + 0b1a48f commit e9a3ac3

File tree

5 files changed

+323
-8
lines changed

5 files changed

+323
-8
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { FunctionComponent } from 'react';
2+
3+
import Message from '@patternfly/chatbot/dist/dynamic/Message';
4+
import patternflyAvatar from './patternfly_avatar.jpg';
5+
6+
export const MessageWithMultipleActionGroups: FunctionComponent = () => (
7+
<>
8+
<Message
9+
name="Bot"
10+
role="bot"
11+
avatar={patternflyAvatar}
12+
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"
13+
actions={[
14+
{
15+
actions: {
16+
// eslint-disable-next-line no-console
17+
positive: { onClick: () => console.log('Good response') },
18+
// eslint-disable-next-line no-console
19+
negative: { onClick: () => console.log('Bad response') }
20+
},
21+
persistActionSelection: true
22+
},
23+
{
24+
actions: {
25+
// eslint-disable-next-line no-console
26+
copy: { onClick: () => console.log('Copy') },
27+
// eslint-disable-next-line no-console
28+
download: { onClick: () => console.log('Download') }
29+
},
30+
persistActionSelection: false
31+
},
32+
{
33+
actions: {
34+
// eslint-disable-next-line no-console
35+
listen: { onClick: () => console.log('Listen') }
36+
},
37+
persistActionSelection: true
38+
}
39+
]}
40+
/>
41+
<Message
42+
name="Bot"
43+
role="bot"
44+
avatar={patternflyAvatar}
45+
content="This message contains multiple action groups, both of which persist selections."
46+
actions={[
47+
{
48+
// eslint-disable-next-line no-console
49+
positive: { onClick: () => console.log('Good response') },
50+
// eslint-disable-next-line no-console
51+
negative: { onClick: () => console.log('Bad response') }
52+
},
53+
{
54+
// eslint-disable-next-line no-console
55+
listen: { onClick: () => console.log('Listen') }
56+
}
57+
]}
58+
persistActionSelection={true}
59+
/>
60+
</>
61+
);

packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,24 @@ When `persistActionSelection` is `true`:
122122

123123
```
124124

125+
### Multiple messsage action groups
126+
127+
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.
128+
129+
To provide flexibility for your use case, there are 2 approaches you can take to pass an array of objects to `actions`:
130+
131+
1. Pass an array of objects, where each object contains:
132+
133+
- `actions`: An `action` object containing the actions for that group (the same format as a single `action` object)
134+
135+
- `persistActionSelection` (optional): A boolean to control whether selections persists for this specific group
136+
137+
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.
138+
139+
```js file="./MessageWithMultipleActionGroups.tsx"
140+
141+
```
142+
125143
### Custom message actions
126144

127145
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:

packages/module/src/Message/Message.test.tsx

Lines changed: 198 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ describe('Message', () => {
437437
expect(screen.queryByRole('button', { name: /No/i })).toBeFalsy();
438438
expect(screen.getByRole('button', { name: /1 more/i }));
439439
});
440-
it('should be able to show actions', async () => {
440+
it('Renders response actions when a single actions object is passed', async () => {
441441
render(
442442
<Message
443443
avatar="./img"
@@ -463,9 +463,204 @@ describe('Message', () => {
463463
/>
464464
);
465465
ALL_ACTIONS.forEach(({ label }) => {
466-
expect(screen.getByRole('button', { name: label })).toBeTruthy();
466+
expect(screen.getByRole('button', { name: label })).toBeVisible();
467467
});
468468
});
469+
it('Renders response actions when an array of actions objects is passed', async () => {
470+
render(
471+
<Message
472+
avatar="./img"
473+
role="bot"
474+
name="Bot"
475+
content="Hi"
476+
actions={[
477+
{
478+
// eslint-disable-next-line no-console
479+
positive: { onClick: () => console.log('Good response') },
480+
// eslint-disable-next-line no-console
481+
negative: { onClick: () => console.log('Bad response') }
482+
},
483+
{
484+
// eslint-disable-next-line no-console
485+
copy: { onClick: () => console.log('Copy') },
486+
// eslint-disable-next-line no-console
487+
edit: { onClick: () => console.log('Edit') },
488+
// eslint-disable-next-line no-console
489+
share: { onClick: () => console.log('Share') },
490+
// eslint-disable-next-line no-console
491+
download: { onClick: () => console.log('Download') }
492+
},
493+
{
494+
// eslint-disable-next-line no-console
495+
listen: { onClick: () => console.log('Listen') }
496+
}
497+
]}
498+
/>
499+
);
500+
ALL_ACTIONS.forEach(({ label }) => {
501+
expect(screen.getByRole('button', { name: label })).toBeVisible();
502+
});
503+
});
504+
it('Renders response actions when an array of objects containing actions objects is passed', async () => {
505+
render(
506+
<Message
507+
avatar="./img"
508+
role="bot"
509+
name="Bot"
510+
content="Hi"
511+
actions={[
512+
{
513+
actions: {
514+
// eslint-disable-next-line no-console
515+
positive: { onClick: () => console.log('Good response') },
516+
// eslint-disable-next-line no-console
517+
negative: { onClick: () => console.log('Bad response') }
518+
}
519+
},
520+
{
521+
actions: {
522+
// eslint-disable-next-line no-console
523+
copy: { onClick: () => console.log('Copy') },
524+
// eslint-disable-next-line no-console
525+
edit: { onClick: () => console.log('Edit') },
526+
// eslint-disable-next-line no-console
527+
share: { onClick: () => console.log('Share') },
528+
// eslint-disable-next-line no-console
529+
download: { onClick: () => console.log('Download') }
530+
}
531+
},
532+
{
533+
actions: {
534+
// eslint-disable-next-line no-console
535+
listen: { onClick: () => console.log('Listen') }
536+
}
537+
}
538+
]}
539+
/>
540+
);
541+
ALL_ACTIONS.forEach(({ label }) => {
542+
expect(screen.getByRole('button', { name: label })).toBeVisible();
543+
});
544+
});
545+
546+
it('should handle persistActionSelection correctly when a single actions object is passed', async () => {
547+
render(
548+
<Message
549+
avatar="./img"
550+
role="bot"
551+
name="Bot"
552+
content="Test message"
553+
persistActionSelection
554+
actions={{
555+
positive: { onClick: jest.fn() },
556+
negative: { onClick: jest.fn() }
557+
}}
558+
/>
559+
);
560+
const goodBtn = screen.getByRole('button', { name: /Good response/i });
561+
const badBtn = screen.getByRole('button', { name: /Bad response/i });
562+
563+
await userEvent.click(goodBtn);
564+
expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
565+
'pf-chatbot__button--response-action-clicked'
566+
);
567+
568+
await userEvent.click(screen.getByText('Test message'));
569+
expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
570+
'pf-chatbot__button--response-action-clicked'
571+
);
572+
573+
await userEvent.click(badBtn);
574+
expect(screen.getByRole('button', { name: /Bad response recorded/i })).toHaveClass(
575+
'pf-chatbot__button--response-action-clicked'
576+
);
577+
expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
578+
});
579+
580+
it('should handle persistActionSelection correctly when an array of actions objects is passed', async () => {
581+
render(
582+
<Message
583+
avatar="./img"
584+
role="bot"
585+
name="Bot"
586+
content="Test message"
587+
persistActionSelection
588+
actions={[
589+
{
590+
positive: { onClick: jest.fn() },
591+
negative: { onClick: jest.fn() }
592+
},
593+
{
594+
copy: { onClick: jest.fn() }
595+
}
596+
]}
597+
/>
598+
);
599+
const goodBtn = screen.getByRole('button', { name: /Good response/i });
600+
const copyBtn = screen.getByRole('button', { name: /Copy/i });
601+
602+
await userEvent.click(goodBtn);
603+
expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
604+
'pf-chatbot__button--response-action-clicked'
605+
);
606+
607+
await userEvent.click(screen.getByText('Test message'));
608+
expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
609+
'pf-chatbot__button--response-action-clicked'
610+
);
611+
612+
await userEvent.click(copyBtn);
613+
expect(screen.getByRole('button', { name: /Copied/i })).toHaveClass('pf-chatbot__button--response-action-clicked');
614+
615+
await userEvent.click(screen.getByText('Test message'));
616+
expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
617+
'pf-chatbot__button--response-action-clicked'
618+
);
619+
expect(screen.getByRole('button', { name: /Copied/i })).toHaveClass('pf-chatbot__button--response-action-clicked');
620+
});
621+
622+
it('should handle persistActionSelection correctly when an array of objects containing actions objects is passed', async () => {
623+
render(
624+
<Message
625+
avatar="./img"
626+
role="bot"
627+
name="Bot"
628+
content="Test message"
629+
actions={[
630+
{
631+
actions: {
632+
positive: { onClick: jest.fn() },
633+
negative: { onClick: jest.fn() }
634+
},
635+
persistActionSelection: true
636+
},
637+
{
638+
actions: {
639+
copy: { onClick: jest.fn() }
640+
},
641+
persistActionSelection: false
642+
}
643+
]}
644+
/>
645+
);
646+
const goodBtn = screen.getByRole('button', { name: /Good response/i });
647+
const copyBtn = screen.getByRole('button', { name: /Copy/i });
648+
649+
await userEvent.click(goodBtn);
650+
expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
651+
'pf-chatbot__button--response-action-clicked'
652+
);
653+
654+
await userEvent.click(copyBtn);
655+
expect(screen.getByRole('button', { name: /Copied/i })).toHaveClass('pf-chatbot__button--response-action-clicked');
656+
657+
await userEvent.click(screen.getByText('Test message'));
658+
expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
659+
'pf-chatbot__button--response-action-clicked'
660+
);
661+
expect(copyBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
662+
});
663+
469664
it('should not show actions if loading', async () => {
470665
render(
471666
<Message
@@ -527,6 +722,7 @@ describe('Message', () => {
527722
expect(screen.queryByRole('button', { name: label })).toBeFalsy();
528723
});
529724
});
725+
530726
it('should render unordered lists correctly', () => {
531727
render(<Message avatar="./img" role="user" name="User" content={UNORDERED_LIST} />);
532728
expect(screen.getByText('Here is an unordered list:')).toBeTruthy();

packages/module/src/Message/Message.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,27 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
104104
isLoading?: boolean;
105105
/** Array of attachments attached to a message */
106106
attachments?: MessageAttachment[];
107-
/** Props for message actions, such as feedback (positive or negative), copy button, edit message, share, and listen */
108-
actions?: {
109-
[key: string]: ActionProps;
110-
};
107+
/** Props for message actions, such as feedback (positive or negative), copy button, edit message, share, and listen.
108+
* 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
109+
* an object that contains an actions property for finer control of selection persistence.
110+
*/
111+
actions?:
112+
| {
113+
[key: string]: ActionProps;
114+
}
115+
| {
116+
[key: string]: ActionProps;
117+
}[]
118+
| {
119+
actions: {
120+
[key: string]: ActionProps;
121+
};
122+
persistActionSelection?: boolean;
123+
}[];
111124
/** When true, the selected action will persist even when clicking outside the component.
112-
* When false (default), clicking outside or clicking another action will deselect the current selection. */
125+
* When false (default), clicking outside or clicking another action will deselect the current selection.
126+
* For finer control of multiple action groups, use persistActionSelection on each group.
127+
*/
113128
persistActionSelection?: boolean;
114129
/** Sources for message */
115130
sources?: SourcesCardProps;
@@ -506,7 +521,21 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
506521
/>
507522
)}
508523
{!isLoading && !isEditable && actions && (
509-
<ResponseActions actions={actions} persistActionSelection={persistActionSelection} />
524+
<>
525+
{Array.isArray(actions) ? (
526+
<div className="pf-chatbot__response-actions-groups">
527+
{actions.map((actionGroup, index) => (
528+
<ResponseActions
529+
key={index}
530+
actions={actionGroup.actions || actionGroup}
531+
persistActionSelection={persistActionSelection || actionGroup.persistActionSelection}
532+
/>
533+
))}
534+
</div>
535+
) : (
536+
<ResponseActions actions={actions} persistActionSelection={persistActionSelection} />
537+
)}
538+
</>
510539
)}
511540
{userFeedbackForm && <UserFeedback {...userFeedbackForm} timestamp={dateString} isCompact={isCompact} />}
512541
{userFeedbackComplete && (

packages/module/src/ResponseActions/ResponseActions.scss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@
2222
}
2323
}
2424

25+
.pf-chatbot__response-actions-groups {
26+
display: grid;
27+
grid-auto-flow: column;
28+
grid-auto-columns: max-content;
29+
gap: var(--pf-t--global--spacer--xs);
30+
31+
.pf-chatbot__response-actions {
32+
display: flex;
33+
}
34+
}
35+
2536
.pf-v6-c-button.pf-chatbot__button--response-action-clicked.pf-v6-c-button.pf-m-plain.pf-m-small {
2637
--pf-v6-c-button--m-plain--BackgroundColor: var(--pf-t--global--background--color--action--plain--alt--clicked);
2738
--pf-v6-c-button__icon--Color: var(--pf-t--global--icon--color--regular);

0 commit comments

Comments
 (0)