343 lines
13 KiB
TypeScript
Executable File
343 lines
13 KiB
TypeScript
Executable File
import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import { sortByProperty } from '../utils/sort-utils';
|
|
|
|
// Default prefix for TMS username search
|
|
const TMS_USERNAME_PREFIX = 'DKI_';
|
|
|
|
// Simple cache for user search results
|
|
const userCache = new Map<string, TmsUser[]>();
|
|
|
|
interface TmsUser {
|
|
email: string;
|
|
name: string;
|
|
}
|
|
|
|
interface UsernameAutocompleteProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
onUserSelect: (username: string) => void;
|
|
disabled?: boolean;
|
|
placeholder?: string;
|
|
rows?: number;
|
|
onHeightChange?: (heightPx: number) => void;
|
|
}
|
|
|
|
const UsernameAutocomplete: React.FC<UsernameAutocompleteProps> = ({
|
|
value,
|
|
onChange,
|
|
onUserSelect,
|
|
disabled = false,
|
|
placeholder = '',
|
|
rows = 5,
|
|
onHeightChange,
|
|
}) => {
|
|
// Use internal state for instant typing, sync to parent via onChange
|
|
const [internalValue, setInternalValue] = useState(value);
|
|
const [suggestions, setSuggestions] = useState<TmsUser[]>([]);
|
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [justSelected, setJustSelected] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const suggestionsRef = useRef<HTMLDivElement | null>(null);
|
|
const [portalStyle, setPortalStyle] = useState<React.CSSProperties | null>(null);
|
|
const debounceTimerRef = useRef<number>(0);
|
|
|
|
// Sync external value changes to internal state
|
|
useEffect(() => {
|
|
setInternalValue(value);
|
|
}, [value]);
|
|
|
|
// report textarea height to parent so they can keep other inputs in sync
|
|
const onHeightChangeRef = useRef(onHeightChange);
|
|
onHeightChangeRef.current = onHeightChange;
|
|
|
|
useEffect(() => {
|
|
const ta = textareaRef.current;
|
|
if (!ta) return;
|
|
const obs = new ResizeObserver(entries => {
|
|
if (!entries || !entries.length) return;
|
|
const h = (entries[0].target as HTMLElement).offsetHeight;
|
|
if (typeof onHeightChangeRef.current === 'function') onHeightChangeRef.current(h);
|
|
});
|
|
obs.observe(ta);
|
|
// report initial size
|
|
if (typeof onHeightChangeRef.current === 'function') onHeightChangeRef.current(ta.offsetHeight);
|
|
return () => obs.disconnect();
|
|
}, []);
|
|
|
|
// Get current line content (word being typed)
|
|
const getCurrentWord = () => {
|
|
if (!textareaRef.current) return '';
|
|
const textarea = textareaRef.current;
|
|
const text = textarea.value;
|
|
const cursorPos = textarea.selectionStart;
|
|
const beforeCursor = text.substring(0, cursorPos);
|
|
const lines = beforeCursor.split('\n');
|
|
const currentLine = lines[lines.length - 1];
|
|
return currentLine;
|
|
};
|
|
|
|
// Get all existing usernames from textarea (excluding current line being typed)
|
|
const getExistingUsernames = (): Set<string> => {
|
|
if (!textareaRef.current) return new Set();
|
|
const textarea = textareaRef.current;
|
|
const text = textarea.value;
|
|
const cursorPos = textarea.selectionStart;
|
|
const beforeCursor = text.substring(0, cursorPos);
|
|
const afterCursor = text.substring(cursorPos);
|
|
|
|
const linesBefore = beforeCursor.split('\n');
|
|
const currentLineIndex = linesBefore.length - 1;
|
|
|
|
// Get all lines except the current one being typed
|
|
const allLines = text.split('\n');
|
|
const existingNames = new Set<string>();
|
|
allLines.forEach((line, index) => {
|
|
const trimmed = line.trim().toLowerCase();
|
|
if (trimmed && index !== currentLineIndex) {
|
|
existingNames.add(trimmed);
|
|
}
|
|
});
|
|
return existingNames;
|
|
};
|
|
|
|
// Fetch suggestions from TMS API (only DKI_ users) with cache
|
|
const fetchSuggestions = async (query: string) => {
|
|
// Don't fetch if just selected a suggestion
|
|
if (justSelected) return;
|
|
|
|
if (query.length < 1) {
|
|
setSuggestions([]);
|
|
setShowSuggestions(false);
|
|
return;
|
|
}
|
|
|
|
const cacheKey = query.toLowerCase();
|
|
|
|
// Check cache first
|
|
if (userCache.has(cacheKey)) {
|
|
const cached = userCache.get(cacheKey)!;
|
|
const existingUsernames = getExistingUsernames();
|
|
const filteredUsers = cached.filter(u => !existingUsernames.has(u.name.toLowerCase()));
|
|
setSuggestions(filteredUsers);
|
|
setShowSuggestions(filteredUsers.length > 0);
|
|
setSelectedIndex(filteredUsers.length > 0 ? 0 : -1);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
// Search with original query
|
|
const response = await fetch('/api/user/search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query }),
|
|
});
|
|
const data = await response.json();
|
|
if (data.success && Array.isArray(data.data)) {
|
|
// Filter: only DKI_ users, then sort by name
|
|
const usersData = data.data as TmsUser[];
|
|
const dkiUsers = sortByProperty(
|
|
usersData.filter((user) => user.name.toUpperCase().includes(TMS_USERNAME_PREFIX)),
|
|
(user) => user.name
|
|
);
|
|
|
|
// Cache the result
|
|
userCache.set(cacheKey, dkiUsers);
|
|
|
|
// Filter out existing usernames for display
|
|
const existingUsernames = getExistingUsernames();
|
|
const filteredUsers = dkiUsers.filter(u => !existingUsernames.has(u.name.toLowerCase()));
|
|
setSuggestions(filteredUsers);
|
|
setShowSuggestions(filteredUsers.length > 0);
|
|
setSelectedIndex(filteredUsers.length > 0 ? 0 : -1);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching suggestions:', error);
|
|
setSuggestions([]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Handle input change - update internal state immediately, debounce parent update
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const newValue = e.target.value;
|
|
setInternalValue(newValue); // Instant update for smooth typing
|
|
|
|
// Clear previous timer
|
|
window.clearTimeout(debounceTimerRef.current);
|
|
|
|
// Debounce: update parent and fetch suggestions after 100ms
|
|
debounceTimerRef.current = window.setTimeout(() => {
|
|
onChange(newValue); // Sync to parent
|
|
const currentWord = getCurrentWord();
|
|
fetchSuggestions(currentWord);
|
|
}, 100);
|
|
};
|
|
|
|
// Cleanup debounce timer on unmount
|
|
useEffect(() => {
|
|
return () => window.clearTimeout(debounceTimerRef.current);
|
|
}, []);
|
|
|
|
// Handle keyboard navigation
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (!showSuggestions || suggestions.length === 0) {
|
|
if (e.key === 'ArrowDown' && e.ctrlKey) {
|
|
e.preventDefault();
|
|
setShowSuggestions(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev + 1) % suggestions.length);
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length);
|
|
break;
|
|
case 'Enter':
|
|
if (selectedIndex >= 0) {
|
|
e.preventDefault();
|
|
selectSuggestion(suggestions[selectedIndex]);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
setShowSuggestions(false);
|
|
setSelectedIndex(-1);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
|
|
// Select suggestion and replace current line with user's name
|
|
const selectSuggestion = (user: TmsUser) => {
|
|
if (!textareaRef.current) return;
|
|
|
|
const textarea = textareaRef.current;
|
|
const text = internalValue;
|
|
const cursorPos = textarea.selectionStart;
|
|
const beforeCursor = text.substring(0, cursorPos);
|
|
const afterCursor = text.substring(cursorPos);
|
|
|
|
const lines = beforeCursor.split('\n');
|
|
const currentLineIndex = lines.length - 1;
|
|
lines[currentLineIndex] = user.name; // Use name as the selected value
|
|
|
|
const newText = lines.join('\n') + afterCursor;
|
|
setInternalValue(newText);
|
|
onChange(newText);
|
|
|
|
// Hide suggestions and set flag to prevent re-showing
|
|
setShowSuggestions(false);
|
|
setSelectedIndex(-1);
|
|
setSuggestions([]);
|
|
setJustSelected(true);
|
|
|
|
// Focus back to textarea and move cursor to end of suggestion
|
|
setTimeout(() => {
|
|
textarea.focus();
|
|
const newCursorPos = lines.join('\n').length;
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
|
// Reset flag after a short delay
|
|
setTimeout(() => setJustSelected(false), 200);
|
|
}, 0);
|
|
};
|
|
|
|
// Close suggestions when clicking outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
const sugEl = suggestionsRef.current;
|
|
const taEl = textareaRef.current;
|
|
if (sugEl && !sugEl.contains(e.target as Node)) {
|
|
if (taEl && !taEl.contains(e.target as Node)) {
|
|
setShowSuggestions(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// compute portal position based on textarea bounding rect
|
|
const updatePortalPosition = () => {
|
|
const ta = textareaRef.current;
|
|
if (!ta) return;
|
|
const rect = ta.getBoundingClientRect();
|
|
const top = rect.bottom + window.scrollY;
|
|
const left = rect.left + window.scrollX;
|
|
const width = rect.width;
|
|
setPortalStyle({ position: 'absolute', top: `${top}px`, left: `${left}px`, width: `${width}px`, zIndex: 99999 });
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
if (showSuggestions) updatePortalPosition();
|
|
}, [showSuggestions, suggestions]);
|
|
|
|
useEffect(() => {
|
|
const onScroll = () => { if (showSuggestions) updatePortalPosition(); };
|
|
const onResize = () => { if (showSuggestions) updatePortalPosition(); };
|
|
window.addEventListener('scroll', onScroll, true);
|
|
window.addEventListener('resize', onResize);
|
|
return () => {
|
|
window.removeEventListener('scroll', onScroll, true);
|
|
window.removeEventListener('resize', onResize);
|
|
};
|
|
}, [showSuggestions]);
|
|
|
|
return (
|
|
<div className="relative">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={internalValue}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleKeyDown}
|
|
className="bg-slate-900/50 border border-slate-700 text-slate-100 text-sm rounded-lg focus:ring-indigo-500 focus:border-indigo-500 block w-full p-2.5 transition-colors duration-200 resize-y min-h-32"
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
rows={rows}
|
|
/>
|
|
|
|
{showSuggestions && suggestions.length > 0 && portalStyle && ReactDOM.createPortal(
|
|
<div
|
|
ref={suggestionsRef}
|
|
style={portalStyle}
|
|
>
|
|
<div className="bg-slate-800 border border-slate-700 rounded-lg shadow-lg max-h-48 overflow-y-auto" style={{ maxHeight: '240px' }}>
|
|
{suggestions.map((user, index) => (
|
|
<div
|
|
key={user.email}
|
|
onClick={() => selectSuggestion(user)}
|
|
className={`px-3 py-2 cursor-pointer transition-colors ${index === selectedIndex
|
|
? 'bg-indigo-600 text-white'
|
|
: 'text-slate-200 hover:bg-slate-700'
|
|
}`}
|
|
>
|
|
<div className="font-medium">{user.name}</div>
|
|
<div className="text-xs text-slate-400">{user.email}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>, document.body
|
|
)}
|
|
|
|
{isLoading && (
|
|
<div className="absolute right-2 top-2 text-xs text-slate-400">
|
|
<span className="animate-pulse">...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default UsernameAutocomplete;
|