@patternfly/chatbot 6.3.0-prerelease.21 → 6.3.0-prerelease.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/cjs/FileDropZone/FileDropZone.d.ts +15 -1
  2. package/dist/cjs/FileDropZone/FileDropZone.js +7 -2
  3. package/dist/cjs/FileDropZone/FileDropZone.test.js +55 -0
  4. package/dist/cjs/MessageBar/AttachButton.d.ts +18 -1
  5. package/dist/cjs/MessageBar/AttachButton.js +4 -6
  6. package/dist/cjs/MessageBar/AttachButton.test.js +54 -0
  7. package/dist/cjs/MessageBar/MessageBar.d.ts +23 -7
  8. package/dist/cjs/MessageBar/MessageBar.js +2 -2
  9. package/dist/cjs/SourcesCard/SourcesCard.d.ts +10 -1
  10. package/dist/cjs/SourcesCard/SourcesCard.js +2 -1
  11. package/dist/cjs/SourcesCard/SourcesCard.test.js +10 -0
  12. package/dist/esm/FileDropZone/FileDropZone.d.ts +15 -1
  13. package/dist/esm/FileDropZone/FileDropZone.js +7 -2
  14. package/dist/esm/FileDropZone/FileDropZone.test.js +55 -0
  15. package/dist/esm/MessageBar/AttachButton.d.ts +18 -1
  16. package/dist/esm/MessageBar/AttachButton.js +4 -6
  17. package/dist/esm/MessageBar/AttachButton.test.js +54 -0
  18. package/dist/esm/MessageBar/MessageBar.d.ts +23 -7
  19. package/dist/esm/MessageBar/MessageBar.js +2 -2
  20. package/dist/esm/SourcesCard/SourcesCard.d.ts +10 -1
  21. package/dist/esm/SourcesCard/SourcesCard.js +2 -1
  22. package/dist/esm/SourcesCard/SourcesCard.test.js +10 -0
  23. package/package.json +1 -1
  24. package/patternfly-docs/content/extensions/chatbot/examples/demos/ChatbotAttachment.tsx +19 -1
  25. package/patternfly-docs/content/extensions/chatbot/examples/demos/ChatbotAttachmentMenu.tsx +1 -0
  26. package/src/FileDropZone/FileDropZone.test.tsx +83 -0
  27. package/src/FileDropZone/FileDropZone.tsx +32 -2
  28. package/src/MessageBar/AttachButton.test.tsx +75 -0
  29. package/src/MessageBar/AttachButton.tsx +35 -2
  30. package/src/MessageBar/MessageBar.tsx +47 -7
  31. package/src/SourcesCard/SourcesCard.test.tsx +14 -0
  32. package/src/SourcesCard/SourcesCard.tsx +12 -0
@@ -1,5 +1,5 @@
1
1
  import type { FunctionComponent } from 'react';
2
- import { Accept } from 'react-dropzone/.';
2
+ import { Accept, DropzoneOptions, FileError, FileRejection } from 'react-dropzone/.';
3
3
  import { ButtonProps, DropEvent, TextAreaProps, TooltipProps } from '@patternfly/react-core';
4
4
  import { ChatbotDisplayMode } from '../Chatbot';
