@patternfly/chatbot 6.5.0-prerelease.20 → 6.5.0-prerelease.22

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 (41) hide show
  1. package/dist/cjs/DeepThinking/DeepThinking.d.ts +2 -0
  2. package/dist/cjs/DeepThinking/DeepThinking.js +2 -2
  3. package/dist/cjs/DeepThinking/DeepThinking.test.js +41 -0
  4. package/dist/cjs/Message/Message.d.ts +15 -3
  5. package/dist/cjs/Message/Message.js +1 -1
  6. package/dist/cjs/Message/Message.test.js +125 -2
  7. package/dist/cjs/ToolCall/ToolCall.d.ts +2 -0
  8. package/dist/cjs/ToolCall/ToolCall.js +7 -2
  9. package/dist/cjs/ToolCall/ToolCall.test.js +26 -0
  10. package/dist/cjs/ToolResponse/ToolResponse.d.ts +2 -0
  11. package/dist/cjs/ToolResponse/ToolResponse.js +2 -2
  12. package/dist/cjs/ToolResponse/ToolResponse.test.js +40 -0
  13. package/dist/css/main.css +10 -0
  14. package/dist/css/main.css.map +1 -1
  15. package/dist/esm/DeepThinking/DeepThinking.d.ts +2 -0
  16. package/dist/esm/DeepThinking/DeepThinking.js +2 -2
  17. package/dist/esm/DeepThinking/DeepThinking.test.js +41 -0
  18. package/dist/esm/Message/Message.d.ts +15 -3
  19. package/dist/esm/Message/Message.js +1 -1
  20. package/dist/esm/Message/Message.test.js +125 -2
  21. package/dist/esm/ToolCall/ToolCall.d.ts +2 -0
  22. package/dist/esm/ToolCall/ToolCall.js +7 -2
  23. package/dist/esm/ToolCall/ToolCall.test.js +26 -0
  24. package/dist/esm/ToolResponse/ToolResponse.d.ts +2 -0
  25. package/dist/esm/ToolResponse/ToolResponse.js +2 -2
  26. package/dist/esm/ToolResponse/ToolResponse.test.js +40 -0
  27. package/package.json +1 -1
  28. package/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithDeepThinking.tsx +25 -11
  29. package/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithMultipleActionGroups.tsx +61 -0
  30. package/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithToolCall.tsx +14 -1
  31. package/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithToolResponse.tsx +222 -105
  32. package/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md +18 -0
  33. package/src/DeepThinking/DeepThinking.test.tsx +61 -0
  34. package/src/DeepThinking/DeepThinking.tsx +4 -1
  35. package/src/Message/Message.test.tsx +198 -2
  36. package/src/Message/Message.tsx +35 -6
  37. package/src/ResponseActions/ResponseActions.scss +11 -0
  38. package/src/ToolCall/ToolCall.test.tsx +51 -0
  39. package/src/ToolCall/ToolCall.tsx +12 -1
  40. package/src/ToolResponse/ToolResponse.test.tsx +44 -0
  41. package/src/ToolResponse/ToolResponse.tsx +4 -1
@@ -437,7 +437,7 @@ describe('Message', () => {
437
437
  expect(screen.queryByRole('button', { name: /No/i })).toBeFalsy();
438
438
  expect(screen.getByRole('button', { name: /1 more/i }));
439
439
  });
