Skip to content

Commit 6abd00d

Browse files
authored
Merge pull request #774 from thatblindgeye/iss768
feat(ConversationHistoryNav): added search actions
2 parents 0af8fe7 + 00e7f7a commit 6abd00d

File tree

5 files changed

+374
-17
lines changed

5 files changed

+374
-17
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { FunctionComponent, useState } from 'react';
2+
import { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot';
3+
import ChatbotConversationHistoryNav, {
4+
Conversation
5+
} from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav';
6+
import {
7+
Button,
8+
Checkbox,
9+
MenuToggle,
10+
MenuToggleElement,
11+
Select,
12+
SelectList,
13+
SelectOption,
14+
Tooltip
15+
} from '@patternfly/react-core';
16+
import { FilterIcon, SortAmountDownIcon } from '@patternfly/react-icons';
17+
18+
const initialConversations: { [key: string]: Conversation[] } = {
19+
Today: [{ id: '1', text: 'Red Hat products and services' }],
20+
'This month': [
21+
{
22+
id: '2',
23+
text: 'Enterprise Linux installation and setup'
24+
},
25+
{ id: '3', text: 'Troubleshoot system crash' }
26+
],
27+
March: [
28+
{ id: '4', text: 'Ansible security and updates' },
29+
{ id: '5', text: 'Red Hat certification' },
30+
{ id: '6', text: 'Lightspeed user documentation' }
31+
],
32+
February: [
33+
{ id: '7', text: 'Crashing pod assistance' },
34+
{ id: '8', text: 'OpenShift AI pipelines' },
35+
{ id: '9', text: 'Updating subscription plan' },
36+
{ id: '10', text: 'Red Hat licensing options' }
37+
],
38+
January: [
39+
{ id: '11', text: 'RHEL system performance' },
40+
{ id: '12', text: 'Manage user accounts' }
41+
]
42+
};
43+
44+
export const ChatbotHeaderTitleDemo: FunctionComponent = () => {
45+
const [isDrawerOpen, setIsDrawerOpen] = useState(true);
46+
const [hasDrawerHeadDivider, setHasDrawerHeadDivider] = useState(false);
47+
const [showSearchActionStart, setShowSearchActionStart] = useState(false);
48+
const [showSearchActionEnd, setShowSearchActionEnd] = useState(false);
49+
const [isLoading, setIsLoading] = useState(false);
50+
const [isSortSelectOpen, setIsSortSelectOpen] = useState(false);
51+
const [selectedSort, setSelectedSort] = useState<string>('newest');
52+
const [conversations, setConversations] = useState<Conversation[] | { [key: string]: Conversation[] }>(
53+
initialConversations
54+
);
55+
const displayMode = ChatbotDisplayMode.embedded;
56+
57+
const sortLabels: { [key: string]: string } = {
58+
newest: 'Date (newest first)',
59+
oldest: 'Date (oldest first)',
60+
'alphabetical-asc': 'Name (A-Z)',
61+
'alphabetical-desc': 'Name (Z-A)'
62+
};
63+
64+
const onSortSelect = (
65+
_event: React.MouseEvent<Element, MouseEvent> | undefined,
66+
value: string | number | undefined
67+
) => {
68+
setSelectedSort(value as string);
69+
setIsSortSelectOpen(false);
70+
};
71+
72+
const findMatchingItems = (targetValue: string) => {
73+
const filteredConversations = Object.entries(initialConversations).reduce((acc, [key, items]) => {
74+
const filteredItems = items.filter((item) => item.text.toLowerCase().includes(targetValue.toLowerCase()));
75+
if (filteredItems.length > 0) {
76+
acc[key] = filteredItems;
77+
}
78+
return acc;
79+
}, {});
80+
81+
return filteredConversations;
82+
};
83+
84+
return (
85+
<>
86+
<Checkbox
87+
label="Display drawer"
88+
isChecked={isDrawerOpen}
89+
onChange={() => setIsDrawerOpen(!isDrawerOpen)}
90+
id="search-actions-drawer-visible"
91+
name="drawer-visible"
92+
/>
93+
<Checkbox
94+
label="Show drawer head divider"
95+
isChecked={hasDrawerHeadDivider}
96+
onChange={() => setHasDrawerHeadDivider(!hasDrawerHeadDivider)}
97+
id="search-actions-drawer-head-divider"
98+
name="drawer-head-divider"
99+
/>
100+
<Checkbox
101+
label="Show search action start"
102+
isChecked={showSearchActionStart}
103+
onChange={() => setShowSearchActionStart(!showSearchActionStart)}
104+
id="search-actions-show-search-action-start"
105+
name="show-search-action-start"
106+
/>
107+
<Checkbox
108+
label="Show search action end"
109+
isChecked={showSearchActionEnd}
110+
onChange={() => setShowSearchActionEnd(!showSearchActionEnd)}
111+
id="search-actions-show-search-action-end"
112+
name="show-search-action-end"
113+
/>
114+
<Checkbox
115+
label="Show loading state"
116+
isChecked={isLoading}
117+
onChange={() => setIsLoading(!isLoading)}
118+
id="search-actions-drawer-is-loading"
119+
name="drawer-is-loading"
120+
/>
121+
<ChatbotConversationHistoryNav
122+
displayMode={displayMode}
123+
onDrawerToggle={() => setIsDrawerOpen(!isDrawerOpen)}
124+
isDrawerOpen={isDrawerOpen}
125+
setIsDrawerOpen={setIsDrawerOpen}
126+
// eslint-disable-next-line no-console
127+
onSelectActiveItem={(e, selectedItem) => console.log(`Selected history item with id ${selectedItem}`)}
128+
conversations={conversations}
129+
onNewChat={() => {
130+
setIsDrawerOpen(!isDrawerOpen);
131+
}}
132+
handleTextInputChange={(value: string) => {
133+
if (value === '') {
134+
setConversations(initialConversations);
135+
} else {
136+
const newConversations: { [key: string]: Conversation[] } = findMatchingItems(value);
137+
setConversations(newConversations);
138+
}
139+
}}
140+
drawerContent={<div>Drawer content</div>}
141+
hasDrawerHeadDivider={hasDrawerHeadDivider}
142+
isLoading={isLoading}
143+
searchActionStart={
144+
showSearchActionStart ? (
145+
<Tooltip content="Filter options" aria="none" aria-live="off">
146+
<Button
147+
variant="control"
148+
aria-label="Filter options"
149+
// eslint-disable-next-line no-console
150+
onClick={() => console.log('Filter button clicked')}
151+
icon={<FilterIcon />}
152+
/>
153+
</Tooltip>
154+
) : undefined
155+
}
156+
searchActionEnd={
157+
showSearchActionEnd ? (
158+
<Select
159+
id="sort-select"
160+
isOpen={isSortSelectOpen}
161+
selected={selectedSort}
162+
onSelect={onSortSelect}
163+
shouldFocusToggleOnSelect
164+
onOpenChange={(isOpen) => setIsSortSelectOpen(isOpen)}
165+
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
166+
<Tooltip aria="none" aria-live="off" content={`Sort - ${sortLabels[selectedSort]}`}>
167+
<MenuToggle
168+
ref={toggleRef}
169+
onClick={() => setIsSortSelectOpen(!isSortSelectOpen)}
170+
isExpanded={isSortSelectOpen}
171+
variant="plain"
172+
aria-label={`${sortLabels[selectedSort]}, Sort conversations`}
173+
icon={
174+
<SortAmountDownIcon
175+
style={{
176+
transform:
177+
selectedSort === 'oldest' || selectedSort === 'alphabetical-asc' ? 'scaleY(-1)' : 'none'
178+
}}
179+
/>
180+
}
181+
/>
182+
</Tooltip>
183+
)}
184+
>
185+
<SelectList>
186+
{Object.keys(sortLabels).map((currentLabel) => (
187+
<SelectOption key={currentLabel} value={currentLabel}>
188+
{sortLabels[currentLabel]}
189+
</SelectOption>
190+
))}
191+
</SelectList>
192+
</Select>
193+
) : undefined
194+
}
195+
/>
196+
</>
197+
);
198+
};

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ import { BellIcon, CalendarAltIcon, ClipboardIcon, CodeIcon, ThumbtackIcon, Uplo
7474
import { useDropzone } from 'react-dropzone';
7575

7676
import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav';
77-
import { DropdownItem, DropdownList, Checkbox } from '@patternfly/react-core';
77+
import { Button, DropdownItem, DropdownList, Checkbox, MenuToggle, Select, SelectList, SelectOption } from '@patternfly/react-core';
7878

7979
import OutlinedWindowRestoreIcon from '@patternfly/react-icons/dist/esm/icons/outlined-window-restore-icon';
8080
import ExpandIcon from '@patternfly/react-icons/dist/esm/icons/expand-icon';
@@ -87,7 +87,7 @@ import userAvatar from '../Messages/user_avatar.svg';
8787
import patternflyAvatar from '../Messages/patternfly_avatar.jpg';
8888
import termsAndConditionsHeader from './PF-TermsAndConditionsHeader.svg';
8989
import onboardingHeader from './RH-Hat-Image.svg';
90-
import { CloseIcon, SearchIcon, OutlinedCommentsIcon } from '@patternfly/react-icons';
90+
import { CloseIcon, SearchIcon, OutlinedCommentsIcon, FilterIcon, SortAmountDownIcon } from '@patternfly/react-icons';
9191
import { FunctionComponent, FormEvent, useState, useRef, MouseEvent, isValidElement, cloneElement, Children, ReactNode, Ref, MouseEvent as ReactMouseEvent, CSSProperties, useEffect} from 'react';
9292
import FilePreview from '@patternfly/chatbot/dist/dynamic/FilePreview';
9393

@@ -371,6 +371,16 @@ Both the search input field and "New chat" buttons are optional. The `reverseBut
371371

372372
```
373373

374+
### Drawer with search actions
375+
376+
You can customize the search experience within the conversation history drawer via the `searchActionStart` and `searchActionEnd` props, which provide additional search controls before and after the input field. These props are useful for adding filtering, sorting, or other search-related functionality.
377+
378+
You can also add a visual divider between the drawer head and the title by setting `hasDrawerHeadDivider` to `true`.
379+
380+
```ts file="./ChatbotHeaderDrawerWithSearchActions.tsx"
381+
382+
```
383+
374384
### Drawer with conversation actions
375385

376386
Actions can be added to conversations with `menuItems`. Optionally, you can also add a `className` to the menu via `menuClassName`, change the default aria-label and tooltip content via `label`, or add an `onSelect` callback for when a user selects an item.

packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.scss

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
border-radius: var(--pf-t--global--border--radius--medium);
88
}
99

