|
| 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 | +}; |
0 commit comments