diff --git a/src/components/OrganizationAutocomplete.jsx b/src/components/OrganizationAutocomplete.jsx new file mode 100644 index 0000000..605e1f1 --- /dev/null +++ b/src/components/OrganizationAutocomplete.jsx @@ -0,0 +1,240 @@ +import React, { useState, useEffect, useRef } from 'react'; +import useDebounce from '../hooks/useDebounce'; +import useClickOutside from '../hooks/useClickOutside'; +import { searchOrganizations } from '../services/github'; +import { useApp } from '../context/AppContext'; +import { Spinner } from './UI'; + +// We use a small in-memory LRU cache specifically for autocomplete to prevent +// duplicating API requests during rapid typing and to avoid unnecessarily polluting +// the global IndexedDB cache with partially-typed, short-lived queries. +const cache = new Map(); +const MAX_SUGGESTIONS = 8; +const MIN_QUERY_LENGTH = 2; + +export default function OrganizationAutocomplete({ + value, + onChange, + onKeyDown, + onBlur, + onSelectOrg, + placeholder, + style +}) { + const { pat } = useApp(); + const [suggestions, setSuggestions] = useState([]); + const [loading, setLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [error, setError] = useState(false); + + const containerRef = useRef(null); + const abortControllerRef = useRef(null); + + const debouncedValue = useDebounce(value, 400); + + useClickOutside(containerRef, () => { + setIsOpen(false); + setHighlightedIndex(-1); + }); + + // Handle query change directly to hide dropdown and show correct states + useEffect(() => { + if (value.trim().length < MIN_QUERY_LENGTH) { + setIsOpen(false); + setSuggestions([]); + } + }, [value]); + + useEffect(() => { + const trimmed = debouncedValue.trim(); + if (trimmed.length < MIN_QUERY_LENGTH) { + setSuggestions([]); + setIsOpen(false); + setLoading(false); + return; + } + + const fetchOrgs = async () => { + setLoading(true); + setError(false); + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + + const cacheKey = trimmed.toLowerCase(); + if (cache.has(cacheKey)) { + setSuggestions(cache.get(cacheKey)); + setIsOpen(true); + setLoading(false); + setHighlightedIndex(-1); + return; + } + + try { + const results = await searchOrganizations(trimmed, pat, controller.signal); + + const deduplicated = results.filter((item, index, self) => + index === self.findIndex((t) => t.login.toLowerCase() === item.login.toLowerCase()) + ).slice(0, MAX_SUGGESTIONS); + + if (cache.size > 100) { + const firstKey = cache.keys().next().value; + cache.delete(firstKey); + } + cache.set(cacheKey, deduplicated); + + setSuggestions(deduplicated); + setIsOpen(true); + setHighlightedIndex(-1); + } catch (err) { + if (err.name !== 'AbortError') { + setError(true); + setSuggestions([]); + setIsOpen(true); + } + } finally { + if (abortControllerRef.current === controller) { + setLoading(false); + } + } + }; + + fetchOrgs(); + + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, [debouncedValue, pat]); + + const handleKeyDown = (e) => { + if (!isOpen) { + if (onKeyDown) onKeyDown(e); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightedIndex(prev => (prev < suggestions.length - 1 ? prev + 1 : 0)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedIndex(prev => (prev > 0 ? prev - 1 : suggestions.length - 1)); + } else if (e.key === 'Enter') { + if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) { + e.preventDefault(); + handleSelect(suggestions[highlightedIndex]); + } else { + setIsOpen(false); + if (onKeyDown) onKeyDown(e); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + setIsOpen(false); + setHighlightedIndex(-1); + } else { + if (onKeyDown) onKeyDown(e); + } + }; + + const handleSelect = (org) => { + onSelectOrg(org.login); + setIsOpen(false); + setHighlightedIndex(-1); + }; + + const handleBlur = (e) => { + if (onBlur) onBlur(e); + }; + + return ( +
+ = 0 ? `suggestion-${highlightedIndex}` : undefined} + /> + + {isOpen && value.trim().length >= MIN_QUERY_LENGTH && ( + + )} +
+ ); +} diff --git a/src/hooks/useClickOutside.js b/src/hooks/useClickOutside.js new file mode 100644 index 0000000..ab525ed --- /dev/null +++ b/src/hooks/useClickOutside.js @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +export default function useClickOutside(ref, handler) { + useEffect(() => { + const listener = (event) => { + if (!ref.current || ref.current.contains(event.target)) { + return; + } + handler(event); + }; + + document.addEventListener('mousedown', listener); + document.addEventListener('touchstart', listener); + + return () => { + document.removeEventListener('mousedown', listener); + document.removeEventListener('touchstart', listener); + }; + }, [ref, handler]); +} diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js new file mode 100644 index 0000000..3401963 --- /dev/null +++ b/src/hooks/useDebounce.js @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export default function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index cffcbd3..152503f 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom' import { FiSearch, FiX } from 'react-icons/fi' import { useApp } from '../context/AppContext' import { C, Spinner } from '../components/UI' +import OrganizationAutocomplete from '../components/OrganizationAutocomplete' const QUICK = ['AOSSIE-Org', 'DjedAlliance', 'StabilityNexus'] @@ -74,13 +75,14 @@ export default function HomePage() { removeChip(c)} /> ))} - setInput(e.target.value)} onKeyDown={handleKey} onBlur={() => input.trim() && addChip(input)} + onSelectOrg={addChip} placeholder={chips.length ? 'Add another org...' : 'AOSSIE-Org, StabilityNexus, DjedAlliance...'} - style={{ flex: 1, minWidth: 160, background: 'none', color: 'var(--text)', fontSize: 14, padding: '4px 8px', border: 'none', outline: 'none' }} + style={{ background: 'none', color: 'var(--text)', fontSize: 14, padding: '4px 8px', border: 'none', outline: 'none' }} />