Skip to content

Commit 5236af7

Browse files
feat(ResponseActions): Allow for manual control over clicked state (#579)
Apply visual change when isClicked prop is applied to ResponseActions component. Only 1 value maintains selection at a time, according to pre-defined order. User-defined click state persists. Also added demo and tests.
1 parent 58e22bd commit 5236af7

File tree

5 files changed

+172
-3
lines changed

5 files changed

+172
-3
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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 ResponseActionClickedExample: 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!"
12+
actions={{
13+
// eslint-disable-next-line no-console
14+
positive: { onClick: () => console.log('Good response'), isClicked: true },
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+
/>
25+
);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const CustomActionExample: FunctionComponent = () => (
1515
regenerate: {
1616
ariaLabel: 'Regenerate',
1717
clickedAriaLabel: 'Regenerated',
18+
isClicked: true,
1819
// eslint-disable-next-line no-console
1920
onClick: () => console.log('Clicked regenerate'),
2021
tooltipContent: 'Regenerate',

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,30 @@ You can add actions to a message, to allow users to interact with the message co
7979

8080
```
8181

82+
### Message actions clicked state
83+
84+
You can apply a clicked state to message actions, to convey that the action has previously been selected.
85+
86+
To define which message response action should show a clicked state when the `<ResponseActions>` component first renders, use the `isClicked` boolean property within each action's configuration object.
87+
88+
To make an action button appear active by default, set `isClicked: true`. Only 1 action can be visually active at any given time.
89+
90+
If you unintentionally set `isClicked: true` for multiple buttons, the component will apply a predefined internal precedence order to resolve the conflict. The button encountered first in this order will be displayed as clicked, while other buttons will sustain their default appearance. The default precedence of button actions is: "positive", "negative", "copy", "share", "listen", followed by any other custom actions (in object key order).
91+
92+
Once the component has rendered, user interactions will take precedence over the initial `isClicked` prop. Clicking a button will activate it and deactivate any other active button. The `isDisabled` prop for each action button specifies if a button is interactive or not.
93+
94+
```js file="./MessageWithClickedResponseActions.tsx"
95+
96+
```
97+
8298
### Custom message actions
8399

84100
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:
85101

86102
- `ariaLabel`
87103
- `onClick`
88104
- `className`
105+
- `isClicked`
89106
- `isDisabled`
90107
- `tooltipContent`
91108
- `tooltipContent`

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

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { render, screen } from '@testing-library/react';
22
import '@testing-library/jest-dom';
3-
import ResponseActions from './ResponseActions';
3+
import ResponseActions, { ActionProps } from './ResponseActions';
44
import userEvent from '@testing-library/user-event';
55
import { DownloadIcon, InfoCircleIcon, RedoIcon } from '@patternfly/react-icons';
66
import Message from '../Message';
@@ -129,6 +129,103 @@ describe('ResponseActions', () => {
129129
expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
130130
expect(badBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
131131
});
132+
133+
it('should handle isClicked prop within group of buttons correctly', async () => {
134+
render(
135+
<ResponseActions
136+
actions={
137+
{
138+
positive: { 'data-testid': 'positive-btn', onClick: jest.fn(), isClicked: true },
139+
negative: { 'data-testid': 'negative-btn', onClick: jest.fn() }
140+
} as Record<string, ActionProps>
141+
}
142+
/>
143+
);
144+
145+
expect(screen.getByTestId('positive-btn')).toHaveClass('pf-chatbot__button--response-action-clicked');
146+
expect(screen.getByTestId('negative-btn')).not.toHaveClass('pf-chatbot__button--response-action-clicked');
147+
});
148+
149+
it('should set "listen" button as active if its `isClicked` is true', async () => {
150+
render(
151+
<ResponseActions
152+
actions={
153+
{
154+
positive: { 'data-testid': 'positive-btn', onClick: jest.fn(), isClicked: false },
155+
negative: { 'data-testid': 'negative-btn', onClick: jest.fn(), isClicked: false },
156+
listen: { 'data-testid': 'listen-btn', onClick: jest.fn(), isClicked: true }
157+
} as Record<string, ActionProps>
158+
}
159+
/>
160+
);
161+
expect(screen.getByTestId('listen-btn')).toHaveClass('pf-chatbot__button--response-action-clicked');
162+
163+
expect(screen.getByTestId('positive-btn')).not.toHaveClass('pf-chatbot__button--response-action-clicked');
164+
expect(screen.getByTestId('negative-btn')).not.toHaveClass('pf-chatbot__button--response-action-clicked');
165+
});
166+
167+
it('should prioritize "positive" when both "positive" and "negative" are set to clicked', async () => {
168+
render(
169+
<ResponseActions
170+
actions={
171+
{
172+
positive: { 'data-testid': 'positive-btn', onClick: jest.fn(), isClicked: true },
173+
negative: { 'data-testid': 'negative-btn', onClick: jest.fn(), isClicked: true }
174+
} as Record<string, ActionProps>
175+
}
176+
/>
177+
);
178+
// Positive button should take precendence
179+
expect(screen.getByTestId('positive-btn')).toHaveClass('pf-chatbot__button--response-action-clicked');
180+
expect(screen.getByTestId('negative-btn')).not.toHaveClass('pf-chatbot__button--response-action-clicked');
181+
});
182+
183+
it('should set an additional action button as active if it is initially clicked and no predefined are clicked', async () => {
184+
const [additionalActions] = CUSTOM_ACTIONS;
185+
const customActions = {
186+
positive: { 'data-testid': 'positive', onClick: jest.fn(), isClicked: false },
187+
negative: { 'data-testid': 'negative', onClick: jest.fn(), isClicked: false },
188+
...Object.keys(additionalActions).reduce((acc, actionKey) => {
189+
acc[actionKey] = {
190+
...additionalActions[actionKey],
191+
'data-testid': actionKey,
192+
isClicked: actionKey === 'regenerate'
193+
};
194+
return acc;
195+
}, {})
196+
};
197+
render(<ResponseActions actions={customActions} />);
198+
199+
Object.keys(customActions).forEach((actionKey) => {
200+
if (actionKey === 'regenerate') {
201+
expect(screen.getByTestId(actionKey)).toHaveClass('pf-chatbot__button--response-action-clicked');
202+
} else {
203+
// Other actions should not have clicked class
204+
expect(screen.getByTestId(actionKey)).not.toHaveClass('pf-chatbot__button--response-action-clicked');
205+
}
206+
});
207+
});
208+
209+
it('should activate the clicked button and deactivate any previously active button', async () => {
210+
const actions = {
211+
positive: { 'data-testid': 'positive', onClick: jest.fn(), isClicked: false },
212+
negative: { 'data-testid': 'negative', onClick: jest.fn(), isClicked: true }
213+
};
214+
render(<ResponseActions actions={actions} />);
215+
216+
const negativeBtn = screen.getByTestId('negative');
217+
const positiveBtn = screen.getByTestId('positive');
218+
// negative button is initially clicked
219+
expect(negativeBtn).toHaveClass('pf-chatbot__button--response-action-clicked');
220+
expect(positiveBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
221+
222+
await userEvent.click(positiveBtn);
223+
224+
// positive button should now have the clicked class
225+
expect(positiveBtn).toHaveClass('pf-chatbot__button--response-action-clicked');
226+
expect(negativeBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
227+
});
228+
132229
it('should render buttons correctly', () => {
133230
ALL_ACTIONS.forEach(({ type, label }) => {
134231
render(<ResponseActions actions={{ [type]: { onClick: jest.fn() } }} />);

packages/module/src/ResponseActions/ResponseActions.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,40 @@ export interface ResponseActionProps {
5555

5656
export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ actions }) => {
5757
const [activeButton, setActiveButton] = useState<string>();
58+
const [clickStatePersisted, setClickStatePersisted] = useState<boolean>(false);
59+
useEffect(() => {
60+
// Define the order of precedence for checking initial `isClicked`
61+
const actionPrecedence = ['positive', 'negative', 'copy', 'share', 'download', 'listen'];
62+
let initialActive: string | undefined;
63+
64+
// Check predefined actions first based on precedence
65+
for (const actionName of actionPrecedence) {
66+
const actionProp = actions[actionName as keyof typeof actions];
67+
if (actionProp?.isClicked) {
68+
initialActive = actionName;
69+
break;
70+
}
71+
}
72+
// If no predefined action was initially clicked, check additionalActions
73+
if (!initialActive) {
74+
const clickedActionName = Object.keys(additionalActions).find(
75+
(actionName) => !actionPrecedence.includes(actionName) && additionalActions[actionName]?.isClicked
76+
);
77+
initialActive = clickedActionName;
78+
}
79+
if (initialActive) {
80+
// Click state is explicitly controlled by consumer.
81+
setClickStatePersisted(true);
82+
}
83+
setActiveButton(initialActive);
84+
}, [actions]);
85+
5886
const { positive, negative, copy, share, download, listen, ...additionalActions } = actions;
5987
const responseActions = useRef<HTMLDivElement>(null);
6088

6189
useEffect(() => {
6290
const handleClickOutside = (e) => {
63-
if (responseActions.current && !responseActions.current.contains(e.target)) {
91+
if (responseActions.current && !responseActions.current.contains(e.target) && !clickStatePersisted) {
6492
setActiveButton(undefined);
6593
}
6694
};
@@ -69,13 +97,14 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
6997
return () => {
7098
window.removeEventListener('click', handleClickOutside);
7199
};
72-
}, []);
100+
}, [clickStatePersisted]);
73101

74102
const handleClick = (
75103
e: MouseEvent | MouseEvent<Element, MouseEvent> | KeyboardEvent,
76104
id: string,
77105
onClick?: (event: MouseEvent | MouseEvent<Element, MouseEvent> | KeyboardEvent) => void
78106
) => {
107+
setClickStatePersisted(false);
79108
setActiveButton(id);
80109
onClick && onClick(e);
81110
};

0 commit comments

Comments
 (0)