Skip to content

Commit 3a1bda0

Browse files
feat(Messages): added edit action (#604)
Adds new response action for edit, as well as a demo for how to hook up "edit a message" functionality in an accessible way. Co-authored-by: Eric Olkowski <[email protected]>
1 parent 57329b0 commit 3a1bda0

File tree

7 files changed

+120
-16
lines changed

7 files changed

+120
-16
lines changed

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import { explorePipelinesQuickStart } from './explore-pipeline-quickstart.ts';
4949
import { monitorSampleAppQuickStart } from '@patternfly/chatbot/src/Message/QuickStarts/monitor-sampleapp-quickstart.ts';
5050
import userAvatar from './user_avatar.svg';
5151
import squareImg from './PF-social-color-square.svg';
52-
import { CSSProperties, useState, Fragment, FunctionComponent, MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent, Ref, isValidElement, cloneElement, Children, ReactNode } from 'react';
52+
import { CSSProperties, useState, Fragment, FunctionComponent, MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent, Ref, isValidElement, cloneElement, Children, ReactNode, useRef, useEffect } from 'react';
5353

5454
The `content` prop of the `<Message>` component is passed to a `<Markdown>` component (from [react-markdown](https://remarkjs.github.io/react-markdown/)), which is configured to translate plain text strings into PatternFly [`<Content>` components](/components/content) and code blocks into PatternFly [`<CodeBlock>` components.](/components/code-block)
5555

@@ -83,6 +83,7 @@ You can add actions to a message, to allow users to interact with the message co
8383

8484
- Feedback responses that allow users to rate a message as "good" or "bad".
8585
- Copy and share controls that allow users to share the message content with others.
86+
- An edit action to allow users to edit a message they previously sent. This should only be applied to user messages - see the [user messages example](#user-messages) for details on how to implement this action.
8687
- A listen action, that will read the message content out loud.
8788

8889
**Note:** The logic for the actions is not built into the component and must be implemented by the consuming application.
@@ -194,6 +195,8 @@ The quick start tile displayed below the message is based on the tile included i
194195

195196
Messages from users have a different background color to differentiate them from bot messages. You can also display a custom avatar that is uploaded by the user. You can further customize the avatar by applying an additional class or passing [PatternFly avatar props](/components/avatar) to the `<Message>` component via `avatarProps`.
196197

198+
User messages can also be made editable by passing an "edit" object to the `actions` property. When editing is enabled focus should be placed on the text area. When editing is completed or canceled the focus should be moved back to the edit button.
199+
197200
```js file="./UserMessage.tsx"
198201

199202
```

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

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Fragment, useState, CSSProperties, FunctionComponent, MouseEvent } from 'react';
1+
import { Fragment, useState, useRef, useEffect, CSSProperties, FunctionComponent, MouseEvent, Ref } from 'react';
22
import Message from '@patternfly/chatbot/dist/dynamic/Message';
33
import userAvatar from './user_avatar.svg';
44
import {
@@ -12,12 +12,32 @@ import {
1212
import { rehypeCodeBlockToggle } from '@patternfly/chatbot/dist/esm/Message/Plugins/rehypeCodeBlockToggle';
1313

1414
export const UserMessageExample: FunctionComponent = () => {
15-
const [variant, setVariant] = useState<string>('Code');
16-
const [isEditable, setIsEditable] = useState<boolean>(true);
15+
const messageInputRef = useRef<HTMLTextAreaElement>(null);
16+
const editButtonRef = useRef<HTMLButtonElement>(null);
17+
const [variant, setVariant] = useState<string | number | undefined>('Code');
1718
const [isOpen, setIsOpen] = useState<boolean>(false);
1819
const [selected, setSelected] = useState<string>('Message content type');
1920
const [isExpandable, setIsExpanded] = useState(false);
2021

22+
const [isEditable, setIsEditable] = useState<boolean>(false);
23+
const prevIsEditable = useRef<boolean>(false);
24+
25+
useEffect(() => {
26+
if (isEditable && messageInputRef?.current) {
27+
messageInputRef.current.focus();
28+
const messageLength = messageInputRef.current.value.length;
29+
// Mimic the behavior of the textarea when the user clicks on a label to place the cursor at the end of the input value
30+
messageInputRef.current.setSelectionRange(messageLength, messageLength);
31+
}
32+
33+
// We only want to re-focus the edit action button if the user has previously clicked on it,
34+
// and prevent it from receiving focus on page load
35+
if (prevIsEditable.current && !isEditable && editButtonRef?.current) {
36+
editButtonRef.current.focus();
37+
prevIsEditable.current = false;
38+
}
39+
}, [isEditable]);
40+
2141
/* eslint-disable indent */
2242
const renderContent = () => {
2343
switch (variant) {
@@ -180,6 +200,11 @@ _Italic text, formatted with single underscores_
180200
setIsOpen(!isOpen);
181201
};
182202

203+
const onUpdateOrCancelEdit = () => {
204+
prevIsEditable.current = isEditable;
205+
setIsEditable(false);
206+
};
207+
183208
const toggle = (toggleRef: Ref<MenuToggleElement>) => (
184209
<MenuToggle
185210
className="pf-v6-u-mb-md"
@@ -212,6 +237,17 @@ _Italic text, formatted with single underscores_
212237
avatar={userAvatar}
213238
avatarProps={{ isBordered: true }}
214239
/>
240+
<Message
241+
name="User"
242+
role="user"
243+
isEditable={isEditable}
244+
onEditUpdate={onUpdateOrCancelEdit}
245+
onEditCancel={onUpdateOrCancelEdit}
246+
actions={{ edit: { onClick: () => setIsEditable(true), innerRef: editButtonRef } }}
247+
content="This is a user message with an edit action."
248+
avatar={userAvatar}
249+
inputRef={messageInputRef}
250+
/>
215251
<Select
216252
id="single-select"
217253
isOpen={isOpen}
@@ -235,7 +271,6 @@ _Italic text, formatted with single underscores_
235271
<SelectOption value="Table">Table</SelectOption>
236272
<SelectOption value="Image">Image</SelectOption>
237273
<SelectOption value="Error">Error</SelectOption>
238-
<SelectOption value="Editable">Editable</SelectOption>
239274
</SelectList>
240275
</Select>
241276
<Message
@@ -246,10 +281,7 @@ _Italic text, formatted with single underscores_
246281
tableProps={
247282
variant === 'Table' ? { 'aria-label': 'App information and user roles for user messages' } : undefined
248283
}
249-
isEditable={variant === 'Editable' ? isEditable : false}
250284
error={variant === 'Error' ? error : undefined}
251-
onEditUpdate={() => setIsEditable(false)}
252-
onEditCancel={() => setIsEditable(false)}
253285
codeBlockProps={{ isExpandable, expandableSectionProps: { truncateMaxLines: isExpandable ? 1 : undefined } }}
254286
// In this example, custom plugin will override any custom expandedText or collapsedText attributes provided
255287
// The purpose of this plugin is to provide unique link names for the code blocks

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const ALL_ACTIONS = [
1212
{ label: /Good response/i },
1313
{ label: /Bad response/i },
1414
{ label: /Copy/i },
15+
{ label: /Edit/i },
1516
{ label: /Share/i },
1617
{ label: /Listen/i }
1718
];
@@ -426,6 +427,8 @@ describe('Message', () => {
426427
// eslint-disable-next-line no-console
427428
copy: { onClick: () => console.log('Copy') },
428429
// eslint-disable-next-line no-console
430+
edit: { onClick: () => console.log('Edit') },
431+
// eslint-disable-next-line no-console
429432
share: { onClick: () => console.log('Share') },
430433
// eslint-disable-next-line no-console
431434
download: { onClick: () => console.log('Download') },
@@ -454,6 +457,8 @@ describe('Message', () => {
454457
// eslint-disable-next-line no-console
455458
copy: { onClick: () => console.log('Copy') },
456459
// eslint-disable-next-line no-console
460+
edit: { onClick: () => console.log('Edit') },
461+
// eslint-disable-next-line no-console
457462
share: { onClick: () => console.log('Share') },
458463
// eslint-disable-next-line no-console
459464
download: { onClick: () => console.log('Download') },
@@ -467,6 +472,36 @@ describe('Message', () => {
467472
expect(screen.queryByRole('button', { name: label })).toBeFalsy();
468473
});
469474
});
475+
it('should not show actions if isEditable is true', async () => {
476+
render(
477+
<Message
478+
avatar="./img"
479+
role="bot"
480+
name="Bot"
481+
content="Hi"
482+
isEditable
483+
actions={{
484+
// eslint-disable-next-line no-console
485+
positive: { onClick: () => console.log('Good response') },
486+
// eslint-disable-next-line no-console
487+
negative: { onClick: () => console.log('Bad response') },
488+
// eslint-disable-next-line no-console
489+
copy: { onClick: () => console.log('Copy') },
490+
// eslint-disable-next-line no-console
491+
edit: { onClick: () => console.log('Edit') },
492+
// eslint-disable-next-line no-console
493+
share: { onClick: () => console.log('Share') },
494+
// eslint-disable-next-line no-console
495+
download: { onClick: () => console.log('Download') },
496+
// eslint-disable-next-line no-console
497+
listen: { onClick: () => console.log('Listen') }
498+
}}
499+
/>
500+
);
501+
ALL_ACTIONS.forEach(({ label }) => {
502+
expect(screen.queryByRole('button', { name: label })).toBeFalsy();
503+
});
504+
});
470505
it('should render unordered lists correctly', () => {
471506
render(<Message avatar="./img" role="user" name="User" content={UNORDERED_LIST} />);
472507
expect(screen.getByText('Here is an unordered list:')).toBeTruthy();

packages/module/src/Message/Message.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
9999
isLoading?: boolean;
100100
/** Array of attachments attached to a message */
101101
attachments?: MessageAttachment[];
102-
/** Props for message actions, such as feedback (positive or negative), copy button, share, and listen */
102+
/** Props for message actions, such as feedback (positive or negative), copy button, edit message, share, and listen */
103103
actions?: {
104104
[key: string]: ActionProps;
105105
};
@@ -179,6 +179,8 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
179179
onEditUpdate?: (event: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
180180
/** Callback functionf or when edit cancel update button is clicked */
181181
onEditCancel?: (event: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
182+
/** Ref applied to editable message input */
183+
inputRef?: Ref<HTMLTextAreaElement>;
182184
/** Props for edit form */
183185
editFormProps?: FormProps;
184186
/** Sets message to compact styling. */
@@ -219,6 +221,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
219221
cancelWord = 'Cancel',
220222
onEditUpdate,
221223
onEditCancel,
224+
inputRef,
222225
editFormProps,
223226
isCompact,
224227
...props
@@ -256,7 +259,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
256259
<>
257260
{beforeMainContent && <>{beforeMainContent}</>}
258261
<MessageInput
259-
content={content}
262+
content={messageText}
260263
editPlaceholder={editPlaceholder}
261264
updateWord={updateWord}
262265
cancelWord={cancelWord}
@@ -265,6 +268,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
265268
setMessageText(value);
266269
}}
267270
onEditCancel={onEditCancel}
271+
inputRef={inputRef}
268272
{...editFormProps}
269273
/>
270274
</>
@@ -369,7 +373,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
369373
isCompact={isCompact}
370374
/>
371375
)}
372-
{!isLoading && actions && <ResponseActions actions={actions} />}
376+
{!isLoading && !isEditable && actions && <ResponseActions actions={actions} />}
373377
{userFeedbackForm && <UserFeedback {...userFeedbackForm} timestamp={dateString} isCompact={isCompact} />}
374378
{userFeedbackComplete && (
375379
<UserFeedbackComplete {...userFeedbackComplete} timestamp={dateString} isCompact={isCompact} />

packages/module/src/Message/MessageInput.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// ============================================================================
22
// Chatbot Main - Message Input
33
// ============================================================================
4-
import type { FormEvent, FunctionComponent } from 'react';
4+
import type { FormEvent, FunctionComponent, Ref } from 'react';
55
import { useState } from 'react';
66
import { ActionGroup, Button, Form, FormProps, TextArea } from '@patternfly/react-core';
77

@@ -16,6 +16,8 @@ export interface MessageInputProps extends FormProps {
1616
onEditUpdate?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>, value: string) => void;
1717
/** Callback functionf or when edit cancel update button is clicked */
1818
onEditCancel?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
19+
/** Ref applied to editable message input */
20+
inputRef?: Ref<HTMLTextAreaElement>;
1921
/** Message text */
2022
content?: string;
2123
}
@@ -26,6 +28,7 @@ const MessageInput: FunctionComponent<MessageInputProps> = ({
2628
cancelWord = 'Cancel',
2729
onEditUpdate,
2830
onEditCancel,
31+
inputRef,
2932
content,
3033
...props
3134
}: MessageInputProps) => {
@@ -43,6 +46,7 @@ const MessageInput: FunctionComponent<MessageInputProps> = ({
4346
onChange={onChange}
4447
aria-label={editPlaceholder}
4548
autoResize
49+
ref={inputRef}
4650
/>
4751
<ActionGroup className="pf-chatbot__message-edit-buttons">
4852
<Button variant="primary" onClick={(event) => onEditUpdate && onEditUpdate(event, messageText)}>

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const ALL_ACTIONS = [
99
{ type: 'positive', label: 'Good response', clickedLabel: 'Response recorded' },
1010
{ type: 'negative', label: 'Bad response', clickedLabel: 'Response recorded' },
1111
{ type: 'copy', label: 'Copy', clickedLabel: 'Copied' },
12+
{ type: 'edit', label: 'Edit', clickedLabel: 'Editing' },
1213
{ type: 'share', label: 'Share', clickedLabel: 'Shared' },
1314
{ type: 'listen', label: 'Listen', clickedLabel: 'Listening' }
1415
];
@@ -44,6 +45,7 @@ const ALL_ACTIONS_DATA_TEST = [
4445
{ type: 'positive', label: 'Good response', dataTestId: 'positive' },
4546
{ type: 'negative', label: 'Bad response', dataTestId: 'negative' },
4647
{ type: 'copy', label: 'Copy', dataTestId: 'copy' },
48+
{ type: 'edit', label: 'Edit', dataTestId: 'edit' },
4749
{ type: 'share', label: 'Share', dataTestId: 'share' },
4850
{ type: 'download', label: 'Download', dataTestId: 'download' },
4951
{ type: 'listen', label: 'Listen', dataTestId: 'listen' }
@@ -60,6 +62,7 @@ describe('ResponseActions', () => {
6062
positive: { onClick: jest.fn() },
6163
negative: { onClick: jest.fn() },
6264
copy: { onClick: jest.fn() },
65+
edit: { onClick: jest.fn() },
6366
share: { onClick: jest.fn() },
6467
download: { onClick: jest.fn() },
6568
listen: { onClick: jest.fn() }
@@ -69,10 +72,11 @@ describe('ResponseActions', () => {
6972
const goodBtn = screen.getByRole('button', { name: 'Good response' });
7073
const badBtn = screen.getByRole('button', { name: 'Bad response' });
7174
const copyBtn = screen.getByRole('button', { name: 'Copy' });
75+
const editBtn = screen.getByRole('button', { name: 'Edit' });
7276
const shareBtn = screen.getByRole('button', { name: 'Share' });
7377
const downloadBtn = screen.getByRole('button', { name: 'Download' });
7478
const listenBtn = screen.getByRole('button', { name: 'Listen' });
75-
const buttons = [goodBtn, badBtn, copyBtn, shareBtn, downloadBtn, listenBtn];
79+
const buttons = [goodBtn, badBtn, copyBtn, editBtn, shareBtn, downloadBtn, listenBtn];
7680
buttons.forEach((button) => {
7781
expect(button).toBeTruthy();
7882
});
@@ -265,6 +269,7 @@ describe('ResponseActions', () => {
265269
{ type: 'positive', ariaLabel: 'Thumbs up' },
266270
{ type: 'negative', ariaLabel: 'Thumbs down' },
267271
{ type: 'copy', ariaLabel: 'Copy the message' },
272+
{ type: 'edit', ariaLabel: 'Edit this message' },
268273
{ type: 'share', ariaLabel: 'Share it with friends' },
269274
{ type: 'download', ariaLabel: 'Download your cool message' },
270275
{ type: 'listen', ariaLabel: 'Listen up' }

packages/module/src/ResponseActions/ResponseActions.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
OutlinedThumbsUpIcon,
77
OutlinedThumbsDownIcon,
88
OutlinedCopyIcon,
9-
DownloadIcon
9+
DownloadIcon,
10+
PencilAltIcon
1011
} from '@patternfly/react-icons';
1112
import ResponseActionButton from './ResponseActionButton';
1213
import { ButtonProps, TooltipProps } from '@patternfly/react-core';
@@ -50,6 +51,7 @@ export interface ResponseActionProps {
5051
share?: ActionProps;
5152
download?: ActionProps;
5253
listen?: ActionProps;
54+
edit?: ActionProps;
5355
};
5456
}
5557

@@ -58,7 +60,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
5860
const [clickStatePersisted, setClickStatePersisted] = useState<boolean>(false);
5961
useEffect(() => {
6062
// Define the order of precedence for checking initial `isClicked`
61-
const actionPrecedence = ['positive', 'negative', 'copy', 'share', 'download', 'listen'];
63+
const actionPrecedence = ['positive', 'negative', 'copy', 'edit', 'share', 'download', 'listen'];
6264
let initialActive: string | undefined;
6365

6466
// Check predefined actions first based on precedence
@@ -83,7 +85,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
8385
setActiveButton(initialActive);
8486
}, [actions]);
8587

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

8991
useEffect(() => {
@@ -165,6 +167,24 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
165167
aria-controls={copy['aria-controls']}
166168
></ResponseActionButton>
167169
)}
170+
{edit && (
171+
<ResponseActionButton
172+
{...edit}
173+
ariaLabel={edit.ariaLabel ?? 'Edit'}
174+
clickedAriaLabel={edit.ariaLabel ?? 'Editing'}
175+
onClick={(e) => handleClick(e, 'edit', edit.onClick)}
176+
className={edit.className}
177+
isDisabled={edit.isDisabled}
178+
tooltipContent={edit.tooltipContent ?? 'Edit '}
179+
clickedTooltipContent={edit.clickedTooltipContent ?? 'Editing'}
180+
tooltipProps={edit.tooltipProps}
181+
icon={<PencilAltIcon />}
182+
isClicked={activeButton === 'edit'}
183+
ref={edit.ref}
184+
aria-expanded={edit['aria-expanded']}
185+
aria-controls={edit['aria-controls']}
186+
></ResponseActionButton>
187+
)}
168188
{share && (
169189
<ResponseActionButton
170190
{...share}
@@ -219,6 +239,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
219239
aria-controls={listen['aria-controls']}
220240
></ResponseActionButton>
221241
)}
242+
222243
{Object.keys(additionalActions).map((action) => (
223244
<ResponseActionButton
224245
{...additionalActions[action]}

0 commit comments

Comments
 (0)