diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md index 469c5d96f..cf4eaeecb 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md @@ -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 `` component is passed to a `` component (from [react-markdown](https://remarkjs.github.io/react-markdown/)), which is configured to translate plain text strings into PatternFly [`` components](/components/content) and code blocks into PatternFly [`` components.](/components/code-block) @@ -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. - 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. @@ -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 `` 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" ``` diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx index dec595d67..482a207ff 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx @@ -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 { @@ -12,12 +12,32 @@ import { import { rehypeCodeBlockToggle } from '@patternfly/chatbot/dist/esm/Message/Plugins/rehypeCodeBlockToggle'; export const UserMessageExample: FunctionComponent = () => { - const [variant, setVariant] = useState('Code'); - const [isEditable, setIsEditable] = useState(true); + const messageInputRef = useRef(null); + const editButtonRef = useRef(null); + const [variant, setVariant] = useState('Code'); const [isOpen, setIsOpen] = useState(false); const [selected, setSelected] = useState('Message content type'); const [isExpandable, setIsExpanded] = useState(false); + const [isEditable, setIsEditable] = useState(false); + const prevIsEditable = useRef(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) { @@ -180,6 +200,11 @@ _Italic text, formatted with single underscores_ setIsOpen(!isOpen); }; + const onUpdateOrCancelEdit = () => { + prevIsEditable.current = isEditable; + setIsEditable(false); + }; + const toggle = (toggleRef: Ref) => ( + setIsEditable(true), innerRef: editButtonRef } }} + content="This is a user message with an edit action." + avatar={userAvatar} + inputRef={messageInputRef} + /> 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 diff --git a/packages/module/src/Message/Message.test.tsx b/packages/module/src/Message/Message.test.tsx index a91c57210..81363e5c8 100644 --- a/packages/module/src/Message/Message.test.tsx +++ b/packages/module/src/Message/Message.test.tsx @@ -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 } ]; @@ -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') }, @@ -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') }, @@ -467,6 +472,36 @@ describe('Message', () => { expect(screen.queryByRole('button', { name: label })).toBeFalsy(); }); }); + it('should not show actions if isEditable is true', async () => { + render( + 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(); expect(screen.getByText('Here is an unordered list:')).toBeTruthy(); diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index b69d5030a..4032eb6db 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -99,7 +99,7 @@ export interface MessageProps extends Omit, '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; }; @@ -179,6 +179,8 @@ export interface MessageProps extends Omit, 'role'> { onEditUpdate?: (event: ReactMouseEvent) => void; /** Callback functionf or when edit cancel update button is clicked */ onEditCancel?: (event: ReactMouseEvent) => void; + /** Ref applied to editable message input */ + inputRef?: Ref; /** Props for edit form */ editFormProps?: FormProps; /** Sets message to compact styling. */ @@ -219,6 +221,7 @@ export const MessageBase: FunctionComponent = ({ cancelWord = 'Cancel', onEditUpdate, onEditCancel, + inputRef, editFormProps, isCompact, ...props @@ -256,7 +259,7 @@ export const MessageBase: FunctionComponent = ({ <> {beforeMainContent && <>{beforeMainContent}} = ({ setMessageText(value); }} onEditCancel={onEditCancel} + inputRef={inputRef} {...editFormProps} /> @@ -369,7 +373,7 @@ export const MessageBase: FunctionComponent = ({ isCompact={isCompact} /> )} - {!isLoading && actions && } + {!isLoading && !isEditable && actions && } {userFeedbackForm && } {userFeedbackComplete && ( diff --git a/packages/module/src/Message/MessageInput.tsx b/packages/module/src/Message/MessageInput.tsx index a87c3a3ff..02f42c6b0 100644 --- a/packages/module/src/Message/MessageInput.tsx +++ b/packages/module/src/Message/MessageInput.tsx @@ -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'; @@ -16,6 +16,8 @@ export interface MessageInputProps extends FormProps { onEditUpdate?: (event: React.MouseEvent, value: string) => void; /** Callback functionf or when edit cancel update button is clicked */ onEditCancel?: (event: React.MouseEvent) => void; + /** Ref applied to editable message input */ + inputRef?: Ref; /** Message text */ content?: string; } @@ -26,6 +28,7 @@ const MessageInput: FunctionComponent = ({ cancelWord = 'Cancel', onEditUpdate, onEditCancel, + inputRef, content, ...props }: MessageInputProps) => { @@ -43,6 +46,7 @@ const MessageInput: FunctionComponent = ({ onChange={onChange} aria-label={editPlaceholder} autoResize + ref={inputRef} />