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
Expand Up @@ -48,7 +48,7 @@ import { explorePipelinesQuickStart } from './explore-pipeline-quickstart.ts';
import { monitorSampleAppQuickStart } from '@patternfly/chatbot/src/Message/QuickStarts/monitor-sampleapp-quickstart.ts';
import userAvatar from './user_avatar.svg';
import squareImg from './PF-social-color-square.svg';
import { CSSProperties, useState, Fragment, FunctionComponent, MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent, Ref, isValidElement, cloneElement, Children, ReactNode } from 'react';
import { CSSProperties, useState, Fragment, FunctionComponent, MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent, Ref, isValidElement, cloneElement, Children, ReactNode, useRef, useEffect } from 'react';

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)

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

- Feedback responses that allow users to rate a message as "good" or "bad".
- Copy and share controls that allow users to share the message content with others.
- 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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- 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.
- 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 implementation details.

- A listen action, that will read the message content out loud.

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

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

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.

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

```
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, useState, CSSProperties, FunctionComponent, MouseEvent } from 'react';
import { Fragment, useState, useRef, useEffect, CSSProperties, FunctionComponent, MouseEvent, Ref } from 'react';
import Message from '@patternfly/chatbot/dist/dynamic/Message';
import userAvatar from './user_avatar.svg';
import {
Expand All @@ -12,12 +12,32 @@ import {
import { rehypeCodeBlockToggle } from '@patternfly/chatbot/dist/esm/Message/Plugins/rehypeCodeBlockToggle';

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

const [isEditable, setIsEditable] = useState<boolean>(false);
const prevIsEditable = useRef<boolean>(false);

useEffect(() => {
if (isEditable && messageInputRef?.current) {
messageInputRef.current.focus();
const messageLength = messageInputRef.current.value.length;
// Mimic the behavior of the textarea when the user clicks on a label to place the cursor at the end of the input value
messageInputRef.current.setSelectionRange(messageLength, messageLength);
}

// We only want to re-focus the edit action button if the user has previously clicked on it,
// and prevent it from receiving focus on page load
if (prevIsEditable.current && !isEditable && editButtonRef?.current) {
editButtonRef.current.focus();
prevIsEditable.current = false;
}
}, [isEditable]);

/* eslint-disable indent */
const renderContent = () => {
switch (variant) {
Expand Down Expand Up @@ -180,6 +200,11 @@ _Italic text, formatted with single underscores_
setIsOpen(!isOpen);
};

const onUpdateOrCancelEdit = () => {
prevIsEditable.current = isEditable;
setIsEditable(false);
};

const toggle = (toggleRef: Ref<MenuToggleElement>) => (
<MenuToggle
className="pf-v6-u-mb-md"
Expand Down Expand Up @@ -212,6 +237,17 @@ _Italic text, formatted with single underscores_
avatar={userAvatar}
avatarProps={{ isBordered: true }}
/>
<Message
name="User"
role="user"
isEditable={isEditable}
onEditUpdate={onUpdateOrCancelEdit}
onEditCancel={onUpdateOrCancelEdit}
actions={{ edit: { onClick: () => setIsEditable(true), innerRef: editButtonRef } }}
content="This is a user message with an edit action."
avatar={userAvatar}
inputRef={messageInputRef}
/>
<Select
id="single-select"
isOpen={isOpen}
Expand All @@ -235,7 +271,6 @@ _Italic text, formatted with single underscores_
<SelectOption value="Table">Table</SelectOption>
<SelectOption value="Image">Image</SelectOption>
<SelectOption value="Error">Error</SelectOption>
<SelectOption value="Editable">Editable</SelectOption>
</SelectList>
</Select>
<Message
Expand All @@ -246,10 +281,7 @@ _Italic text, formatted with single underscores_
tableProps={
variant === 'Table' ? { 'aria-label': 'App information and user roles for user messages' } : undefined
}
isEditable={variant === 'Editable' ? isEditable : false}
error={variant === 'Error' ? error : undefined}
onEditUpdate={() => setIsEditable(false)}
onEditCancel={() => setIsEditable(false)}
codeBlockProps={{ isExpandable, expandableSectionProps: { truncateMaxLines: isExpandable ? 1 : undefined } }}
// In this example, custom plugin will override any custom expandedText or collapsedText attributes provided
// The purpose of this plugin is to provide unique link names for the code blocks
Expand Down
35 changes: 35 additions & 0 deletions packages/module/src/Message/Message.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const ALL_ACTIONS = [
{ label: /Good response/i },
{ label: /Bad response/i },
{ label: /Copy/i },
{ label: /Edit/i },
{ label: /Share/i },
{ label: /Listen/i }
];
Expand Down Expand Up @@ -426,6 +427,8 @@ describe('Message', () => {
// eslint-disable-next-line no-console
copy: { onClick: () => console.log('Copy') },
// eslint-disable-next-line no-console
edit: { onClick: () => console.log('Edit') },
// eslint-disable-next-line no-console
share: { onClick: () => console.log('Share') },
// eslint-disable-next-line no-console
download: { onClick: () => console.log('Download') },
Expand Down Expand Up @@ -454,6 +457,8 @@ describe('Message', () => {
// eslint-disable-next-line no-console
copy: { onClick: () => console.log('Copy') },
// eslint-disable-next-line no-console
edit: { onClick: () => console.log('Edit') },
// eslint-disable-next-line no-console
share: { onClick: () => console.log('Share') },
// eslint-disable-next-line no-console
download: { onClick: () => console.log('Download') },
Expand All @@ -467,6 +472,36 @@ describe('Message', () => {
expect(screen.queryByRole('button', { name: label })).toBeFalsy();
});
});
it('should not show actions if isEditable is true', async () => {
render(
<Message
avatar="./img"
role="bot"
name="Bot"
content="Hi"
isEditable
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
copy: { onClick: () => console.log('Copy') },
// eslint-disable-next-line no-console
edit: { onClick: () => console.log('Edit') },
// eslint-disable-next-line no-console
share: { onClick: () => console.log('Share') },
// eslint-disable-next-line no-console
download: { onClick: () => console.log('Download') },
// eslint-disable-next-line no-console
listen: { onClick: () => console.log('Listen') }
}}
/>
);
ALL_ACTIONS.forEach(({ label }) => {
expect(screen.queryByRole('button', { name: label })).toBeFalsy();
});
});
it('should render unordered lists correctly', () => {
render(<Message avatar="./img" role="user" name="User" content={UNORDERED_LIST} />);
expect(screen.getByText('Here is an unordered list:')).toBeTruthy();
Expand Down
10 changes: 7 additions & 3 deletions packages/module/src/Message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
isLoading?: boolean;
/** Array of attachments attached to a message */
attachments?: MessageAttachment[];
/** Props for message actions, such as feedback (positive or negative), copy button, share, and listen */
/** Props for message actions, such as feedback (positive or negative), copy button, edit message, share, and listen */
actions?: {
[key: string]: ActionProps;
};
Expand Down Expand Up @@ -179,6 +179,8 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
onEditUpdate?: (event: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
/** Callback functionf or when edit cancel update button is clicked */
onEditCancel?: (event: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
/** Ref applied to editable message input */
inputRef?: Ref<HTMLTextAreaElement>;
/** Props for edit form */
editFormProps?: FormProps;
/** Sets message to compact styling. */
Expand Down Expand Up @@ -219,6 +221,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
cancelWord = 'Cancel',
onEditUpdate,
onEditCancel,
inputRef,
editFormProps,
isCompact,
...props
Expand Down Expand Up @@ -256,7 +259,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
<>
{beforeMainContent && <>{beforeMainContent}</>}
<MessageInput
content={content}
content={messageText}
editPlaceholder={editPlaceholder}
updateWord={updateWord}
cancelWord={cancelWord}
Expand All @@ -265,6 +268,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
setMessageText(value);
}}
onEditCancel={onEditCancel}
inputRef={inputRef}
{...editFormProps}
/>
</>
Expand Down Expand Up @@ -369,7 +373,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
isCompact={isCompact}
/>
)}
{!isLoading && actions && <ResponseActions actions={actions} />}
{!isLoading && !isEditable && actions && <ResponseActions actions={actions} />}
{userFeedbackForm && <UserFeedback {...userFeedbackForm} timestamp={dateString} isCompact={isCompact} />}
{userFeedbackComplete && (
<UserFeedbackComplete {...userFeedbackComplete} timestamp={dateString} isCompact={isCompact} />
Expand Down
6 changes: 5 additions & 1 deletion packages/module/src/Message/MessageInput.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// ============================================================================
// Chatbot Main - Message Input
// ============================================================================
import type { FormEvent, FunctionComponent } from 'react';
import type { FormEvent, FunctionComponent, Ref } from 'react';
import { useState } from 'react';
import { ActionGroup, Button, Form, FormProps, TextArea } from '@patternfly/react-core';

Expand All @@ -16,6 +16,8 @@ export interface MessageInputProps extends FormProps {
onEditUpdate?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>, value: string) => void;
/** Callback functionf or when edit cancel update button is clicked */
onEditCancel?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
/** Ref applied to editable message input */
inputRef?: Ref<HTMLTextAreaElement>;
/** Message text */
content?: string;
}
Expand All @@ -26,6 +28,7 @@ const MessageInput: FunctionComponent<MessageInputProps> = ({
cancelWord = 'Cancel',
onEditUpdate,
onEditCancel,
inputRef,
content,
...props
}: MessageInputProps) => {
Expand All @@ -43,6 +46,7 @@ const MessageInput: FunctionComponent<MessageInputProps> = ({
onChange={onChange}
aria-label={editPlaceholder}
autoResize
ref={inputRef}
/>
<ActionGroup className="pf-chatbot__message-edit-buttons">
<Button variant="primary" onClick={(event) => onEditUpdate && onEditUpdate(event, messageText)}>
Expand Down
7 changes: 6 additions & 1 deletion packages/module/src/ResponseActions/ResponseActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const ALL_ACTIONS = [
{ type: 'positive', label: 'Good response', clickedLabel: 'Response recorded' },
{ type: 'negative', label: 'Bad response', clickedLabel: 'Response recorded' },
{ type: 'copy', label: 'Copy', clickedLabel: 'Copied' },
{ type: 'edit', label: 'Edit', clickedLabel: 'Editing' },
{ type: 'share', label: 'Share', clickedLabel: 'Shared' },
{ type: 'listen', label: 'Listen', clickedLabel: 'Listening' }
];
Expand Down Expand Up @@ -44,6 +45,7 @@ const ALL_ACTIONS_DATA_TEST = [
{ type: 'positive', label: 'Good response', dataTestId: 'positive' },
{ type: 'negative', label: 'Bad response', dataTestId: 'negative' },
{ type: 'copy', label: 'Copy', dataTestId: 'copy' },
{ type: 'edit', label: 'Edit', dataTestId: 'edit' },
{ type: 'share', label: 'Share', dataTestId: 'share' },
{ type: 'download', label: 'Download', dataTestId: 'download' },
{ type: 'listen', label: 'Listen', dataTestId: 'listen' }
Expand All @@ -60,6 +62,7 @@ describe('ResponseActions', () => {
positive: { onClick: jest.fn() },
negative: { onClick: jest.fn() },
copy: { onClick: jest.fn() },
edit: { onClick: jest.fn() },
share: { onClick: jest.fn() },
download: { onClick: jest.fn() },
listen: { onClick: jest.fn() }
Expand All @@ -69,10 +72,11 @@ describe('ResponseActions', () => {
const goodBtn = screen.getByRole('button', { name: 'Good response' });
const badBtn = screen.getByRole('button', { name: 'Bad response' });
const copyBtn = screen.getByRole('button', { name: 'Copy' });
const editBtn = screen.getByRole('button', { name: 'Edit' });
const shareBtn = screen.getByRole('button', { name: 'Share' });
const downloadBtn = screen.getByRole('button', { name: 'Download' });
const listenBtn = screen.getByRole('button', { name: 'Listen' });
const buttons = [goodBtn, badBtn, copyBtn, shareBtn, downloadBtn, listenBtn];
const buttons = [goodBtn, badBtn, copyBtn, editBtn, shareBtn, downloadBtn, listenBtn];
buttons.forEach((button) => {
expect(button).toBeTruthy();
});
Expand Down Expand Up @@ -265,6 +269,7 @@ describe('ResponseActions', () => {
{ type: 'positive', ariaLabel: 'Thumbs up' },
{ type: 'negative', ariaLabel: 'Thumbs down' },
{ type: 'copy', ariaLabel: 'Copy the message' },
{ type: 'edit', ariaLabel: 'Edit this message' },
{ type: 'share', ariaLabel: 'Share it with friends' },
{ type: 'download', ariaLabel: 'Download your cool message' },
{ type: 'listen', ariaLabel: 'Listen up' }
Expand Down
27 changes: 24 additions & 3 deletions packages/module/src/ResponseActions/ResponseActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
OutlinedThumbsUpIcon,
OutlinedThumbsDownIcon,
OutlinedCopyIcon,
DownloadIcon
DownloadIcon,
PencilAltIcon
} from '@patternfly/react-icons';
import ResponseActionButton from './ResponseActionButton';
import { ButtonProps, TooltipProps } from '@patternfly/react-core';
Expand Down Expand Up @@ -50,6 +51,7 @@ export interface ResponseActionProps {
share?: ActionProps;
download?: ActionProps;
listen?: ActionProps;
edit?: ActionProps;
};
}

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

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

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

useEffect(() => {
Expand Down Expand Up @@ -165,6 +167,24 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
aria-controls={copy['aria-controls']}
></ResponseActionButton>
)}
{edit && (
<ResponseActionButton
{...edit}
ariaLabel={edit.ariaLabel ?? 'Edit'}
clickedAriaLabel={edit.ariaLabel ?? 'Editing'}
onClick={(e) => handleClick(e, 'edit', edit.onClick)}
className={edit.className}
isDisabled={edit.isDisabled}
tooltipContent={edit.tooltipContent ?? 'Edit '}
clickedTooltipContent={edit.clickedTooltipContent ?? 'Editing'}
tooltipProps={edit.tooltipProps}
icon={<PencilAltIcon />}
isClicked={activeButton === 'edit'}
ref={edit.ref}
aria-expanded={edit['aria-expanded']}
aria-controls={edit['aria-controls']}
></ResponseActionButton>
)}
{share && (
<ResponseActionButton
{...share}
Expand Down Expand Up @@ -219,6 +239,7 @@ export const ResponseActions: FunctionComponent<ResponseActionProps> = ({ action
aria-controls={listen['aria-controls']}
></ResponseActionButton>
)}

{Object.keys(additionalActions).map((action) => (
<ResponseActionButton
{...additionalActions[action]}
Expand Down
Loading