Skip to content

Commit 56e929c

Browse files
Merge pull request #812 from wise-king-sullyman/add-icon-swapping-on-click
feat(messages): fill positive, negative, and copy action icons on click
2 parents bb287cc + 9d0f420 commit 56e929c

File tree

6 files changed

+337
-4
lines changed

6 files changed

+337
-4
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 IconSwappingExample: FunctionComponent = () => (
7+
<Message
8+
name="Bot"
9+
role="bot"
10+
avatar={patternflyAvatar}
11+
content="Click the response actions to see the outlined icons swapped with the filled variants!"
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('Copied') }
19+
}}
20+
useFilledIconsOnClick
21+
/>
22+
);

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,17 @@ When `persistActionSelection` is `true`:
138138

139139
```
140140

141+
### Message actions that fill
142+
143+
To provide enhanced visual feedback when users interact with response actions, you can enable icon swapping by setting `useFilledIconsOnClick` to `true`. When enabled, the predefined "positive" and "negative" actions will automatically swap to their filled icon counterparts when clicked, replacing the original outlined icon variants.
144+
145+
This is especially useful for actions that are intended to persist (such as the "positive" and "negative" responses), so that a user's selection is more clear and emphasized.
146+
147+
148+
```js file="./MessageWithIconSwapping.tsx"
149+
150+
```
151+
141152
### Multiple messsage action groups
142153

143154
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.

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ import rehypeExternalLinks from '../__mocks__/rehype-external-links';
99
import { AlertActionLink, Button, CodeBlockAction } from '@patternfly/react-core';
1010
import { DeepThinkingProps } from '../DeepThinking';
1111

12+
// Mock the icon components
13+
jest.mock('@patternfly/react-icons', () => ({
14+
OutlinedThumbsUpIcon: () => <div>OutlinedThumbsUpIcon</div>,
15+
ThumbsUpIcon: () => <div>ThumbsUpIcon</div>,
16+
OutlinedThumbsDownIcon: () => <div>OutlinedThumbsDownIcon</div>,
17+
ThumbsDownIcon: () => <div>ThumbsDownIcon</div>,
18+
OutlinedCopyIcon: () => <div>OutlinedCopyIcon</div>,
19+
DownloadIcon: () => <div>DownloadIcon</div>,
20+
ExternalLinkAltIcon: () => <div>ExternalLinkAltIcon</div>,
21+
VolumeUpIcon: () => <div>VolumeUpIcon</div>,
22+
PencilAltIcon: () => <div>PencilAltIcon</div>,
23+
CheckIcon: () => <div>CheckIcon</div>,
24+
CloseIcon: () => <div>CloseIcon</div>,
25+
ExternalLinkSquareAltIcon: () => <div>ExternalLinkSquareAltIcon</div>,
26+
TimesIcon: () => <div>TimesIcon</div>
27+
}));
28+
1229
const ALL_ACTIONS = [
1330
{ label: /Good response/i },
1431
{ label: /Bad response/i },
@@ -1351,4 +1368,51 @@ describe('Message', () => {
13511368
render(<Message alignment="end" avatar="./img" role="user" name="User" content="" />);
13521369
expect(screen.getByRole('region')).toHaveClass('pf-m-end');
13531370
});
1371+
1372+
// We're just testing the positive action here to ensure logic passes through as needed, the other actions are
1373+
// tested in ResponseActions.test.tsx along with other aspects of this functionality
1374+
it('should not swap icons when useFilledIconsOnClick is omitted', async () => {
1375+
const user = userEvent.setup();
1376+
1377+
render(
1378+
<Message
1379+
avatar="./img"
1380+
role="bot"
1381+
name="Bot"
1382+
content="Hi"
1383+
actions={{
1384+
positive: { onClick: jest.fn() }
1385+
}}
1386+
/>
1387+
);
1388+
1389+
expect(screen.getByText('OutlinedThumbsUpIcon')).toBeInTheDocument();
1390+
1391+
await user.click(screen.getByRole('button', { name: /Good response/i }));
1392+
1393+
expect(screen.getByText('OutlinedThumbsUpIcon')).toBeInTheDocument();
1394+
expect(screen.queryByText('ThumbsUpIcon')).not.toBeInTheDocument();
1395+
});
1396+
1397+
it('should swap icons when useFilledIconsOnClick is true', async () => {
1398+
const user = userEvent.setup();
1399+
1400+
render(
1401+
<Message
1402+
avatar="./img"
1403+
role="bot"
1404+
name="Bot"
1405+
content="Hi"
1406+
actions={{
1407+
positive: { onClick: jest.fn() }
1408+
}}
1409+
useFilledIconsOnClick
1410+
/>
1411+
);
1412+
1413+
await user.click(screen.getByRole('button', { name: /Good response/i }));
1414+
1415+
expect(screen.getByText('ThumbsUpIcon')).toBeInTheDocument();
1416+
expect(screen.queryByText('OutlinedThumbsUpIcon')).not.toBeInTheDocument();
1417+
});
13541418
});

packages/module/src/Message/Message.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
197197
hasNoImagesInUserMessages?: boolean;
198198
/** Sets background colors to be appropriate on primary chatbot background */
199199
isPrimary?: boolean;
200+
/** When true, automatically swaps to filled icon variants when predefined actions are clicked. */
201+
useFilledIconsOnClick?: boolean;
200202
}
201203

202204
export const MessageBase: FunctionComponent<MessageProps> = ({
@@ -249,6 +251,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
249251
toolCall,
250252
hasNoImagesInUserMessages = true,
251253
isPrimary,
254+
useFilledIconsOnClick,
252255
...props
253256
}: MessageProps) => {
254257
const [messageText, setMessageText] = useState(content);
@@ -385,11 +388,16 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
385388
key={index}
386389
actions={actionGroup.actions || actionGroup}
387390
persistActionSelection={persistActionSelection || actionGroup.persistActionSelection}
391+
useFilledIconsOnClick={useFilledIconsOnClick}
388392
/>
389393
))}
390394
</div>
391395
) : (
392-
<ResponseActions actions={actions} persistActionSelection={persistActionSelection} />
396+
<ResponseActions
397+
actions={actions}
398+
persistActionSelection={persistActionSelection}
399+
useFilledIconsOnClick={useFilledIconsOnClick}
400+
/>
393401
)}
394402
</>
395403
)}

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

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ import userEvent from '@testing-library/user-event';
55
import { DownloadIcon, InfoCircleIcon, RedoIcon } from '@patternfly/react-icons';
66
import Message from '../Message';
77

8+
// Mock the icon components
9+
jest.mock('@patternfly/react-icons', () => ({
10+
OutlinedThumbsUpIcon: () => <div>OutlinedThumbsUpIcon</div>,
11+
ThumbsUpIcon: () => <div>ThumbsUpIcon</div>,
12+
OutlinedThumbsDownIcon: () => <div>OutlinedThumbsDownIcon</div>,
13+
ThumbsDownIcon: () => <div>ThumbsDownIcon</div>,
14+
OutlinedCopyIcon: () => <div>OutlinedCopyIcon</div>,
15+
DownloadIcon: () => <div>DownloadIcon</div>,
16+
InfoCircleIcon: () => <div>InfoCircleIcon</div>,
17+
RedoIcon: () => <div>RedoIcon</div>,
18+
ExternalLinkAltIcon: () => <div>ExternalLinkAltIcon</div>,
19+
VolumeUpIcon: () => <div>VolumeUpIcon</div>,
20+
PencilAltIcon: () => <div>PencilAltIcon</div>
21+
}));
22+
823
const ALL_ACTIONS = [
924
{ type: 'positive', label: 'Good response', clickedLabel: 'Good response recorded' },
1025
{ type: 'negative', label: 'Bad response', clickedLabel: 'Bad response recorded' },
@@ -421,4 +436,189 @@ describe('ResponseActions', () => {
421436
await userEvent.click(customBtn);
422437
expect(customBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
423438
});
439+
440+
describe('icon swapping with useFilledIconsOnClick', () => {
441+
it('should render outline icons by default', () => {
442+
render(
443+
<ResponseActions
444+
actions={{
445+
positive: { onClick: jest.fn() },
446+
negative: { onClick: jest.fn() }
447+
}}
448+
/>
449+
);
450+
451+
expect(screen.getByText('OutlinedThumbsUpIcon')).toBeInTheDocument();
452+
expect(screen.getByText('OutlinedThumbsDownIcon')).toBeInTheDocument();
453+
454+
expect(screen.queryByText('ThumbsUpIcon')).not.toBeInTheDocument();
455+
expect(screen.queryByText('ThumbsDownIcon')).not.toBeInTheDocument();
456+
});
457+
458+
describe('positive actions', () => {
459+
it('should not swap positive icon when clicked and useFilledIconsOnClick is false', async () => {
460+
const user = userEvent.setup();
461+
462+
render(
463+
<ResponseActions
464+
actions={{
465+
positive: { onClick: jest.fn() }
466+
}}
467+
useFilledIconsOnClick={false}
468+
/>
469+
);
470+
471+
await user.click(screen.getByRole('button', { name: 'Good response' }));
472+
473+
expect(screen.getByText('OutlinedThumbsUpIcon')).toBeInTheDocument();
474+
expect(screen.queryByText('ThumbsUpIcon')).not.toBeInTheDocument();
475+
});
476+
477+
it('should swap positive icon from outline to filled when clicked with useFilledIconsOnClick', async () => {
478+
const user = userEvent.setup();
479+
480+
render(
481+
<ResponseActions
482+
actions={{
483+
positive: { onClick: jest.fn() }
484+
}}
485+
useFilledIconsOnClick
486+
/>
487+
);
488+
489+
await user.click(screen.getByRole('button', { name: 'Good response' }));
490+
491+
expect(screen.getByText('ThumbsUpIcon')).toBeInTheDocument();
492+
expect(screen.queryByText('OutlinedThumbsUpIcon')).not.toBeInTheDocument();
493+
});
494+
495+
it('should revert positive icon to outline icon when clicking outside', async () => {
496+
const user = userEvent.setup();
497+
498+
render(
499+
<div>
500+
<ResponseActions
501+
actions={{
502+
positive: { onClick: jest.fn() }
503+
}}
504+
useFilledIconsOnClick
505+
/>
506+
<div data-testid="outside">Outside</div>
507+
</div>
508+
);
509+
510+
await user.click(screen.getByRole('button', { name: 'Good response' }));
511+
expect(screen.getByText('ThumbsUpIcon')).toBeInTheDocument();
512+
513+
await user.click(screen.getByTestId('outside'));
514+
expect(screen.getByText('OutlinedThumbsUpIcon')).toBeInTheDocument();
515+
});
516+
517+
it('should not revert positive icon to outline icon when clicking outside if persistActionSelection is true', async () => {
518+
const user = userEvent.setup();
519+
520+
render(
521+
<div>
522+
<ResponseActions
523+
actions={{
524+
positive: { onClick: jest.fn() }
525+
}}
526+
persistActionSelection
527+
useFilledIconsOnClick
528+
/>
529+
<div data-testid="outside">Outside</div>
530+
</div>
531+
);
532+
533+
await user.click(screen.getByRole('button', { name: 'Good response' }));
534+
expect(screen.getByText('ThumbsUpIcon')).toBeInTheDocument();
535+
536+
await user.click(screen.getByTestId('outside'));
537+
expect(screen.getByText('ThumbsUpIcon')).toBeInTheDocument();
538+
});
539+
540+
describe('negative actions', () => {
541+
it('should not swap negative icon when clicked and useFilledIconsOnClick is false', async () => {
542+
const user = userEvent.setup();
543+
544+
render(
545+
<ResponseActions
546+
actions={{
547+
negative: { onClick: jest.fn() }
548+
}}
549+
useFilledIconsOnClick={false}
550+
/>
551+
);
552+
553+
await user.click(screen.getByRole('button', { name: 'Bad response' }));
554+
555+
expect(screen.getByText('OutlinedThumbsDownIcon')).toBeInTheDocument();
556+
expect(screen.queryByText('ThumbsDownIcon')).not.toBeInTheDocument();
557+
});
558+
559+
it('should swap negative icon from outline to filled when clicked with useFilledIconsOnClick', async () => {
560+
const user = userEvent.setup();
561+
562+
render(
563+
<ResponseActions
564+
actions={{
565+
negative: { onClick: jest.fn() }
566+
}}
567+
useFilledIconsOnClick
568+
/>
569+
);
570+
571+
await user.click(screen.getByRole('button', { name: 'Bad response' }));
572+
573+
expect(screen.getByText('ThumbsDownIcon')).toBeInTheDocument();
574+
expect(screen.queryByText('OutlinedThumbsDownIcon')).not.toBeInTheDocument();
575+
});
576+
577+
it('should revert negative icon to outline when clicking outside', async () => {
578+
const user = userEvent.setup();
579+
580+
render(
581+
<div>
582+
<ResponseActions
583+
actions={{
584+
negative: { onClick: jest.fn() }
585+
}}
586+
useFilledIconsOnClick
587+
/>
588+
<div data-testid="outside">Outside</div>
589+
</div>
590+
);
591+
592+
await user.click(screen.getByRole('button', { name: 'Bad response' }));
593+
expect(screen.getByText('ThumbsDownIcon')).toBeInTheDocument();
594+
595+
await user.click(screen.getByTestId('outside'));
596+
expect(screen.getByText('OutlinedThumbsDownIcon')).toBeInTheDocument();
597+
});
598+
599+
it('should not revert negative icon to outline icon when clicking outside if persistActionSelection is true', async () => {
600+
const user = userEvent.setup();
601+
602+
render(
603+
<div>
604+
<ResponseActions
605+
actions={{
606+
negative: { onClick: jest.fn() }
607+
}}
608+
persistActionSelection
609+
useFilledIconsOnClick
610+
/>
611+
<div data-testid="outside">Outside</div>
612+
</div>
613+
);
614+
615+
await user.click(screen.getByRole('button', { name: 'Bad response' }));
616+
expect(screen.getByText('ThumbsDownIcon')).toBeInTheDocument();
617+
618+
await user.click(screen.getByTestId('outside'));
619+
expect(screen.getByText('ThumbsDownIcon')).toBeInTheDocument();
620+
});
621+
});
622+
});
623+
});
424624
});

0 commit comments

Comments
 (0)