Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { FunctionComponent, useState } from 'react';
import { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot';
import ChatbotConversationHistoryNav, {
Conversation
} from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav';
import {
Button,
Checkbox,
MenuToggle,
MenuToggleElement,
Select,
SelectList,
SelectOption,
Tooltip
} from '@patternfly/react-core';
import { FilterIcon, SortAmountDownIcon } from '@patternfly/react-icons';

const initialConversations: { [key: string]: Conversation[] } = {
Today: [{ id: '1', text: 'Red Hat products and services' }],
'This month': [
{
id: '2',
text: 'Enterprise Linux installation and setup'
},
{ id: '3', text: 'Troubleshoot system crash' }
],
March: [
{ id: '4', text: 'Ansible security and updates' },
{ id: '5', text: 'Red Hat certification' },
{ id: '6', text: 'Lightspeed user documentation' }
],
February: [
{ id: '7', text: 'Crashing pod assistance' },
{ id: '8', text: 'OpenShift AI pipelines' },
{ id: '9', text: 'Updating subscription plan' },
{ id: '10', text: 'Red Hat licensing options' }
],
January: [
{ id: '11', text: 'RHEL system performance' },
{ id: '12', text: 'Manage user accounts' }
]
};

export const ChatbotHeaderTitleDemo: FunctionComponent = () => {
const [isDrawerOpen, setIsDrawerOpen] = useState(true);
const [hasDrawerHeadDivider, setHasDrawerHeadDivider] = useState(false);
const [showSearchActionStart, setShowSearchActionStart] = useState(false);
const [showSearchActionEnd, setShowSearchActionEnd] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSortSelectOpen, setIsSortSelectOpen] = useState(false);
const [selectedSort, setSelectedSort] = useState<string>('newest');
const [conversations, setConversations] = useState<Conversation[] | { [key: string]: Conversation[] }>(
initialConversations
);
const displayMode = ChatbotDisplayMode.embedded;

const sortLabels: { [key: string]: string } = {
newest: 'Date (newest first)',
oldest: 'Date (oldest first)',
'alphabetical-asc': 'Name (A-Z)',
'alphabetical-desc': 'Name (Z-A)'
};

const onSortSelect = (
_event: React.MouseEvent<Element, MouseEvent> | undefined,
value: string | number | undefined
) => {
setSelectedSort(value as string);
setIsSortSelectOpen(false);
};

const findMatchingItems = (targetValue: string) => {
const filteredConversations = Object.entries(initialConversations).reduce((acc, [key, items]) => {
const filteredItems = items.filter((item) => item.text.toLowerCase().includes(targetValue.toLowerCase()));
if (filteredItems.length > 0) {
acc[key] = filteredItems;
}
return acc;
}, {});

return filteredConversations;
};

return (
<>
<Checkbox
label="Display drawer"
isChecked={isDrawerOpen}
onChange={() => setIsDrawerOpen(!isDrawerOpen)}
id="search-actions-drawer-visible"
name="drawer-visible"
/>
<Checkbox
label="Show drawer head divider"
isChecked={hasDrawerHeadDivider}
onChange={() => setHasDrawerHeadDivider(!hasDrawerHeadDivider)}
id="search-actions-drawer-head-divider"
name="drawer-head-divider"
/>
<Checkbox
label="Show search action start"
isChecked={showSearchActionStart}
onChange={() => setShowSearchActionStart(!showSearchActionStart)}
id="search-actions-show-search-action-start"
name="show-search-action-start"
/>
<Checkbox
label="Show search action end"
isChecked={showSearchActionEnd}
onChange={() => setShowSearchActionEnd(!showSearchActionEnd)}
id="search-actions-show-search-action-end"
name="show-search-action-end"
/>
<Checkbox
label="Show loading state"
isChecked={isLoading}
onChange={() => setIsLoading(!isLoading)}
id="search-actions-drawer-is-loading"
name="drawer-is-loading"
/>
<ChatbotConversationHistoryNav
displayMode={displayMode}
onDrawerToggle={() => setIsDrawerOpen(!isDrawerOpen)}
isDrawerOpen={isDrawerOpen}
setIsDrawerOpen={setIsDrawerOpen}
// eslint-disable-next-line no-console
onSelectActiveItem={(e, selectedItem) => console.log(`Selected history item with id ${selectedItem}`)}
conversations={conversations}
onNewChat={() => {
setIsDrawerOpen(!isDrawerOpen);
}}
handleTextInputChange={(value: string) => {
if (value === '') {
setConversations(initialConversations);
} else {
const newConversations: { [key: string]: Conversation[] } = findMatchingItems(value);
setConversations(newConversations);
}
}}
drawerContent={<div>Drawer content</div>}
hasDrawerHeadDivider={hasDrawerHeadDivider}
isLoading={isLoading}
searchActionStart={
showSearchActionStart ? (
<Tooltip content="Filter options" aria="none" aria-live="off">
<Button
variant="control"
aria-label="Filter options"
// eslint-disable-next-line no-console
onClick={() => console.log('Filter button clicked')}
icon={<FilterIcon />}
/>
</Tooltip>
) : undefined
}
searchActionEnd={
showSearchActionEnd ? (
<Select
id="sort-select"
isOpen={isSortSelectOpen}
selected={selectedSort}
onSelect={onSortSelect}
shouldFocusToggleOnSelect
onOpenChange={(isOpen) => setIsSortSelectOpen(isOpen)}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<Tooltip aria="none" aria-live="off" content={`Sort - ${sortLabels[selectedSort]}`}>
<MenuToggle
ref={toggleRef}
onClick={() => setIsSortSelectOpen(!isSortSelectOpen)}
isExpanded={isSortSelectOpen}
variant="plain"
aria-label={`${sortLabels[selectedSort]}, Sort conversations`}
icon={
<SortAmountDownIcon
style={{
transform:
selectedSort === 'oldest' || selectedSort === 'alphabetical-asc' ? 'scaleY(-1)' : 'none'
}}
/>
}
/>
</Tooltip>
)}
>
<SelectList>
{Object.keys(sortLabels).map((currentLabel) => (
<SelectOption key={currentLabel} value={currentLabel}>
{sortLabels[currentLabel]}
</SelectOption>
))}
</SelectList>
</Select>
) : undefined
}
/>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ import { BellIcon, CalendarAltIcon, ClipboardIcon, CodeIcon, ThumbtackIcon, Uplo
import { useDropzone } from 'react-dropzone';

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

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

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

```

### Drawer with search actions

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.

You can also add a visual divider between the drawer head and the title by setting `hasDrawerHeadDivider` to `true`.

```ts file="./ChatbotHeaderDrawerWithSearchActions.tsx"

```

### Drawer with conversation actions

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
border-radius: var(--pf-t--global--border--radius--medium);
}

.pf-chatbot__heading-divider {
padding-inline-start: var(--pf-t--global--spacer--lg);
padding-inline-end: var(--pf-t--global--spacer--lg);
}

// Drawer title
// ----------------------------------------------------------------------------
.pf-chatbot__heading-container {
Expand All @@ -28,6 +33,19 @@
justify-content: flex-start;
gap: var(--pf-t--global--spacer--gap--text-to-element--default);
}

// Drawer search and actions
.pf-chatbot__history-search-actions {
.pf-v6-c-button.pf-m-control {
--pf-v6-c-button--m-control--PaddingInlineStart: var(--pf-t--global--spacer--control--horizontal--compact);
--pf-v6-c-button--m-control--PaddingInlineEnd: var(--pf-t--global--spacer--control--horizontal--compact);
}
}

.pf-chatbot__input {
width: 100%;
}

// Drawer menu
// ----------------------------------------------------------------------------
.pf-v6-c-menu {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,101 @@ describe('ChatbotConversationHistoryNav', () => {
expect(screen.getByRole('dialog', { name: /Chat history I am a sample search/i })).toBeInTheDocument();
});

it('Does not render search actions by default', () => {
const handleSearch = jest.fn();
const groupedConversations: { [key: string]: Conversation[] } = {
Today: [...initialConversations, { id: '2', text: 'Chatbot extension' }]
};

render(
<ChatbotConversationHistoryNav
onDrawerToggle={onDrawerToggle}
isDrawerOpen={true}
displayMode={ChatbotDisplayMode.fullscreen}
setIsDrawerOpen={jest.fn()}
reverseButtonOrder={false}
conversations={groupedConversations}
handleTextInputChange={handleSearch}
/>
);

const searchInput = screen.getByPlaceholderText(/Search/i);

expect(searchInput.parentElement?.previousElementSibling).toBeNull();
expect(searchInput.parentElement?.nextElementSibling).toBeNull();
});

it('Renders with action at start when searchActionStart is passed', () => {
const handleSearch = jest.fn();
const groupedConversations: { [key: string]: Conversation[] } = {
Today: [...initialConversations, { id: '2', text: 'Chatbot extension' }]
};

render(
<ChatbotConversationHistoryNav
onDrawerToggle={onDrawerToggle}
isDrawerOpen={true}
displayMode={ChatbotDisplayMode.fullscreen}
setIsDrawerOpen={jest.fn()}
reverseButtonOrder={false}
conversations={groupedConversations}
handleTextInputChange={handleSearch}
searchActionStart={<div>Search action start test</div>}
/>
);

expect(screen.getByText('Search action start test')).toBeVisible();
});

it('Renders with action at end when searchActionEnd is passed', () => {
const handleSearch = jest.fn();
const groupedConversations: { [key: string]: Conversation[] } = {
Today: [...initialConversations, { id: '2', text: 'Chatbot extension' }]
};

render(
<ChatbotConversationHistoryNav
onDrawerToggle={onDrawerToggle}
isDrawerOpen={true}
displayMode={ChatbotDisplayMode.fullscreen}
setIsDrawerOpen={jest.fn()}
reverseButtonOrder={false}
handleTextInputChange={handleSearch}
conversations={groupedConversations}
searchActionEnd={<div>Search action end test</div>}
/>
);

expect(screen.getByText('Search action end test')).toBeVisible();
});

it('Overrides default search input and actions when searchToolbar is passed', () => {
const handleSearch = jest.fn();
const groupedConversations: { [key: string]: Conversation[] } = {
Today: [...initialConversations, { id: '2', text: 'Chatbot extension' }]
};

render(
<ChatbotConversationHistoryNav
onDrawerToggle={onDrawerToggle}
isDrawerOpen={true}
displayMode={ChatbotDisplayMode.fullscreen}
setIsDrawerOpen={jest.fn()}
reverseButtonOrder={false}
conversations={groupedConversations}
handleTextInputChange={handleSearch}
searchActionStart={<div>Search action start test</div>}
searchActionEnd={<div>Search action end test</div>}
searchToolbar={<div>Custom toolbar</div>}
/>
);

expect(screen.queryByPlaceholderText(/Search/i)).not.toBeInTheDocument();
expect(screen.queryByText('Search action start test')).not.toBeInTheDocument();
expect(screen.queryByText('Search action end test')).not.toBeInTheDocument();
expect(screen.getByText('Custom toolbar')).toBeInTheDocument();
});

it('overrides nav title heading level when navTitleProps.headingLevel is passed', () => {
render(
<ChatbotConversationHistoryNav
Expand Down
Loading
Loading