Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { FunctionComponent } from 'react';

import Message from '@patternfly/chatbot/dist/dynamic/Message';
import patternflyAvatar from './patternfly_avatar.jpg';

export const MessageWithPersistedActions: FunctionComponent = () => (
<Message
name="Bot"
role="bot"
avatar={patternflyAvatar}
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."
actions={{
// eslint-disable-next-line no-console
positive: { onClick: () => console.log('Good response') },
// eslint-disable-next-line no-console
negative: { onClick: () => console.log('Bad response') },
// eslint-disable-next-line no-console
listen: { onClick: () => console.log('Listen') }
}}
persistActionSelection
/>
);
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ Once the component has rendered, user interactions will take precedence over the

```

### Message actions persistent selections

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

When `persistActionSelection` is `true`:

- The selected action will remain selected even when you click outside the component.
- Clicking a different button will still switch the selection to that button.
- 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.

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

```

### Custom message actions

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:
Expand Down
8 changes: 7 additions & 1 deletion packages/module/src/Message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
actions?: {
[key: string]: ActionProps;
};
/** When true, the selected action will persist even when clicking outside the component.
* When false (default), clicking outside or clicking another action will deselect the current selection. */
persistActionSelection?: boolean;
/** Sources for message */
sources?: SourcesCardProps;
/** Label for the English word "AI," used to tag messages with role "bot" */
Expand Down Expand Up @@ -202,6 +205,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
timestamp,
isLoading,
actions,
persistActionSelection,
sources,
botWord = 'AI',
loadingWord = 'Loading message',
Expand Down Expand Up @@ -499,7 +503,9 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
isCompact={isCompact}
/>
)}
{!isLoading && !isEditable && actions && <ResponseActions actions={actions} />}
{!isLoading && !isEditable && actions && (
<ResponseActions actions={actions} persistActionSelection={persistActionSelection} />
)}
{userFeedbackForm && <UserFeedback {...userFeedbackForm} timestamp={dateString} isCompact={isCompact} />}
{userFeedbackComplete && (
<UserFeedbackComplete {...userFeedbackComplete} timestamp={dateString} isCompact={isCompact} />
Expand Down
123 changes: 111 additions & 12 deletions packages/module/src/ResponseActions/ResponseActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { DownloadIcon, InfoCircleIcon, RedoIcon } from '@patternfly/react-icons'
import Message from '../Message';

const ALL_ACTIONS = [
{ type: 'positive', label: 'Good response', clickedLabel: 'Response recorded' },
{ type: 'negative', label: 'Bad response', clickedLabel: 'Response recorded' },
{ type: 'positive', label: 'Good response', clickedLabel: 'Good response recorded' },
{ type: 'negative', label: 'Bad response', clickedLabel: 'Bad response recorded' },
{ type: 'copy', label: 'Copy', clickedLabel: 'Copied' },
{ type: 'edit', label: 'Edit', clickedLabel: 'Editing' },
{ type: 'share', label: 'Share', clickedLabel: 'Shared' },
Expand Down Expand Up @@ -81,15 +81,15 @@ describe('ResponseActions', () => {
expect(button).toBeTruthy();
});
await userEvent.click(goodBtn);
expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass(
expect(screen.getByRole('button', { name: 'Good 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(
expect(screen.getByRole('button', { name: 'Bad response recorded' })).toHaveClass(
'pf-chatbot__button--response-action-clicked'
);
unclickedButtons = buttons.filter((button) => button !== badBtn);
Expand Down Expand Up @@ -117,13 +117,13 @@ describe('ResponseActions', () => {
expect(badBtn).toBeTruthy();

await userEvent.click(goodBtn);
expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass(
expect(screen.getByRole('button', { name: 'Good 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(
expect(screen.getByRole('button', { name: 'Bad response recorded' })).toHaveClass(
'pf-chatbot__button--response-action-clicked'
);
expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
Expand Down Expand Up @@ -238,30 +238,30 @@ describe('ResponseActions', () => {
});

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

it('should swap clicked and non-clicked aria labels on click', async () => {
ALL_ACTIONS.forEach(async ({ type, label, clickedLabel }) => {
for (const { type, label, clickedLabel } of ALL_ACTIONS) {
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 }) => {
for (const { type, label, clickedLabel } of ALL_ACTIONS) {
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', () => {
Expand Down Expand Up @@ -322,4 +322,103 @@ describe('ResponseActions', () => {
expect(screen.getByTestId(action[key])).toBeTruthy();
});
});

// we are testing for the reverse case already above
it('should not deselect when clicking outside when persistActionSelection is true', async () => {
render(
<Message
name="Bot"
role="bot"
avatar=""
content="Test content"
actions={{
positive: {},
negative: {}
}}
persistActionSelection
/>
);
const goodBtn = screen.getByRole('button', { name: 'Good response' });

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

await userEvent.click(screen.getByText('Test content'));

expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass(
'pf-chatbot__button--response-action-clicked'
);
});

it('should switch selection to another button when persistActionSelection is true', async () => {
render(
<Message
name="Bot"
role="bot"
avatar=""
content="Test content"
actions={{
positive: {},
negative: {}
}}
persistActionSelection
/>
);
const goodBtn = screen.getByRole('button', { name: 'Good response' });
const badBtn = screen.getByRole('button', { name: 'Bad response' });

await userEvent.click(goodBtn);
expect(goodBtn).toHaveClass('pf-chatbot__button--response-action-clicked');

await userEvent.click(badBtn);
expect(badBtn).toHaveClass('pf-chatbot__button--response-action-clicked');
expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
});

it('should toggle off when clicking the same button when persistActionSelection is true', async () => {
render(
<Message
name="Bot"
role="bot"
avatar=""
content="Test content"
actions={{
positive: {},
negative: {}
}}
persistActionSelection
/>
);
const goodBtn = screen.getByRole('button', { name: 'Good response' });

await userEvent.click(goodBtn);
expect(goodBtn).toHaveClass('pf-chatbot__button--response-action-clicked');

await userEvent.click(goodBtn);
expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
});

it('should work with custom actions when persistActionSelection is true', async () => {
const actions = {
positive: { 'data-testid': 'positive', onClick: jest.fn() },
negative: { 'data-testid': 'negative', onClick: jest.fn() },
custom: {
'data-testid': 'custom',
onClick: jest.fn(),
ariaLabel: 'Custom',
tooltipContent: 'Custom action',
icon: <DownloadIcon />
}
};
render(<ResponseActions actions={actions} persistActionSelection />);

const customBtn = screen.getByTestId('custom');
await userEvent.click(customBtn);
expect(customBtn).toHaveClass('pf-chatbot__button--response-action-clicked');

await userEvent.click(customBtn);
expect(customBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
});
});
48 changes: 38 additions & 10 deletions packages/module/src/ResponseActions/ResponseActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,20 @@ export interface ResponseActionProps {
listen?: ActionProps;
edit?: ActionProps;
};
/** When true, the selected action will persist even when clicking outside the component.
* When false (default), clicking outside or clicking another action will deselect the current selection. */
persistActionSelection?: boolean;
}

export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ actions }) => {
export const ResponseActions: FunctionComponent<ResponseActionProps> = ({
actions,
persistActionSelection = false
}) => {
const [activeButton, setActiveButton] = useState<string>();
const [clickStatePersisted, setClickStatePersisted] = useState<boolean>(false);

const { positive, negative, copy, edit, share, download, listen, ...additionalActions } = actions;

useEffect(() => {
// Define the order of precedence for checking initial `isClicked`
const actionPrecedence = ['positive', 'negative', 'copy', 'edit', 'share', 'download', 'listen'];
Expand All @@ -82,13 +91,21 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
// Click state is explicitly controlled by consumer.
setClickStatePersisted(true);
}
// If persistActionSelection is true, all selections are persisted
if (persistActionSelection) {
setClickStatePersisted(true);
}
setActiveButton(initialActive);
}, [actions]);
}, [actions, persistActionSelection]);

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

useEffect(() => {
// Only add click outside listener if not persisting selection
if (persistActionSelection) {
return;
}

const handleClickOutside = (e) => {
if (responseActions.current && !responseActions.current.contains(e.target) && !clickStatePersisted) {
setActiveButton(undefined);
Expand All @@ -99,15 +116,26 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
return () => {
window.removeEventListener('click', handleClickOutside);
};
}, [clickStatePersisted]);
}, [clickStatePersisted, persistActionSelection]);

const handleClick = (
e: MouseEvent | MouseEvent<Element, MouseEvent> | KeyboardEvent,
id: string,
onClick?: (event: MouseEvent | MouseEvent<Element, MouseEvent> | KeyboardEvent) => void
) => {
setClickStatePersisted(false);
setActiveButton(id);
if (persistActionSelection) {
if (activeButton === id) {
// Toggle off if clicking the same button
setActiveButton(undefined);
} else {
// Set new active button
setActiveButton(id);
}
setClickStatePersisted(true);
} else {
setClickStatePersisted(false);
setActiveButton(id);
}
onClick && onClick(e);
};

Expand All @@ -117,12 +145,12 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
<ResponseActionButton
{...positive}
ariaLabel={positive.ariaLabel ?? 'Good response'}
clickedAriaLabel={positive.ariaLabel ?? 'Response recorded'}
clickedAriaLabel={positive.ariaLabel ?? 'Good response recorded'}
onClick={(e) => handleClick(e, 'positive', positive.onClick)}
className={positive.className}
isDisabled={positive.isDisabled}
tooltipContent={positive.tooltipContent ?? 'Good response'}
clickedTooltipContent={positive.clickedTooltipContent ?? 'Response recorded'}
clickedTooltipContent={positive.clickedTooltipContent ?? 'Good response recorded'}
tooltipProps={positive.tooltipProps}
icon={<OutlinedThumbsUpIcon />}
isClicked={activeButton === 'positive'}
Expand All @@ -135,12 +163,12 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
<ResponseActionButton
{...negative}
ariaLabel={negative.ariaLabel ?? 'Bad response'}
clickedAriaLabel={negative.ariaLabel ?? 'Response recorded'}
clickedAriaLabel={negative.ariaLabel ?? 'Bad response recorded'}
onClick={(e) => handleClick(e, 'negative', negative.onClick)}
className={negative.className}
isDisabled={negative.isDisabled}
tooltipContent={negative.tooltipContent ?? 'Bad response'}
clickedTooltipContent={negative.clickedTooltipContent ?? 'Response recorded'}
clickedTooltipContent={negative.clickedTooltipContent ?? 'Bad response recorded'}
tooltipProps={negative.tooltipProps}
icon={<OutlinedThumbsDownIcon />}
isClicked={activeButton === 'negative'}
Expand Down
Loading