Skip to content

Commit 49b998a

Browse files
feat(ImagePreview): Add image preview modal (#665)
Co-authored-by: Erin Donehoo <[email protected]>
1 parent 82496f7 commit 49b998a

File tree

22 files changed

+812
-97
lines changed

22 files changed

+812
-97
lines changed

packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/AttachmentEdit.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const AttachmentEditModalExample: FunctionComponent = () => {
1919
id="modal-compact-edit"
2020
name="modal-compact-edit"
2121
></Checkbox>
22-
<Button onClick={handleModalToggle}>Launch modal</Button>
22+
<Button onClick={handleModalToggle}>Launch attachment edit modal</Button>
2323
<AttachmentEdit
2424
code="I am a code snippet"
2525
fileName="test.yaml"
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useState, FunctionComponent, MouseEvent as ReactMouseEvent } from 'react';
2+
import { Button, Checkbox } from '@patternfly/react-core';
3+
import ImagePreview from '@patternfly/chatbot/dist/dynamic/ImagePreview';
4+
import filePreview from './file-preview.svg';
5+
6+
export const AttachmentEditModalExample: FunctionComponent = () => {
7+
const [isModalOpen, setIsModalOpen] = useState(false);
8+
const [isCompact, setIsCompact] = useState(false);
9+
const [hasNav, setHasNav] = useState(false);
10+
11+
const handleModalToggle = (_event: ReactMouseEvent | MouseEvent | KeyboardEvent) => {
12+
setIsModalOpen(!isModalOpen);
13+
};
14+
15+
return (
16+
<>
17+
<Checkbox
18+
label="Show multiple images"
19+
isChecked={hasNav}
20+
onChange={() => setHasNav(!hasNav)}
21+
id="modal-compact-image-has-nav"
22+
name="modal-compact-image-has-nav"
23+
></Checkbox>
24+
<Checkbox
25+
label="Show compact version"
26+
isChecked={isCompact}
27+
onChange={() => setIsCompact(!isCompact)}
28+
id="modal-compact-image-preview"
29+
name="modal-compact-image-preview"
30+
></Checkbox>
31+
<Button onClick={handleModalToggle}>Launch image preview modal</Button>
32+
<ImagePreview
33+
isModalOpen={isModalOpen}
34+
handleModalToggle={handleModalToggle}
35+
isCompact={isCompact}
36+
onCloseFileDetailsLabel={() => {
37+
// eslint-disable-next-line no-console
38+
console.log('Clicked close button');
39+
}}
40+
images={
41+
hasNav
42+
? /* eslint-disable indent */
43+
[
44+
{ fileName: 'image1.png', fileSize: '134KB', image: <img src={filePreview} alt="Preview one" /> },
45+
{ fileName: 'image2.png', fileSize: '134KB', image: <img src={filePreview} alt="Preview two" /> }
46+
]
47+
: [{ fileName: 'image.png', fileSize: '134KB', image: <img src={filePreview} alt="One" /> }]
48+
/* eslint-enable indent */
49+
}
50+
></ImagePreview>
51+
</>
52+
);
53+
};

packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import userAvatar from './user_avatar.svg';
4848
import squareImg from './PF-social-color-square.svg';
4949
import { CSSProperties, useState, Fragment, FunctionComponent, MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent, Ref, isValidElement, cloneElement, Children, ReactNode, useRef, useEffect } from 'react';
5050
import FilePreview from '@patternfly/chatbot/dist/dynamic/FilePreview';
51+
import ImagePreview from '@patternfly/chatbot/dist/dynamic/ImagePreview';
52+
import filePreview from './file-preview.svg';
5153

5254
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)
5355

