Skip to content

Commit d90facc

Browse files
committed
feat(ResponseActions): Add option for persistent selections
Assisted-by: Cursor
1 parent c082f9c commit d90facc

5 files changed

Lines changed: 194 additions & 13 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
copy: { onClick: () => console.log('Copy') },
19+
// eslint-disable-next-line no-console
20+
download: { onClick: () => console.log('Download') },
21+
// eslint-disable-next-line no-console
22+
listen: { onClick: () => console.log('Listen') }
23+
}}
24+
persistActionSelection
25+
/>
26+
);

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 selection options
112+
113+
By default, message actions will automatically deselect when clicking outside the component or clicking a different action button. You can opt-in to persist the selection by setting `persistActionSelection` to `true`.
114+
115+
When `persistActionSelection` is `true`:
116+
117+
- The selected action will remain selected even when clicking outside the component
118+
- Clicking the same button again will toggle the selection off, though you will have to move your focus elsewhere to see a visual state change
119+
- Clicking a different button will switch the selection to that button
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',
@@ -499,7 +503,9 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
499503
isCompact={isCompact}
500504
/>
501505
)}
502-
{!isLoading && !isEditable && actions && <ResponseActions actions={actions} />}
506+
{!isLoading && !isEditable && actions && (
507+
<ResponseActions actions={actions} persistActionSelection={persistActionSelection} />
508+
)}
503509
{userFeedbackForm && <UserFeedback {...userFeedbackForm} timestamp={dateString} isCompact={isCompact} />}
504510
{userFeedbackComplete && (
505511
<UserFeedbackComplete {...userFeedbackComplete} timestamp={dateString} isCompact={isCompact} />

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

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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: '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: '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: 42 additions & 6 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

@@ -129,6 +157,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
129157
ref={positive.ref}
130158
aria-expanded={positive['aria-expanded']}
131159
aria-controls={positive['aria-controls']}
160+
aria-pressed={persistActionSelection ? activeButton === 'positive' : undefined}
132161
></ResponseActionButton>
133162
)}
134163
{negative && (
@@ -147,6 +176,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
147176
ref={negative.ref}
148177
aria-expanded={negative['aria-expanded']}
149178
aria-controls={negative['aria-controls']}
179+
aria-pressed={persistActionSelection ? activeButton === 'negative' : undefined}
150180
></ResponseActionButton>
151181
)}
152182
{copy && (
@@ -165,6 +195,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
165195
ref={copy.ref}
166196
aria-expanded={copy['aria-expanded']}
167197
aria-controls={copy['aria-controls']}
198+
aria-pressed={persistActionSelection ? activeButton === 'copy' : undefined}
168199
></ResponseActionButton>
169200
)}
170201
{edit && (
@@ -183,6 +214,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
183214
ref={edit.ref}
184215
aria-expanded={edit['aria-expanded']}
185216
aria-controls={edit['aria-controls']}
217+
aria-pressed={persistActionSelection ? activeButton === 'edit' : undefined}
186218
></ResponseActionButton>
187219
)}
188220
{share && (
@@ -201,6 +233,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
201233
ref={share.ref}
202234
aria-expanded={share['aria-expanded']}
203235
aria-controls={share['aria-controls']}
236+
aria-pressed={persistActionSelection ? activeButton === 'share' : undefined}
204237
></ResponseActionButton>
205238
)}
206239
{download && (
@@ -219,6 +252,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
219252
ref={download.ref}
220253
aria-expanded={download['aria-expanded']}
221254
aria-controls={download['aria-controls']}
255+
aria-pressed={persistActionSelection ? activeButton === 'download' : undefined}
222256
></ResponseActionButton>
223257
)}
224258
{listen && (
@@ -237,6 +271,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
237271
ref={listen.ref}
238272
aria-expanded={listen['aria-expanded']}
239273
aria-controls={listen['aria-controls']}
274+
aria-pressed={persistActionSelection ? activeButton === 'listen' : undefined}
240275
></ResponseActionButton>
241276
)}
242277

@@ -257,6 +292,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
257292
ref={additionalActions[action]?.ref}
258293
aria-expanded={additionalActions[action]?.['aria-expanded']}
259294
aria-controls={additionalActions[action]?.['aria-controls']}
295+
aria-pressed={persistActionSelection ? activeButton === action : undefined}
260296
/>
261297
))}
262298
</div>

0 commit comments

Comments
 (0)