Skip to content

Commit

Permalink
Merge pull request #382 from rebeccaalpert/response-action-clicked
Browse files Browse the repository at this point in the history
feat(ResponseActions): Add click state
  • Loading branch information
nicolethoen authored Dec 17, 2024
2 parents 3b45d76 + 862113a commit bfa650d
Show file tree
Hide file tree
Showing 8 changed files with 284 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,20 @@ export const CustomActionExample: React.FunctionComponent = () => (
actions={{
regenerate: {
ariaLabel: 'Regenerate',
clickedAriaLabel: 'Regenerated',
// eslint-disable-next-line no-console
onClick: () => console.log('Clicked regenerate'),
tooltipContent: 'Regenerate',
clickedTooltipContent: 'Regenerated',
icon: <RedoIcon />
},
download: {
ariaLabel: 'Download',
clickedAriaLabel: 'Downloaded',
// eslint-disable-next-line no-console
onClick: () => console.log('Clicked download'),
tooltipContent: 'Download',
clickedTooltipContent: 'Downloaded',
icon: <DownloadIcon />
},
info: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ You can further customize the avatar by applying an additional class or passing

```

### Messages actions
### Message actions

You can add actions to a message, to allow users to interact with the message content. These actions can include:

Expand All @@ -79,7 +79,18 @@ You can add actions to a message, to allow users to interact with the message co

### Custom message actions

Beyond the standard message actions (positive, negative, 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: `ariaLabel`, `onClick`, `className`, `isDisabled`, `tooltipContent`, `tooltipProps`, and `icon`.
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:

- `ariaLabel`
- `onClick`
- `className`
- `isDisabled`
- `tooltipContent`
- `tooltipContent`
- `tooltipProps`
- `icon`

You can apply a `clickedAriaLabel` and `clickedTooltipContent` once a button is clicked. If either of these props are omitted, their values will default to the `ariaLabel` or `tooltipContent` supplied.

```js file="./MessageWithCustomResponseActions.tsx"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ This demo displays a basic ChatBot, which includes:
4. [`<ChatbotContent>` and `<MessageBox>`](/patternfly-ai/chatbot/ui#content-and-message-box) with:

- A `<ChatbotWelcomePrompt>`
- An initial [user `<Message>`](/patternfly-ai/chatbot/messages#user-messages) and an initial bot message with [message actions.](/patternfly-ai/chatbot/messages#messages-actions)
- An initial [user `<Message>`](/patternfly-ai/chatbot/messages#user-messages) and an initial bot message with [message actions.](/patternfly-ai/chatbot/messages#message-actions)
- Logic for enabling auto-scrolling to the most recent message whenever a new message is sent or received using a `scrollToBottomRef`

5. A [`<ChatbotFooter>`](/patternfly-ai/chatbot/ui#footer) with a [`<ChatbotFootNote>`](/patternfly-ai/chatbot/ui#footnote-with-popover) and a `<MessageBar>` that contains the abilities of:
Expand All @@ -92,7 +92,7 @@ This demo displays an embedded ChatBot. Embedded ChatBots are meant to be placed
3. A [`<ChatbotHeader>`](/patternfly-ai/chatbot/ui#header) with all built sub-components laid out, including a `<ChatbotHeaderTitle>`
4. [`<ChatbotContent>` and `<MessageBox>`](/patternfly-ai/chatbot/ui#content-and-message-box) with:
- A `<ChatbotWelcomePrompt>`
- An initial [user `<Message>`](/patternfly-ai/chatbot/messages#user-messages) and an initial bot message with [message actions.](/patternfly-ai/chatbot/messages/#messages-actions)
- An initial [user `<Message>`](/patternfly-ai/chatbot/messages#user-messages) and an initial bot message with [message actions.](/patternfly-ai/chatbot/messages/#message-actions)
- Logic for enabling auto-scrolling to the most recent message whenever a new message is sent or received using a `scrollToBottomRef`
5. A [`<ChatbotFooter>`](/patternfly-ai/chatbot/ui#footer) with a [`<ChatbotFootNote>`](/patternfly-ai/chatbot/ui#footnote-with-popover) and a `<MessageBar>` that contains the abilities of:
- [Speech to text.](/patternfly-ai/chatbot/ui#message-bar-with-speech-recognition-and-file-attachment)
Expand Down
52 changes: 52 additions & 0 deletions packages/module/src/ResponseActions/ResponseActionButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import { DownloadIcon } from '@patternfly/react-icons';
import ResponseActionButton from './ResponseActionButton';

describe('ResponseActionButton', () => {
it('renders aria-label correctly if not clicked', () => {
render(<ResponseActionButton icon={<DownloadIcon />} ariaLabel="Download" clickedAriaLabel="Downloaded" />);
expect(screen.getByRole('button', { name: 'Download' })).toBeTruthy();
});
it('renders aria-label correctly if clicked', () => {
render(
<ResponseActionButton icon={<DownloadIcon />} ariaLabel="Download" clickedAriaLabel="Downloaded" isClicked />
);
expect(screen.getByRole('button', { name: 'Downloaded' })).toBeTruthy();
});
it('renders tooltip correctly if not clicked', async () => {
render(
<ResponseActionButton icon={<DownloadIcon />} tooltipContent="Download" clickedTooltipContent="Downloaded" />
);
expect(screen.getByRole('button', { name: 'Download' })).toBeTruthy();
// clicking here just triggers the tooltip; in this button, the logic is divorced from whether it is actually clicked
await userEvent.click(screen.getByRole('button', { name: 'Download' }));
expect(screen.getByRole('tooltip', { name: 'Download' })).toBeTruthy();
});
it('renders tooltip correctly if clicked', async () => {
render(
<ResponseActionButton
icon={<DownloadIcon />}
tooltipContent="Download"
clickedTooltipContent="Downloaded"
isClicked
/>
);
expect(screen.getByRole('button', { name: 'Downloaded' })).toBeTruthy();
// clicking here just triggers the tooltip; in this button, the logic is divorced from whether it is actually clicked
await userEvent.click(screen.getByRole('button', { name: 'Downloaded' }));
expect(screen.getByRole('tooltip', { name: 'Downloaded' })).toBeTruthy();
});
it('if clicked variant for tooltip is not supplied, it uses the default', async () => {
render(<ResponseActionButton icon={<DownloadIcon />} tooltipContent="Download" isClicked />);
// clicking here just triggers the tooltip; in this button, the logic is divorced from whether it is actually clicked
await userEvent.click(screen.getByRole('button', { name: 'Download' }));
expect(screen.getByRole('button', { name: 'Download' })).toBeTruthy();
});
it('if clicked variant for aria label is not supplied, it uses the default', async () => {
render(<ResponseActionButton icon={<DownloadIcon />} ariaLabel="Download" isClicked />);
expect(screen.getByRole('button', { name: 'Download' })).toBeTruthy();
});
});
73 changes: 46 additions & 27 deletions packages/module/src/ResponseActions/ResponseActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Button, Icon, Tooltip, TooltipProps } from '@patternfly/react-core';
export interface ResponseActionButtonProps {
/** Aria-label for the button. Defaults to the value of the tooltipContent if none provided */
ariaLabel?: string;
/** Aria-label for the button, shown when the button is clicked. Defaults to the value of ariaLabel or tooltipContent if not provided. */
clickedAriaLabel?: string;
/** Icon for the button */
icon: React.ReactNode;
/** On-click handler for the button */
Expand All @@ -14,43 +16,60 @@ export interface ResponseActionButtonProps {
isDisabled?: boolean;
/** Content shown in the tooltip */
tooltipContent?: string;
/** Content shown in the tooltip when the button is clicked. Defaults to the value of tooltipContent if not provided. */
clickedTooltipContent?: string;
/** Props to control the PF Tooltip component */
tooltipProps?: TooltipProps;
/** Whether button is in clicked state */
isClicked?: boolean;
}

export const ResponseActionButton: React.FunctionComponent<ResponseActionButtonProps> = ({
ariaLabel,
clickedAriaLabel = ariaLabel,
className,
icon,
isDisabled,
onClick,
tooltipContent,
tooltipProps
}) => (
<Tooltip
id={`pf-chatbot__tooltip-response-action-${tooltipContent}`}
content={tooltipContent}
position="bottom"
entryDelay={tooltipProps?.entryDelay || 0}
exitDelay={tooltipProps?.exitDelay || 0}
distance={tooltipProps?.distance || 8}
animationDuration={tooltipProps?.animationDuration || 0}
{...tooltipProps}
>
<Button
variant="plain"
className={`pf-chatbot__button--response-action ${className ?? ''}`}
aria-label={ariaLabel ?? tooltipContent}
icon={
<Icon isInline size="lg">
{icon}
</Icon>
}
isDisabled={isDisabled}
onClick={onClick}
size="sm"
></Button>
</Tooltip>
);
clickedTooltipContent = tooltipContent,
tooltipProps,
isClicked = false
}) => {
const generateAriaLabel = () => {
if (ariaLabel) {
return isClicked ? clickedAriaLabel : ariaLabel;
}
return isClicked ? clickedTooltipContent : tooltipContent;
};

return (
<Tooltip
id={`pf-chatbot__tooltip-response-action-${tooltipContent}`}
content={isClicked ? clickedTooltipContent : tooltipContent}
aria-live="polite"
position="bottom"
entryDelay={tooltipProps?.entryDelay || 0}
exitDelay={tooltipProps?.exitDelay || 0}
distance={tooltipProps?.distance || 8}
animationDuration={tooltipProps?.animationDuration || 0}
{...tooltipProps}
>
<Button
variant="plain"
className={`pf-chatbot__button--response-action ${isClicked ? 'pf-chatbot__button--response-action-clicked' : ''} ${className ?? ''}`}
aria-label={generateAriaLabel()}
icon={
<Icon isInline size="lg">
{icon}
</Icon>
}
isDisabled={isDisabled}
onClick={onClick}
size="sm"
></Button>
</Tooltip>
);
};

export default ResponseActionButton;
18 changes: 10 additions & 8 deletions packages/module/src/ResponseActions/ResponseActions.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,25 @@
grid-template-columns: repeat(auto-fit, minmax(0, max-content));

.pf-v6-c-button {
--pf-v6-c-button__icon--Color: var(--pf-t--global--icon--color--subtle);
border-radius: var(--pf-t--global--border--radius--pill);
width: 2.3125rem;
height: 2.3125rem;
display: flex;
align-items: center;
justify-content: center;

.pf-v6-c-button__icon {
color: var(--pf-t--global--icon--color--subtle);
&:hover {
--pf-v6-c-button__icon--Color: var(--pf-t--global--icon--color--subtle);
}

// Interactive states
&:hover,
&:focus {
.pf-v6-c-button__icon {
color: var(--pf-t--global--icon--color--subtle);
}
--pf-v6-c-button--hover--BackgroundColor: var(--pf-t--global--background--color--action--plain--alt--clicked);
--pf-v6-c-button__icon--Color: var(--pf-t--global--icon--color--regular);
}
}
}

.pf-v6-c-button.pf-chatbot__button--response-action-clicked {
--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);
}
108 changes: 103 additions & 5 deletions packages/module/src/ResponseActions/ResponseActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,32 @@ import '@testing-library/jest-dom';
import ResponseActions from './ResponseActions';
import userEvent from '@testing-library/user-event';
import { DownloadIcon, InfoCircleIcon, RedoIcon } from '@patternfly/react-icons';
import Message from '../Message';

const ALL_ACTIONS = [
{ type: 'positive', label: 'Good response' },
{ type: 'negative', label: 'Bad response' },
{ type: 'copy', label: 'Copy' },
{ type: 'share', label: 'Share' },
{ type: 'listen', label: 'Listen' }
{ type: 'positive', label: 'Good response', clickedLabel: 'Response recorded' },
{ type: 'negative', label: 'Bad response', clickedLabel: 'Response recorded' },
{ type: 'copy', label: 'Copy', clickedLabel: 'Copied' },
{ type: 'share', label: 'Share', clickedLabel: 'Shared' },
{ type: 'listen', label: 'Listen', clickedLabel: 'Listening' }
];

const CUSTOM_ACTIONS = [
{
regenerate: {
ariaLabel: 'Regenerate',
clickedAriaLabel: 'Regenerated',
onClick: jest.fn(),
tooltipContent: 'Regenerate',
clickedTooltipContent: 'Regenerated',
icon: <RedoIcon />
},
download: {
ariaLabel: 'Download',
clickedAriaLabel: 'Downloaded',
onClick: jest.fn(),
tooltipContent: 'Download',
clickedTooltipContent: 'Downloaded',
icon: <DownloadIcon />
},
info: {
Expand All @@ -37,6 +42,81 @@ const CUSTOM_ACTIONS = [
];

describe('ResponseActions', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should handle click within group of buttons correctly', async () => {
render(
<ResponseActions
actions={{
positive: { onClick: jest.fn() },
negative: { onClick: jest.fn() },
copy: { onClick: jest.fn() },
share: { onClick: jest.fn() },
listen: { onClick: jest.fn() }
}}
/>
);
const goodBtn = screen.getByRole('button', { name: 'Good response' });
const badBtn = screen.getByRole('button', { name: 'Bad response' });
const copyBtn = screen.getByRole('button', { name: 'Copy' });
const shareBtn = screen.getByRole('button', { name: 'Share' });
const listenBtn = screen.getByRole('button', { name: 'Listen' });
const buttons = [goodBtn, badBtn, copyBtn, shareBtn, listenBtn];
buttons.forEach((button) => {
expect(button).toBeTruthy();
});
await userEvent.click(goodBtn);
expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass(
'pf-chatbot__button--response-action-clicked'
);
let unclickedButtons = buttons.filter((button) => button !== goodBtn);
unclickedButtons.forEach((button) => {
expect(button).not.toHaveClass('pf-chatbot__button--response-action-clicked');
});
await userEvent.click(badBtn);
expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass(
'pf-chatbot__button--response-action-clicked'
);
unclickedButtons = buttons.filter((button) => button !== badBtn);
unclickedButtons.forEach((button) => {
expect(button).not.toHaveClass('pf-chatbot__button--response-action-clicked');
});
});
it('should handle click outside of group of buttons correctly', async () => {
// using message just so we have something outside the group that's rendered
render(
<Message
name="Bot"
role="bot"
avatar=""
content="Example with all prebuilt actions"
actions={{
positive: {},
negative: {}
}}
/>
);
const goodBtn = screen.getByRole('button', { name: 'Good response' });
const badBtn = screen.getByRole('button', { name: 'Bad response' });
expect(goodBtn).toBeTruthy();
expect(badBtn).toBeTruthy();

await userEvent.click(goodBtn);
expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass(
'pf-chatbot__button--response-action-clicked'
);
expect(badBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');

await userEvent.click(badBtn);
expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass(
'pf-chatbot__button--response-action-clicked'
);
expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
await userEvent.click(screen.getByText('Example with all prebuilt actions'));
expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
expect(badBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
});
it('should render buttons correctly', () => {
ALL_ACTIONS.forEach(({ type, label }) => {
render(<ResponseActions actions={{ [type]: { onClick: jest.fn() } }} />);
Expand All @@ -53,6 +133,24 @@ describe('ResponseActions', () => {
});
});

it('should swap clicked and non-clicked aria labels on click', async () => {
ALL_ACTIONS.forEach(async ({ type, label, clickedLabel }) => {
render(<ResponseActions actions={{ [type]: { onClick: jest.fn() } }} />);
expect(screen.getByRole('button', { name: label })).toBeTruthy();
await userEvent.click(screen.getByRole('button', { name: label }));
expect(screen.getByRole('button', { name: clickedLabel })).toBeTruthy();
});
});

it('should swap clicked and non-clicked tooltips on click', async () => {
ALL_ACTIONS.forEach(async ({ type, label, clickedLabel }) => {
render(<ResponseActions actions={{ [type]: { onClick: jest.fn() } }} />);
expect(screen.getByRole('button', { name: label })).toBeTruthy();
await userEvent.click(screen.getByRole('button', { name: label }));
expect(screen.getByRole('tooltip', { name: clickedLabel })).toBeTruthy();
});
});

it('should be able to change aria labels', () => {
const actions = [
{ type: 'positive', ariaLabel: 'Thumbs up' },
Expand Down
Loading

0 comments on commit bfa650d

Please sign in to comment.