440
- it('should be able to show actions', async () => {
440
+ it('Renders response actions when a single actions object is passed', async () => {
441
441
  render(
442
442
  <Message
443
443
  avatar="./img"
@@ -463,9 +463,204 @@ describe('Message', () => {
463
463
  />
464
464
  );
465
465
  ALL_ACTIONS.forEach(({ label }) => {
466
- expect(screen.getByRole('button', { name: label })).toBeTruthy();
466
+ expect(screen.getByRole('button', { name: label })).toBeVisible();
467
467
  });
468
468
  });
469
+ it('Renders response actions when an array of actions objects is passed', async () => {
470
+ render(
471
+ <Message
472
+ avatar="./img"
473
+ role="bot"
474
+ name="Bot"
475
+ content="Hi"
476
+ actions={[
477
+ {
478
+ // eslint-disable-next-line no-console
479
+ positive: { onClick: () => console.log('Good response') },
480
+ // eslint-disable-next-line no-console
481
+ negative: { onClick: () => console.log('Bad response') }
482
+ },
483
+ {
484
+ // eslint-disable-next-line no-console
485
+ copy: { onClick: () => console.log('Copy') },
486
+ // eslint-disable-next-line no-console
487
+ edit: { onClick: () => console.log('Edit') },
488
+ // eslint-disable-next-line no-console
489
+ share: { onClick: () => console.log('Share') },
490
+ // eslint-disable-next-line no-console
491
+ download: { onClick: () => console.log('Download') }
492
+ },
493
+ {
494
+ // eslint-disable-next-line no-console
495
+ listen: { onClick: () => console.log('Listen') }
496
+ }
497
+ ]}
498
+ />
499
+ );
500
+ ALL_ACTIONS.forEach(({ label }) => {
501
+ expect(screen.getByRole('button', { name: label })).toBeVisible();
502
+ });
503
+ });
504
+ it('Renders response actions when an array of objects containing actions objects is passed', async () => {
505
+ render(
506
+ <Message
507
+ avatar="./img"
508
+ role="bot"
509
+ name="Bot"
510
+ content="Hi"
511
+ actions={[
512
+ {
513
+ actions: {
514
+ // eslint-disable-next-line no-console
515
+ positive: { onClick: () => console.log('Good response') },
516
+ // eslint-disable-next-line no-console
517
+ negative: { onClick: () => console.log('Bad response') }
518
+ }
519
+ },
520
+ {
521
+ actions: {
522
+ // eslint-disable-next-line no-console
523
+ copy: { onClick: () => console.log('Copy') },
524
+ // eslint-disable-next-line no-console
525
+ edit: { onClick: () => console.log('Edit') },
526
+ // eslint-disable-next-line no-console
527
+ share: { onClick: () => console.log('Share') },
528
+ // eslint-disable-next-line no-console
529
+ download: { onClick: () => console.log('Download') }
530
+ }
531
+ },
532
+ {
533
+ actions: {
534
+ // eslint-disable-next-line no-console
535
+ listen: { onClick: () => console.log('Listen') }
536
+ }
537
+ }
538
+ ]}
539
+ />
540
+ );
541
+ ALL_ACTIONS.forEach(({ label }) => {
542
+ expect(screen.getByRole('button', { name: label })).toBeVisible();
543
+ });
544
+ });
545
+
546
+ it('should handle persistActionSelection correctly when a single actions object is passed', async () => {
547
+ render(
548
+ <Message
549
+ avatar="./img"
550
+ role="bot"
551
+ name="Bot"
552
+ content="Test message"
553
+ persistActionSelection
554
+ actions={{
555
+ positive: { onClick: jest.fn() },
556
+ negative: { onClick: jest.fn() }
557
+ }}
558
+ />
559
+ );
560
+ const goodBtn = screen.getByRole('button', { name: /Good response/i });
561
+ const badBtn = screen.getByRole('button', { name: /Bad response/i });
562
+
563
+ await userEvent.click(goodBtn);
564
+ expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
565
+ 'pf-chatbot__button--response-action-clicked'
566
+ );
567
+
568
+ await userEvent.click(screen.getByText('Test message'));
569
+ expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
570
+ 'pf-chatbot__button--response-action-clicked'
571
+ );
572
+
573
+ await userEvent.click(badBtn);
574
+ expect(screen.getByRole('button', { name: /Bad response recorded/i })).toHaveClass(
575
+ 'pf-chatbot__button--response-action-clicked'
576
+ );
577
+ expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
578
+ });
579
+
580
+ it('should handle persistActionSelection correctly when an array of actions objects is passed', async () => {
581
+ render(
582
+ <Message
583
+ avatar="./img"
584
+ role="bot"
585
+ name="Bot"
586
+ content="Test message"
587
+ persistActionSelection
588
+ actions={[
589
+ {
590
+ positive: { onClick: jest.fn() },
591
+ negative: { onClick: jest.fn() }
592
+ },
593
+ {
594
+ copy: { onClick: jest.fn() }
595
+ }
596
+ ]}
597
+ />
598
+ );
599
+ const goodBtn = screen.getByRole('button', { name: /Good response/i });
600
+ const copyBtn = screen.getByRole('button', { name: /Copy/i });
601
+
602
+ await userEvent.click(goodBtn);
603
+ expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
604
+ 'pf-chatbot__button--response-action-clicked'
605
+ );
606
+
607
+ await userEvent.click(screen.getByText('Test message'));
608
+ expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
609
+ 'pf-chatbot__button--response-action-clicked'
610
+ );
611
+
612
+ await userEvent.click(copyBtn);
613
+ expect(screen.getByRole('button', { name: /Copied/i })).toHaveClass('pf-chatbot__button--response-action-clicked');
614
+
615
+ await userEvent.click(screen.getByText('Test message'));
616
+ expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
617
+ 'pf-chatbot__button--response-action-clicked'
618
+ );
619
+ expect(screen.getByRole('button', { name: /Copied/i })).toHaveClass('pf-chatbot__button--response-action-clicked');
620
+ });
621
+
622
+ it('should handle persistActionSelection correctly when an array of objects containing actions objects is passed', async () => {
623
+ render(
624
+ <Message
625
+ avatar="./img"
626
+ role="bot"
627
+ name="Bot"
628
+ content="Test message"
629
+ actions={[
630
+ {
631
+ actions: {
632
+ positive: { onClick: jest.fn() },
633
+ negative: { onClick: jest.fn() }
634
+ },
635
+ persistActionSelection: true
636
+ },
637
+ {
638
+ actions: {
639
+ copy: { onClick: jest.fn() }
640
+ },
641
+ persistActionSelection: false
642
+ }
643
+ ]}
644
+ />
645
+ );
646
+ const goodBtn = screen.getByRole('button', { name: /Good response/i });
647
+ const copyBtn = screen.getByRole('button', { name: /Copy/i });
648
+
649
+ await userEvent.click(goodBtn);
650
+ expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
651
+ 'pf-chatbot__button--response-action-clicked'
652
+ );
653
+
654
+ await userEvent.click(copyBtn);
655
+ expect(screen.getByRole('button', { name: /Copied/i })).toHaveClass('pf-chatbot__button--response-action-clicked');
656
+
657
+ await userEvent.click(screen.getByText('Test message'));
658
+ expect(screen.getByRole('button', { name: /Good response recorded/i })).toHaveClass(
659
+ 'pf-chatbot__button--response-action-clicked'
660
+ );
661
+ expect(copyBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked');
662
+ });
663
+
469
664
  it('should not show actions if loading', async () => {
470
665
  render(
471
666
  <Message
@@ -527,6 +722,7 @@ describe('Message', () => {
527
722
  expect(screen.queryByRole('button', { name: label })).toBeFalsy();
528
723
  });
529
724
  });
725
+
530
726
  it('should render unordered lists correctly', () => {
531
727
  render(<Message avatar="./img" role="user" name="User" content={UNORDERED_LIST} />);
532
728
  expect(screen.getByText('Here is an unordered list:')).toBeTruthy();
@@ -104,12 +104,27 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
104
104
  isLoading?: boolean;
105
105
  /** Array of attachments attached to a message */
106
106
  attachments?: MessageAttachment[];
107
- /** Props for message actions, such as feedback (positive or negative), copy button, edit message, share, and listen */
108
- actions?: {
109
- [key: string]: ActionProps;
110
- };
107
+ /** Props for message actions, such as feedback (positive or negative), copy button, edit message, share, and listen.
108
+ * Can be a single actions object or an array of action group objects. When passing an array, you can pass an object of actions or
109
+ * an object that contains an actions property for finer control of selection persistence.
110
+ */
111
+ actions?:
112
+ | {
113
+ [key: string]: ActionProps;
114
+ }
115
+ | {
116
+ [key: string]: ActionProps;
117
+ }[]
118
+ | {
119
+ actions: {
120
+ [key: string]: ActionProps;
121
+ };
122
+ persistActionSelection?: boolean;
123
+ }[];
111
124
  /** When true, the selected action will persist even when clicking outside the component.
112
- * When false (default), clicking outside or clicking another action will deselect the current selection. */
125
+ * When false (default), clicking outside or clicking another action will deselect the current selection.
126
+ * For finer control of multiple action groups, use persistActionSelection on each group.
127
+ */
113
128
  persistActionSelection?: boolean;
114
129
  /** Sources for message */
115
130
  sources?: SourcesCardProps;
@@ -506,7 +521,21 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
506
521
  />
507
522
  )}
508
523
  {!isLoading && !isEditable && actions && (
509
- <ResponseActions actions={actions} persistActionSelection={persistActionSelection} />
524
+ <>
525
+ {Array.isArray(actions) ? (
526
+ <div className="pf-chatbot__response-actions-groups">
527
+ {actions.map((actionGroup, index) => (
528
+ <ResponseActions
529
+ key={index}
530
+ actions={actionGroup.actions || actionGroup}
531
+ persistActionSelection={persistActionSelection || actionGroup.persistActionSelection}
532
+ />
533
+ ))}
534
+ </div>
535
+ ) : (
536
+ <ResponseActions actions={actions} persistActionSelection={persistActionSelection} />
537
+ )}
538
+ </>
510
539
  )}
511
540
  {userFeedbackForm && <UserFeedback {...userFeedbackForm} timestamp={dateString} isCompact={isCompact} />}
512
541
  {userFeedbackComplete && (
@@ -22,6 +22,17 @@
22
22
  }
23
23
  }
24
24
 
25
+ .pf-chatbot__response-actions-groups {
26
+ display: grid;
27
+ grid-auto-flow: column;
28
+ grid-auto-columns: max-content;
29
+ gap: var(--pf-t--global--spacer--xs);
30
+
31
+ .pf-chatbot__response-actions {
32
+ display: flex;
33
+ }
34
+ }
35
+
25
36
  .pf-v6-c-button.pf-chatbot__button--response-action-clicked.pf-v6-c-button.pf-m-plain.pf-m-small {
26
37
  --pf-v6-c-button--m-plain--BackgroundColor: var(--pf-t--global--background--color--action--plain--alt--clicked);
27
38
  --pf-v6-c-button__icon--Color: var(--pf-t--global--icon--color--regular);
@@ -181,4 +181,55 @@ describe('ToolCall', () => {
181
181
  render(<ToolCall {...defaultProps} cardFooterProps={{ id: 'card-footer-test-id' }} />);
182
182
  expect(screen.getByRole('button', { name: 'Run tool' }).closest('#card-footer-test-id')).toBeVisible();
183
183
  });
184
+
185
+ it('Renders collapsed by default when expandableContent is provided', () => {
186
+ render(<ToolCall {...defaultProps} expandableContent="Expandable Content" />);
187
+
188
+ expect(screen.getByRole('button', { name: defaultProps.titleText })).toHaveAttribute('aria-expanded', 'false');
189
+ expect(screen.queryByText('Expandable Content')).not.toBeVisible();
190
+ });
191
+
192
+ it('Renders expanded when isDefaultExpanded is true', () => {
193
+ render(<ToolCall {...defaultProps} isDefaultExpanded expandableContent="Expandable Content" />);
194
+
195
+ expect(screen.getByRole('button', { name: defaultProps.titleText })).toHaveAttribute('aria-expanded', 'true');
196
+ expect(screen.getByText('Expandable Content')).toBeVisible();
197
+ });
198
+
199
+ it('expandableSectionProps.isExpanded overrides isDefaultExpanded', () => {
200
+ render(
201
+ <ToolCall
202
+ {...defaultProps}
203
+ isDefaultExpanded={false}
204
+ expandableContent="Expandable Content"
205
+ expandableSectionProps={{ isExpanded: true }}
206
+ />
207
+ );
208
+
209
+ expect(screen.getByRole('button', { name: defaultProps.titleText })).toHaveAttribute('aria-expanded', 'true');
210
+ expect(screen.getByText('Expandable Content')).toBeVisible();
211
+ });
212
+
213
+ it('expandableSectionProps.onToggle overrides internal onToggle behavior', async () => {
214
+ const user = userEvent.setup();
215
+ const customOnToggle = jest.fn();
216
+
217
+ render(
218
+ <ToolCall
219
+ {...defaultProps}
220
+ isDefaultExpanded={false}
221
+ expandableContent="Expandable Content"
222
+ expandableSectionProps={{ onToggle: customOnToggle }}
223
+ />
224
+ );
225
+
226
+ const toggleButton = screen.getByRole('button', { name: defaultProps.titleText });
227
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'false');
228
+
229
+ await user.click(toggleButton);
230
+
231
+ expect(customOnToggle).toHaveBeenCalledTimes(1);
232
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'false');
233
+ expect(screen.queryByText('Expandable Content')).not.toBeVisible();
234
+ });
184
235
  });