@@ -189,7 +191,7 @@ If you are using [model context protocol (MCP)](https://www.redhat.com/en/blog/m
189191

190192
### Messages with deep thinking
191193

192-
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 `<Message>` and provide a subheading (optional) and content body.
194+
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 `<Message>` and provide a subheading (optional) and content body.
193195

194196
Because this is an evolving area, this card content is currently fully customizable.
195197

@@ -274,6 +276,14 @@ To allow users to edit an attached file, load a new code editor within the ChatB
274276

275277
```
276278

279+
### Image preview
280+
281+
To allow users to preview images, load a modal that contains a view of the file name, file size, and the image. Users can toggle between multiple images by using pagination controls at the bottom of the modal. Return users to the main ChatBot window once they close the modal.
282+
283+
```js file="./ImagePreview.tsx"
284+
285+
```
286+
277287
### File preview
278288

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

packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/PreviewAttachment.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const PreviewAttachmentExample: FunctionComponent = () => {
1919
id="modal-compact-preview"
2020
name="modal-compact-preview"
2121
></Checkbox>
22-
<Button onClick={handleModalToggle}>Launch modal</Button>
22+
<Button onClick={handleModalToggle}>Launch attachment preview modal</Button>
2323
<PreviewAttachment
2424
code="I am a code snippet"
2525
fileName="test.yaml"

packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/file-preview.svg

Lines changed: 9 additions & 0 deletions
Loading

packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,6 @@
22
// Chatbot Header - Menu
33
// ============================================================================
44
.pf-chatbot__history {
5-
// hide from view but not assistive technologies
6-
// https://css-tricks.com/inclusively-hidden/
7-
.pf-chatbot__filter-announcement {
8-
clip: rect(0 0 0 0);
9-
clip-path: inset(50%);
10-
height: 1px;
11-
overflow: hidden;
12-
position: absolute;
13-
white-space: nowrap;
14-
width: 1px;
15-
}
16-
175
.pf-chatbot__drawer-backdrop {
186
position: absolute;
197
border-radius: var(--pf-t--global--border--radius--medium);

packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ export const ChatbotConversationHistoryNav: FunctionComponent<ChatbotConversatio
327327
{...searchInputProps}
328328
/>
329329
{searchInputScreenReaderText && (
330-
<div className="pf-chatbot__filter-announcement">{searchInputScreenReaderText}</div>
330+
<div className="pf-chatbot__filter-announcement pf-chatbot-m-hidden">{searchInputScreenReaderText}</div>
331331
)}
332332
</div>
333333
)}

packages/module/src/FileDetails/FileDetails.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,20 @@
1111
height: 24px;
1212
}
1313

14+
.pf-chatbot__image-icon {
15+
color: var(--pf-t--global--icon--color--status--info--default);
16+
width: 24px;
17+
height: 24px;
18+
}
19+
1420
.pf-chatbot__code-fileName {
1521
font-size: var(--pf-t--global--font--size--body--default);
1622
}
1723

24+
.pf-chatbot__code-file-size {
25+
color: var(--pf-t--global--text--color--subtle);
26+
}
27+
1828
// This is used in demos only
1929
.pf-chatbot__file-details-example {
2030
background: var(--pf-t--global--background--color--secondary--default);

packages/module/src/FileDetails/FileDetails.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ describe('FileDetails', () => {
1111
it('should render file details correctly if an extension we support is passed in', () => {
1212
render(<FileDetails fileName="test.txt" languageTestId="language" />);
1313
expect(screen.getByText('test')).toBeTruthy();
14+
expect(screen.queryByText('test.txt')).toBeFalsy();
1415
expect(screen.getByText('TEXT')).toBeTruthy();
1516
expect(screen.getByTestId('language')).toBeTruthy();
1617
});
@@ -19,4 +20,19 @@ describe('FileDetails', () => {
1920
expect(screen.getByText('test')).toBeTruthy();
2021
expect(screen.queryByTestId('language')).toBeFalsy();
2122
});
23+
it('should support image formats by rendering extension differently', () => {
24+
render(<FileDetails fileName="test.svg" languageTestId="language" />);
25+
expect(screen.getByText('test')).toBeTruthy();
26+
expect(screen.queryByText('test.svg')).toBeFalsy();
27+
expect(screen.queryByTestId('language')).toBeFalsy();
28+
});
29+
it('should handle truncation differently', () => {
30+
render(<FileDetails fileName="test.svg" languageTestId="language" hasTruncation={false} />);
31+
expect(screen.getByText('test.svg')).toBeTruthy();
32+
expect(screen.queryByTestId('language')).toBeFalsy();
33+
});
34+
it('should include file size if prop passed in', () => {
35+
render(<FileDetails fileName="test.joke" languageTestId="language" fileSize="100MB" />);
36+
expect(screen.getByText('100MB')).toBeTruthy();
37+
});
2238
});

packages/module/src/FileDetails/FileDetails.tsx

Lines changed: 89 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { PropsWithChildren } from 'react';
2-
import { Flex, Stack, StackItem, Truncate } from '@patternfly/react-core';
2+
import { Flex, FlexItem, Truncate } from '@patternfly/react-core';
33
import path from 'path-browserify';
4-
interface FileDetailsProps {
4+
export interface FileDetailsProps {
55
/** Class name applied to container */
66
className?: string;
77
/** Name of file, including extension */
@@ -10,8 +10,24 @@ interface FileDetailsProps {
1010
languageTestId?: string;
1111
/** Class name applied to file name */
1212
fileNameClassName?: string;
13+
/** File size */
14+
fileSize?: string;
15+
/** Whether to truncate file name */
16+
hasTruncation?: boolean;
1317
}
1418

19+
// manually added for image modal
20+
// based on https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types
21+
export const IMAGE_FORMATS = {
22+
png: 'PNG',
23+
apng: 'APNG',
24+
avif: 'AVIF',
25+
gif: 'GIF',
26+
jpg: 'JPEG',
27+
svg: 'SVG',
28+
webp: 'WebP'
29+
};
30+
1531
// source https://gist.github.com/ppisarczyk/43962d06686722d26d176fad46879d41
1632
// FIXME We could probably check against the PF Language hash to trim this down to what the code editor supports.
1733
// 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 = {
678694
viw: 'SQL',
679695
db2: 'SQLPL',
680696
ston: 'STON',
681-
svg: 'SVG',
682697
sage: 'Sage',
683698
sagews: 'Sage',
684699
sls: 'Scheme',
@@ -935,54 +950,96 @@ export const extensionToLanguage = {
935950
ppt: 'Presentation',
936951
pptx: 'Presentation',
937952
odp: 'Presentation',
938-
pdf: 'PDF'
953+
pdf: 'PDF',
954+
// manually added for image modal
955+
// based on https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types
956+
...IMAGE_FORMATS
939957
};
940958

941959
export const FileDetails = ({
942960
className,
943961
fileName,
944962
fileNameClassName,
945-
languageTestId
963+
languageTestId,
964+
fileSize,
965+
hasTruncation = true
946966
}: PropsWithChildren<FileDetailsProps>) => {
947967
const language = extensionToLanguage[path.extname(fileName).slice(1)]?.toUpperCase();
968+
const isImage = IMAGE_FORMATS[path.extname(fileName).slice(1)] ? true : false;
948969
return (
949970
<Flex className={`pf-chatbot__file-details ${className ? className : ''}`} gap={{ default: 'gapSm' }}>
950971
<Flex
951-
className="pf-chatbot__code-icon"
972+
className={`${isImage ? 'pf-chatbot__image-icon' : 'pf-chatbot__code-icon'}`}
952973
justifyContent={{ default: 'justifyContentCenter' }}
953974
alignItems={{ default: 'alignItemsCenter' }}
954975
alignSelf={{ default: 'alignSelfCenter' }}
955976
>
956-
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
957-
<path
958-
d="M0 4C0 1.79086 1.79086 0 4 0H20C22.2091 0 24 1.79086 24 4V20C24 22.2091 22.2091 24 20 24H4C1.79086 24 0 22.2091 0 20V4Z"
959-
fill="currentColor"
960-
/>
961-
<g clipPath="url(#clip0_3280_27505)">
977+
{isImage ? (
978+
<svg
979+
aria-hidden="true"
980+
xmlns="http://www.w3.org/2000/svg"
981+
width="24"
982+
height="25"
983+
viewBox="0 0 24 25"
984+
fill="none"
985+
>
986+
<path
987+
d="M0 4.5C0 2.29086 1.79086 0.5 4 0.5H20C22.2091 0.5 24 2.29086 24 4.5V20.5C24 22.7091 22.2091 24.5 20 24.5H4C1.79086 24.5 0 22.7091 0 20.5V4.5Z"
988+
fill="currentColor"
989+
/>
962990
<path
963-
d="M13.8204 5.63002C13.3954 5.50752 12.9529 5.75502 12.8304 6.18002L9.63035 17.38C9.50785 17.805 9.75535 18.2475 10.1804 18.37C10.6054 18.4925 11.0479 18.245 11.1704 17.82L14.3704 6.62002C14.4929 6.19502 14.2454 5.75252 13.8204 5.63002ZM15.8354 8.63252C15.5229 8.94502 15.5229 9.45252 15.8354 9.76502L18.0679 12L15.8329 14.235C15.5204 14.5475 15.5204 15.055 15.8329 15.3675C16.1454 15.68 16.6529 15.68 16.9654 15.3675L19.7654 12.5675C20.0779 12.255 20.0779 11.7475 19.7654 11.435L16.9654 8.63502C16.6529 8.32252 16.1454 8.32252 15.8329 8.63502L15.8354 8.63252ZM8.16785 8.63252C7.85535 8.32002 7.34785 8.32002 7.03535 8.63252L4.23535 11.4325C3.92285 11.745 3.92285 12.2525 4.23535 12.565L7.03535 15.365C7.34785 15.6775 7.85535 15.6775 8.16785 15.365C8.48035 15.0525 8.48035 14.545 8.16785 14.2325L5.93285 12L8.16785 9.76502C8.48035 9.45252 8.48035 8.94502 8.16785 8.63252Z"
991+
d="M4 7.5C4 6.39688 4.89688 5.5 6 5.5H18C19.1031 5.5 20 6.39688 20 7.5V17.5C20 18.6031 19.1031 19.5 18 19.5H6C4.89688 19.5 4 18.6031 4 17.5V7.5ZM14.1187 10.8281C13.9781 10.6219 13.7469 10.5 13.5 10.5C13.2531 10.5 13.0188 10.6219 12.8813 10.8281L10.1625 14.8156L9.33437 13.7812C9.19062 13.6031 8.975 13.5 8.75 13.5C8.525 13.5 8.30625 13.6031 8.16563 13.7812L6.16563 16.2812C5.98438 16.5063 5.95 16.8156 6.075 17.075C6.2 17.3344 6.4625 17.5 6.75 17.5H9.75H10.75H17.25C17.5281 17.5 17.7844 17.3469 17.9125 17.1C18.0406 16.8531 18.025 16.5562 17.8687 16.3281L14.1187 10.8281ZM7.5 10.5C7.89782 10.5 8.27936 10.342 8.56066 10.0607C8.84196 9.77936 9 9.39782 9 9C9 8.60218 8.84196 8.22064 8.56066 7.93934C8.27936 7.65804 7.89782 7.5 7.5 7.5C7.10218 7.5 6.72064 7.65804 6.43934 7.93934C6.15804 8.22064 6 8.60218 6 9C6 9.39782 6.15804 9.77936 6.43934 10.0607C6.72064 10.342 7.10218 10.5 7.5 10.5Z"
964992
fill="white"
965993
/>
966-
</g>
967-
<defs>
968-
<clipPath>
969-
<rect width="16" height="12.8" fill="white" transform="translate(4 5.60001)" />
970-
</clipPath>
971-
</defs>
972-
</svg>
973-
</Flex>
974-
<Stack>
975-
<StackItem>
976-
<span className="pf-chatbot__code-fileName">
977-
<Truncate className={fileNameClassName} content={path.parse(fileName).name} />
978-
</span>
979-
</StackItem>
980-
{language && (
981-
<StackItem data-testid={languageTestId} className="pf-chatbot__code-language">
982-
{language}
983-
</StackItem>
994+
</svg>
995+
) : (
996+
<svg
997+
aria-hidden="true"
998+
width="24"
999+
height="24"
1000+
viewBox="0 0 24 24"
1001+
fill="currentColor"
1002+
xmlns="http://www.w3.org/2000/svg"
1003+
>
1004+
<path
1005+
d="M0 4C0 1.79086 1.79086 0 4 0H20C22.2091 0 24 1.79086 24 4V20C24 22.2091 22.2091 24 20 24H4C1.79086 24 0 22.2091 0 20V4Z"
1006+
fill="currentColor"
1007+
/>
1008+
<g clipPath="url(#clip0_3280_27505)">
1009+
<path
1010+
d="M13.8204 5.63002C13.3954 5.50752 12.9529 5.75502 12.8304 6.18002L9.63035 17.38C9.50785 17.805 9.75535 18.2475 10.1804 18.37C10.6054 18.4925 11.0479 18.245 11.1704 17.82L14.3704 6.62002C14.4929 6.19502 14.2454 5.75252 13.8204 5.63002ZM15.8354 8.63252C15.5229 8.94502 15.5229 9.45252 15.8354 9.76502L18.0679 12L15.8329 14.235C15.5204 14.5475 15.5204 15.055 15.8329 15.3675C16.1454 15.68 16.6529 15.68 16.9654 15.3675L19.7654 12.5675C20.0779 12.255 20.0779 11.7475 19.7654 11.435L16.9654 8.63502C16.6529 8.32252 16.1454 8.32252 15.8329 8.63502L15.8354 8.63252ZM8.16785 8.63252C7.85535 8.32002 7.34785 8.32002 7.03535 8.63252L4.23535 11.4325C3.92285 11.745 3.92285 12.2525 4.23535 12.565L7.03535 15.365C7.34785 15.6775 7.85535 15.6775 8.16785 15.365C8.48035 15.0525 8.48035 14.545 8.16785 14.2325L5.93285 12L8.16785 9.76502C8.48035 9.45252 8.48035 8.94502 8.16785 8.63252Z"
1011+
fill="white"
1012+
/>
1013+
</g>
1014+
<defs>
1015+
<clipPath>
1016+
<rect width="16" height="12.8" fill="white" transform="translate(4 5.60001)" />
1017+
</clipPath>
1018+
</defs>
1019+
</svg>
9841020
)}
985-
</Stack>
1021+
</Flex>
1022+
<Flex gap={{ default: 'gapXs' }}>
1023+
<FlexItem>
1024+
<Flex direction={{ default: 'column' }} gap={{ default: 'gapNone' }}>
1025+
<FlexItem>
1026+
<span className="pf-chatbot__code-fileName">
1027+
{hasTruncation ? (
1028+
<Truncate className={fileNameClassName} content={path.parse(fileName).name} />
1029+
) : (
1030+
fileName
1031+
)}
1032+
</span>
1033+
</FlexItem>
1034+
{!isImage && language && (
1035+
<FlexItem data-testid={languageTestId} className="pf-chatbot__code-language">
1036+
{language}
1037+
</FlexItem>
1038+
)}
1039+
</Flex>
1040+
</FlexItem>
1041+
{fileSize && <FlexItem className="pf-chatbot__code-file-size">{fileSize}</FlexItem>}
1042+
</Flex>
9861043
</Flex>
9871044
);
9881045
};

0 commit comments

Comments
 (0)