diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotMessageBarResourceTagging.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotMessageBarResourceTagging.tsx new file mode 100644 index 00000000..b088ea68 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/ChatbotMessageBarResourceTagging.tsx @@ -0,0 +1,434 @@ +import { useState, FunctionComponent, useRef, useEffect, ReactNode } from 'react'; +import { MessageBar } from '@patternfly/chatbot/dist/dynamic/MessageBar'; +import { FileDetailsLabel } from '@patternfly/chatbot/dist/dynamic/FileDetailsLabel'; +import { + Divider, + DropdownItem, + DropdownList, + Flex, + FlexItem, + Label, + LabelGroup, + Menu, + MenuContent, + MenuItem, + MenuList, + MenuToggle, + Popper, + Select, + SelectList, + SelectOption +} from '@patternfly/react-core'; +import { PlusIcon, ClipboardIcon, CodeIcon, UploadIcon, HashtagIcon } from '@patternfly/react-icons'; + +interface Resource { + id: string; + name: string; + type: string; +} + +export const ChatbotMessageBarResourceTaggingExample: FunctionComponent = () => { + const [message, setMessage] = useState(''); + const [isResourceMenuOpen, setIsResourceMenuOpen] = useState(false); + const [isAttachMenuOpen, setIsAttachMenuOpen] = useState(false); + const [selectedResources, setSelectedResources] = useState([]); + const [filteredResources, setFilteredResources] = useState([]); + const [triggerPosition, setTriggerPosition] = useState(-1); + const [searchTerm, setSearchTerm] = useState(''); + const [activeItemIndex, setActiveItemIndex] = useState(0); + const [isRenderModeSelectOpen, setIsRenderModeSelectOpen] = useState(false); + const [renderMode, setRenderMode] = useState('label-with-category'); + + const textareaRef = useRef(null); + const resourceMenuRef = useRef(null); + + // Sample resources + const availableResources: Resource[] = [ + { id: '1', name: 'OpenShift Ansible Playbook', type: 'Chat' }, + { id: '2', name: 'Q4 Sales Performance', type: 'Dashboard' }, + { id: '3', name: 'Ansible RHEL Patcher', type: 'Code File' }, + { id: '4', name: 'prod-apps-useast1-a-7d9bd4...', type: 'Pod' }, + { id: '5', name: 'ingress-controller', type: 'Deployment' }, + { id: '6', name: 'prod-apps-eucentral1-a', type: 'Cluster' }, + { id: '7', name: 'apex-monitoring', type: 'Namespace' } + ]; + + const handleSend = (msg: string | number) => { + alert(`Sending message: ${msg}\nWith resources: ${selectedResources.map((r) => r.name).join(', ')}`); + setSelectedResources([]); + setMessage(''); + }; + + const handleChange = (_event: React.ChangeEvent, value: string | number) => { + const newValue = value.toString(); + setMessage(newValue); + + // Check if "#" was just typed + const lastChar = newValue[newValue.length - 1]; + const cursorPos = textareaRef.current?.selectionStart || 0; + + if (lastChar === '#') { + setTriggerPosition(cursorPos - 1); + setIsResourceMenuOpen(true); + setSearchTerm(''); + // Filter out already-selected resources + const unselectedResources = availableResources.filter( + (resource) => !selectedResources.find((r) => r.id === resource.id) + ); + setFilteredResources(unselectedResources); + setActiveItemIndex(0); + } else if (isResourceMenuOpen && triggerPosition >= 0) { + // Extract the search term after the "#" + const textAfterTrigger = newValue.substring(triggerPosition + 1, cursorPos); + + // Check if we've moved away from the tag or pressed space + if (textAfterTrigger.includes(' ') || cursorPos < triggerPosition) { + setIsResourceMenuOpen(false); + setTriggerPosition(-1); + } else { + setSearchTerm(textAfterTrigger); + // Filter resources based on search term and exclude already-selected resources + const filtered = availableResources.filter( + (resource) => + resource.name.toLowerCase().includes(textAfterTrigger.toLowerCase()) && + !selectedResources.find((r) => r.id === resource.id) + ); + setFilteredResources(filtered); + setActiveItemIndex(0); + } + } + }; + + const openResourceMenu = () => { + // Close attach menu first + setIsAttachMenuOpen(false); + + if (!textareaRef.current) { + return; + } + + // Get current cursor position and insert "#" + const cursorPos = textareaRef.current.selectionStart || 0; + const beforeCursor = message.substring(0, cursorPos); + const afterCursor = message.substring(cursorPos); + const newMessage = `${beforeCursor}#${afterCursor}`; + + setMessage(newMessage); + setTriggerPosition(cursorPos); + setIsResourceMenuOpen(true); + setSearchTerm(''); + // Filter out already-selected resources + const unselectedResources = availableResources.filter( + (resource) => !selectedResources.find((r) => r.id === resource.id) + ); + setFilteredResources(unselectedResources); + setActiveItemIndex(0); + + // Focus the textarea and position cursor after the "#" + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + const newCursorPos = cursorPos + 1; + textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); + } + }, 0); + }; + + const handleResourceSelect = (resource: Resource) => { + if (!textareaRef.current) { + return; + } + + // Get the text before the "#" and after the current cursor position + const beforeTag = message.substring(0, triggerPosition); + const cursorPos = textareaRef.current.selectionStart || 0; + const afterCursor = message.substring(cursorPos); + + // Build new message with the full resource name, keeping the "#" + const newMessage = `${beforeTag}#${resource.name} ${afterCursor}`; + + // Update state - MessageBar will sync via its internal useEffect + setMessage(newMessage); + + // Add resource to selected resources if not already added + if (!selectedResources.find((r) => r.id === resource.id)) { + setSelectedResources([...selectedResources, resource]); + } + + // Close the menu and reset + setIsResourceMenuOpen(false); + setTriggerPosition(-1); + setSearchTerm(''); + + // Focus textarea and set cursor position after the inserted resource + setTimeout(() => { + if (textareaRef.current) { + const newCursorPos = beforeTag.length + resource.name.length + 2; // +2 for "#" and space + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); + } + }, 0); + }; + + const handleRemoveResource = (resourceId: string) => { + setSelectedResources(selectedResources.filter((r) => r.id !== resourceId)); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!isResourceMenuOpen || filteredResources.length === 0) { + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setActiveItemIndex((prev) => (prev + 1) % filteredResources.length); + break; + case 'ArrowUp': + event.preventDefault(); + setActiveItemIndex((prev) => (prev - 1 + filteredResources.length) % filteredResources.length); + break; + case 'Enter': + if (isResourceMenuOpen) { + event.preventDefault(); + const selectedResource = filteredResources[activeItemIndex]; + if (selectedResource) { + handleResourceSelect(selectedResource); + } + } + break; + case 'Escape': + if (isResourceMenuOpen) { + event.preventDefault(); + setIsResourceMenuOpen(false); + setTriggerPosition(-1); + } + break; + } + }; + + const onAttachMenuToggleClick = () => { + setIsAttachMenuOpen(!isAttachMenuOpen); + }; + + const onRenderModeSelect = ( + _event: React.MouseEvent | undefined, + value: string | number | undefined + ) => { + setRenderMode(value as string); + setIsRenderModeSelectOpen(false); + }; + + // Close resource menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + resourceMenuRef.current && + !resourceMenuRef.current.contains(event.target as Node) && + textareaRef.current && + !textareaRef.current.contains(event.target as Node) + ) { + setIsResourceMenuOpen(false); + setTriggerPosition(-1); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const attachMenuItems: ReactNode = ( + + } onClick={openResourceMenu}> + Add resource + + + }> + Logs + + }> + YAML - Status + + }> + YAML - All contents + + + }> + Upload from computer + + + ); + + const resourceMenu = ( + { + const resource = filteredResources.find((r) => r.id === itemId?.toString()); + if (resource) { + handleResourceSelect(resource); + } + }} + > + + + {filteredResources.length > 0 ? ( + filteredResources.map((resource, index) => ( + + {resource.name} + + )) + ) : ( + No resources found + )} + + + + ); + + const renderResources = () => { + if (selectedResources.length === 0) { + return null; + } + + switch (renderMode) { + case 'label-with-category': + return ( +
+ + {selectedResources.map((resource) => ( + + ))} + +
+ ); + + case 'label-without-category': + return ( +
+ + {selectedResources.map((resource) => ( + + ))} + +
+ ); + + case 'attachment-tiles': + return ( +
+ + {selectedResources.map((resource) => ( + + handleRemoveResource(resource.id)} + closeButtonAriaLabel={`Remove ${resource.name}`} + /> + + ))} + +
+ ); + + default: + return null; + } + }; + + const renderModeOptions = [ + { value: 'label-with-category', label: 'Label with category' }, + { value: 'label-without-category', label: 'Label without category' }, + { value: 'attachment-tiles', label: 'Attachment tiles' } + ]; + + const selectedModeLabel = renderModeOptions.find((opt) => opt.value === renderMode)?.label || ''; + + return ( + <> +
+ +
+
+ + {renderResources()} + { + console.log('selected', value); + if (value !== 'Add resource') { + setIsAttachMenuOpen(false); + } + }, + attachMenuInputPlaceholder: 'Search options...', + onAttachMenuToggleClick + }} + buttonProps={{ + attach: { + icon: , + tooltipContent: 'Add content' + } + }} + /> +
+ + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md index ddc1d82b..f4fd8619 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/UI/UI.md @@ -70,11 +70,11 @@ import { MessageBar } from '@patternfly/chatbot/dist/dynamic/MessageBar'; import SourceDetailsMenuItem from '@patternfly/chatbot/dist/dynamic/SourceDetailsMenuItem'; import { ChatbotModal } from '@patternfly/chatbot/dist/dynamic/ChatbotModal'; import SettingsForm from '@patternfly/chatbot/dist/dynamic/Settings'; -import { BellIcon, CalendarAltIcon, ClipboardIcon, CodeIcon, PlusIcon, ThumbtackIcon, UploadIcon } from '@patternfly/react-icons'; +import { BellIcon, CalendarAltIcon, ClipboardIcon, CodeIcon, HashtagIcon, PlusIcon, ThumbtackIcon, UploadIcon } from '@patternfly/react-icons'; import { useDropzone } from 'react-dropzone'; import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; -import { Button, Label, DropdownItem, DropdownList, Checkbox, MenuToggle, Select, SelectList, SelectOption } from '@patternfly/react-core'; +import { Button, Divider, DropdownItem, DropdownList, Checkbox, Label, LabelGroup, Menu, MenuContent, MenuItem, MenuList, MenuToggle, Popper, 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'; @@ -304,6 +304,31 @@ This example shows two message bar variations: ``` +### Message bar with resource tagging + +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 that combines a custom attach menu with resource tagging functionality: + +**Attach menu features:** + +- Plus icon attach button positioned at the start +- "Add resource" option that triggers the resource tagging flow +- When clicked, the attach menu closes, focus moves to the input, and the resource menu opens + +**Resource tagging features:** + +1. Typing "#" opens a dropdown menu of available resources +2. The menu automatically filters as you continue typing +3. Arrow keys navigate the menu (ArrowUp/ArrowDown), Enter selects, Escape closes +4. Selecting a resource autofills the name in the input (e.g., "Hello #" → "Hello #service/auth-service") +5. A dismissable label appears above the message input showing the selected resource +6. Multiple resources can be tagged in a single message + +This pattern is useful for mentioning resources, users, channels, or other entities within chat messages. + +```js file="./ChatbotMessageBarResourceTagging.tsx" + +``` + ### Footer with message bar and footnote A simple footer with a message bar and footnote would have this code structure: