Add multi-tenancy with Teams feature
This commit is contained in:
171
frontend/src/components/UserAutocomplete.tsx
Normal file
171
frontend/src/components/UserAutocomplete.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { searchUsers, UserSearchResult } from '../api';
|
||||
import './UserAutocomplete.css';
|
||||
|
||||
interface UserAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (username: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function UserAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search users...',
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
}: UserAutocompleteProps) {
|
||||
const [query, setQuery] = useState(value);
|
||||
const [results, setResults] = useState<UserSearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Search for users with debounce
|
||||
const doSearch = useCallback(async (searchQuery: string) => {
|
||||
if (searchQuery.length < 1) {
|
||||
setResults([]);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const users = await searchUsers(searchQuery);
|
||||
setResults(users);
|
||||
setIsOpen(users.length > 0);
|
||||
setSelectedIndex(-1);
|
||||
} catch {
|
||||
setResults([]);
|
||||
setIsOpen(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle input change with debounce
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setQuery(newValue);
|
||||
onChange(newValue); // Update parent immediately for form validation
|
||||
|
||||
// Debounce the search
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
doSearch(newValue);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Handle selecting a user
|
||||
const handleSelect = (user: UserSearchResult) => {
|
||||
setQuery(user.username);
|
||||
onChange(user.username);
|
||||
setIsOpen(false);
|
||||
setResults([]);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev < results.length - 1 ? prev + 1 : prev));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||
handleSelect(results[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Sync external value changes
|
||||
useEffect(() => {
|
||||
setQuery(value);
|
||||
}, [value]);
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="user-autocomplete" ref={containerRef}>
|
||||
<div className="user-autocomplete__input-wrapper">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => query.length >= 1 && results.length > 0 && setIsOpen(true)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
autoComplete="off"
|
||||
className="user-autocomplete__input"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="user-autocomplete__spinner" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && results.length > 0 && (
|
||||
<ul className="user-autocomplete__dropdown">
|
||||
{results.map((user, index) => (
|
||||
<li
|
||||
key={user.id}
|
||||
className={`user-autocomplete__option ${index === selectedIndex ? 'selected' : ''}`}
|
||||
onClick={() => handleSelect(user)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="user-autocomplete__avatar">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="user-autocomplete__user-info">
|
||||
<span className="user-autocomplete__username">{user.username}</span>
|
||||
{user.is_admin && (
|
||||
<span className="user-autocomplete__admin-badge">Admin</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user