From a70c7be057fd13c57924bdc5b2c8bf42431307ec Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Fri, 29 Aug 2025 10:17:19 -0400 Subject: [PATCH 1/4] feat(ImagePreview): Add image preview modal --- .../examples/Messages/ImagePreview.tsx | 53 ++++ .../chatbot/examples/Messages/Messages.md | 12 +- .../examples/Messages/file-preview.svg | 9 + .../module/src/FileDetails/FileDetails.scss | 10 + .../src/FileDetails/FileDetails.test.tsx | 16 ++ .../module/src/FileDetails/FileDetails.tsx | 107 +++++--- .../__snapshots__/FileDetails.test.tsx.snap | 40 +-- .../FileDetailsLabel.test.tsx | 22 +- .../src/FileDetailsLabel/FileDetailsLabel.tsx | 19 +- .../FileDetailsLabel.test.tsx.snap | 40 +-- .../module/src/ImagePreview/ImagePreview.scss | 61 +++++ .../src/ImagePreview/ImagePreview.test.tsx | 253 ++++++++++++++++++ .../module/src/ImagePreview/ImagePreview.tsx | 183 +++++++++++++ packages/module/src/ImagePreview/index.ts | 3 + packages/module/src/index.ts | 3 + packages/module/src/main.scss | 1 + 16 files changed, 763 insertions(+), 69 deletions(-) create mode 100644 packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/ImagePreview.tsx create mode 100644 packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/file-preview.svg create mode 100644 packages/module/src/ImagePreview/ImagePreview.scss create mode 100644 packages/module/src/ImagePreview/ImagePreview.test.tsx create mode 100644 packages/module/src/ImagePreview/ImagePreview.tsx create mode 100644 packages/module/src/ImagePreview/index.ts diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/ImagePreview.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/ImagePreview.tsx new file mode 100644 index 000000000..9c785ae7c --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/ImagePreview.tsx @@ -0,0 +1,53 @@ +import { useState, FunctionComponent, MouseEvent as ReactMouseEvent } from 'react'; +import { Button, Checkbox } from '@patternfly/react-core'; +import ImagePreview from '@patternfly/chatbot/dist/dynamic/ImagePreview'; +import filePreview from './file-preview.svg'; + +export const AttachmentEditModalExample: FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isCompact, setIsCompact] = useState(false); + const [hasNav, setHasNav] = useState(false); + + const handleModalToggle = (_event: ReactMouseEvent | MouseEvent | KeyboardEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + <> + setHasNav(!hasNav)} + id="modal-compact-image-has-nav" + name="modal-compact-image-has-nav" + > + setIsCompact(!isCompact)} + id="modal-compact-image-preview" + name="modal-compact-image-preview" + > + + { + // eslint-disable-next-line no-console + console.log('Clicked close button'); + }} + images={ + hasNav + ? /* eslint-disable indent */ + [ + { fileName: 'image1.png', fileSize: '134KB', image: One }, + { fileName: 'image2.png', fileSize: '134KB', image: Two } + ] + : [{ fileName: 'image.png', fileSize: '134KB', image: One }] + /* eslint-enable indent */ + } + > + + ); +}; 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 e9691222b..801078e9a 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,6 +48,8 @@ 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, useRef, useEffect } from 'react'; import FilePreview from '@patternfly/chatbot/dist/dynamic/FilePreview'; +import ImagePreview from '@patternfly/chatbot/dist/dynamic/ImagePreview'; +import filePreview from './file-preview.svg'; 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) @@ -189,7 +191,7 @@ If you are using [model context protocol (MCP)](https://www.redhat.com/en/blog/m ### Messages with deep thinking -You can share details about the "thought process" behind an LLM's response, also known as deep thinking. To display a customizable, expandable card with these details, pass `deepThinking` to `` and provide a subheading (optional) and content body. +You can share details about the "thought process" behind an LLM's response, also known as deep thinking. To display a customizable, expandable card with these details, pass `deepThinking` to `` and provide a subheading (optional) and content body. Because this is an evolving area, this card content is currently fully customizable. @@ -274,6 +276,14 @@ To allow users to edit an attached file, load a new code editor within the ChatB ``` +### Image preview + +For images, load a view of the file name and other information in a new modal. Return users to the main ChatBot window once they dismiss the modal. + +```js file="./ImagePreview.tsx" + +``` + ### File preview If the contents of an attachment cannot be previewed, load a file preview modal with a view of the file name and an unavailable message. When users close the modal, return to the main ChatBot window. diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/file-preview.svg b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/file-preview.svg new file mode 100644 index 000000000..1c20127f4 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/file-preview.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/module/src/FileDetails/FileDetails.scss b/packages/module/src/FileDetails/FileDetails.scss index dfdba5c67..06958f9f2 100644 --- a/packages/module/src/FileDetails/FileDetails.scss +++ b/packages/module/src/FileDetails/FileDetails.scss @@ -11,10 +11,20 @@ height: 24px; } +.pf-chatbot__image-icon { + color: var(--pf-t--global--icon--color--status--info--default); + width: 24px; + height: 24px; +} + .pf-chatbot__code-fileName { font-size: var(--pf-t--global--font--size--body--default); } +.pf-chatbot__code-file-size { + color: var(--pf-t--global--text--color--subtle); +} + // This is used in demos only .pf-chatbot__file-details-example { background: var(--pf-t--global--background--color--secondary--default); diff --git a/packages/module/src/FileDetails/FileDetails.test.tsx b/packages/module/src/FileDetails/FileDetails.test.tsx index e9665c167..1d7d2f0ba 100644 --- a/packages/module/src/FileDetails/FileDetails.test.tsx +++ b/packages/module/src/FileDetails/FileDetails.test.tsx @@ -11,6 +11,7 @@ describe('FileDetails', () => { it('should render file details correctly if an extension we support is passed in', () => { render(); expect(screen.getByText('test')).toBeTruthy(); + expect(screen.queryByText('test.txt')).toBeFalsy(); expect(screen.getByText('TEXT')).toBeTruthy(); expect(screen.getByTestId('language')).toBeTruthy(); }); @@ -19,4 +20,19 @@ describe('FileDetails', () => { expect(screen.getByText('test')).toBeTruthy(); expect(screen.queryByTestId('language')).toBeFalsy(); }); + it('should support image formats by rendering extension differently', () => { + render(); + expect(screen.getByText('test')).toBeTruthy(); + expect(screen.queryByText('test.svg')).toBeFalsy(); + expect(screen.queryByTestId('language')).toBeFalsy(); + }); + it('should handle truncation differently', () => { + render(); + expect(screen.getByText('test.svg')).toBeTruthy(); + expect(screen.queryByTestId('language')).toBeFalsy(); + }); + it('should include file size if prop passed in', () => { + render(); + expect(screen.getByText('100MB')).toBeTruthy(); + }); }); diff --git a/packages/module/src/FileDetails/FileDetails.tsx b/packages/module/src/FileDetails/FileDetails.tsx index fe003e74c..e1d44aad2 100644 --- a/packages/module/src/FileDetails/FileDetails.tsx +++ b/packages/module/src/FileDetails/FileDetails.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren } from 'react'; -import { Flex, Stack, StackItem, Truncate } from '@patternfly/react-core'; +import { Flex, FlexItem, Truncate } from '@patternfly/react-core'; import path from 'path-browserify'; -interface FileDetailsProps { +export interface FileDetailsProps { /** Class name applied to container */ className?: string; /** Name of file, including extension */ @@ -10,8 +10,24 @@ interface FileDetailsProps { languageTestId?: string; /** Class name applied to file name */ fileNameClassName?: string; + /** File size */ + fileSize?: string; + /** Whether to truncate file name */ + hasTruncation?: boolean; } +// manually added for image modal +// based on https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types +export const IMAGE_FORMATS = { + png: 'PNG', + apng: 'APNG', + avif: 'AVIF', + gif: 'GIF', + jpg: 'JPEG', + svg: 'SVG', + webp: 'WebP' +}; + // source https://gist.github.com/ppisarczyk/43962d06686722d26d176fad46879d41 // FIXME We could probably check against the PF Language hash to trim this down to what the code editor supports. // I can also see an argument to leaving this open, or researching what the third-party we use for uploads is using @@ -678,7 +694,6 @@ export const extensionToLanguage = { viw: 'SQL', db2: 'SQLPL', ston: 'STON', - svg: 'SVG', sage: 'Sage', sagews: 'Sage', sls: 'Scheme', @@ -935,54 +950,82 @@ export const extensionToLanguage = { ppt: 'Presentation', pptx: 'Presentation', odp: 'Presentation', - pdf: 'PDF' + pdf: 'PDF', + // manually added for image modal + // based on https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types + ...IMAGE_FORMATS }; export const FileDetails = ({ className, fileName, fileNameClassName, - languageTestId + languageTestId, + fileSize, + hasTruncation = true }: PropsWithChildren) => { const language = extensionToLanguage[path.extname(fileName).slice(1)]?.toUpperCase(); + const isImage = IMAGE_FORMATS[path.extname(fileName).slice(1)] ? true : false; return ( - - - + {isImage ? ( + + - - - - - - - - - - - - - - - {language && ( - - {language} - + + ) : ( + + + + + + + + + + + )} - + + + + + + + {hasTruncation ? ( + + ) : ( + fileName + )} + + + {!isImage && language && ( + + {language} + + )} + + + {fileSize && {fileSize}} + ); }; diff --git a/packages/module/src/FileDetails/__snapshots__/FileDetails.test.tsx.snap b/packages/module/src/FileDetails/__snapshots__/FileDetails.test.tsx.snap index e2c79dd21..c515f6c7a 100644 --- a/packages/module/src/FileDetails/__snapshots__/FileDetails.test.tsx.snap +++ b/packages/module/src/FileDetails/__snapshots__/FileDetails.test.tsx.snap @@ -40,30 +40,38 @@ exports[`FileDetails should render file details 1`] = `
- - - test + + + test + + - - -
-
- TEXT +
+
+ TEXT +
+
diff --git a/packages/module/src/FileDetailsLabel/FileDetailsLabel.test.tsx b/packages/module/src/FileDetailsLabel/FileDetailsLabel.test.tsx index cc38a832e..a4cfd2985 100644 --- a/packages/module/src/FileDetailsLabel/FileDetailsLabel.test.tsx +++ b/packages/module/src/FileDetailsLabel/FileDetailsLabel.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import FileDetailsLabel from './FileDetailsLabel'; import userEvent from '@testing-library/user-event'; +import { BellIcon } from '@patternfly/react-icons'; describe('FileDetailsLabel', () => { it('should render file details label', () => { @@ -18,6 +19,19 @@ describe('FileDetailsLabel', () => { expect(screen.getByText('test')).toBeTruthy(); expect(screen.queryByTestId('language')).toBeFalsy(); }); + it('should pass file size down', () => { + render(); + expect(screen.getByText('100MB')).toBeTruthy(); + }); + it('should pass truncation prop down as true by default', () => { + render(); + expect(screen.getByText('test')).toBeTruthy(); + expect(screen.queryByText('test.svg')).toBeFalsy(); + }); + it('should pass truncation prop down when false', () => { + render(); + expect(screen.getByText('test.svg')).toBeTruthy(); + }); it('should not show spinner by default', () => { render(); expect(screen.queryByTestId('spinner')).toBeFalsy(); @@ -42,6 +56,12 @@ describe('FileDetailsLabel', () => { }); it('should use closeButtonAriaLabel prop appropriately', () => { render(); - screen.getByRole('button', { name: /Delete file/i }); + expect(screen.getByRole('button', { name: /Delete file/i })).toBeTruthy(); + }); + it('should support custom close icon', () => { + render( + } /> + ); + expect(screen.getByTestId('bell')).toBeTruthy(); }); }); diff --git a/packages/module/src/FileDetailsLabel/FileDetailsLabel.tsx b/packages/module/src/FileDetailsLabel/FileDetailsLabel.tsx index 49f22c4b9..735709999 100644 --- a/packages/module/src/FileDetailsLabel/FileDetailsLabel.tsx +++ b/packages/module/src/FileDetailsLabel/FileDetailsLabel.tsx @@ -4,7 +4,7 @@ import FileDetails from '../FileDetails'; import { Spinner } from '@patternfly/react-core'; import { TimesIcon } from '@patternfly/react-icons'; -interface FileDetailsLabelProps { +export interface FileDetailsLabelProps { /** Name of file, including extension */ fileName: string; /** Unique id of file */ @@ -21,6 +21,12 @@ interface FileDetailsLabelProps { languageTestId?: string; /** Custom test id for the loading spinner in the component */ spinnerTestId?: string; + /** File size */ + fileSize?: string; + /** Whether to truncate file name */ + hasTruncation?: boolean; + /** Icon used for close button */ + closeButtonIcon?: React.ReactNode; } export const FileDetailsLabel = ({ @@ -31,7 +37,11 @@ export const FileDetailsLabel = ({ onClose, closeButtonAriaLabel, languageTestId, - spinnerTestId + spinnerTestId, + fileSize, + hasTruncation = true, + closeButtonIcon = , + ...props }: PropsWithChildren) => { const handleClose = (event) => { onClose && onClose(event, fileName, fileId); @@ -45,17 +55,20 @@ export const FileDetailsLabel = ({ type="button" variant="plain" aria-label={closeButtonAriaLabel ?? `Close ${fileName}`} - icon={} + icon={closeButtonIcon} onClick={handleClose} /> } {...(onClick && { onClick: (event) => onClick(event, fileName, fileId) })} + {...props} >
{isLoading && }
diff --git a/packages/module/src/FileDetailsLabel/__snapshots__/FileDetailsLabel.test.tsx.snap b/packages/module/src/FileDetailsLabel/__snapshots__/FileDetailsLabel.test.tsx.snap index edccc1a47..0851cf36f 100644 --- a/packages/module/src/FileDetailsLabel/__snapshots__/FileDetailsLabel.test.tsx.snap +++ b/packages/module/src/FileDetailsLabel/__snapshots__/FileDetailsLabel.test.tsx.snap @@ -52,30 +52,38 @@ exports[`FileDetailsLabel should render file details label 1`] = `
- - - test + + + test + + - - -
-
- TEXT +
+
+ TEXT +
+
diff --git a/packages/module/src/ImagePreview/ImagePreview.scss b/packages/module/src/ImagePreview/ImagePreview.scss new file mode 100644 index 000000000..629833a5c --- /dev/null +++ b/packages/module/src/ImagePreview/ImagePreview.scss @@ -0,0 +1,61 @@ +.pf-chatbot__image-preview-body { + display: flex; + flex-direction: column; + gap: var(--pf-t--global--spacer--lg); + --pf-v6-c-label--MaxWidth: initial; + --pf-v6-c-modal-box--ZIndex: var(--pf-t--global--z-index--2xl); + + img { + flex: 1 0 0; + align-self: stretch; + } + .pf-chatbot__file-label { + min-width: fit-content; + } +} + +.pf-chatbot__image-preview-stack { + height: unset; +} + +.pf-v6-c-modal-box__footer.pf-chatbot__image-preview-footer { + padding-block-start: var(--pf-t--global--spacer--sm); +} + +.pf-chatbot__image-preview-footer-buttons { + display: flex; + gap: var(--pf-t--global--spacer--xs); + align-items: center; + justify-content: space-between; + flex: 1; + + .pf-v6-c-button { + border-radius: var(--pf-t--global--border--radius--pill); + padding: var(--pf-t--global--spacer--sm); + width: 2.31rem; + height: 2.31rem; + display: flex; + align-items: center; + justify-content: center; + } + button:disabled, + button[disabled] { + .pf-v6-c-icon__content { + color: var(--pf-t--global--icon--color--disabled); + } + } + .pf-v6-c-button__text { + display: flex; + align-items: center; + } + // Interactive states + .pf-v6-c-button:hover, + .pf-v6-c-button:focus { + .pf-v6-c-button__icon { + color: var(--pf-t--global--icon--color--regular); + } + } + .pf-v6-c-button__icon { + color: var(--pf-t--global--icon--color--subtle); + } +} diff --git a/packages/module/src/ImagePreview/ImagePreview.test.tsx b/packages/module/src/ImagePreview/ImagePreview.test.tsx new file mode 100644 index 000000000..c6e00747d --- /dev/null +++ b/packages/module/src/ImagePreview/ImagePreview.test.tsx @@ -0,0 +1,253 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ImagePreview from './ImagePreview'; +import { ChatbotDisplayMode } from '../Chatbot'; + +const mockImages = [ + { + fileName: 'image1.jpg', + fileSize: '2.5 MB', + image: Test image 1 + }, + { + fileName: 'image2.png', + fileSize: '1.8 MB', + image: Test image 2 + }, + { + fileName: 'image3.gif', + image: Test image 3 + } +]; + +const defaultProps = { + isModalOpen: true, + handleModalToggle: jest.fn(), + images: mockImages +}; + +describe('ImagePreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders modal when isModalOpen is true', () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('does not render modal when isModalOpen is false', () => { + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('displays custom title when provided', () => { + const customTitle = 'Custom image preview'; + render(); + expect(screen.getByRole('heading', { name: customTitle })).toBeInTheDocument(); + }); + + it('displays default title when no title provided', () => { + render(); + expect(screen.getByRole('heading', { name: /Preview images/i })).toBeInTheDocument(); + }); + + it('calls handleModalToggle when modal is closed', () => { + const mockHandleToggle = jest.fn(); + render(); + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + expect(mockHandleToggle).toHaveBeenCalledTimes(1); + }); + + it('displays first image by default', () => { + render(); + expect(screen.getByText('image1.jpg')).toBeInTheDocument(); + expect(screen.getByText('2.5 MB')).toBeInTheDocument(); + expect(screen.getByAltText('Test image 1')).toBeInTheDocument(); + }); + + it('displays page counter correctly', () => { + render(); + expect(screen.getByText('1/3')).toBeInTheDocument(); + }); + + it('navigates to next image when next button is clicked', () => { + const mockOnNextClick = jest.fn(); + render(); + const nextButton = screen.getByRole('button', { name: /Go to next page/i }); + fireEvent.click(nextButton); + expect(mockOnNextClick).toHaveBeenCalled(); + expect(screen.getByText('2/3')).toBeInTheDocument(); + expect(screen.getByText('image2.png')).toBeInTheDocument(); + }); + + it('navigates to previous image when previous button is clicked', () => { + const mockOnPreviousClick = jest.fn(); + render(); + // First go to page 2 + const nextButton = screen.getByRole('button', { name: /Go to next page/i }); + fireEvent.click(nextButton); + // Then go back to page 1 + const previousButton = screen.getByRole('button', { name: /Go to previous page/i }); + fireEvent.click(previousButton); + expect(mockOnPreviousClick).toHaveBeenCalled(); + expect(screen.getByText('1/3')).toBeInTheDocument(); + }); + + it('calls onSetPage when page changes', () => { + const mockOnSetPage = jest.fn(); + render(); + const nextButton = screen.getByRole('button', { name: /Go to next page/i }); + fireEvent.click(nextButton); + expect(mockOnSetPage).toHaveBeenCalledWith(expect.any(Object), 2); + }); + + it('disables previous button on first page', () => { + render(); + const previousButton = screen.getByRole('button', { name: /Go to previous page/i }); + expect(previousButton).toBeDisabled(); + }); + + it('disables next button on last page', () => { + render(); + // Navigate to last page + const nextButton = screen.getByRole('button', { name: /Go to next page/i }); + fireEvent.click(nextButton); // page 2 + fireEvent.click(nextButton); // page 3 + expect(nextButton).toBeDisabled(); + }); + + it('disables both navigation buttons when isDisabled is true', () => { + render(); + const previousButton = screen.getByRole('button', { name: /Go to previous page/i }); + const nextButton = screen.getByRole('button', { name: /Go to next page/i }); + expect(previousButton).toBeDisabled(); + expect(nextButton).toBeDisabled(); + }); + + it('uses custom aria labels for pagination', () => { + const customLabels = { + paginationAriaLabel: 'Custom pagination', + toPreviousPageAriaLabel: 'Go to previous image', + toNextPageAriaLabel: 'Go to next image' + }; + + render(); + expect(screen.getByRole('navigation', { name: 'Custom pagination' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go to previous image' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go to next image' })).toBeInTheDocument(); + }); + + it('renders with compact mode when isCompact is true', () => { + render(); + const modal = screen.getByRole('dialog'); + expect(modal).toHaveClass('pf-m-compact'); + }); + + it('applies custom className when provided', () => { + const customClassName = 'custom-image-preview'; + render(); + const modal = screen.getByRole('dialog'); + expect(modal).toHaveClass(customClassName); + }); + + it('applies display mode class correctly', () => { + render(); + const modal = screen.getByRole('dialog'); + expect(modal).toHaveClass('pf-chatbot__image-preview-modal--embedded'); + }); + + it('passes additional props to ChatbotModal', () => { + const modalClass = 'custom-modal-class'; + const additionalProps = { + 'data-testid': 'modal', + className: modalClass + }; + render(); + const modal = screen.getByTestId('modal'); + expect(modal).toBeInTheDocument(); + expect(modal).toBeInTheDocument(); + expect(modal).toHaveClass(modalClass); + }); + + it('passes modalHeaderProps correctly', () => { + const headerClass = 'custom-modal-header-class'; + const headerProps = { + 'data-testid': 'header', + className: headerClass + }; + render(); + expect(screen.getByTestId('header')).toBeInTheDocument(); + expect(screen.getByTestId('header')).toHaveClass(headerClass); + }); + + it('passes modalBodyProps correctly', () => { + const bodyClass = 'custom-modal-body-class'; + const bodyProps = { + 'data-testid': 'body', + className: bodyClass + }; + render(); + expect(screen.getByTestId('body')).toBeInTheDocument(); + expect(screen.getByTestId('body')).toHaveClass(bodyClass); + }); + + it('handles single image without pagination', () => { + const singleImage = [mockImages[0]]; + render(); + expect(screen.queryByText('1/1')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Go to previous page/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Go to next page/i })).not.toBeInTheDocument(); + }); + + it('calls onCloseFileDetailsLabel when file details close button is clicked', () => { + const mockOnClose = jest.fn(); + render(); + const closeButton = screen.getByRole('button', { name: /Close image1.jpg/i }); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('passes fileDetailsLabelProps correctly to FileDetailsLabel', () => { + const customFileDetailsProps = { + 'data-testid': 'custom-file-details' + }; + render(); + expect(screen.getByTestId('custom-file-details')).toBeInTheDocument(); + }); + + it('displays file details for current page when navigating', () => { + render(); + // Initially shows first image details + expect(screen.getByText('image1.jpg')).toBeInTheDocument(); + expect(screen.getByText('2.5 MB')).toBeInTheDocument(); + + // Navigate to second page + const nextButton = screen.getByRole('button', { name: /Go to next page/i }); + fireEvent.click(nextButton); + + // Should now show second image details + expect(screen.getByText('image2.png')).toBeInTheDocument(); + expect(screen.getByText('1.8 MB')).toBeInTheDocument(); + + // Navigate to third page + fireEvent.click(nextButton); + + // Should now show third image details (no file size) + expect(screen.getByText('image3.gif')).toBeInTheDocument(); + expect(screen.queryByText(/MB/)).not.toBeInTheDocument(); + }); + + it('sets hasTruncation to false on FileDetailsLabel', () => { + const longFileName = 'very-long-filename-that-would-normally-be-truncated-in-other-contexts.jpg'; + const imageWithLongName = { + fileName: longFileName, + fileSize: '1.0 MB', + image: Test image with long name + }; + render(); + expect(screen.getByText(longFileName)).toBeInTheDocument(); + }); +}); diff --git a/packages/module/src/ImagePreview/ImagePreview.tsx b/packages/module/src/ImagePreview/ImagePreview.tsx new file mode 100644 index 000000000..a23bbacd0 --- /dev/null +++ b/packages/module/src/ImagePreview/ImagePreview.tsx @@ -0,0 +1,183 @@ +import { + Button, + ButtonVariant, + Icon, + ModalBody, + ModalBodyProps, + ModalFooter, + ModalHeader, + ModalHeaderProps, + Stack, + StackItem +} from '@patternfly/react-core'; +import { + useState, + type FunctionComponent, + MouseEvent as ReactMouseEvent, + KeyboardEvent as ReactKeyboardEvent +} from 'react'; +import { ChatbotDisplayMode } from '../Chatbot'; +import ChatbotModal, { ChatbotModalProps } from '../ChatbotModal'; +import FileDetailsLabel, { FileDetailsLabelProps } from '../FileDetailsLabel'; +import { TrashIcon } from '@patternfly/react-icons'; + +export interface ImagePreviewProps extends Omit { + /** Class applied to modal */ + className?: string; + /** Function that handles modal toggle */ + handleModalToggle: (event: React.MouseEvent | MouseEvent | KeyboardEvent) => void; + /** Whether modal is open */ + isModalOpen: boolean; + /** Title of modal */ + title?: string; + /** Display mode for the Chatbot parent; this influences the styles applied */ + displayMode?: ChatbotDisplayMode; + /** Sets modal to compact styling. */ + isCompact?: boolean; + /** Additional props passed to modal header */ + modalHeaderProps?: ModalHeaderProps; + /** Additional props passed to modal body */ + modalBodyProps?: ModalBodyProps; + /** Images displayed in modal */ + images: { fileName: string; fileSize?: string; image: React.ReactNode }[]; + /** Flag indicating if the pagination is disabled. */ + isDisabled?: boolean; + /** Accessible label for the pagination component. */ + paginationAriaLabel?: string; + /** Accessible label for the button which moves to the next page. */ + toNextPageAriaLabel?: string; + /** Accessible label for the button which moves to the previous page. */ + toPreviousPageAriaLabel?: string; + /** Function called when user clicks to navigate to next page. */ + onNextClick?: (event: React.SyntheticEvent, page: number) => void; + /** Function called when user clicks to navigate to previous page. */ + onPreviousClick?: (event: React.SyntheticEvent, page: number) => void; + /** Function called when page is changed. */ + onSetPage?: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent, newPage: number) => void; + /** Callback function for when file details label close button is clicked */ + onCloseFileDetailsLabel?: (event: React.MouseEvent, fileName: string, fileId?: string | number) => void; + /** Props passed to file details label */ + fileDetailsLabelProps?: Omit; +} + +const ImagePreview: FunctionComponent = ({ + isModalOpen, + displayMode = ChatbotDisplayMode.default, + isCompact, + className, + handleModalToggle, + title = 'Preview images', + modalHeaderProps, + modalBodyProps, + images, + isDisabled, + onSetPage, + onPreviousClick, + toNextPageAriaLabel = 'Go to next page', + toPreviousPageAriaLabel = 'Go to previous page', + onNextClick, + paginationAriaLabel, + onCloseFileDetailsLabel, + fileDetailsLabelProps, + ...props +}: ImagePreviewProps) => { + const [page, setPage] = useState(1); + + const handleNewPage = (_evt: ReactMouseEvent | ReactKeyboardEvent | MouseEvent, newPage: number) => { + setPage(newPage); + onSetPage && onSetPage(_evt, newPage); + }; + + return ( + + + + + + } + {...fileDetailsLabelProps} + /> + + +
{images[page - 1].image}
+
+
+
+ {images.length > 1 && ( + + + + )} +
+ ); +}; + +export default ImagePreview; diff --git a/packages/module/src/ImagePreview/index.ts b/packages/module/src/ImagePreview/index.ts new file mode 100644 index 000000000..1b79ce184 --- /dev/null +++ b/packages/module/src/ImagePreview/index.ts @@ -0,0 +1,3 @@ +export { default } from './ImagePreview'; + +export * from './ImagePreview'; diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index f453819e6..cc4b75c38 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -57,6 +57,9 @@ export * from './FileDropZone'; export { default as FilePreview } from './FilePreview'; export * from './FilePreview'; +export { default as ImagePreview } from './ImagePreview'; +export * from './ImagePreview'; + export { default as LoadingMessage } from './LoadingMessage'; export * from './LoadingMessage'; diff --git a/packages/module/src/main.scss b/packages/module/src/main.scss index ec8beb743..8410ef024 100644 --- a/packages/module/src/main.scss +++ b/packages/module/src/main.scss @@ -16,6 +16,7 @@ @import './FileDetailsLabel/FileDetailsLabel'; @import './FileDropZone/FileDropZone'; @import './FilePreview/FilePreview.scss'; +@import './ImagePreview/ImagePreview'; @import './Message/Message'; @import './Message/CodeBlockMessage/CodeBlockMessage'; @import './Message/ImageMessage/ImageMessage'; From 7e0ff1f5096d79a0b445e7ee9ca08fb4b599a26b Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Thu, 4 Sep 2025 12:36:28 -0400 Subject: [PATCH 2/4] Fix bug --- .../module/src/ImagePreview/ImagePreview.tsx | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/packages/module/src/ImagePreview/ImagePreview.tsx b/packages/module/src/ImagePreview/ImagePreview.tsx index a23bbacd0..4da08f36d 100644 --- a/packages/module/src/ImagePreview/ImagePreview.tsx +++ b/packages/module/src/ImagePreview/ImagePreview.tsx @@ -12,6 +12,7 @@ import { } from '@patternfly/react-core'; import { useState, + useEffect, type FunctionComponent, MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent @@ -83,6 +84,12 @@ const ImagePreview: FunctionComponent = ({ }: ImagePreviewProps) => { const [page, setPage] = useState(1); + useEffect(() => { + if (images.length === 0 || page > images.length) { + setPage(1); + } + }, [images.length, page]); + const handleNewPage = (_evt: ReactMouseEvent | ReactKeyboardEvent | MouseEvent, newPage: number) => { setPage(newPage); onSetPage && onSetPage(_evt, newPage); @@ -99,21 +106,23 @@ const ImagePreview: FunctionComponent = ({ > - - - } - {...fileDetailsLabelProps} - /> - - -
{images[page - 1].image}
-
-
+ {images.length > 0 && images[page - 1] && ( + + + } + {...fileDetailsLabelProps} + /> + + +
{images[page - 1].image}
+
+
+ )}
{images.length > 1 && ( @@ -123,9 +132,9 @@ const ImagePreview: FunctionComponent = ({ isDisabled={isDisabled || page === 1} data-action="previous" onClick={(event) => { - const newPage = page >= 1 ? page - 1 : 1; - onPreviousClick && onPreviousClick(event, newPage); + const newPage = page > 1 ? page - 1 : 1; handleNewPage(event, newPage); + onPreviousClick && onPreviousClick(event, newPage); }} aria-label={toPreviousPageAriaLabel} > @@ -154,8 +163,8 @@ const ImagePreview: FunctionComponent = ({ data-action="next" onClick={(event) => { const newPage = page + 1 <= images.length ? page + 1 : images.length; - onNextClick && onNextClick(event, newPage); handleNewPage(event, newPage); + onNextClick && onNextClick(event, newPage); }} > From 834b23c45a4da975bea74546493b30ab3aa1759b Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Fri, 5 Sep 2025 10:03:02 -0400 Subject: [PATCH 3/4] Address Eric feedback --- .../examples/Messages/AttachmentEdit.tsx | 2 +- .../examples/Messages/ImagePreview.tsx | 6 ++--- .../examples/Messages/PreviewAttachment.tsx | 2 +- .../ChatbotConversationHistoryNav.scss | 12 ---------- .../ChatbotConversationHistoryNav.tsx | 2 +- .../module/src/FileDetails/FileDetails.tsx | 18 +++++++++++++-- .../__snapshots__/FileDetails.test.tsx.snap | 1 + .../FileDetailsLabel.test.tsx.snap | 1 + .../src/ImagePreview/ImagePreview.test.tsx | 22 +++++++++---------- .../module/src/ImagePreview/ImagePreview.tsx | 18 ++++++++++----- .../module/src/MessageBox/MessageBox.scss | 12 ---------- packages/module/src/MessageBox/MessageBox.tsx | 2 +- packages/module/src/main.scss | 12 ++++++++++ 13 files changed, 61 insertions(+), 49 deletions(-) diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/AttachmentEdit.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/AttachmentEdit.tsx index 4a3101f70..18412b34b 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/AttachmentEdit.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/AttachmentEdit.tsx @@ -19,7 +19,7 @@ export const AttachmentEditModalExample: FunctionComponent = () => { id="modal-compact-edit" name="modal-compact-edit" > - + { id="modal-compact-image-preview" name="modal-compact-image-preview" > - + { hasNav ? /* eslint-disable indent */ [ - { fileName: 'image1.png', fileSize: '134KB', image: One }, - { fileName: 'image2.png', fileSize: '134KB', image: Two } + { fileName: 'image1.png', fileSize: '134KB', image: Preview one }, + { fileName: 'image2.png', fileSize: '134KB', image: Preview two } ] : [{ fileName: 'image.png', fileSize: '134KB', image: One }] /* eslint-enable indent */ diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/PreviewAttachment.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/PreviewAttachment.tsx index 26b6475af..22a4c0eea 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/PreviewAttachment.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/PreviewAttachment.tsx @@ -19,7 +19,7 @@ export const PreviewAttachmentExample: FunctionComponent = () => { id="modal-compact-preview" name="modal-compact-preview" > - + {searchInputScreenReaderText && ( -
{searchInputScreenReaderText}
+
{searchInputScreenReaderText}
)} )} diff --git a/packages/module/src/FileDetails/FileDetails.tsx b/packages/module/src/FileDetails/FileDetails.tsx index e1d44aad2..0e604d1eb 100644 --- a/packages/module/src/FileDetails/FileDetails.tsx +++ b/packages/module/src/FileDetails/FileDetails.tsx @@ -975,7 +975,14 @@ export const FileDetails = ({ alignSelf={{ default: 'alignSelfCenter' }} > {isImage ? ( - + ) : ( - +
- + {paginationText} +
+ {screenreaderText ?? `Image ${page} of ${images.length}`} +