@eventcatalog/core 2.34.7 → 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-RHCB6E6X.js → chunk-5XXPX5HQ.js} +1 -1
  6. package/dist/{chunk-Y6K4D4LS.js → chunk-7LCJC7ER.js} +1 -1
  7. package/dist/{chunk-ZPWE3CVX.js → chunk-HDG7YSFG.js} +9 -0
  8. package/dist/{chunk-F22TOAQN.js → chunk-TVAQVUFO.js} +1 -1
  9. package/dist/constants.cjs +1 -1
  10. package/dist/constants.js +1 -1
  11. package/dist/eventcatalog.cjs +20 -1
  12. package/dist/eventcatalog.config.d.cts +1 -0
  13. package/dist/eventcatalog.config.d.ts +1 -0
  14. package/dist/eventcatalog.js +21 -5
  15. package/dist/features.cjs +44 -2
  16. package/dist/features.d.cts +2 -1
  17. package/dist/features.d.ts +2 -1
  18. package/dist/features.js +6 -3
  19. package/eventcatalog/astro.config.mjs +8 -2
  20. package/eventcatalog/src/components/Tables/columns/DomainTableColumns.tsx +1 -2
  21. package/eventcatalog/src/content.config.ts +13 -2
  22. package/eventcatalog/src/enterprise/collections/chat-prompts.ts +32 -0
  23. package/eventcatalog/src/enterprise/collections/custom-pages.ts +12 -15
  24. package/eventcatalog/src/enterprise/collections/index.ts +2 -0
  25. package/eventcatalog/src/enterprise/custom-documentation/collection.ts +1 -1
  26. package/eventcatalog/src/enterprise/eventcatalog-chat/EventCatalogVectorStore.ts +50 -0
  27. package/eventcatalog/src/enterprise/eventcatalog-chat/components/Chat.tsx +50 -0
  28. package/eventcatalog/src/enterprise/eventcatalog-chat/components/ChatMessage.tsx +231 -0
  29. package/eventcatalog/src/enterprise/eventcatalog-chat/components/InputModal.tsx +233 -0
  30. package/eventcatalog/src/enterprise/eventcatalog-chat/components/MentionInput.tsx +211 -0
  31. package/eventcatalog/src/enterprise/eventcatalog-chat/components/WelcomePromptArea.tsx +88 -0
  32. package/eventcatalog/src/enterprise/{ai-assistant/components/ChatWindow.tsx → eventcatalog-chat/components/windows/ChatWindow.client.tsx} +3 -5
  33. package/eventcatalog/src/enterprise/eventcatalog-chat/components/windows/ChatWindow.server.tsx +499 -0
  34. package/eventcatalog/src/enterprise/eventcatalog-chat/pages/api/ai/chat.ts +56 -0
  35. package/eventcatalog/src/enterprise/eventcatalog-chat/pages/api/ai/resources.ts +42 -0
  36. package/eventcatalog/src/enterprise/eventcatalog-chat/pages/chat/index.astro +189 -0
  37. package/eventcatalog/src/enterprise/eventcatalog-chat/utils/ai.ts +151 -0
  38. package/eventcatalog/src/enterprise/eventcatalog-chat/utils/chat-prompts.ts +50 -0
  39. package/eventcatalog/src/pages/chat/index.astro +2 -168
  40. package/eventcatalog/src/types/react-syntax-highlighter.d.ts +1 -0
  41. package/package.json +8 -1
  42. package/eventcatalog/src/enterprise/ai-assistant/components/Chat.tsx +0 -16
  43. /package/eventcatalog/src/{shared-collections.ts → content.config-shared-collections.ts} +0 -0
  44. /package/eventcatalog/src/enterprise/{ai-assistant → eventcatalog-chat}/components/ChatSidebar.tsx +0 -0
  45. /package/eventcatalog/src/enterprise/{ai-assistant → eventcatalog-chat}/components/hooks/ChatProvider.tsx +0 -0
  46. /package/eventcatalog/src/enterprise/{ai-assistant → eventcatalog-chat}/components/workers/document-importer.ts +0 -0
  47. /package/eventcatalog/src/enterprise/{ai-assistant → eventcatalog-chat}/components/workers/engine.ts +0 -0