5
5
  export interface MessageBarWithAttachMenuProps {
@@ -43,6 +43,28 @@ export interface MessageBarProps extends Omit<TextAreaProps, 'innerRef'> {
43
43
  handleStopButton?: (event: React.MouseEvent<HTMLButtonElement>) => void;
44
44
  /** Callback function for when attach button is used to upload a file */
45
45
  handleAttach?: (data: File[], event: DropEvent) => void;
46
+ /** Specifies the file types accepted by the attachment upload component.
47
+ * Files that don't match the accepted types will be disabled in the file picker.
48
+ * For example,
49
+ * allowedFileTypes: { 'application/json': ['.json'], 'text/plain': ['.txt'] }
50
+ **/
51
+ allowedFileTypes?: Accept;
52
+ /** Minimum file size allowed */
53
+ minSize?: number;
54
+ /** Max file size allowed */
55
+ maxSize?: number;
56
+ /** Max number of files allowed */
57
+ maxFiles?: number;
58
+ /** Whether attachments are disabled */
59
+ isAttachmentDisabled?: boolean;
60
+ /** Callback when file(s) are attached */
61
+ onAttach?: <T extends File>(acceptedFiles: T[], fileRejections: FileRejection[], event: DropEvent) => void;
62
+ /** Callback function for AttachButton when an attachment fails */
63
+ onAttachRejected?: (fileRejections: FileRejection[], event: DropEvent) => void;
64
+ /** Validator for files; see https://react-dropzone.js.org/#!/Custom%20validation for more information */
65
+ validator?: <T extends File>(file: T) => FileError | readonly FileError[] | null;
66
+ /** Additional props passed to react-dropzone */
67
+ dropzoneProps?: DropzoneOptions;
46
68
  /** Props to enable a menu that opens when the Attach button is clicked, instead of the attachment window */
47
69
  attachMenuProps?: MessageBarWithAttachMenuProps;
48
70
  /** Flag to provide manual control over whether send button is disabled */
@@ -81,12 +103,6 @@ export interface MessageBarProps extends Omit<TextAreaProps, 'innerRef'> {
81
103
  displayMode?: ChatbotDisplayMode;
82
104
  /** Whether message bar is compact */
83
105
  isCompact?: boolean;
84
- /** Specifies the file types accepted by the attachment upload component.
85
- * Files that don't match the accepted types will be disabled in the file picker.
86
- * For example,
87
- * allowedFileTypes: { 'application/json': ['.json'], 'text/plain': ['.txt'] }
88
- **/
89
- allowedFileTypes?: Accept;
90
106
  /** Ref applied to message bar textarea, for use with focus or other custom behaviors */
91
107
  innerRef?: React.Ref<HTMLTextAreaElement>;
92
108
  }
@@ -20,7 +20,7 @@ import AttachMenu from '../AttachMenu';
20
20
  import StopButton from './StopButton';
21
21
  export const MessageBarBase = (_a) => {
22
22
  var _b;
23
- var { onSendMessage, className, alwayShowSendButton, placeholder = 'Send a message...', hasAttachButton = true, hasMicrophoneButton, listeningText = 'Listening', handleAttach, attachMenuProps, isSendButtonDisabled, handleStopButton, hasStopButton, buttonProps, onChange, displayMode, value, isCompact = false, allowedFileTypes, innerRef } = _a, props = __rest(_a, ["onSendMessage", "className", "alwayShowSendButton", "placeholder", "hasAttachButton", "hasMicrophoneButton", "listeningText", "handleAttach", "attachMenuProps", "isSendButtonDisabled", "handleStopButton", "hasStopButton", "buttonProps", "onChange", "displayMode", "value", "isCompact", "allowedFileTypes", "innerRef"]);
23
+ var { onSendMessage, className, alwayShowSendButton, placeholder = 'Send a message...', hasAttachButton = true, hasMicrophoneButton, listeningText = 'Listening', handleAttach, attachMenuProps, isSendButtonDisabled, handleStopButton, hasStopButton, buttonProps, onChange, displayMode, value, isCompact = false, allowedFileTypes, minSize, maxSize, maxFiles, isAttachmentDisabled, onAttach, onAttachRejected, validator, dropzoneProps, innerRef } = _a, props = __rest(_a, ["onSendMessage", "className", "alwayShowSendButton", "placeholder", "hasAttachButton", "hasMicrophoneButton", "listeningText", "handleAttach", "attachMenuProps", "isSendButtonDisabled", "handleStopButton", "hasStopButton", "buttonProps", "onChange", "displayMode", "value", "isCompact", "allowedFileTypes", "minSize", "maxSize", "maxFiles", "isAttachmentDisabled", "onAttach", "onAttachRejected", "validator", "dropzoneProps", "innerRef"]);
24
24
  // Text Input
25
25
  // --------------------------------------------------------------------------
26
26
  const [message, setMessage] = useState(value !== null && value !== void 0 ? value : '');
@@ -176,7 +176,7 @@ export const MessageBarBase = (_a) => {
176
176
  if (hasStopButton && handleStopButton) {
177
177
  return (_jsx(StopButton, Object.assign({ onClick: handleStopButton, tooltipContent: (_a = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.stop) === null || _a === void 0 ? void 0 : _a.tooltipContent, isCompact: isCompact, tooltipProps: (_b = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.stop) === null || _b === void 0 ? void 0 : _b.tooltipProps }, (_c = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.stop) === null || _c === void 0 ? void 0 : _c.props)));
178
178
  }
179
- return (_jsxs(_Fragment, { children: [attachMenuProps && (_jsx(AttachButton, Object.assign({ ref: attachButtonRef, onClick: handleAttachMenuToggle, isDisabled: isListeningMessage, tooltipContent: (_d = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _d === void 0 ? void 0 : _d.tooltipContent, isCompact: isCompact, tooltipProps: (_e = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _e === void 0 ? void 0 : _e.tooltipProps, allowedFileTypes: allowedFileTypes }, (_f = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _f === void 0 ? void 0 : _f.props))), !attachMenuProps && hasAttachButton && (_jsx(AttachButton, Object.assign({ onAttachAccepted: handleAttach, isDisabled: isListeningMessage, tooltipContent: (_g = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _g === void 0 ? void 0 : _g.tooltipContent, inputTestId: (_h = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _h === void 0 ? void 0 : _h.inputTestId, isCompact: isCompact, tooltipProps: (_j = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _j === void 0 ? void 0 : _j.tooltipProps, allowedFileTypes: allowedFileTypes }, (_k = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _k === void 0 ? void 0 : _k.props))), hasMicrophoneButton && (_jsx(MicrophoneButton, Object.assign({ isListening: isListeningMessage, onIsListeningChange: setIsListeningMessage, onSpeechRecognition: handleSpeechRecognition, tooltipContent: (_l = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.microphone) === null || _l === void 0 ? void 0 : _l.tooltipContent, language: (_m = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.microphone) === null || _m === void 0 ? void 0 : _m.language, isCompact: isCompact, tooltipProps: (_o = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.microphone) === null || _o === void 0 ? void 0 : _o.tooltipProps }, (_p = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.microphone) === null || _p === void 0 ? void 0 : _p.props))), (alwayShowSendButton || message) && (_jsx(SendButton, Object.assign({ value: message, onClick: () => handleSend(message), isDisabled: isSendButtonDisabled, tooltipContent: (_q = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.send) === null || _q === void 0 ? void 0 : _q.tooltipContent, isCompact: isCompact, tooltipProps: (_r = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.send) === null || _r === void 0 ? void 0 : _r.tooltipProps }, (_s = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.send) === null || _s === void 0 ? void 0 : _s.props)))] }));
179
+ return (_jsxs(_Fragment, { children: [attachMenuProps && (_jsx(AttachButton, Object.assign({ ref: attachButtonRef, onClick: handleAttachMenuToggle, isDisabled: isListeningMessage, tooltipContent: (_d = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _d === void 0 ? void 0 : _d.tooltipContent, isCompact: isCompact, tooltipProps: (_e = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _e === void 0 ? void 0 : _e.tooltipProps, allowedFileTypes: allowedFileTypes, minSize: minSize, maxSize: maxSize, maxFiles: maxFiles, isAttachmentDisabled: isAttachmentDisabled, onAttach: onAttach, onAttachRejected: onAttachRejected, validator: validator, dropzoneProps: dropzoneProps }, (_f = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _f === void 0 ? void 0 : _f.props))), !attachMenuProps && hasAttachButton && (_jsx(AttachButton, Object.assign({ onAttachAccepted: handleAttach, isDisabled: isListeningMessage, tooltipContent: (_g = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _g === void 0 ? void 0 : _g.tooltipContent, inputTestId: (_h = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _h === void 0 ? void 0 : _h.inputTestId, isCompact: isCompact, tooltipProps: (_j = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _j === void 0 ? void 0 : _j.tooltipProps, allowedFileTypes: allowedFileTypes, minSize: minSize, maxSize: maxSize, maxFiles: maxFiles, isAttachmentDisabled: isAttachmentDisabled, onAttach: onAttach, onAttachRejected: onAttachRejected, validator: validator, dropzoneProps: dropzoneProps }, (_k = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.attach) === null || _k === void 0 ? void 0 : _k.props))), hasMicrophoneButton && (_jsx(MicrophoneButton, Object.assign({ isListening: isListeningMessage, onIsListeningChange: setIsListeningMessage, onSpeechRecognition: handleSpeechRecognition, tooltipContent: (_l = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.microphone) === null || _l === void 0 ? void 0 : _l.tooltipContent, language: (_m = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.microphone) === null || _m === void 0 ? void 0 : _m.language, isCompact: isCompact, tooltipProps: (_o = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.microphone) === null || _o === void 0 ? void 0 : _o.tooltipProps }, (_p = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.microphone) === null || _p === void 0 ? void 0 : _p.props))), (alwayShowSendButton || message) && (_jsx(SendButton, Object.assign({ value: message, onClick: () => handleSend(message), isDisabled: isSendButtonDisabled, tooltipContent: (_q = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.send) === null || _q === void 0 ? void 0 : _q.tooltipContent, isCompact: isCompact, tooltipProps: (_r = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.send) === null || _r === void 0 ? void 0 : _r.tooltipProps }, (_s = buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.send) === null || _s === void 0 ? void 0 : _s.props)))] }));
180
180
  };
181
181
  const messageBarContents = (_jsxs(_Fragment, { children: [_jsx("div", { className: `pf-chatbot__message-bar-input ${isCompact ? 'pf-m-compact' : ''}`, children: _jsx(TextArea, Object.assign({ className: "pf-chatbot__message-textarea", value: message, onChange: handleChange, "aria-label": isListeningMessage ? listeningText : placeholder, placeholder: isListeningMessage ? listeningText : placeholder, ref: textareaRef, onKeyDown: handleKeyDown }, props)) }), _jsx("div", { className: "pf-chatbot__message-bar-actions", children: renderButtons() })] }));
182
182
  if (attachMenuProps) {
@@ -1,5 +1,5 @@
1
1
  import type { FunctionComponent } from 'react';
2
- import { CardProps } from '@patternfly/react-core';
2
+ import { ButtonProps, CardProps } from '@patternfly/react-core';
3
3
  export interface SourcesCardProps extends CardProps {
4
4
  /** Additional classes for the pagination navigation container. */
5
5
  className?: string;
@@ -11,11 +11,20 @@ export interface SourcesCardProps extends CardProps {
11
11
  paginationAriaLabel?: string;
12
12
  /** Content rendered inside the paginated card */
13
13
  sources: {
14
+ /** Title of sources card */
14
15
  title?: string;
16
+ /** Link to source */
15
17
  link: string;
18
+ /** Body of sources card */
16
19
  body?: React.ReactNode | string;
20
+ /** Whether link is external */
17
21
  isExternal?: boolean;
22
+ /** Whether sources card is expandable */
18
23
  hasShowMore?: boolean;
24
+ /** onClick event applied to the title of the Sources card */
25
+ onClick?: React.MouseEventHandler<HTMLButtonElement>;
26
+ /** Any additional props applied to the title of the Sources card */
27
+ titleProps?: ButtonProps;
19
28
  }[];
20
29
  /** Label for the English word "source" */
21
30
  sourceWord?: string;
@@ -15,6 +15,7 @@ import { useState } from 'react';
15
15
  import { Button, ButtonVariant, Card, CardBody, CardFooter, CardTitle, ExpandableSection, ExpandableSectionVariant, Icon, pluralize, Truncate } from '@patternfly/react-core';
16
16
  import { ExternalLinkSquareAltIcon } from '@patternfly/react-icons';
17
17
  const SourcesCard = (_a) => {
18
+ var _b;
18
19
  var { className, isDisabled, paginationAriaLabel = 'Pagination', sources, sourceWord = 'source', sourceWordPlural = 'sources', toNextPageAriaLabel = 'Go to next page', toPreviousPageAriaLabel = 'Go to previous page', onNextClick, onPreviousClick, onSetPage, showMoreWords = 'show more', showLessWords = 'show less', isCompact } = _a, props = __rest(_a, ["className", "isDisabled", "paginationAriaLabel", "sources", "sourceWord", "sourceWordPlural", "toNextPageAriaLabel", "toPreviousPageAriaLabel", "onNextClick", "onPreviousClick", "onSetPage", "showMoreWords", "showLessWords", "isCompact"]);
19
20
  const [page, setPage] = useState(1);
20
21
  const [isExpanded, setIsExpanded] = useState(false);
@@ -31,7 +32,7 @@ const SourcesCard = (_a) => {
31
32
  }
32
33
  return `Source ${page}`;
33
34
  };
34
- return (_jsxs("div", { className: "pf-chatbot__source", children: [_jsx("span", { children: pluralize(sources.length, sourceWord, sourceWordPlural) }), _jsxs(Card, Object.assign({ isCompact: isCompact, className: "pf-chatbot__sources-card" }, props, { children: [_jsx(CardTitle, { className: "pf-chatbot__sources-card-title", children: _jsx(Button, { component: "a", variant: ButtonVariant.link, href: sources[page - 1].link, icon: sources[page - 1].isExternal ? _jsx(ExternalLinkSquareAltIcon, {}) : undefined, iconPosition: "end", isInline: true, rel: sources[page - 1].isExternal ? 'noreferrer' : undefined, target: sources[page - 1].isExternal ? '_blank' : undefined, children: renderTitle(sources[page - 1].title) }) }), sources[page - 1].body && (_jsx(CardBody, { className: `pf-chatbot__sources-card-body`, children: sources[page - 1].hasShowMore ? (
35
+ return (_jsxs("div", { className: "pf-chatbot__source", children: [_jsx("span", { children: pluralize(sources.length, sourceWord, sourceWordPlural) }), _jsxs(Card, Object.assign({ isCompact: isCompact, className: "pf-chatbot__sources-card" }, props, { children: [_jsx(CardTitle, { className: "pf-chatbot__sources-card-title", children: _jsx(Button, Object.assign({ component: "a", variant: ButtonVariant.link, href: sources[page - 1].link, icon: sources[page - 1].isExternal ? _jsx(ExternalLinkSquareAltIcon, {}) : undefined, iconPosition: "end", isInline: true, rel: sources[page - 1].isExternal ? 'noreferrer' : undefined, target: sources[page - 1].isExternal ? '_blank' : undefined, onClick: (_b = sources[page - 1].onClick) !== null && _b !== void 0 ? _b : undefined }, sources[page - 1].titleProps, { children: renderTitle(sources[page - 1].title) })) }), sources[page - 1].body && (_jsx(CardBody, { className: `pf-chatbot__sources-card-body`, children: sources[page - 1].hasShowMore ? (
35
36
  // prevents extra VO announcements of button text - parent Message has aria-live
36
37
  _jsx("div", { "aria-live": "off", children: _jsx(ExpandableSection, { variant: ExpandableSectionVariant.truncate, toggleText: isExpanded ? showLessWords : showMoreWords, onToggle: onToggle, isExpanded: isExpanded, truncateMaxLines: 2, children: sources[page - 1].body }) })) : (_jsx("div", { className: "pf-chatbot__sources-card-body-text", children: sources[page - 1].body })) })), sources.length > 1 && (_jsx(CardFooter, { className: "pf-chatbot__sources-card-footer-container", children: _jsx("div", { className: "pf-chatbot__sources-card-footer", children: _jsxs("nav", { className: `pf-chatbot__sources-card-footer-buttons ${className}`, "aria-label": paginationAriaLabel, children: [_jsx(Button, { variant: ButtonVariant.plain, isDisabled: isDisabled || page === 1, "data-action": "previous", onClick: (event) => {
37
38
  const newPage = page >= 1 ? page - 1 : 1;
@@ -169,4 +169,14 @@ describe('SourcesCard', () => {
169
169
  ] }));
170
170
  expect(screen.getByRole('region')).toHaveAttribute('class', 'pf-v6-c-expandable-section__content');
171
171
  }));
172
+ it('should call onClick appropriately', () => __awaiter(void 0, void 0, void 0, function* () {
173
+ const spy = jest.fn();
174
+ render(_jsx(SourcesCard, { sources: [{ title: 'How to make an apple pie', link: '', onClick: spy }] }));
175
+ yield userEvent.click(screen.getByRole('link', { name: /How to make an apple pie/i }));
176
+ expect(spy).toHaveBeenCalled();
177
+ }));
178
+ it('should apply titleProps appropriately', () => {
179
+ render(_jsx(SourcesCard, { sources: [{ title: 'How to make an apple pie', link: '', titleProps: { className: 'test' } }] }));
180
+ expect(screen.getByRole('link', { name: /How to make an apple pie/i })).toHaveClass('test');
181
+ });
172
182
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patternfly/chatbot",
3
- "version": "6.3.0-prerelease.21",
3
+ "version": "6.3.0-prerelease.23",
4
4
  "description": "This library provides React components based on PatternFly 6 that can be used to build chatbots.",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -138,10 +138,17 @@ export const BasicDemo: FunctionComponent = () => {
138
138
  }, 1000);
139
139
  })
140
140
  .catch((error: DOMException) => {
141
+ setShowAlert(true);
141
142
  setError(`Failed to read file: ${error.message}`);
142
143
  });
143
144
  };
144
145
 
146
+ const handleAttachRejected = () => {
147
+ setFile(undefined);
148
+ setShowAlert(true);
149
+ setError('This demo only supports file extensions .txt, .json, .yaml, and .yaml. Please try a different file.');
150
+ };
151
+
145
152
  const handleFileDrop = (event: DropEvent, data: File[]) => {
146
153
  handleFile(data);
147
154
  };
@@ -227,6 +234,7 @@ export const BasicDemo: FunctionComponent = () => {
227
234
  'application/json': ['.json'],
228
235
  'application/yaml': ['.yaml', '.yml']
229
236
  }}
237
+ onAttachRejected={handleAttachRejected}
230
238
  >
231
239
  <ChatbotContent>
232
240
  <MessageBox>
@@ -254,7 +262,17 @@ export const BasicDemo: FunctionComponent = () => {
254
262
  <FileDetailsLabel fileName={file.name} isLoading={isLoadingFile} onClose={onClose} />
255
263
  </div>
256
264
  )}
257
- <MessageBar onSendMessage={handleSend} hasAttachButton handleAttach={handleAttach} />
265
+ <MessageBar
266
+ onSendMessage={handleSend}
267
+ hasAttachButton
268
+ handleAttach={handleAttach}
269
+ allowedFileTypes={{
270
+ 'text/plain': ['.txt'],
271
+ 'application/json': ['.json'],
272
+ 'application/yaml': ['.yaml', '.yml']
273
+ }}
274
+ onAttachRejected={handleAttachRejected}
275
+ />
258
276
  <ChatbotFootnote label="ChatBot uses AI. Check for mistakes." />
259
277
  </ChatbotFooter>
260
278
  </FileDropZone>
@@ -127,6 +127,7 @@ export const AttachmentMenuDemo: FunctionComponent = () => {
127
127
  // Attachments
128
128
  // --------------------------------------------------------------------------
129
129
  const handleFileDrop = (event: DropEvent, data: File[]) => {
130
+ setIsOpen(false);
130
131
  setFile(data[0]);
131
132
  setIsLoadingFile(true);
132
133
  setTimeout(() => {
@@ -40,4 +40,87 @@ describe('FileDropZone', () => {
40
40
 
41
41
  expect(onFileDrop).not.toHaveBeenCalled();
42
42
  });
43
+
44
+ it('should respect minSize restriction', async () => {
45
+ const onAttachRejected = jest.fn();
46
+ const { container } = render(
47
+ <FileDropZone onFileDrop={jest.fn()} minSize={1000} onAttachRejected={onAttachRejected} />
48
+ );
49
+
50
+ const file = new File(['Test'], 'example.txt', { type: 'text/plain' });
51
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
52
+
53
+ await userEvent.upload(fileInput, file);
54
+
55
+ expect(onAttachRejected).toHaveBeenCalled();
56
+ });
57
+ it('should respect maxSize restriction', async () => {
58
+ const onAttachRejected = jest.fn();
59
+ const { container } = render(
60
+ <FileDropZone onFileDrop={jest.fn()} maxSize={100} onAttachRejected={onAttachRejected} />
61
+ );
62
+
63
+ const largeContent = 'x'.repeat(200);
64
+ const file = new File([largeContent], 'example.txt', { type: 'text/plain' });
65
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
66
+
67
+ await userEvent.upload(fileInput, file);
68
+
69
+ expect(onAttachRejected).toHaveBeenCalled();
70
+ });
71
+
72
+ it('should respect maxFiles restriction', async () => {
73
+ const onAttachRejected = jest.fn();
74
+ const { container } = render(
75
+ <FileDropZone onFileDrop={jest.fn()} maxFiles={1} onAttachRejected={onAttachRejected} />
76
+ );
77
+
78
+ const files = [
79
+ new File(['Test1'], 'example1.txt', { type: 'text/plain' }),
80
+ new File(['Test2'], 'example2.txt', { type: 'text/plain' })
81
+ ];
82
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
83
+
84
+ await userEvent.upload(fileInput, files);
85
+
86
+ expect(onAttachRejected).toHaveBeenCalled();
87
+ });
88
+
89
+ it('should be disabled when isAttachmentDisabled is true', async () => {
90
+ const onFileDrop = jest.fn();
91
+ const { container } = render(<FileDropZone onFileDrop={onFileDrop} isAttachmentDisabled={true} />);
92
+
93
+ const file = new File(['Test'], 'example.text', { type: 'text/plain' });
94
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
95
+ await userEvent.upload(fileInput, file);
96
+
97
+ expect(onFileDrop).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it('should call onAttach when files are attached', async () => {
101
+ const onAttach = jest.fn();
102
+ const { container } = render(<FileDropZone onFileDrop={jest.fn()} onAttach={onAttach} />);
103
+
104
+ const file = new File(['Test'], 'example.txt', { type: 'text/plain' });
105
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
106
+
107
+ await userEvent.upload(fileInput, file);
108
+
109
+ expect(onAttach).toHaveBeenCalled();
110
+ });
111
+ it('should use custom validator when provided', async () => {
112
+ const validator = jest.fn().mockReturnValue({ message: 'Custom error' });
113
+ const onAttachRejected = jest.fn();
114
+ const onFileDrop = jest.fn();
115
+ const { container } = render(
116
+ <FileDropZone onFileDrop={onFileDrop} validator={validator} onAttachRejected={onAttachRejected} />
117
+ );
118
+
119
+ const file = new File(['Test'], 'example.txt', { type: 'text/plain' });
120
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
121
+ await userEvent.upload(fileInput, file);
122
+
123
+ expect(validator).toHaveBeenCalledWith(file);
124
+ expect(onAttachRejected).toHaveBeenCalled();
125
+ });
43
126
  });
@@ -3,7 +3,7 @@ import type { FunctionComponent } from 'react';
3
3
  import { useState } from 'react';
4
4
  import { ChatbotDisplayMode } from '../Chatbot';
5
5
  import { UploadIcon } from '@patternfly/react-icons';
6
- import { Accept } from 'react-dropzone/.';
6
+ import { Accept, FileError, FileRejection } from 'react-dropzone/.';
7
7
 
8
8
  export interface FileDropZoneProps {
9
9
  /** Content displayed when the drop zone is not currently in use */
@@ -22,6 +22,20 @@ export interface FileDropZoneProps {
22
22
  allowedFileTypes?: Accept;
23
23
  /** Display mode for the Chatbot parent; this influences the styles applied */
24
24
  displayMode?: ChatbotDisplayMode;
25
+ /** Minimum file size allowed */
26
+ minSize?: number;
27
+ /** Max file size allowed */
28
+ maxSize?: number;
29
+ /** Max number of files allowed */
30
+ maxFiles?: number;
31
+ /** Whether attachments are disabled */
32
+ isAttachmentDisabled?: boolean;
33
+ /** Callback when file(s) are attached */
34
+ onAttach?: <T extends File>(acceptedFiles: T[], fileRejections: FileRejection[], event: DropEvent) => void;
35
+ /** Callback function for AttachButton when an attachment fails */
36
+ onAttachRejected?: (fileRejections: FileRejection[], event: DropEvent) => void;
37
+ /** Validator for files; see https://react-dropzone.js.org/#!/Custom%20validation for more information */
38
+ validator?: <T extends File>(file: T) => FileError | readonly FileError[] | null;
25
39
  }
26
40
 
27
41
  const FileDropZone: FunctionComponent<FileDropZoneProps> = ({
@@ -30,6 +44,13 @@ const FileDropZone: FunctionComponent<FileDropZoneProps> = ({
30
44
  infoText = 'Maximum file size is 25 MB',
31
45
  onFileDrop,
32
46
  allowedFileTypes,
47
+ minSize,
48
+ maxSize,
49
+ maxFiles,
50
+ isAttachmentDisabled,
51
+ onAttach,
52
+ onAttachRejected,
53
+ validator,
33
54
  displayMode = ChatbotDisplayMode.default,
34
55
  ...props
35
56
  }: FileDropZoneProps) => {
@@ -50,7 +71,16 @@ const FileDropZone: FunctionComponent<FileDropZoneProps> = ({
50
71
  <MultipleFileUpload
51
72
  dropzoneProps={{
52
73
  accept: allowedFileTypes,
53
- onDrop: () => setShowDropZone(false),
74
+ onDrop: (acceptedFiles, fileRejections: FileRejection[], event: DropEvent) => {
75
+ setShowDropZone(false);
76
+ onAttach && onAttach(acceptedFiles, fileRejections, event);
77
+ },
78
+ minSize,
79
+ maxSize,
80
+ maxFiles,
81
+ disabled: isAttachmentDisabled,
82
+ onDropRejected: onAttachRejected,
83
+ validator,
54
84
  ...props
55
85
  }}
56
86
  onDragEnter={() => setShowDropZone(true)}
@@ -98,4 +98,79 @@ describe('Attach button', () => {
98
98
 
99
99
  expect(onAttachAccepted).not.toHaveBeenCalled();
100
100
  });
101
+
102
+ it('should respect minSize restriction', async () => {
103
+ const onAttachRejected = jest.fn();
104
+ render(<AttachButton inputTestId="input" minSize={1000} onAttachRejected={onAttachRejected} />);
105
+
106
+ const file = new File(['Test'], 'example.txt', { type: 'text/plain' });
107
+ const input = screen.getByTestId('input');
108
+
109
+ await userEvent.upload(input, file);
110
+
111
+ expect(onAttachRejected).toHaveBeenCalled();
112
+ });
113
+
114
+ it('should respect maxSize restriction', async () => {
115
+ const onAttachRejected = jest.fn();
116
+ render(<AttachButton inputTestId="input" maxSize={100} onAttachRejected={onAttachRejected} />);
117
+
118
+ const largeContent = 'x'.repeat(200);
119
+ const file = new File([largeContent], 'example.txt', { type: 'text/plain' });
120
+ const input = screen.getByTestId('input');
121
+
122
+ await userEvent.upload(input, file);
123
+
124
+ expect(onAttachRejected).toHaveBeenCalled();
125
+ });
126
+
127
+ it('should respect maxFiles restriction', async () => {
128
+ const onAttachRejected = jest.fn();
129
+ render(<AttachButton inputTestId="input" maxFiles={1} onAttachRejected={onAttachRejected} />);
130
+
131
+ const files = [
132
+ new File(['Test1'], 'example1.txt', { type: 'text/plain' }),
133
+ new File(['Test2'], 'example2.txt', { type: 'text/plain' })
134
+ ];
135
+
136
+ const input = screen.getByTestId('input');
137
+ await userEvent.upload(input, files);
138
+
139
+ expect(onAttachRejected).toHaveBeenCalled();
140
+ });
141
+
142
+ it('should be disabled when isAttachmentDisabled is true', async () => {
143
+ const onFileDrop = jest.fn();
144
+ render(<AttachButton inputTestId="input" isAttachmentDisabled={true} />);
145
+
146
+ const file = new File(['Test'], 'example.text', { type: 'text/plain' });
147
+ const input = screen.getByTestId('input');
148
+ await userEvent.upload(input, file);
149
+
150
+ expect(onFileDrop).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('should call onAttach when files are attached', async () => {
154
+ const onAttach = jest.fn();
155
+ render(<AttachButton inputTestId="input" onAttach={onAttach} />);
156
+
157
+ const file = new File(['Test'], 'example.txt', { type: 'text/plain' });
158
+ const input = screen.getByTestId('input');
159
+
160
+ await userEvent.upload(input, file);
161
+
162
+ expect(onAttach).toHaveBeenCalled();
163
+ });
164
+ it('should use custom validator when provided', async () => {
165
+ const validator = jest.fn().mockReturnValue({ message: 'Custom error' });
166
+ const onAttachRejected = jest.fn();
167
+ render(<AttachButton inputTestId="input" validator={validator} onAttachRejected={onAttachRejected} />);
168
+
169
+ const file = new File(['Test'], 'example.txt', { type: 'text/plain' });
170
+ const input = screen.getByTestId('input');
171
+ await userEvent.upload(input, file);
172
+
173
+ expect(validator).toHaveBeenCalledWith(file);
174
+ expect(onAttachRejected).toHaveBeenCalled();
175
+ });
101
176
  });
@@ -7,7 +7,7 @@ import { forwardRef } from 'react';
7
7
 
8
8
  // Import PatternFly components
9
9
  import { Button, ButtonProps, DropEvent, Icon, Tooltip, TooltipProps } from '@patternfly/react-core';
10
- import { Accept, useDropzone } from 'react-dropzone';
10
+ import { Accept, DropzoneOptions, FileError, FileRejection, useDropzone } from 'react-dropzone';
11
11
  import { PaperclipIcon } from '@patternfly/react-icons/dist/esm/icons/paperclip-icon';
12
12
 
13
13
  export interface AttachButtonProps extends ButtonProps {
@@ -33,7 +33,24 @@ export interface AttachButtonProps extends ButtonProps {
33
33
  tooltipContent?: string;
34
34
  /** Test id applied to input */
35
35
  inputTestId?: string;
36
+ /** Whether button is compact */
36
37
  isCompact?: boolean;
38
+ /** Minimum file size allowed */
39
+ minSize?: number;
40
+ /** Max file size allowed */
41
+ maxSize?: number;
42
+ /** Max number of files allowed */
43
+ maxFiles?: number;
44
+ /** Whether attachments are disabled */
45
+ isAttachmentDisabled?: boolean;
46
+ /** Callback when file(s) are attached */
47
+ onAttach?: <T extends File>(acceptedFiles: T[], fileRejections: FileRejection[], event: DropEvent) => void;
48
+ /** Callback function for AttachButton when an attachment fails */
49
+ onAttachRejected?: (fileRejections: FileRejection[], event: DropEvent) => void;
50
+ /** Validator for files; see https://react-dropzone.js.org/#!/Custom%20validation for more information */
51
+ validator?: <T extends File>(file: T) => FileError | readonly FileError[] | null;
52
+ /** Additional props passed to react-dropzone */
53
+ dropzoneProps?: DropzoneOptions;
37
54
  }
38
55
 
39
56
  const AttachButtonBase: FunctionComponent<AttachButtonProps> = ({
@@ -47,12 +64,28 @@ const AttachButtonBase: FunctionComponent<AttachButtonProps> = ({
47
64
  inputTestId,
48
65
  isCompact,
49
66
  allowedFileTypes,
67
+ minSize,
68
+ maxSize,
69
+ maxFiles,
70
+ isAttachmentDisabled,
71
+ onAttach,
72
+ onAttachRejected,
73
+ validator,
74
+ dropzoneProps,
50
75
  ...props
51
76
  }: AttachButtonProps) => {
52
77
  const { open, getInputProps } = useDropzone({
53
78
  multiple: true,
54
79
  onDropAccepted: onAttachAccepted,
55
- accept: allowedFileTypes
80
+ accept: allowedFileTypes,
81
+ minSize,
82
+ maxSize,
83
+ maxFiles,
84
+ disabled: isAttachmentDisabled,
85
+ onDrop: onAttach,
86
+ onDropRejected: onAttachRejected,
87
+ validator,
88
+ ...dropzoneProps
56
89
  });
57
90
 
58
91
  return (
@@ -1,6 +1,6 @@
1
1
  import type { ChangeEvent, FunctionComponent, KeyboardEvent as ReactKeyboardEvent, Ref } from 'react';
2
2
  import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
3
- import { Accept } from 'react-dropzone/.';
3
+ import { Accept, DropzoneOptions, FileError, FileRejection } from 'react-dropzone/.';
4
4
  import { ButtonProps, DropEvent, TextArea, TextAreaProps, TooltipProps } from '@patternfly/react-core';
5
5
 
6
6
  // Import Chatbot components
@@ -53,6 +53,28 @@ export interface MessageBarProps extends Omit<TextAreaProps, 'innerRef'> {
53
53
  handleStopButton?: (event: React.MouseEvent<HTMLButtonElement>) => void;
54
54
  /** Callback function for when attach button is used to upload a file */
55
55
  handleAttach?: (data: File[], event: DropEvent) => void;
56
+ /** Specifies the file types accepted by the attachment upload component.
57
+ * Files that don't match the accepted types will be disabled in the file picker.
58
+ * For example,
59
+ * allowedFileTypes: { 'application/json': ['.json'], 'text/plain': ['.txt'] }
60
+ **/
61
+ allowedFileTypes?: Accept;
62
+ /** Minimum file size allowed */
63
+ minSize?: number;
64
+ /** Max file size allowed */
65
+ maxSize?: number;
66
+ /** Max number of files allowed */
67
+ maxFiles?: number;
68
+ /** Whether attachments are disabled */
69
+ isAttachmentDisabled?: boolean;
70
+ /** Callback when file(s) are attached */
71
+ onAttach?: <T extends File>(acceptedFiles: T[], fileRejections: FileRejection[], event: DropEvent) => void;
72
+ /** Callback function for AttachButton when an attachment fails */
73
+ onAttachRejected?: (fileRejections: FileRejection[], event: DropEvent) => void;
74
+ /** Validator for files; see https://react-dropzone.js.org/#!/Custom%20validation for more information */
75
+ validator?: <T extends File>(file: T) => FileError | readonly FileError[] | null;
76
+ /** Additional props passed to react-dropzone */
77
+ dropzoneProps?: DropzoneOptions;
56
78
  /** Props to enable a menu that opens when the Attach button is clicked, instead of the attachment window */
57
79
  attachMenuProps?: MessageBarWithAttachMenuProps;
58
80
  /** Flag to provide manual control over whether send button is disabled */
@@ -80,12 +102,6 @@ export interface MessageBarProps extends Omit<TextAreaProps, 'innerRef'> {
80
102
  displayMode?: ChatbotDisplayMode;
81
103
  /** Whether message bar is compact */
82
104
  isCompact?: boolean;
83
- /** Specifies the file types accepted by the attachment upload component.
84
- * Files that don't match the accepted types will be disabled in the file picker.
85
- * For example,
86
- * allowedFileTypes: { 'application/json': ['.json'], 'text/plain': ['.txt'] }
87
- **/
88
- allowedFileTypes?: Accept;
89
105
  /** Ref applied to message bar textarea, for use with focus or other custom behaviors */
90
106
  innerRef?: React.Ref<HTMLTextAreaElement>;
91
107
  }
@@ -109,6 +125,14 @@ export const MessageBarBase: FunctionComponent<MessageBarProps> = ({
109
125
  value,
110
126
  isCompact = false,
111
127
  allowedFileTypes,
128
+ minSize,
129
+ maxSize,
130
+ maxFiles,
131
+ isAttachmentDisabled,
132
+ onAttach,
133
+ onAttachRejected,
134
+ validator,
135
+ dropzoneProps,
112
136
  innerRef,
113
137
  ...props
114
138
  }: MessageBarProps) => {
@@ -309,6 +333,14 @@ export const MessageBarBase: FunctionComponent<MessageBarProps> = ({
309
333
  isCompact={isCompact}
310
334
  tooltipProps={buttonProps?.attach?.tooltipProps}
311
335
  allowedFileTypes={allowedFileTypes}
336
+ minSize={minSize}
337
+ maxSize={maxSize}
338
+ maxFiles={maxFiles}
339
+ isAttachmentDisabled={isAttachmentDisabled}
340
+ onAttach={onAttach}
341
+ onAttachRejected={onAttachRejected}
342
+ validator={validator}
343
+ dropzoneProps={dropzoneProps}
312
344
  {...buttonProps?.attach?.props}
313
345
  />
314
346
  )}
@@ -321,6 +353,14 @@ export const MessageBarBase: FunctionComponent<MessageBarProps> = ({
321
353
  isCompact={isCompact}
322
354
  tooltipProps={buttonProps?.attach?.tooltipProps}
323
355
  allowedFileTypes={allowedFileTypes}
356
+ minSize={minSize}
357
+ maxSize={maxSize}
358
+ maxFiles={maxFiles}
359
+ isAttachmentDisabled={isAttachmentDisabled}
360
+ onAttach={onAttach}
361
+ onAttachRejected={onAttachRejected}
362
+ validator={validator}
363
+ dropzoneProps={dropzoneProps}
324
364
  {...buttonProps?.attach?.props}
325
365
  />
326
366
  )}
@@ -242,4 +242,18 @@ describe('SourcesCard', () => {
242
242
  );
243
243
  expect(screen.getByRole('region')).toHaveAttribute('class', 'pf-v6-c-expandable-section__content');
244
244
  });
245
+
246
+ it('should call onClick appropriately', async () => {
247
+ const spy = jest.fn();
248
+ render(<SourcesCard sources={[{ title: 'How to make an apple pie', link: '', onClick: spy }]} />);
249
+ await userEvent.click(screen.getByRole('link', { name: /How to make an apple pie/i }));
250
+ expect(spy).toHaveBeenCalled();
251
+ });
252
+
253
+ it('should apply titleProps appropriately', () => {
254
+ render(
255
+ <SourcesCard sources={[{ title: 'How to make an apple pie', link: '', titleProps: { className: 'test' } }]} />
256
+ );
257
+ expect(screen.getByRole('link', { name: /How to make an apple pie/i })).toHaveClass('test');
258
+ });
245
259
  });
@@ -6,6 +6,7 @@ import { useState } from 'react';
6
6
  // Import PatternFly components
7
7
  import {
8
8
  Button,
9
+ ButtonProps,
9
10
  ButtonVariant,
10
11
  Card,
11
12
  CardBody,
@@ -31,11 +32,20 @@ export interface SourcesCardProps extends CardProps {
31
32
  paginationAriaLabel?: string;
32
33
  /** Content rendered inside the paginated card */
33
34
  sources: {
35
+ /** Title of sources card */
34
36
  title?: string;
37
+ /** Link to source */
35
38
  link: string;
39
+ /** Body of sources card */
36
40
  body?: React.ReactNode | string;
41
+ /** Whether link is external */
37
42
  isExternal?: boolean;
43
+ /** Whether sources card is expandable */
38
44
  hasShowMore?: boolean;
45
+ /** onClick event applied to the title of the Sources card */
46
+ onClick?: React.MouseEventHandler<HTMLButtonElement>;
47
+ /** Any additional props applied to the title of the Sources card */
48
+ titleProps?: ButtonProps;
39
49
  }[];
40
50
  /** Label for the English word "source" */
41
51
  sourceWord?: string;
@@ -107,6 +117,8 @@ const SourcesCard: FunctionComponent<SourcesCardProps> = ({
107
117
  isInline
108
118
  rel={sources[page - 1].isExternal ? 'noreferrer' : undefined}
109
119
  target={sources[page - 1].isExternal ? '_blank' : undefined}
120
+ onClick={sources[page - 1].onClick ?? undefined}
121
+ {...sources[page - 1].titleProps}
110
122
  >
111
123
  {renderTitle(sources[page - 1].title)}
112
124
  </Button>