10+
.pf-chatbot__heading-divider {
11+
padding-inline-start: var(--pf-t--global--spacer--lg);
12+
padding-inline-end: var(--pf-t--global--spacer--lg);
13+
}
14+
1015
// Drawer title
1116
// ----------------------------------------------------------------------------
1217
.pf-chatbot__heading-container {
@@ -28,6 +33,19 @@
2833
justify-content: flex-start;
2934
gap: var(--pf-t--global--spacer--gap--text-to-element--default);
3035
}
36+
37+
// Drawer search and actions
38+
.pf-chatbot__history-search-actions {
39+
.pf-v6-c-button.pf-m-control {
40+
--pf-v6-c-button--m-control--PaddingInlineStart: var(--pf-t--global--spacer--control--horizontal--compact);
41+
--pf-v6-c-button--m-control--PaddingInlineEnd: var(--pf-t--global--spacer--control--horizontal--compact);
42+
}
43+
}
44+
45+
.pf-chatbot__input {
46+
width: 100%;
47+
}
48+
3149
// Drawer menu
3250
// ----------------------------------------------------------------------------
3351
.pf-v6-c-menu {

packages/module/src/ChatbotConversationHistoryNav/ChatbotConversationHistoryNav.test.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,101 @@ describe('ChatbotConversationHistoryNav', () => {
592592
expect(screen.getByRole('dialog', { name: /Chat history I am a sample search/i })).toBeInTheDocument();
593593
});
594594

595+
it('Does not render search actions by default', () => {
596+
const handleSearch = jest.fn();
597+
const groupedConversations: { [key: string]: Conversation[] } = {
598+
Today: [...initialConversations, { id: '2', text: 'Chatbot extension' }]
599+
};
600+
601+
render(
602+
<ChatbotConversationHistoryNav
603+
onDrawerToggle={onDrawerToggle}
604+
isDrawerOpen={true}
605+
displayMode={ChatbotDisplayMode.fullscreen}
606+
setIsDrawerOpen={jest.fn()}
607+
reverseButtonOrder={false}
608+
conversations={groupedConversations}
609+
handleTextInputChange={handleSearch}
610+
/>
611+
);
612+
613+
const searchInput = screen.getByPlaceholderText(/Search/i);
614+
615+
expect(searchInput.parentElement?.previousElementSibling).toBeNull();
616+
expect(searchInput.parentElement?.nextElementSibling).toBeNull();
617+
});
618+
619+
it('Renders with action at start when searchActionStart is passed', () => {
620+
const handleSearch = jest.fn();
621+
const groupedConversations: { [key: string]: Conversation[] } = {
622+
Today: [...initialConversations, { id: '2', text: 'Chatbot extension' }]
623+
};
624+
625+
render(
626+
<ChatbotConversationHistoryNav
627+
onDrawerToggle={onDrawerToggle}
628+
isDrawerOpen={true}
629+
displayMode={ChatbotDisplayMode.fullscreen}
630+
setIsDrawerOpen={jest.fn()}
631+
reverseButtonOrder={false}
632+
conversations={groupedConversations}
633+
handleTextInputChange={handleSearch}
634+
searchActionStart={<div>Search action start test</div>}
635+
/>
636+
);
637+
638+
expect(screen.getByText('Search action start test')).toBeVisible();
639+
});
640+
641+
it('Renders with action at end when searchActionEnd is passed', () => {
642+
const handleSearch = jest.fn();
643+
const groupedConversations: { [key: string]: Conversation[] } = {
644+
Today: [...initialConversations, { id: '2', text: 'Chatbot extension' }]
645+
};
646+
647+
render(
648+
<ChatbotConversationHistoryNav
649+
onDrawerToggle={onDrawerToggle}
650+
isDrawerOpen={true}
651+
displayMode={ChatbotDisplayMode.fullscreen}
652+
setIsDrawerOpen={jest.fn()}
653+
reverseButtonOrder={false}
654+
handleTextInputChange={handleSearch}
655+
conversations={groupedConversations}
656+
searchActionEnd={<div>Search action end test</div>}
657+
/>
658+
);
659+
660+
expect(screen.getByText('Search action end test')).toBeVisible();
661+
});
662+
663+
it('Overrides default search input and actions when searchToolbar is passed', () => {
664+
const handleSearch = jest.fn();
665+
const groupedConversations: { [key: string]: Conversation[] } = {
666+
Today: [...initialConversations, { id: '2', text: 'Chatbot extension' }]
667+
};
668+
669+
render(
670+
<ChatbotConversationHistoryNav
671+
onDrawerToggle={onDrawerToggle}
672+
isDrawerOpen={true}
673+
displayMode={ChatbotDisplayMode.fullscreen}
674+
setIsDrawerOpen={jest.fn()}
675+
reverseButtonOrder={false}
676+
conversations={groupedConversations}
677+
handleTextInputChange={handleSearch}
678+
searchActionStart={<div>Search action start test</div>}
679+
searchActionEnd={<div>Search action end test</div>}
680+
searchToolbar={<div>Custom toolbar</div>}
681+
/>
682+
);
683+
684+
expect(screen.queryByPlaceholderText(/Search/i)).not.toBeInTheDocument();
685+
expect(screen.queryByText('Search action start test')).not.toBeInTheDocument();
686+
expect(screen.queryByText('Search action end test')).not.toBeInTheDocument();
687+
expect(screen.getByText('Custom toolbar')).toBeInTheDocument();
688+
});
689+
595690
it('overrides nav title heading level when navTitleProps.headingLevel is passed', () => {
596691
render(
597692
<ChatbotConversationHistoryNav

0 commit comments

Comments
 (0)