@@ -1,4 +1,4 @@
1
- import { type FunctionComponent } from 'react';
1
+ import { useState, type FunctionComponent } from 'react';
2
2
  import {
3
3
  ActionList,
4
4
  ActionListProps,
@@ -31,6 +31,8 @@ export interface ToolCallProps {
31
31
  spinnerProps?: SpinnerProps;
32
32
  /** Content to render within an expandable section. */
33
33
  expandableContent?: React.ReactNode;
34
+ /** Flag indicating whether the expandable content is expanded by default. */
35
+ isDefaultExpanded?: boolean;
34
36
  /** Text content for the "run" action button. */
35
37
  runButtonText?: string;
36
38
  /** Additional props for the "run" action button. */
@@ -66,6 +68,7 @@ export const ToolCall: FunctionComponent<ToolCallProps> = ({
66
68
  loadingText,
67
69
  isLoading,
68
70
  expandableContent,
71
+ isDefaultExpanded = false,
69
72
  runButtonText = 'Run tool',
70
73
  runButtonProps,
71
74
  runActionItemProps,
@@ -82,6 +85,12 @@ export const ToolCall: FunctionComponent<ToolCallProps> = ({
82
85
  expandableSectionProps,
83
86
  spinnerProps
84
87
  }: ToolCallProps) => {
88
+ const [isExpanded, setIsExpanded] = useState(isDefaultExpanded);
89
+
90
+ const onToggle = (_event: React.MouseEvent, isExpanded: boolean) => {
91
+ setIsExpanded(isExpanded);
92
+ };
93
+
85
94
  const titleContent = (
86
95
  <span className={`pf-chatbot__tool-call-title-content`}>
87
96
  {isLoading ? (
@@ -124,6 +133,8 @@ export const ToolCall: FunctionComponent<ToolCallProps> = ({
124
133
  <ExpandableSection
125
134
  className="pf-chatbot__tool-call-expandable-section"
126
135
  toggleContent={titleContent}
136
+ onToggle={onToggle}
137
+ isExpanded={isExpanded}
127
138
  isIndented
128
139
  {...expandableSectionProps}
129
140
  >
@@ -1,4 +1,5 @@
1
1
  import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
2
3
  import '@testing-library/jest-dom';
3
4
  import ToolResponse from './ToolResponse';
4
5
 
@@ -105,4 +106,47 @@ describe('ToolResponse', () => {
105
106
  const { container } = render(<ToolResponse {...defaultProps} cardBody={undefined} />);
106
107
  expect(container.querySelector('.pf-v6-c-divider')).toBeFalsy();
107
108
  });
109
+
110
+ it('Renders expanded by default', () => {
111
+ render(<ToolResponse {...defaultProps} />);
112
+
113
+ expect(screen.getByRole('button', { name: defaultProps.toggleContent })).toHaveAttribute('aria-expanded', 'true');
114
+ expect(screen.getByText(defaultProps.cardTitle)).toBeVisible();
115
+ expect(screen.getByText(defaultProps.cardBody)).toBeVisible();
116
+ });
117
+
118
+ it('Renders collapsed when isDefaultExpanded is false', () => {
119
+ render(<ToolResponse isDefaultExpanded={false} {...defaultProps} />);
120
+
121
+ expect(screen.getByRole('button', { name: defaultProps.toggleContent })).toHaveAttribute('aria-expanded', 'false');
122
+ expect(screen.getByText(defaultProps.cardTitle)).not.toBeVisible();
123
+ expect(screen.getByText(defaultProps.cardBody)).not.toBeVisible();
124
+ });
125
+
126
+ it('expandableSectionProps.isExpanded overrides isDefaultExpanded', () => {
127
+ render(<ToolResponse {...defaultProps} isDefaultExpanded={false} expandableSectionProps={{ isExpanded: true }} />);
128
+
129
+ expect(screen.getByRole('button', { name: defaultProps.toggleContent })).toHaveAttribute('aria-expanded', 'true');
130
+ expect(screen.getByText(defaultProps.cardTitle)).toBeVisible();
131
+ expect(screen.getByText(defaultProps.cardBody)).toBeVisible();
132
+ });
133
+
134
+ it('expandableSectionProps.onToggle overrides internal onToggle behavior', async () => {
135
+ const user = userEvent.setup();
136
+ const customOnToggle = jest.fn();
137
+
138
+ render(
139
+ <ToolResponse {...defaultProps} isDefaultExpanded={false} expandableSectionProps={{ onToggle: customOnToggle }} />
140
+ );
141
+
142
+ const toggleButton = screen.getByRole('button', { name: defaultProps.toggleContent });
143
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'false');
144
+
145
+ await user.click(toggleButton);
146
+
147
+ expect(customOnToggle).toHaveBeenCalledTimes(1);
148
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'false');
149
+ expect(screen.getByText(defaultProps.cardTitle)).not.toBeVisible();
150
+ expect(screen.getByText(defaultProps.cardBody)).not.toBeVisible();
151
+ });
108
152
  });
@@ -18,6 +18,8 @@ import { useState, type FunctionComponent } from 'react';
18
18
  export interface ToolResponseProps {
19
19
  /** Toggle content shown for expandable section */
20
20
  toggleContent: React.ReactNode;
21
+ /** Flag indicating whether the expandable content is expanded by default. */
22
+ isDefaultExpanded?: boolean;
21
23
  /** Additional props passed to expandable section */
22
24
  expandableSectionProps?: Omit<ExpandableSectionProps, 'ref'>;
23
25
  /** Subheading rendered inside expandable section */
@@ -51,12 +53,13 @@ export const ToolResponse: FunctionComponent<ToolResponseProps> = ({
51
53
  cardTitle,
52
54
  cardBodyProps,
53
55
  toggleContent,
56
+ isDefaultExpanded = true,
54
57
  toolResponseCardBodyProps,
55
58
  toolResponseCardDividerProps,
56
59
  toolResponseCardProps,
57
60
  toolResponseCardTitleProps
58
61
  }: ToolResponseProps) => {
59
- const [isExpanded, setIsExpanded] = useState(true);
62
+ const [isExpanded, setIsExpanded] = useState(isDefaultExpanded);
60
63
 
61
64
  const onToggle = (_event: React.MouseEvent, isExpanded: boolean) => {
62
65
  setIsExpanded(isExpanded);