Skip to content

Commit 425b516

Browse files
feat(ResponseActions): Add option for persistent selections (#740)
Assisted-by: Cursor Co-authored-by: Erin Donehoo <[email protected]>
1 parent 1212a78 commit 425b516

File tree

5 files changed

+192
-23
lines changed

5 files changed

+192
-23
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 MessageWithPersistedActions: FunctionComponent = () => (
7+
<Message
8+
name="Bot"
9+
role="bot"
10+
avatar={patternflyAvatar}
11+
content="I updated your account with those settings. You're ready to set up your first dashboard! Click a button and then click outside the message - notice the selection persists."
12+
actions={{
13+
// eslint-disable-next-line no-console
14+
positive: { onClick: () => console.log('Good response') },
15+
// eslint-disable-next-line no-console
16+
negative: { onClick: () => console.log('Bad response') },
17+
// eslint-disable-next-line no-console
18+
listen: { onClick: () => console.log('Listen') }
19+
}}
20+
persistActionSelection
21+
/>
22+
);

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,20 @@ Once the component has rendered, user interactions will take precedence over the
108108

109109
```
110110

111+
### Message actions persistent selections
112+
113+
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`.
114+
115+
When `persistActionSelection` is `true`:
116+
117+
- The selected action will remain selected even when you click outside the component.
118+
- Clicking a different button will still switch the selection to that button.
119+
- 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.
120+
121+
```js file="./MessageWithPersistedActions.tsx"
122+
123+
```
124+
111125
### Custom message actions
112126

113127
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.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
108108
actions?: {
109109
[key: string]: ActionProps;
110110
};
111+
/** 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. */
113+
persistActionSelection?: boolean;
111114
/** Sources for message */
112115
sources?: SourcesCardProps;
113116
/** Label for the English word "AI," used to tag messages with role "bot" */
@@ -202,6 +205,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
202205
timestamp,
203206
isLoading,
204207
actions,
208+
persistActionSelection,
205209
sources,
206210
botWord = 'AI',
207211
loadingWord = 'Loading message',
@@ -501,7 +505,9 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
501505
isCompact={isCompact}
502506
/>
503507
)}
504-
{!isLoading && !isEditable && actions && <ResponseActions actions={actions} />}
508+
{!isLoading && !isEditable && actions && (
509+
<ResponseActions actions={actions} persistActionSelection={persistActionSelection} />
510+
)}
505511
{userFeedbackForm && <UserFeedback {...userFeedbackForm} timestamp={dateString} isCompact={isCompact} />}
506512
{userFeedbackComplete && (
507513
<UserFeedbackComplete {...userFeedbackComplete} timestamp={dateString} isCompact={isCompact} />

packages/module/src/ResponseActions/ResponseActions.test.tsx

Lines changed: 111 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { DownloadIcon, InfoCircleIcon, RedoIcon } from '@patternfly/react-icons'
66
import Message from '../Message';
77

88
const ALL_ACTIONS = [
9-
{ type: 'positive', label: 'Good response', clickedLabel: 'Response recorded' },
10-
{ type: 'negative', label: 'Bad response', clickedLabel: 'Response recorded' },
9+
{ type: 'positive', label: 'Good response', clickedLabel: 'Good response recorded' },
10+
{ type: 'negative', label: 'Bad response', clickedLabel: 'Bad response recorded' },
1111
{ type: 'copy', label: 'Copy', clickedLabel: 'Copied' },
1212
{ type: 'edit', label: 'Edit', clickedLabel: 'Editing' },
1313
{ type: 'share', label: 'Share', clickedLabel: 'Shared' },
@@ -81,15 +81,15 @@ describe('ResponseActions', () => {
8181
expect(button).toBeTruthy();
8282
});
8383
await userEvent.click(goodBtn);
84-
expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass(
84+
expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass(
8585
'pf-chatbot__button--response-action-clicked'
8686
);
8787
let unclickedButtons = buttons.filter((button) => button !== goodBtn);
8888
unclickedButtons.forEach((button) => {
8989
expect(button).not.toHaveClass('pf-chatbot__button--response-action-clicked');
9090
});
9191
await userEvent.click(badBtn);
92-
expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass(
92+
expect(screen.getByRole('button', { name: 'Bad response recorded' })).toHaveClass(
9393
'pf-chatbot__button--response-action-clicked'
9494
);
9595
unclickedButtons = buttons.filter((button) => button !== badBtn);
@@ -117,13 +117,13 @@ describe('ResponseActions', () => {
117117
expect(badBtn).toBeTruthy();
118118

119119
await userEvent.click(goodBtn);
120-
expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass(
120+
expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass(
121121
'pf-chatbot__button--response-action-clicked'
122122
);
123123
expect(badBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
124124

125125
await userEvent.click(badBtn);
126-
expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass(
126+
expect(screen.getByRole('button', { name: 'Bad response recorded' })).toHaveClass(
127127
'pf-chatbot__button--response-action-clicked'
128128
);
129129
expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
@@ -238,30 +238,30 @@ describe('ResponseActions', () => {
238238
});
239239

240240
it('should be able to call onClick correctly', async () => {
241-
ALL_ACTIONS.forEach(async ({ type, label }) => {
241+
for (const { type, label } of ALL_ACTIONS) {
242242
const spy = jest.fn();
243243
render(<ResponseActions actions={{ [type]: { onClick: spy } }} />);
244244
await userEvent.click(screen.getByRole('button', { name: label }));
245245
expect(spy).toHaveBeenCalledTimes(1);
246-
});
246+
}
247247
});
248248

249249
it('should swap clicked and non-clicked aria labels on click', async () => {
250-
ALL_ACTIONS.forEach(async ({ type, label, clickedLabel }) => {
250+
for (const { type, label, clickedLabel } of ALL_ACTIONS) {
251251
render(<ResponseActions actions={{ [type]: { onClick: jest.fn() } }} />);
252252
expect(screen.getByRole('button', { name: label })).toBeTruthy();
253253
await userEvent.click(screen.getByRole('button', { name: label }));
254254
expect(screen.getByRole('button', { name: clickedLabel })).toBeTruthy();
255-
});
255+
}
256256
});
257257

258258
it('should swap clicked and non-clicked tooltips on click', async () => {
259-
ALL_ACTIONS.forEach(async ({ type, label, clickedLabel }) => {
259+
for (const { type, label, clickedLabel } of ALL_ACTIONS) {
260260
render(<ResponseActions actions={{ [type]: { onClick: jest.fn() } }} />);
261261
expect(screen.getByRole('button', { name: label })).toBeTruthy();
262262
await userEvent.click(screen.getByRole('button', { name: label }));
263263
expect(screen.getByRole('tooltip', { name: clickedLabel })).toBeTruthy();
264-
});
264+
}
265265
});
266266

267267
it('should be able to change aria labels', () => {
@@ -322,4 +322,103 @@ describe('ResponseActions', () => {
322322
expect(screen.getByTestId(action[key])).toBeTruthy();
323323
});
324324
});
325+
326+
// we are testing for the reverse case already above
327+
it('should not deselect when clicking outside when persistActionSelection is true', async () => {
328+
render(
329+
<Message
330+
name="Bot"
331+
role="bot"
332+
avatar=""
333+
content="Test content"
334+
actions={{
335+
positive: {},
336+
negative: {}
337+
}}
338+
persistActionSelection
339+
/>
340+
);
341+
const goodBtn = screen.getByRole('button', { name: 'Good response' });
342+
343+
await userEvent.click(goodBtn);
344+
expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass(
345+
'pf-chatbot__button--response-action-clicked'
346+
);
347+
348+
await userEvent.click(screen.getByText('Test content'));
349+
350+
expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass(
351+
'pf-chatbot__button--response-action-clicked'
352+
);
353+
});
354+
355+
it('should switch selection to another button when persistActionSelection is true', async () => {
356+
render(
357+
<Message
358+
name="Bot"
359+
role="bot"
360+
avatar=""
361+
content="Test content"
362+
actions={{
363+
positive: {},
364+
negative: {}
365+
}}
366+
persistActionSelection
367+
/>
368+
);
369+
const goodBtn = screen.getByRole('button', { name: 'Good response' });
370+
const badBtn = screen.getByRole('button', { name: 'Bad response' });
371+
372+
await userEvent.click(goodBtn);
373+
expect(goodBtn).toHaveClass('pf-chatbot__button--response-action-clicked');
374+
375+
await userEvent.click(badBtn);
376+
expect(badBtn).toHaveClass('pf-chatbot__button--response-action-clicked');
377+
expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
378+
});
379+
380+
it('should toggle off when clicking the same button when persistActionSelection is true', async () => {
381+
render(
382+
<Message
383+
name="Bot"
384+
role="bot"
385+
avatar=""
386+
content="Test content"
387+
actions={{
388+
positive: {},
389+
negative: {}
390+
}}
391+
persistActionSelection
392+
/>
393+
);
394+
const goodBtn = screen.getByRole('button', { name: 'Good response' });
395+
396+
await userEvent.click(goodBtn);
397+
expect(goodBtn).toHaveClass('pf-chatbot__button--response-action-clicked');
398+
399+
await userEvent.click(goodBtn);
400+
expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
401+
});
402+
403+
it('should work with custom actions when persistActionSelection is true', async () => {
404+
const actions = {
405+
positive: { 'data-testid': 'positive', onClick: jest.fn() },
406+
negative: { 'data-testid': 'negative', onClick: jest.fn() },
407+
custom: {
408+
'data-testid': 'custom',
409+
onClick: jest.fn(),
410+
ariaLabel: 'Custom',
411+
tooltipContent: 'Custom action',
412+
icon: <DownloadIcon />
413+
}
414+
};
415+
render(<ResponseActions actions={actions} persistActionSelection />);
416+
417+
const customBtn = screen.getByTestId('custom');
418+
await userEvent.click(customBtn);
419+
expect(customBtn).toHaveClass('pf-chatbot__button--response-action-clicked');
420+
421+
await userEvent.click(customBtn);
422+
expect(customBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
423+
});
325424
});

packages/module/src/ResponseActions/ResponseActions.tsx

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,20 @@ export interface ResponseActionProps {
5353
listen?: ActionProps;
5454
edit?: ActionProps;
5555
};
56+
/** When true, the selected action will persist even when clicking outside the component.
57+
* When false (default), clicking outside or clicking another action will deselect the current selection. */
58+
persistActionSelection?: boolean;
5659
}
5760

58-
export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ actions }) => {
61+
export const ResponseActions: FunctionComponent<ResponseActionProps> = ({
62+
actions,
63+
persistActionSelection = false
64+
}) => {
5965
const [activeButton, setActiveButton] = useState<string>();
6066
const [clickStatePersisted, setClickStatePersisted] = useState<boolean>(false);
67+
68+
const { positive, negative, copy, edit, share, download, listen, ...additionalActions } = actions;
69+
6170
useEffect(() => {
6271
// Define the order of precedence for checking initial `isClicked`
6372
const actionPrecedence = ['positive', 'negative', 'copy', 'edit', 'share', 'download', 'listen'];
@@ -82,13 +91,21 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
8291
// Click state is explicitly controlled by consumer.
8392
setClickStatePersisted(true);
8493
}
94+
// If persistActionSelection is true, all selections are persisted
95+
if (persistActionSelection) {
96+
setClickStatePersisted(true);
97+
}
8598
setActiveButton(initialActive);
86-
}, [actions]);
99+
}, [actions, persistActionSelection]);
87100

88-
const { positive, negative, copy, edit, share, download, listen, ...additionalActions } = actions;
89101
const responseActions = useRef<HTMLDivElement>(null);
90102

91103
useEffect(() => {
104+
// Only add click outside listener if not persisting selection
105+
if (persistActionSelection) {
106+
return;
107+
}
108+
92109
const handleClickOutside = (e) => {
93110
if (responseActions.current && !responseActions.current.contains(e.target) && !clickStatePersisted) {
94111
setActiveButton(undefined);
@@ -99,15 +116,26 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
99116
return () => {
100117
window.removeEventListener('click', handleClickOutside);
101118
};
102-
}, [clickStatePersisted]);
119+
}, [clickStatePersisted, persistActionSelection]);
103120

104121
const handleClick = (
105122
e: MouseEvent | MouseEvent<Element, MouseEvent> | KeyboardEvent,
106123
id: string,
107124
onClick?: (event: MouseEvent | MouseEvent<Element, MouseEvent> | KeyboardEvent) => void
108125
) => {
109-
setClickStatePersisted(false);
110-
setActiveButton(id);
126+
if (persistActionSelection) {
127+
if (activeButton === id) {
128+
// Toggle off if clicking the same button
129+
setActiveButton(undefined);
130+
} else {
131+
// Set new active button
132+
setActiveButton(id);
133+
}
134+
setClickStatePersisted(true);
135+
} else {
136+
setClickStatePersisted(false);
137+
setActiveButton(id);
138+
}
111139
onClick && onClick(e);
112140
};
113141

@@ -117,12 +145,12 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
117145
<ResponseActionButton
118146
{...positive}
119147
ariaLabel={positive.ariaLabel ?? 'Good response'}
120-
clickedAriaLabel={positive.ariaLabel ?? 'Response recorded'}
148+
clickedAriaLabel={positive.ariaLabel ?? 'Good response recorded'}
121149
onClick={(e) => handleClick(e, 'positive', positive.onClick)}
122150
className={positive.className}
123151
isDisabled={positive.isDisabled}
124152
tooltipContent={positive.tooltipContent ?? 'Good response'}
125-
clickedTooltipContent={positive.clickedTooltipContent ?? 'Response recorded'}
153+
clickedTooltipContent={positive.clickedTooltipContent ?? 'Good response recorded'}
126154
tooltipProps={positive.tooltipProps}
127155
icon={<OutlinedThumbsUpIcon />}
128156
isClicked={activeButton === 'positive'}
@@ -135,12 +163,12 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
135163
<ResponseActionButton
136164
{...negative}
137165
ariaLabel={negative.ariaLabel ?? 'Bad response'}
138-
clickedAriaLabel={negative.ariaLabel ?? 'Response recorded'}
166+
clickedAriaLabel={negative.ariaLabel ?? 'Bad response recorded'}
139167
onClick={(e) => handleClick(e, 'negative', negative.onClick)}
140168
className={negative.className}
141169
isDisabled={negative.isDisabled}
142170
tooltipContent={negative.tooltipContent ?? 'Bad response'}
143-
clickedTooltipContent={negative.clickedTooltipContent ?? 'Response recorded'}
171+
clickedTooltipContent={negative.clickedTooltipContent ?? 'Bad response recorded'}
144172
tooltipProps={negative.tooltipProps}
145173
icon={<OutlinedThumbsDownIcon />}
146174
isClicked={activeButton === 'negative'}

0 commit comments

Comments
 (0)