@@ -0,0 +1,231 @@
1
+ import React, { useState } from 'react';
2
+ import ReactMarkdown, { type ExtraProps } from 'react-markdown';
3
+ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
4
+ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
5
+ import type { Message } from '@enterprise/eventcatalog-chat/components/hooks/ChatProvider';
6
+ import * as Dialog from '@radix-ui/react-dialog';
7
+ import { Fullscreen, X, Clipboard, Check, ChevronDown, ChevronRight } from 'lucide-react'; // Add Clipboard, Check, ChevronDown, ChevronRight icons
8
+
9
+ // Define Resource type locally
10
+ interface Resource {
11
+ id: string;
12
+ type: string;
13
+ url: string;
14
+ title?: string;
15
+ name?: string | null; // Allow null for name
16
+ }
17
+
18
+ interface ChatMessageProps {
19
+ message: Message;
20
+ }
21
+
22
+ // Function to escape special characters for regex
23
+ function escapeRegex(string: string): string {
24
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
+ }
26
+
27
+ // Define props for the code component explicitly
28
+ interface CodeComponentProps extends React.HTMLAttributes<HTMLElement>, ExtraProps {
29
+ inline?: boolean;
30
+ }
31
+
32
+ const ChatMessage = React.memo(({ message }: ChatMessageProps) => {
33
+ const [isModalOpen, setIsModalOpen] = useState(false);
34
+ const [modalContent, setModalContent] = useState<{ language: string; code: string } | null>(null);
35
+ const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({}); // State for copy feedback
36
+ const [isResourcesCollapsed, setIsResourcesCollapsed] = useState(true); // State for resource section collapse
37
+
38
+ // Helper to get display name for resource, ensuring a fallback
39
+ const getResourceDisplayName = (resource: Resource): string => {
40
+ return resource.title || resource.name || resource.id || 'Resource'; // Added fallback
41
+ };
42
+
43
+ const handleCopy = (codeToCopy: string, id: string) => {
44
+ navigator.clipboard
45
+ .writeText(codeToCopy)
46
+ .then(() => {
47
+ setCopiedStates((prev) => ({ ...prev, [id]: true }));
48
+ setTimeout(() => {
49
+ setCopiedStates((prev) => ({ ...prev, [id]: false }));
50
+ }, 1500); // Reset after 1.5 seconds
51
+ })
52
+ .catch((err) => {
53
+ console.error('Failed to copy code: ', err);
54
+ });
55
+ };
56
+
57
+ return (
58
+ <div className={`flex ${message.isUser ? 'justify-end' : 'justify-start'} mb-4`}>
59
+ <div
60
+ className={`max-w-[80%] rounded-lg p-3 ${message.isUser ? 'bg-purple-600 text-white rounded-br-none' : 'bg-gray-100 text-gray-800 rounded-bl-none'}`}
61
+ >
62
+ {/* Apply prose styles, including prose-invert for user messages for better text contrast */}
63
+ <div className={`prose prose-sm max-w-none ${message.isUser ? 'prose-invert' : ''}`}>
64
+ <ReactMarkdown
65
+ components={{
66
+ code({ node, className, children, ...props }: CodeComponentProps) {
67
+ const inline = props.inline;
68
+ const match = /language-(\w+)/.exec(className || '');
69
+ const codeString = String(children);
70
+ const language = match ? match[1] : 'text';
71
+ const codeBlockId = `code-${React.useId()}`;
72
+ const isCopied = copiedStates[codeBlockId];
73
+
74
+ const handleOpenModal = () => {
75
+ setModalContent({ language, code: codeString.replace(/\n$/, '') });
76
+ setIsModalOpen(true);
77
+ };
78
+
79
+ // Heuristic: Treat as inline if it doesn't contain newlines OR if explicitly inline.
80
+ // This handles parser quirks with single-line snippets in lists, etc.
81
+ const treatAsInline = inline || !codeString.includes('\n');
82
+
83
+ return !treatAsInline ? (
84
+ <div className="code-block bg-[#1e1e1e] rounded-md overflow-hidden my-4 relative group">
85
+ <div className="flex justify-between items-center px-4 py-1.5 bg-gray-700 text-gray-300 text-xs">
86
+ <span>{language}</span>
87
+ <div className="flex items-center gap-2">
88
+ <button
89
+ onClick={() => handleCopy(codeString.replace(/\n$/, ''), codeBlockId)}
90
+ className="text-gray-300 hover:text-white flex items-center"
91
+ aria-label={isCopied ? 'Copied' : 'Copy code'}
92
+ >
93
+ {isCopied ? <Check size={14} className="text-green-400" /> : <Clipboard size={14} />}
94
+ </button>
95
+ <button
96
+ onClick={handleOpenModal}
97
+ className="text-gray-300 hover:text-white"
98
+ aria-label="View code fullscreen"
99
+ >
100
+ <Fullscreen size={14} />
101
+ </button>
102
+ </div>
103
+ </div>
104
+ <SyntaxHighlighter
105
+ style={vscDarkPlus as any}
106
+ language={language}
107
+ PreTag="div"
108
+ showLineNumbers
109
+ wrapLines={true}
110
+ customStyle={{ margin: 0, borderRadius: '0 0 0.375rem 0.375rem', padding: '1rem' }}
111
+ >
112
+ {codeString.replace(/\n$/, '')}
113
+ </SyntaxHighlighter>
114
+ </div>
115
+ ) : (
116
+ <code
117
+ className={`px-1 py-0.5 rounded text-xs font-mono ${
118
+ message.isUser
119
+ ? 'bg-purple-800/70 text-purple-100' // Darker purple bg, light text for user
120
+ : 'bg-gray-300/70 text-gray-900' // Darker gray bg, dark text for assistant
121
+ } ${className || ''}`}
122
+ {...props}
123
+ >
124
+ {/* Render trimmed version for inline code */}
125
+ {codeString.trim()}
126
+ </code>
127
+ );
128
+ },
129
+ a: ({ node, ...props }) => (
130
+ <a {...props} target="_blank" rel="noopener noreferrer" className="text-purple-600 hover:text-purple-800" />
131
+ ),
132
+ p: ({ node, ...props }) => <p {...props} className="mb-2 last:mb-0" />,
133
+ }}
134
+ >
135
+ {/* Use raw content again */}
136
+ {message.content || ''}
137
+ </ReactMarkdown>
138
+ </div>
139
+
140
+ {/* Resource section */}
141
+ {!message.isUser && message.resources && message.resources.length > 0 && (
142
+ <div className="mt-3 pt-3 border-t border-gray-200">
143
+ {/* Make the title clickable to toggle collapse */}
144
+ <button
145
+ className="flex items-center text-xs text-gray-500 mb-1 w-full text-left focus:outline-none"
146
+ onClick={() => setIsResourcesCollapsed(!isResourcesCollapsed)}
147
+ aria-expanded={!isResourcesCollapsed}
148
+ aria-controls="resource-list"
149
+ >
150
+ {isResourcesCollapsed ? <ChevronRight size={14} className="mr-1" /> : <ChevronDown size={14} className="mr-1" />}
151
+ Referenced Resources:
152
+ </button>
153
+ {/* Conditionally render the list based on the collapsed state */}
154
+ {!isResourcesCollapsed && (
155
+ <div className="text-[10px] mt-1 pl-5" id="resource-list">
156
+ {' '}
157
+ {/* Added pl-5 for indentation */}
158
+ {(message.resources as Resource[]).map((resource: Resource, idx: number) => (
159
+ <span key={resource.id || `res-${idx}`}>
160
+ <a
161
+ href={resource.url}
162
+ className="text-purple-600 hover:text-purple-800"
163
+ target="_blank"
164
+ rel="noopener noreferrer"
165
+ >
166
+ {/* Use helper function for clarity and add parentheses for operator precedence */}
167
+ {getResourceDisplayName(resource)} ({resource.type})
168
+ </a>
169
+ {idx < (message.resources?.length || 0) - 1 ? ', ' : ''}
170
+ </span>
171
+ ))}
172
+ </div>
173
+ )}
174
+ </div>
175
+ )}
176
+ </div>
177
+
178
+ {/* Fullscreen Code Modal */}
179
+ <Dialog.Root open={isModalOpen} onOpenChange={setIsModalOpen}>
180
+ <Dialog.Portal>
181
+ {/* Add z-index and animations to Overlay */}
182
+ {/* NOTE: Define overlayShow/overlayHide keyframes in CSS/Tailwind config */}
183
+ <Dialog.Overlay className="fixed inset-0 bg-black/60 z-50 data-[state=open]:animate-overlayShow data-[state=closed]:animate-overlayHide" />
184
+ {/* Add z-index and animations to Content */}
185
+ {/* NOTE: Define contentShow/contentHide keyframes in CSS/Tailwind config */}
186
+ <Dialog.Content className="fixed left-1/2 top-1/2 z-[100] w-[90vw] max-w-4xl max-h-[85vh] -translate-x-1/2 -translate-y-1/2 rounded-lg bg-[#1e1e1e] p-0 shadow-lg focus:outline-none data-[state=open]:animate-contentShow data-[state=closed]:animate-contentHide overflow-hidden flex flex-col">
187
+ {/* Modal Header with Copy Button */}
188
+ <div className="flex justify-between items-center px-4 py-2.5 bg-gray-700 text-gray-300 text-sm flex-shrink-0 border-b border-gray-600">
189
+ <Dialog.Title className="font-medium">{modalContent?.language}</Dialog.Title>
190
+ <div className="flex items-center gap-3">
191
+ {modalContent && (
192
+ <button
193
+ onClick={() => handleCopy(modalContent.code, 'modal-copy')}
194
+ className="text-gray-300 hover:text-white flex items-center gap-1 text-xs"
195
+ aria-label={copiedStates['modal-copy'] ? 'Copied' : 'Copy code'}
196
+ >
197
+ {copiedStates['modal-copy'] ? <Check size={14} className="text-green-400" /> : <Clipboard size={14} />}
198
+ {copiedStates['modal-copy'] ? 'Copied!' : 'Copy'}
199
+ </button>
200
+ )}
201
+ <Dialog.Close asChild>
202
+ <button className="text-gray-300 hover:text-white" aria-label="Close">
203
+ <X size={18} />
204
+ </button>
205
+ </Dialog.Close>
206
+ </div>
207
+ </div>
208
+ <div className="flex-grow overflow-auto">
209
+ {modalContent && (
210
+ <SyntaxHighlighter
211
+ style={vscDarkPlus as any}
212
+ language={modalContent.language}
213
+ PreTag="div"
214
+ showLineNumbers
215
+ wrapLines={true}
216
+ customStyle={{ margin: 0, height: '100%', padding: '1rem' }} // Ensure it fills height and has padding
217
+ >
218
+ {modalContent.code}
219
+ </SyntaxHighlighter>
220
+ )}
221
+ </div>
222
+ </Dialog.Content>
223
+ </Dialog.Portal>
224
+ </Dialog.Root>
225
+ </div>
226
+ );
227
+ });
228
+
229
+ ChatMessage.displayName = 'ChatMessage';
230
+
231
+ export default ChatMessage;
@@ -0,0 +1,233 @@
1
+ import React, { useEffect, useState, useMemo } from 'react';
2
+ import * as Dialog from '@radix-ui/react-dialog';
3
+ // TODO: Import ChatPrompt and ChatPromptInput from a central types location
4
+ import type { ChatPrompt } from '@enterprise/eventcatalog-chat/utils/chat-prompts';
5
+
6
+ // Define the possible resource list types
7
+ type ResourceListType = 'resource-list-events' | 'resource-list-commands' | 'resource-list-services' | 'resource-list-queries';
8
+
9
+ // Define ChatPromptInput type based on usage
10
+ // NOTE: This should ideally match the schema defined for Astro content collections
11
+ interface ChatPromptInput {
12
+ id: string;
13
+ label: string;
14
+ type: 'text' | ResourceListType | 'select' | 'code' | 'text-area'; // Use the union type, add 'select', 'code', 'text-area'
15
+ options?: string[]; // Add optional options for select type
16
+ }
17
+
18
+ // Define Resource type based on usage in ChatWindow
19
+ interface Resource {
20
+ id: string;
21
+ type: string; // e.g., 'event', 'command', 'service', 'query'
22
+ url: string;
23
+ title?: string;
24
+ name?: string;
25
+ }
26
+
27
+ // --- Input Modal Component ---
28
+ interface InputModalProps {
29
+ isOpen: boolean;
30
+ onClose: () => void;
31
+ prompt: ChatPrompt | null;
32
+ onSubmit: (prompt: ChatPrompt, inputValues: Record<string, string>) => void;
33
+ resources: Resource[]; // Add resources prop
34
+ }
35
+
36
+ // Helper to extract resource type from input type string
37
+ const getResourceTypeFromInputType = (inputType: ResourceListType): string => {
38
+ return inputType.replace('resource-list-', '');
39
+ };
40
+
41
+ const InputModal: React.FC<InputModalProps> = ({ isOpen, onClose, prompt, onSubmit, resources }) => {
42
+ const [inputValues, setInputValues] = useState<Record<string, string>>({});
43
+
44
+ // Memoize resource filtering based on prompt inputs
45
+ const filteredResourcesByType = useMemo(() => {
46
+ const resourceMap: Record<string, Resource[]> = {};
47
+ prompt?.data?.inputs?.forEach((input) => {
48
+ if (input.type.startsWith('resource-list-')) {
49
+ const resourceType = getResourceTypeFromInputType(input.type as ResourceListType);
50
+ const filtered = resources.filter((r) => r.type === resourceType);
51
+ resourceMap[input.id] = filtered;
52
+ }
53
+ });
54
+ return resourceMap;
55
+ }, [prompt, resources]);
56
+
57
+ // Reset input values when the prompt or resources change
58
+ useEffect(() => {
59
+ if (prompt?.data?.inputs) {
60
+ const initialValues: Record<string, string> = {};
61
+ prompt.data.inputs.forEach((input) => {
62
+ const relevantResources = filteredResourcesByType[input.id];
63
+ if (input.type.startsWith('resource-list-') && relevantResources?.length > 0) {
64
+ initialValues[input.id] = relevantResources[0].id; // Pre-select the first available resource
65
+ } else {
66
+ initialValues[input.id] = ''; // Initialize others with empty strings
67
+ }
68
+ });
69
+ setInputValues(initialValues);
70
+ } else {
71
+ setInputValues({}); // Clear if no inputs
72
+ }
73
+ }, [prompt, filteredResourcesByType]); // Depend on memoized filtered resources
74
+
75
+ if (!prompt || !prompt.data?.inputs) {
76
+ return null; // Don't render if no prompt or inputs
77
+ }
78
+
79
+ const handleInputChange = (id: string, value: string) => {
80
+ setInputValues((prev) => ({ ...prev, [id]: value }));
81
+ };
82
+
83
+ const handleSubmit = (e: React.FormEvent) => {
84
+ e.preventDefault();
85
+ // Basic validation: Check if all required fields are filled
86
+ const allFilled = prompt?.data?.inputs?.every((input) => {
87
+ if (input.type.startsWith('resource-list-')) {
88
+ return inputValues[input.id] && inputValues[input.id] !== '';
89
+ }
90
+ return inputValues[input.id]?.trim();
91
+ });
92
+
93
+ if (allFilled) {
94
+ // Pass the selected resource NAME for resource lists, not the ID
95
+ const processedValues = { ...inputValues };
96
+ prompt.data.inputs?.forEach((input) => {
97
+ if (input.type.startsWith('resource-list-')) {
98
+ const relevantResources = filteredResourcesByType[input.id] || [];
99
+ const selectedResource = relevantResources.find((r) => r.id === processedValues[input.id]);
100
+ // Use resource name if available, otherwise fallback to the submitted ID (which might be name if no resource found)
101
+ processedValues[input.id] = selectedResource?.name || processedValues[input.id];
102
+ }
103
+ });
104
+ onSubmit(prompt, processedValues);
105
+ } else {
106
+ alert('Please fill in all required fields.');
107
+ }
108
+ };
109
+
110
+ return (
111
+ <Dialog.Root open={isOpen} onOpenChange={onClose}>
112
+ <Dialog.Portal>
113
+ <Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-overlayShow z-50" />
114
+ <Dialog.Content className="fixed top-1/2 left-1/2 w-[90vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-lg focus:outline-none data-[state=open]:animate-contentShow z-[100]">
115
+ <Dialog.Title className="text-lg font-semibold text-gray-900">{prompt.data.title}</Dialog.Title>
116
+ <Dialog.Description className="mt-1 mb-5 text-sm text-gray-500">
117
+ Please provide the following details:
118
+ </Dialog.Description>
119
+ <form onSubmit={handleSubmit} className="space-y-4">
120
+ {prompt.data.inputs.map((input: ChatPromptInput) => {
121
+ const isResourceList = input.type.startsWith('resource-list-');
122
+ const isSelect = input.type === 'select';
123
+ const isCode = input.type === 'code';
124
+ const isTextArea = input.type === 'text-area'; // Added for potential future use or consistency
125
+ const resourceType = isResourceList ? getResourceTypeFromInputType(input.type as ResourceListType) : '';
126
+ const relevantResources = filteredResourcesByType[input.id] || [];
127
+
128
+ return (
129
+ <div key={input.id}>
130
+ <label htmlFor={input.id} className="block text-sm font-medium text-gray-700 mb-1">
131
+ {input.label}
132
+ </label>
133
+ {isResourceList ? (
134
+ <select
135
+ id={input.id}
136
+ name={input.id}
137
+ value={inputValues[input.id] || ''}
138
+ onChange={(e) => handleInputChange(input.id, e.target.value)}
139
+ required
140
+ className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500 sm:text-sm"
141
+ >
142
+ <option value="" disabled>
143
+ Select a {resourceType}...
144
+ </option>
145
+ {relevantResources.map((resource) => (
146
+ <option key={resource.id} value={resource.id}>
147
+ {resource.name || resource.id} {/* Display name or ID as fallback */}
148
+ </option>
149
+ ))}
150
+ {relevantResources.length === 0 && (
151
+ <option value="" disabled>
152
+ No {resourceType}s found
153
+ </option>
154
+ )}
155
+ </select>
156
+ ) : isSelect ? (
157
+ <select
158
+ id={input.id}
159
+ name={input.id}
160
+ value={inputValues[input.id] || ''}
161
+ onChange={(e) => handleInputChange(input.id, e.target.value)}
162
+ required
163
+ className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500 sm:text-sm"
164
+ >
165
+ <option value="" disabled>
166
+ Select an option...
167
+ </option>
168
+ {input.options?.map((option) => (
169
+ <option key={option} value={option}>
170
+ {option}
171
+ </option>
172
+ ))}
173
+ {(!input.options || input.options.length === 0) && (
174
+ <option value="" disabled>
175
+ No options available
176
+ </option>
177
+ )}
178
+ </select>
179
+ ) : (
180
+ <>
181
+ {isCode || isTextArea ? (
182
+ <textarea
183
+ id={input.id}
184
+ name={input.id}
185
+ value={inputValues[input.id] || ''}
186
+ onChange={(e) => handleInputChange(input.id, e.target.value)}
187
+ required
188
+ rows={isCode ? 6 : 3} // More rows for code input
189
+ className={`w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500 sm:text-sm ${isCode ? 'font-mono text-sm' : ''}`} // Basic monospaced font for code
190
+ placeholder={isCode ? 'Paste your code here...' : ''}
191
+ />
192
+ ) : (
193
+ <input
194
+ type="text"
195
+ id={input.id}
196
+ name={input.id}
197
+ value={inputValues[input.id] || ''}
198
+ onChange={(e) => handleInputChange(input.id, e.target.value)}
199
+ required
200
+ className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500 sm:text-sm"
201
+ />
202
+ )}
203
+ </>
204
+ )}
205
+ </div>
206
+ );
207
+ })}
208
+ <div className="mt-6 flex justify-end space-x-2">
209
+ <Dialog.Close asChild>
210
+ <button
211
+ type="button"
212
+ className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
213
+ onClick={onClose} // Ensure onClose is called when Cancel is clicked
214
+ >
215
+ Cancel
216
+ </button>
217
+ </Dialog.Close>
218
+ <button
219
+ type="submit"
220
+ className="inline-flex justify-center rounded-md border border-transparent bg-purple-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
221
+ >
222
+ Submit
223
+ </button>
224
+ </div>
225
+ </form>
226
+ </Dialog.Content>
227
+ </Dialog.Portal>
228
+ </Dialog.Root>
229
+ );
230
+ };
231
+ // --- End Input Modal Component ---
232
+
233
+ export default InputModal;
@@ -0,0 +1,211 @@
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+
3
+ // Define the structure for suggestions with types
4
+ interface MentionSuggestion {
5
+ id: string; // Unique ID for the key prop
6
+ name: string; // The suggestion text (e.g., 'PaymentProcessed')
7
+ type: string; // The group type (e.g., 'event', 'service')
8
+ }
9
+
10
+ interface MentionInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
11
+ suggestions: MentionSuggestion[]; // Use the new type
12
+ trigger?: string;
13
+ onChange: (value: string) => void;
14
+ value: string;
15
+ }
16
+
17
+ const MentionInput: React.FC<MentionInputProps> = ({ suggestions, trigger = '@', onChange, value, ...inputProps }) => {
18
+ const [showSuggestions, setShowSuggestions] = useState(false);
19
+ const [filteredSuggestions, setFilteredSuggestions] = useState<MentionSuggestion[]>([]); // Use the new type
20
+ const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
21
+ const [currentQuery, setCurrentQuery] = useState('');
22
+ const inputRef = useRef<HTMLInputElement>(null);
23
+ const suggestionsRef = useRef<HTMLUListElement>(null);
24
+
25
+ const updateSuggestions = useCallback(
26
+ (inputValue: string, cursorPosition: number | null) => {
27
+ if (cursorPosition === null) {
28
+ setShowSuggestions(false);
29
+ return;
30
+ }
31
+
32
+ // Find the start of the potential mention query
33
+ let queryStartIndex = -1;
34
+ for (let i = cursorPosition - 1; i >= 0; i--) {
35
+ const char = inputValue[i];
36
+ if (char === trigger) {
37
+ queryStartIndex = i;
38
+ break;
39
+ }
40
+ // Stop if we hit whitespace before the trigger
41
+ if (/\s/.test(char)) {
42
+ break;
43
+ }
44
+ }
45
+
46
+ if (queryStartIndex !== -1) {
47
+ const query = inputValue.substring(queryStartIndex + 1, cursorPosition).toLowerCase();
48
+ setCurrentQuery(query);
49
+
50
+ const filtered = suggestions.filter((s) => s.name.toLowerCase().includes(query));
51
+
52
+ // Update the filtered list and reset index
53
+ setFilteredSuggestions(filtered);
54
+ setActiveSuggestionIndex(0);
55
+ // Keep suggestions open as long as the trigger character is active
56
+ setShowSuggestions(true);
57
+ } else {
58
+ // Only hide suggestions if the trigger character sequence is broken
59
+ setShowSuggestions(false);
60
+ }
61
+ },
62
+ [suggestions, trigger]
63
+ );
64
+
65
+ const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
66
+ const newValue = event.target.value;
67
+ onChange(newValue);
68
+ updateSuggestions(newValue, event.target.selectionStart);
69
+ };
70
+
71
+ // Modify handleSuggestionClick to accept the suggestion object
72
+ const handleSuggestionClick = (suggestion: MentionSuggestion) => {
73
+ if (inputRef.current) {
74
+ const cursorPosition = inputRef.current.selectionStart;
75
+ if (cursorPosition !== null) {
76
+ // Find the start of the @mention query relative to the cursor
77
+ let queryStartIndex = -1;
78
+ for (let i = cursorPosition - 1; i >= 0; i--) {
79
+ if (value[i] === trigger) {
80
+ queryStartIndex = i;
81
+ break;
82
+ }
83
+ if (/\s/.test(value[i])) {
84
+ break;
85
+ }
86
+ }
87
+
88
+ if (queryStartIndex !== -1) {
89
+ // Use suggestion.name for the inserted text
90
+ const newValue =
91
+ value.substring(0, queryStartIndex) +
92
+ suggestion.name +
93
+ ' ' + // Insert selected suggestion name and a space
94
+ value.substring(cursorPosition);
95
+
96
+ onChange(newValue);
97
+ setShowSuggestions(false);
98
+ setTimeout(() => {
99
+ if (inputRef.current) {
100
+ inputRef.current.focus();
101
+ const newCursorPos = queryStartIndex + suggestion.name.length + 1;
102
+ inputRef.current.setSelectionRange(newCursorPos, newCursorPos);
103
+ }
104
+ }, 0);
105
+ } else {
106
+ // Fallback: Use suggestion.name
107
+ onChange(value + suggestion.name + ' ');
108
+ setShowSuggestions(false);
109
+ inputRef.current.focus();
110
+ }
111
+ }
112
+ }
113
+ };
114
+
115
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
116
+ if (showSuggestions && filteredSuggestions.length > 0) {
117
+ if (event.key === 'ArrowDown') {
118
+ event.preventDefault();
119
+ setActiveSuggestionIndex((prevIndex) => (prevIndex + 1) % filteredSuggestions.length);
120
+ } else if (event.key === 'ArrowUp') {
121
+ event.preventDefault();
122
+ setActiveSuggestionIndex((prevIndex) => (prevIndex - 1 + filteredSuggestions.length) % filteredSuggestions.length);
123
+ } else if (event.key === 'Enter' || event.key === 'Tab') {
124
+ event.preventDefault();
125
+ // Pass the selected suggestion object
126
+ handleSuggestionClick(filteredSuggestions[activeSuggestionIndex]);
127
+ } else if (event.key === 'Escape') {
128
+ setShowSuggestions(false);
129
+ }
130
+ }
131
+
132
+ // Propagate other keydown events if needed (e.g., for parent's Enter key handling)
133
+ // Check if the event is Enter and if we are NOT showing suggestions before calling parent's submit
134
+ // This prevents submitting the form when selecting a suggestion with Enter
135
+ if (inputProps.onKeyDown && !(showSuggestions && event.key === 'Enter')) {
136
+ inputProps.onKeyDown(event);
137
+ }
138
+ };
139
+
140
+ // Scroll active suggestion into view
141
+ useEffect(() => {
142
+ if (showSuggestions && suggestionsRef.current) {
143
+ const activeItem = suggestionsRef.current.children[activeSuggestionIndex] as HTMLLIElement;
144
+ if (activeItem) {
145
+ activeItem.scrollIntoView({ block: 'nearest', inline: 'nearest' });
146
+ }
147
+ }
148
+ }, [activeSuggestionIndex, showSuggestions]);
149
+
150
+ // Handle clicks outside the input/suggestions list to close it
151
+ useEffect(() => {
152
+ const handleClickOutside = (event: MouseEvent) => {
153
+ if (
154
+ inputRef.current &&
155
+ !inputRef.current.contains(event.target as Node) &&
156
+ suggestionsRef.current &&
157
+ !suggestionsRef.current.contains(event.target as Node)
158
+ ) {
159
+ setShowSuggestions(false);
160
+ }
161
+ };
162
+
163
+ document.addEventListener('mousedown', handleClickOutside);
164
+ return () => {
165
+ document.removeEventListener('mousedown', handleClickOutside);
166
+ };
167
+ }, []);
168
+
169
+ return (
170
+ <div className="relative w-full">
171
+ <input
172
+ ref={inputRef}
173
+ {...inputProps}
174
+ value={value}
175
+ onChange={handleChange}
176
+ onKeyDown={handleKeyDown} // Use onKeyDown for better event control
177
+ onClick={(e) => updateSuggestions(value, e.currentTarget.selectionStart)} // Update suggestions on click too
178
+ />
179
+ {/* Keep the suggestions box open if showSuggestions is true */}
180
+ {showSuggestions && (
181
+ <ul
182
+ ref={suggestionsRef}
183
+ className="absolute bottom-full mb-2 left-0 right-0 bg-white border border-gray-300 rounded-md shadow-lg max-h-40 overflow-y-auto z-10"
184
+ style={{ minWidth: inputRef.current?.offsetWidth }}
185
+ >
186
+ {/* Conditionally render suggestions or 'No results' message */}
187
+ {filteredSuggestions.length > 0 ? (
188
+ filteredSuggestions.map((suggestion, index) => (
189
+ <li
190
+ key={suggestion.id + '-' + index}
191
+ className={`px-4 py-2 text-sm cursor-pointer flex justify-between items-center ${
192
+ index === activeSuggestionIndex ? 'bg-purple-100 text-purple-800' : 'text-gray-700 hover:bg-gray-100'
193
+ }`}
194
+ onClick={() => handleSuggestionClick(suggestion)}
195
+ onMouseEnter={() => setActiveSuggestionIndex(index)}
196
+ >
197
+ <span>{suggestion.name}</span>
198
+ <span className="text-xs text-gray-500 ml-2">({suggestion.type})</span>
199
+ </li>
200
+ ))
201
+ ) : (
202
+ /* Render this list item when no suggestions match */
203
+ <li className="px-4 py-2 text-sm text-gray-500 italic">No matching items found</li>
204
+ )}
205
+ </ul>
206
+ )}
207
+ </div>
208
+ );
209
+ };
210
+
211
+ export default MentionInput;