Skip to content

Commit 2001c11

Browse files
committed
Built resource tagging demo
1 parent f5e29d6 commit 2001c11

File tree

2 files changed

+251
-1
lines changed

2 files changed

+251
-1
lines changed
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { useState, FunctionComponent, useRef, useEffect } from 'react';
2+
import { MessageBar } from '@patternfly/chatbot/dist/dynamic/MessageBar';
3+
import { Label, LabelGroup, Menu, MenuContent, MenuItem, MenuList, Popper } from '@patternfly/react-core';
4+
5+
interface Resource {
6+
id: string;
7+
name: string;
8+
type: string;
9+
}
10+
11+
export const ChatbotMessageBarResourceTaggingExample: FunctionComponent = () => {
12+
const [message, setMessage] = useState<string>('');
13+
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
14+
const [selectedResources, setSelectedResources] = useState<Resource[]>([]);
15+
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
16+
const [triggerPosition, setTriggerPosition] = useState<number>(-1);
17+
const [searchTerm, setSearchTerm] = useState<string>('');
18+
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
19+
20+
const textareaRef = useRef<HTMLTextAreaElement>(null);
21+
const menuRef = useRef<HTMLDivElement>(null);
22+
23+
// Sample resources
24+
const availableResources: Resource[] = [
25+
{ id: '1', name: 'pod/auth-operator', type: 'Pod' },
26+
{ id: '2', name: 'deployment/frontend-app', type: 'Deployment' },
27+
{ id: '3', name: 'service/backend-api', type: 'Service' },
28+
{ id: '4', name: 'configmap/app-config', type: 'ConfigMap' },
29+
{ id: '5', name: 'secret/db-credentials', type: 'Secret' },
30+
{ id: '6', name: 'pod/redis-cache', type: 'Pod' },
31+
{ id: '7', name: 'deployment/nginx-proxy', type: 'Deployment' },
32+
{ id: '8', name: 'service/auth-service', type: 'Service' }
33+
];
34+
35+
const handleSend = (msg: string | number) => {
36+
alert(`Sending message: ${msg}\nWith resources: ${selectedResources.map((r) => r.name).join(', ')}`);
37+
setSelectedResources([]);
38+
setMessage('');
39+
};
40+
41+
const handleChange = (_event: React.ChangeEvent<HTMLTextAreaElement>, value: string | number) => {
42+
const newValue = value.toString();
43+
setMessage(newValue);
44+
45+
// Check if "#" was just typed
46+
const lastChar = newValue[newValue.length - 1];
47+
const cursorPos = textareaRef.current?.selectionStart || 0;
48+
49+
if (lastChar === '#') {
50+
setTriggerPosition(cursorPos - 1);
51+
setIsMenuOpen(true);
52+
setSearchTerm('');
53+
setFilteredResources(availableResources);
54+
setActiveItemIndex(0);
55+
} else if (isMenuOpen && triggerPosition >= 0) {
56+
// Extract the search term after the "#"
57+
const textAfterTrigger = newValue.substring(triggerPosition + 1, cursorPos);
58+
59+
// Check if we've moved away from the tag or pressed space
60+
if (textAfterTrigger.includes(' ') || cursorPos < triggerPosition) {
61+
setIsMenuOpen(false);
62+
setTriggerPosition(-1);
63+
} else {
64+
setSearchTerm(textAfterTrigger);
65+
// Filter resources based on search term
66+
const filtered = availableResources.filter((resource) =>
67+
resource.name.toLowerCase().includes(textAfterTrigger.toLowerCase())
68+
);
69+
setFilteredResources(filtered);
70+
setActiveItemIndex(0);
71+
}
72+
}
73+
};
74+
75+
const handleResourceSelect = (resource: Resource) => {
76+
if (!textareaRef.current) {
77+
return;
78+
}
79+
80+
// Get the text before the "#" and after the current cursor position
81+
const beforeTag = message.substring(0, triggerPosition);
82+
const cursorPos = textareaRef.current.selectionStart || 0;
83+
const afterCursor = message.substring(cursorPos);
84+
85+
// Build new message with the full resource name, keeping the "#"
86+
const newMessage = `${beforeTag}#${resource.name} ${afterCursor}`;
87+
88+
// Update state - MessageBar will sync via its internal useEffect
89+
setMessage(newMessage);
90+
91+
// Add resource to selected resources if not already added
92+
if (!selectedResources.find((r) => r.id === resource.id)) {
93+
setSelectedResources([...selectedResources, resource]);
94+
}
95+
96+
// Close the menu and reset
97+
setIsMenuOpen(false);
98+
setTriggerPosition(-1);
99+
setSearchTerm('');
100+
101+
// Focus textarea and set cursor position after the inserted resource
102+
setTimeout(() => {
103+
if (textareaRef.current) {
104+
const newCursorPos = beforeTag.length + resource.name.length + 2; // +2 for "#" and space
105+
textareaRef.current.focus();
106+
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
107+
}
108+
}, 0);
109+
};
110+
111+
const handleRemoveResource = (resourceId: string) => {
112+
setSelectedResources(selectedResources.filter((r) => r.id !== resourceId));
113+
};
114+
115+
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
116+
if (!isMenuOpen || filteredResources.length === 0) {
117+
return;
118+
}
119+
120+
switch (event.key) {
121+
case 'ArrowDown':
122+
event.preventDefault();
123+
setActiveItemIndex((prev) => (prev + 1) % filteredResources.length);
124+
break;
125+
case 'ArrowUp':
126+
event.preventDefault();
127+
setActiveItemIndex((prev) => (prev - 1 + filteredResources.length) % filteredResources.length);
128+
break;
129+
case 'Enter':
130+
if (isMenuOpen) {
131+
event.preventDefault();
132+
const selectedResource = filteredResources[activeItemIndex];
133+
if (selectedResource) {
134+
handleResourceSelect(selectedResource);
135+
}
136+
}
137+
break;
138+
case 'Escape':
139+
if (isMenuOpen) {
140+
event.preventDefault();
141+
setIsMenuOpen(false);
142+
setTriggerPosition(-1);
143+
}
144+
break;
145+
}
146+
};
147+
148+
// Close menu when clicking outside
149+
useEffect(() => {
150+
const handleClickOutside = (event: MouseEvent) => {
151+
if (
152+
menuRef.current &&
153+
!menuRef.current.contains(event.target as Node) &&
154+
textareaRef.current &&
155+
!textareaRef.current.contains(event.target as Node)
156+
) {
157+
setIsMenuOpen(false);
158+
setTriggerPosition(-1);
159+
}
160+
};
161+
162+
document.addEventListener('mousedown', handleClickOutside);
163+
return () => {
164+
document.removeEventListener('mousedown', handleClickOutside);
165+
};
166+
}, []);
167+
168+
const menu = (
169+
<Menu
170+
ref={menuRef}
171+
onSelect={(_event, itemId) => {
172+
const resource = filteredResources.find((r) => r.id === itemId?.toString());
173+
if (resource) {
174+
handleResourceSelect(resource);
175+
}
176+
}}
177+
>
178+
<MenuContent>
179+
<MenuList>
180+
{filteredResources.length > 0 ? (
181+
filteredResources.map((resource, index) => (
182+
<MenuItem
183+
key={resource.id}
184+
itemId={resource.id}
185+
description={resource.type}
186+
isFocused={index === activeItemIndex}
187+
>
188+
{resource.name}
189+
</MenuItem>
190+
))
191+
) : (
192+
<MenuItem isDisabled>No resources found</MenuItem>
193+
)}
194+
</MenuList>
195+
</MenuContent>
196+
</Menu>
197+
);
198+
199+
return (
200+
<div className="pf-chatbot__footer-container" style={{ position: 'relative' }}>
201+
<Popper
202+
triggerRef={textareaRef}
203+
popper={menu}
204+
isVisible={isMenuOpen}
205+
enableFlip={true}
206+
placement="top-start"
207+
/>
208+
{selectedResources.length > 0 && (
209+
<div style={{ padding: '0.5rem 1rem' }}>
210+
<LabelGroup categoryName="Resources" isClosable={false}>
211+
{selectedResources.map((resource) => (
212+
<Label
213+
key={resource.id}
214+
onClose={() => handleRemoveResource(resource.id)}
215+
closeBtnAriaLabel={`Remove ${resource.name}`}
216+
>
217+
{resource.name}
218+
</Label>
219+
))}
220+
</LabelGroup>
221+
</div>
222+
)}
223+
<MessageBar
224+
onSendMessage={handleSend}
225+
value={message}
226+
onChange={handleChange}
227+
onKeyDown={handleKeyDown}
228+
innerRef={textareaRef}
229+
placeholder="Type # to tag a resource..."
230+
/>
231+
</div>
232+
);
233+
};

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ import { BellIcon, CalendarAltIcon, ClipboardIcon, CodeIcon, PlusIcon, Thumbtack
7474
import { useDropzone } from 'react-dropzone';
7575

7676
import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav';
77-
import { Button, Label, DropdownItem, DropdownList, Checkbox, MenuToggle, Select, SelectList, SelectOption } from '@patternfly/react-core';
77+
import { Button, DropdownItem, DropdownList, Checkbox, Label, LabelGroup, Menu, MenuContent, MenuItem, MenuList, MenuToggle, Popper, 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';
@@ -304,6 +304,23 @@ This example shows two message bar variations:
304304

305305
```
306306

307+
### Message bar with resource tagging
308+
309+
You can implement custom keyboard logic to create a typeahead-style dropdown that opens when users type special characters. This example demonstrates a resource tagging feature where:
310+
311+
1. Typing "#" opens a dropdown menu of available resources
312+
2. The menu automatically filters as you continue typing
313+
3. Selecting a resource autofills the name in the input
314+
4. A dismissable label appears above the message input showing the selected resource
315+
5. Multiple resources can be tagged in a single message
316+
6. Arrow keys navigate the menu (ArrowUp/ArrowDown), Enter selects, Escape closes
317+
318+
This pattern is useful for mentioning resources, users, channels, or other entities within chat messages.
319+
320+
```js file="./ChatbotMessageBarResourceTagging.tsx"
321+
322+
```
323+
307324
### Footer with message bar and footnote
308325

309326
A simple footer with a message bar and footnote would have this code structure:

0 commit comments

Comments
 (0)