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 @@ -5,29 +5,45 @@ import PreviewAttachment from '@patternfly/virtual-assistant/dist/dynamic/Previe
import AttachmentEdit from '@patternfly/virtual-assistant/dist/dynamic/AttachmentEdit';
import userAvatar from './user_avatar.jpg';

interface ModalData {
code: string;
fileName: string;
}

export const AttachmentMenuExample: React.FunctionComponent = () => {
const [isPreviewModalOpen, setIsPreviewModalOpen] = React.useState<boolean>(false);
const [isEditModalOpen, setIsEditModalOpen] = React.useState<boolean>(false);
const [currentModalData, setCurrentModalData] = React.useState<ModalData>();

const onClick = (event: React.MouseEvent, name: string) => {
setCurrentModalData({ fileName: name, code: 'test' });
setIsEditModalOpen(false);
setIsPreviewModalOpen(true);
};

const onClose = (event: React.MouseEvent, name: string, id: number | string | undefined) => {
// eslint-disable-next-line no-console
console.log(`Closed attachment with name: ${name} and id: ${id}`);
};

return (
<>
<Message
name="User"
role="user"
avatar={userAvatar}
content="Here is an uploaded file"
attachmentName="auth-operator.yml"
attachmentId="1"
onAttachmentClick={() => {
setCurrentModalData({ fileName: 'auth-operator.yml', code: 'test' });
setIsEditModalOpen(false);
setIsPreviewModalOpen(true);
}}
onAttachmentClose={(id: string) => {
// eslint-disable-next-line no-console
console.log(`Closed attachment id ${id}`);
}}
attachments={[{ name: 'auth-operator.yml', id: '1', onClick, onClose }]}
/>
<Message
name="User"
role="user"
avatar={userAvatar}
content="Here are two uploaded files"
attachments={[
{ name: 'auth-operator.yml', id: '1' },
{ name: 'patternfly.svg', id: '2' }
]}
/>
{currentModalData && (
<PreviewAttachment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,19 @@ id: Messages
source: react
# If you use typescript, the name of the interface to display props for
# These are found through the sourceProps function provided in patternfly-docs.source.js
propComponents: [
'AttachMenu',
'AttachmentEdit',
'FileDetails',
'FileDetailsLabel',
'FileDropZone',
propComponents:
[
'AttachMenu',
'AttachmentEdit',
'FileDetails',
'FileDetailsLabel',
'FileDropZone',
'PreviewAttachment',
'Message',
'PreviewAttachment',
'ActionProps',
'SourcesCardProps'
]
]
sortValue: 3
---

Expand Down Expand Up @@ -122,7 +123,7 @@ If a `displayMode` is not passed to `<PreviewAttachment>` or `<AttachmentEdit>`,

```

We are using [react-dropzone](https://react-dropzone.js.org) for opening the file dialog and handling drag and drop. It does not process files or provide any way to make HTTP requests to a server. If you need this, [react-dropzone](https://react-dropzone.js.org) suggests [filepond](https://pqina.nl/filepond/) or [uppy.io.](https://uppy.io/)
We are using [react-dropzone](https://react-dropzone.js.org) for opening the file dialog and handling drag and drop. It does not process files or provide any way to make HTTP requests to a server. If you need this, [react-dropzone](https://react-dropzone.js.org) suggests [filepond](https://pqina.nl/filepond/) or [uppy.io.](https://uppy.io/). To handle edge cases, like restricting the number or size of files, you can pass a function to the `handleAttach` prop on `MessageBar` or `onFileDrop` prop in `FileDropZone.`

### Attachment label

Expand Down
66 changes: 37 additions & 29 deletions packages/module/src/FileDetailsLabel/FileDetailsLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { TimesIcon } from '@patternfly/react-icons';
interface FileDetailsLabelProps {
/** Name of file, including extension */
fileName: string;
/** Unique id of file */
fileId?: string | number;
/** Whether to display loading icon */
isLoading?: boolean;
/** Callback function for when label is clicked */
onClick?: (event: React.MouseEvent) => void;
onClick?: (event: React.MouseEvent, fileName: string, fileId?: string | number) => void;
/** Callback function for when close button is clicked */
onClose?: (event: React.MouseEvent) => void;
onClose?: (event: React.MouseEvent, fileName: string, fileId?: string | number) => void;
/** Aria label for close button */
closeButtonAriaLabel?: string;
/** Custom test id for the component-generated language */
Expand All @@ -23,36 +25,42 @@ interface FileDetailsLabelProps {

export const FileDetailsLabel = ({
fileName,
fileId,
isLoading,
onClick = undefined,
onClose = undefined,
onClick,
onClose,
closeButtonAriaLabel,
languageTestId,
spinnerTestId
}: PropsWithChildren<FileDetailsLabelProps>) => (
<Label
className="pf-chatbot__file-label"
onClose={onClose}
closeBtn={
<Button
type="button"
variant="plain"
onClick={onClose}
aria-label={closeButtonAriaLabel ?? `Close ${fileName}`}
icon={<TimesIcon />}
/>
}
onClick={onClick}
>
<div className="pf-chatbot__file-label-contents">
<FileDetails
className={isLoading ? 'pf-chatbot__file-label-loading' : undefined}
fileName={fileName}
languageTestId={languageTestId}
/>
{isLoading && <Spinner data-testid={spinnerTestId} size="sm" />}
</div>
</Label>
);
}: PropsWithChildren<FileDetailsLabelProps>) => {
const handleClose = (event) => {
onClose && onClose(event, fileName, fileId);
};
return (
<Label
className="pf-chatbot__file-label"
{...(onClose && { onClose: (event) => onClose(event, fileName, fileId) })}
closeBtn={
<Button
type="button"
variant="plain"
aria-label={closeButtonAriaLabel ?? `Close ${fileName}`}
icon={<TimesIcon />}
onClick={handleClose}
/>
}
{...(onClick && { onClick: (event) => onClick(event, fileName, fileId) })}
>
<div className="pf-chatbot__file-label-contents">
<FileDetails
className={isLoading ? 'pf-chatbot__file-label-loading' : undefined}
fileName={fileName}
languageTestId={languageTestId}
/>
{isLoading && <Spinner data-testid={spinnerTestId} size="sm" />}
</div>
</Label>
);
};

export default FileDetailsLabel;
8 changes: 8 additions & 0 deletions packages/module/src/Message/Message.scss
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@
}
}

// Attachments
// --------------------------------------------------------------------------
.pf-chatbot__message-attachments-container {
display: flex;
gap: var(--pf-t--global--spacer--md);
flex-wrap: wrap;
}

@import './MessageLoading';
@import './CodeBlockMessage/CodeBlockMessage';
@import './TextMessage/TextMessage';
10 changes: 4 additions & 6 deletions packages/module/src/Message/Message.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,28 +100,26 @@ describe('Message', () => {
expect(screen.queryByText(`${date.toLocaleDateString()}, ${date.toLocaleTimeString()}`)).toBeFalsy();
});
it('should render attachments', () => {
render(<Message role="user" content="Hi" attachmentName="testAttachment" />);
render(<Message role="user" content="Hi" attachments={[{ name: 'testAttachment' }]} />);
expect(screen.getByText('Hi')).toBeTruthy();
expect(screen.getByText('testAttachment')).toBeTruthy();
});
it('should be able to click attachments', async () => {
const spy = jest.fn();
render(<Message role="user" content="Hi" attachmentName="testAttachment" onAttachmentClick={spy} />);
render(<Message role="user" content="Hi" attachments={[{ name: 'testAttachment', onClick: spy }]} />);
expect(screen.getByText('Hi')).toBeTruthy();
expect(screen.getByText('testAttachment')).toBeTruthy();
await userEvent.click(screen.getByRole('button', { name: /testAttachment/i }));
expect(spy).toHaveBeenCalledTimes(1);
});
it('should be able to close attachments', async () => {
const spy = jest.fn();
render(
<Message role="user" content="Hi" attachmentId="001" attachmentName="testAttachment" onAttachmentClose={spy} />
);
render(<Message role="user" content="Hi" attachments={[{ name: 'testAttachment', onClose: spy }]} />);
expect(screen.getByText('Hi')).toBeTruthy();
expect(screen.getByText('testAttachment')).toBeTruthy();
expect(screen.getByRole('button', { name: /close testAttachment/i })).toBeTruthy();
await userEvent.click(screen.getByRole('button', { name: /close testAttachment/i }));
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('001');
});
it('should render loading state', () => {
render(<Message role="bot" name="Bot" content="Hi" isLoading />);
Expand Down
61 changes: 38 additions & 23 deletions packages/module/src/Message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@ export interface QuickResponse extends Omit<LabelProps, 'children'> {
id: string;
onClick: () => void;
}
export interface MessageAttachment {
/** Name of file attached to the message */
name: string;
/** Unique identifier of file attached to the message */
id?: string | number;
/** Callback for when attachment label is clicked */
onClick?: (event: React.MouseEvent, name: string, id?: string | number) => void;
/** Callback for when attachment label is closed */
onClose?: (event: React.MouseEvent, name: string, id?: string | number) => void;
/** Whether file is loading */
isLoading?: boolean;
/** Aria label for attachment close button */
closeButtonAriaLabel?: string;
/** Custom test id for the language in the attachment component */
languageTestId?: string;
/** Custom test id for the loading spinner in the attachment component */
spinnerTestId?: string;
}

export interface MessageProps extends Omit<React.HTMLProps<HTMLDivElement>, 'role'> {
/** Unique id for message */
id?: string;
Expand All @@ -37,14 +56,8 @@ export interface MessageProps extends Omit<React.HTMLProps<HTMLDivElement>, 'rol
timestamp?: string;
/** Set this to true if message is being loaded */
isLoading?: boolean;
/** Unique identifier of file attached to the message */
attachmentId?: string;
/** Name of file attached to the message */
attachmentName?: string;
/** Callback for when attachment label is clicked */
onAttachmentClick?: () => void;
/** Callback for when attachment label is closed */
onAttachmentClose?: (attachmentId: string) => void;
/** Array of attachments attached to a message */
attachments?: MessageAttachment[];
/** Props for message actions, such as feedback (positive or negative), copy button, share, and listen */
actions?: {
[key: string]: ActionProps;
Expand Down Expand Up @@ -72,17 +85,14 @@ export const Message: React.FunctionComponent<MessageProps> = ({
avatar,
timestamp,
isLoading,
attachmentId,
attachmentName,
onAttachmentClick,
onAttachmentClose,
actions,
sources,
botWord = 'AI',
loadingWord = 'Loading message',
codeBlockProps,
quickResponses,
quickResponseContainerProps = { numLabels: 5 },
attachments,
...props
}: MessageProps) => {
// Configure default values
Expand All @@ -98,10 +108,6 @@ export const Message: React.FunctionComponent<MessageProps> = ({
}
};

const onClose = () => {
onAttachmentClose && attachmentId && onAttachmentClose(attachmentId);
};

// Keep timestamps consistent between Timestamp component and aria-label
const date = new Date();
const dateString = timestamp ?? `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
Expand Down Expand Up @@ -157,13 +163,22 @@ export const Message: React.FunctionComponent<MessageProps> = ({
</LabelGroup>
)}
</div>
{attachmentName && (
<div className="pf-chatbot__message-attachment">
<FileDetailsLabel
fileName={attachmentName}
onClick={onAttachmentClick}
onClose={onAttachmentClose && attachmentId ? onClose : undefined}
/>
{attachments && (
<div className="pf-chatbot__message-attachments-container">
{attachments.map((attachment) => (
<div key={attachment.id ?? attachment.name} className="pf-chatbot__message-attachment">
<FileDetailsLabel
fileName={attachment.name}
fileId={attachment.id}
onClose={attachment.onClose}
onClick={attachment.onClick}
isLoading={attachment.isLoading}
closeButtonAriaLabel={attachment.closeButtonAriaLabel}
languageTestId={attachment.languageTestId}
spinnerTestId={attachment.spinnerTestId}
/>
</div>
))}
</div>
)}
</div>
Expand Down
Loading