From 8ee2acb03ad6f8b9b66bd8cbe232338c6d700d6a Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Wed, 2 Apr 2025 12:40:24 -0400 Subject: [PATCH] feat(Message): Add ability to edit user prompt Add prop to trigger edit mode and display form when edit mode is on. Add examples/tests. --- .../chatbot/examples/Messages/BotMessage.tsx | 2 +- .../chatbot/examples/Messages/UserMessage.tsx | 75 ++++++-- packages/module/src/Message/Message.scss | 4 + packages/module/src/Message/Message.test.tsx | 48 ++++++ packages/module/src/Message/Message.tsx | 160 ++++++++++++------ packages/module/src/Message/MessageInput.tsx | 59 +++++++ 6 files changed, 280 insertions(+), 68 deletions(-) create mode 100644 packages/module/src/Message/MessageInput.tsx diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx index e24d28448..f68a929e0 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx @@ -276,7 +276,7 @@ _Italic text, formatted with single underscores_ setVariant('error')} - name="bot-message-error" + name="bot-message-type" label="Error" id="error" /> 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 148f29e5f..2bab96603 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 @@ -6,6 +6,7 @@ import { AlertActionLink, Form, FormGroup, Radio } from '@patternfly/react-core' export const UserMessageExample: React.FunctionComponent = () => { const [variant, setVariant] = React.useState('code'); + const [isEditable, setIsEditable] = React.useState(true); /* eslint-disable indent */ const renderContent = () => { @@ -33,7 +34,7 @@ export const UserMessageExample: React.FunctionComponent = () => { case 'image': return image; default: - return; + return ''; } }; /* eslint-enable indent */ @@ -175,88 +176,131 @@ _Italic text, formatted with single underscores_ setVariant('code')} + onChange={() => { + setVariant('code'); + setIsEditable(true); + }} name="user-message-type" label="Code" id="user-code" /> setVariant('inlineCode')} + onChange={() => { + setVariant('inlineCode'); + setIsEditable(true); + }} name="user-message-type" label="Inline code" id="user-inline-code" /> setVariant('heading')} + onChange={() => { + setVariant('heading'); + setIsEditable(true); + }} name="user-message-type" label="Heading" id="user-heading" /> setVariant('blockQuotes')} + onChange={() => { + setVariant('blockQuotes'); + setIsEditable(true); + }} name="user-message-type" label="Block quote" id="user-block-quotes" /> setVariant('emphasis')} + onChange={() => { + setVariant('emphasis'); + setIsEditable(true); + }} name="user-message-type" label="Emphasis" id="user-emphasis" /> setVariant('link')} + onChange={() => { + setVariant('link'); + setIsEditable(true); + }} name="user-message-type" label="Link" id="user-link" /> setVariant('unorderedList')} + onChange={() => { + setVariant('unorderedList'); + setIsEditable(true); + }} name="user-message-type" label="Unordered list" id="user-unordered-list" /> setVariant('orderedList')} + onChange={() => { + setVariant('orderedList'); + setIsEditable(true); + }} name="user-message-type" label="Ordered list" id="user-ordered-list" /> setVariant('moreComplexList')} + onChange={() => { + setVariant('moreComplexList'); + setIsEditable(true); + }} name="user-message-type" label="More complex list" id="user-more-complex-list" /> setVariant('table')} + onChange={() => { + setVariant('table'); + setIsEditable(true); + }} name="user-message-type" label="Table" id="user-table" /> setVariant('image')} + onChange={() => { + setVariant('image'); + setIsEditable(true); + }} name="user-message-type" label="Image" id="user-image" /> setVariant('error')} - name="user-message-error" + onChange={() => { + setVariant('error'); + setIsEditable(true); + }} + name="user-message-type" label="Error" id="user-error" /> + setVariant('editable')} + name="user-message-type" + label="Editable" + id="user-edit" + /> setIsEditable(false)} + onEditCancel={() => setIsEditable(false)} /> ); diff --git a/packages/module/src/Message/Message.scss b/packages/module/src/Message/Message.scss index e7827c883..ad673473e 100644 --- a/packages/module/src/Message/Message.scss +++ b/packages/module/src/Message/Message.scss @@ -97,6 +97,10 @@ flex-wrap: wrap; } +.pf-chatbot__message-edit-buttons { + --pf-v6-c-form__group--m-action--MarginBlockStart: 0; +} + @import './MessageLoading'; @import './CodeBlockMessage/CodeBlockMessage'; @import './TextMessage/TextMessage'; diff --git a/packages/module/src/Message/Message.test.tsx b/packages/module/src/Message/Message.test.tsx index c7a713a08..fe35d0b16 100644 --- a/packages/module/src/Message/Message.test.tsx +++ b/packages/module/src/Message/Message.test.tsx @@ -815,4 +815,52 @@ describe('Message', () => { expect(screen.getByRole('heading', { name: /Could not load chat/i })).toBeTruthy(); expect(screen.queryByText('Test')).toBeFalsy(); }); + it('should handle isEditable when there is message content', () => { + render(); + expect(screen.getByRole('textbox')).toBeTruthy(); + expect(screen.getByRole('textbox')).toHaveValue('Test'); + expect(screen.getByRole('button', { name: /Update/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /Cancel/i })).toBeTruthy(); + }); + it('should handle isEditable when there is no message content', () => { + render(); + expect(screen.getByRole('textbox')).toBeTruthy(); + expect(screen.getByRole('textbox')).toHaveValue(''); + expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'Edit prompt message...'); + expect(screen.getByRole('button', { name: /Update/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /Cancel/i })).toBeTruthy(); + }); + it('should be able to change edit placeholder', () => { + render(); + expect(screen.getByRole('textbox')).toBeTruthy(); + expect(screen.getByRole('textbox')).toHaveValue(''); + expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'I am a placeholder'); + }); + it('should be able to change updateWord', () => { + render(); + expect(screen.getByRole('button', { name: /Submit/i })).toBeTruthy(); + }); + it('should be able to change cancelWord', () => { + render(); + expect(screen.getByRole('button', { name: /Don't submit/i })).toBeTruthy(); + }); + it('should be able to add onEditUpdate', async () => { + const spy = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /Update/i })); + expect(spy).toHaveBeenCalledTimes(1); + }); + it('should be able to add onEditCancel', async () => { + const spy = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /Cancel/i })); + expect(spy).toHaveBeenCalledTimes(1); + }); + it('should be able to add editFormProps', () => { + const { container } = render( + + ); + const form = container.querySelector('form'); + expect(form).toHaveClass('test'); + }); }); diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index 1f582ece9..814b47bab 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -12,6 +12,7 @@ import { AvatarProps, ButtonProps, ContentVariants, + FormProps, Label, LabelGroupProps, Timestamp, @@ -45,6 +46,7 @@ import rehypeSanitize from 'rehype-sanitize'; import { PluggableList } from 'react-markdown/lib'; import LinkMessage from './LinkMessage/LinkMessage'; import ErrorMessage from './ErrorMessage/ErrorMessage'; +import MessageInput from './MessageInput'; export interface MessageAttachment { /** Name of file attached to the message */ @@ -148,6 +150,20 @@ export interface MessageProps extends Omit, 'rol error?: AlertProps; /** Props for links */ linkProps?: ButtonProps; + /** Whether message is in edit mode */ + isEditable?: boolean; + /** Placeholder for edit input */ + editPlaceholder?: string; + /** Label for the English word "Update" used in edit mode. */ + updateWord?: string; + /** Label for the English word "Cancel" used in edit mode. */ + cancelWord?: string; + /** Callback function for when edit mode update button is clicked */ + onEditUpdate?: (event: React.MouseEvent) => void; + /** Callback functionf or when edit cancel update button is clicked */ + onEditCancel?: (event: React.MouseEvent) => void; + /** Props for edit form */ + editFormProps?: FormProps; } export const MessageBase: React.FunctionComponent = ({ @@ -178,8 +194,21 @@ export const MessageBase: React.FunctionComponent = ({ additionalRehypePlugins = [], linkProps, error, + isEditable, + editPlaceholder = 'Edit prompt message...', + updateWord = 'Update', + cancelWord = 'Cancel', + onEditUpdate, + onEditCancel, + editFormProps, ...props }: MessageProps) => { + const [messageText, setMessageText] = React.useState(content); + + React.useEffect(() => { + setMessageText(content); + }, [content]); + const { beforeMainContent, afterMainContent, endContent } = extraContent || {}; let rehypePlugins: PluggableList = [rehypeUnwrapImages]; if (openLinkInNewTab) { @@ -197,6 +226,82 @@ export const MessageBase: React.FunctionComponent = ({ // Keep timestamps consistent between Timestamp component and aria-label const date = new Date(); const dateString = timestamp ?? `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; + + const renderMessage = () => { + if (isLoading) { + return ; + } + if (isEditable) { + return ( + <> + {beforeMainContent && <>{beforeMainContent}} + { + onEditUpdate && onEditUpdate(event); + setMessageText(text); + }} + onEditCancel={onEditCancel} + {...editFormProps} + /> + + ); + } + return ( + <> + {beforeMainContent && <>{beforeMainContent}} + {error ? ( + + ) : ( + , + code: ({ children, ...props }) => ( + + {children} + + ), + h1: (props) => , + h2: (props) => , + h3: (props) => , + h4: (props) => , + h5: (props) => , + h6: (props) => , + blockquote: (props) => , + ul: (props) => , + ol: (props) => , + li: (props) => , + table: (props) => , + tbody: (props) => , + thead: (props) => , + tr: (props) => , + td: (props) => { + // Conflicts with Td type + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { width, ...rest } = props; + return ; + }, + th: (props) => , + img: (props) => , + a: (props) => ( + + {props.children} + + ) + }} + remarkPlugins={[remarkGfm]} + rehypePlugins={rehypePlugins} + > + {messageText} + + )} + + ); + }; + return (
= ({
- {isLoading ? ( - - ) : ( - <> - {beforeMainContent && <>{beforeMainContent}} - {error ? ( - - ) : ( - , - code: ({ children, ...props }) => ( - - {children} - - ), - h1: (props) => , - h2: (props) => , - h3: (props) => , - h4: (props) => , - h5: (props) => , - h6: (props) => , - blockquote: (props) => , - ul: (props) => , - ol: (props) => , - li: (props) => , - table: (props) => , - tbody: (props) => , - thead: (props) => , - tr: (props) => , - td: (props) => { - // Conflicts with Td type - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { width, ...rest } = props; - return ; - }, - th: (props) => , - img: (props) => , - a: (props) => ( - - {props.children} - - ) - }} - remarkPlugins={[remarkGfm]} - rehypePlugins={rehypePlugins} - > - {content} - - )} - {afterMainContent && <>{afterMainContent}} - - )} + {renderMessage()} + {afterMainContent && <>{afterMainContent}} {!isLoading && sources && } {quickStarts && quickStarts.quickStart && ( , value: string) => void; + /** Callback functionf or when edit cancel update button is clicked */ + onEditCancel?: (event: React.MouseEvent) => void; + /** Message text */ + content?: string; +} + +const MessageInput: React.FunctionComponent = ({ + editPlaceholder = 'Edit prompt message...', + updateWord = 'Update', + cancelWord = 'Cancel', + onEditUpdate, + onEditCancel, + content, + ...props +}: MessageInputProps) => { + const [messageText, setMessageText] = React.useState(content ?? ''); + + const onChange = (event: React.FormEvent, value: string) => { + setMessageText(value); + }; + + return ( +
+