-
-
Notifications
You must be signed in to change notification settings - Fork 48
feat: Implemented autocomplete dropdown for GitHub organization search with chip support #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f011867
cf8d09a
4784ff0
c5e796a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,142 +1,205 @@ | ||
| import React, { useState } from 'react' | ||
| 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 React, { useState,useEffect } from 'react' | ||
| 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 { searchOrgs } from '../services/github' | ||
|
|
||
| const QUICK = ['AOSSIE-Org', 'DjedAlliance', 'StabilityNexus'] | ||
| const QUICK = ['AOSSIE-Org', 'DjedAlliance', 'StabilityNexus'] | ||
|
|
||
| export default function HomePage() { | ||
| const { explore, loading, loadMsg, error } = useApp() | ||
| const navigate = useNavigate() | ||
| const [input, setInput] = useState('') | ||
| const [chips, setChips] = useState([]) | ||
| export default function HomePage() { | ||
| const { explore, loading, loadMsg, error } = useApp() | ||
| const navigate = useNavigate() | ||
| const [input, setInput] = useState('') | ||
| const [chips, setChips] = useState([]) | ||
| const[suggestions, setSuggestions] = useState([]) | ||
| const[showSuggestion, setShowSuggestions] = useState(false) | ||
|
|
||
| const recent = JSON.parse(localStorage.getItem('oe_recent') || '[]') | ||
| useEffect(() => { | ||
| if (!input.trim()) { | ||
| setSuggestions([]) | ||
| return | ||
| } | ||
| const t = setTimeout(async () => { | ||
| const result = await searchOrgs(input.trim()) | ||
| setSuggestions(result.slice(0,6)) | ||
| setShowSuggestions(true) | ||
| }, 300) | ||
| return () => clearTimeout(t) | ||
| }, [input]) | ||
|
|
||
| const addChip = raw => { | ||
| const parts = raw.split(/[,+\s]+/).map(s => s.trim()).filter(Boolean) | ||
| setChips(prev => [...new Set([...prev, ...parts])]) | ||
| setInput('') | ||
| } | ||
| const selectSuggestion = (login) => { | ||
| setChips(prev => [...new Set([...prev,login])]) | ||
| setInput('') | ||
| setSuggestion([]) | ||
| setShowSuggestions(false) | ||
| } | ||
|
|
||
| const removeChip = c => setChips(prev => prev.filter(x => x !== c)) | ||
| const recent = JSON.parse(localStorage.getItem('oe_recent') || '[]') | ||
|
|
||
| const handleKey = e => { | ||
| if ((e.key === 'Enter' || e.key === ',') && input.trim()) { | ||
| e.preventDefault() | ||
| addChip(input) | ||
| } | ||
| if (e.key === 'Backspace' && !input && chips.length) { | ||
| setChips(prev => prev.slice(0, -1)) | ||
| const addChip = raw => { | ||
| const parts = raw.split(/[,+\s]+/).map(s => s.trim()).filter(Boolean) | ||
| setChips(prev => [...new Set([...prev, ...parts])]) | ||
| setInput('') | ||
| } | ||
| } | ||
|
|
||
| const go = async (targets) => { | ||
| const orgs = targets || (chips.length ? chips : input.trim() ? [input.trim()] : []) | ||
| if (!orgs.length) return | ||
| const success = await explore(orgs) | ||
| if(success) navigate('/overview') | ||
| } | ||
| const removeChip = c => setChips(prev => prev.filter(x => x !== c)) | ||
|
|
||
| return ( | ||
| <div style={{ | ||
| minHeight: '90vh', display: 'flex', flexDirection: 'column', | ||
| alignItems: 'center', justifyContent: 'center', | ||
| padding: '40px 24px', gap: 32, | ||
| }}> | ||
| {/* Hero */} | ||
| <div style={{ textAlign: 'center', maxWidth: 580 }}> | ||
| <h1 style={{ fontSize: 'clamp(30px,6vw,58px)', fontWeight: 800, lineHeight: 1.1, marginBottom: 14 }}> | ||
| Architect Your{' '} | ||
| <span style={{ color: 'var(--accent)' }}>Insights</span> | ||
| </h1> | ||
| <p style={{ fontSize: 15, color: 'var(--text2)', lineHeight: 1.7 }}> | ||
| Unified analytics across one or many GitHub organizations. Multi-org portfolio analysis, contributor network graphs, time-series trends, and governance audits — entirely in the browser. | ||
| </p> | ||
| </div> | ||
| const handleKey = e => { | ||
| if ((e.key === 'Enter' || e.key === ',') && input.trim()) { | ||
| e.preventDefault() | ||
| addChip(input) | ||
| } | ||
| if (e.key === 'Backspace' && !input && chips.length) { | ||
| setChips(prev => prev.slice(0, -1)) | ||
| } | ||
| } | ||
|
|
||
| {/* Search */} | ||
| <div style={{ width: '100%', maxWidth: 680 }}> | ||
| <div style={{ | ||
| background: 'var(--surface)', border: '1px solid var(--border)', | ||
| borderRadius: 10, padding: '6px 8px', | ||
| display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', minHeight: 54, | ||
| }}> | ||
| <FiSearch size={16} color="var(--text2)" style={{ marginLeft: 4, flexShrink: 0 }} /> | ||
| {chips.map(c => ( | ||
| <span key={c} style={{ | ||
| background: 'rgba(245,197,24,.1)', border: '1px solid rgba(245,197,24,.3)', | ||
| color: 'var(--accent)', borderRadius: 4, padding: '3px 8px', | ||
| fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4, | ||
| }}> | ||
| {c} | ||
| <FiX size={12} style={{ cursor: 'pointer', opacity: .7 }} onClick={() => removeChip(c)} /> | ||
| </span> | ||
| ))} | ||
| <input | ||
| value={input} | ||
| onChange={e => setInput(e.target.value)} | ||
| onKeyDown={handleKey} | ||
| onBlur={() => input.trim() && addChip(input)} | ||
| 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' }} | ||
| /> | ||
| <button onClick={() => go()} style={{ ...C.btn('primary'), padding: '8px 22px', flexShrink: 0 }}> | ||
| EXPLORE | ||
| </button> | ||
| </div> | ||
| <p style={{ fontSize: 11, color: 'var(--text3)', marginTop: 6, paddingLeft: 4 }}> | ||
| Type an org name and press Enter or comma to add. Add multiple orgs to analyze as a unified portfolio. | ||
| </p> | ||
| {error && <p style={{ color: 'var(--red)', fontSize: 12, marginTop: 8 }}>{error}</p>} | ||
| </div> | ||
| const go = async (targets) => { | ||
| const orgs = targets || (chips.length ? chips : input.trim() ? [input.trim()] : []) | ||
| if (!orgs.length) return | ||
| const success = await explore(orgs) | ||
| if(success) navigate('/overview') | ||
| } | ||
|
|
||
| {/* Loading */} | ||
| {loading && ( | ||
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}> | ||
| <Spinner /> | ||
| <p style={{ color: 'var(--text2)', fontSize: 13 }}>{loadMsg}</p> | ||
| return ( | ||
| <div style={{ | ||
| minHeight: '90vh', display: 'flex', flexDirection: 'column', | ||
| alignItems: 'center', justifyContent: 'center', | ||
| padding: '40px 24px', gap: 32, | ||
| }}> | ||
| {/* Hero */} | ||
| <div style={{ textAlign: 'center', maxWidth: 580 }}> | ||
| <h1 style={{ fontSize: 'clamp(30px,6vw,58px)', fontWeight: 800, lineHeight: 1.1, marginBottom: 14 }}> | ||
| Architect Your{' '} | ||
| <span style={{ color: 'var(--accent)' }}>Insights</span> | ||
| </h1> | ||
| <p style={{ fontSize: 15, color: 'var(--text2)', lineHeight: 1.7 }}> | ||
| Unified analytics across one or many GitHub organizations. Multi-org portfolio analysis, contributor network graphs, time-series trends, and governance audits — entirely in the browser. | ||
| </p> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* Recent */} | ||
| {recent.length > 0 && !loading && ( | ||
| <div style={{ textAlign: 'center' }}> | ||
| <span style={{ ...C.label, display: 'block', marginBottom: 8 }}>Recent searches</span> | ||
| <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}> | ||
| {recent.map(r => ( | ||
| <button key={r} onClick={() => go(r.split(',').map(s => s.trim()))} style={{ ...C.btn('ghost'), fontSize: 12, padding: '4px 12px' }}> | ||
| {r} | ||
| </button> | ||
|
|
||
| {/* Search */} | ||
| <div style={{ width: '100%', maxWidth: 680, position: 'relative'}}> | ||
| <div style={{ | ||
| background: 'var(--surface)', border: '1px solid var(--border)', | ||
| borderRadius: 10, padding: '6px 8px', | ||
| display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', minHeight: 54, | ||
| }}> | ||
| <FiSearch size={16} color="var(--text2)" style={{ marginLeft: 4, flexShrink: 0 }} /> | ||
| {chips.map(c => ( | ||
| <span key={c} style={{ | ||
| background: 'rgba(245,197,24,.1)', border: '1px solid rgba(245,197,24,.3)', | ||
| color: 'var(--accent)', borderRadius: 4, padding: '3px 8px', | ||
| fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4, | ||
| }}> | ||
| {c} | ||
| <FiX size={12} style={{ cursor: 'pointer', opacity: .7 }} onClick={() => removeChip(c)} /> | ||
| </span> | ||
| ))} | ||
| <input | ||
| value={input} | ||
| onChange={e => setInput(e.target.value)} | ||
| onKeyDown={handleKey} | ||
| onBlur={() => !showSuggestions && input.trim() && addChip(input)} | ||
| aria-label="Search GitHub organizations" | ||
| 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' }} | ||
| /> | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| <button type = "button" onClick={() => go()} style={{ ...C.btn('primary'), padding: '8px 22px', flexShrink: 0 }}> | ||
| EXPLORE | ||
| </button> | ||
| </div> | ||
| <p style={{ fontSize: 11, color: 'var(--text3)', marginTop: 6, paddingLeft: 4 }}> | ||
| Type an org name and press Enter or comma to add. Add multiple orgs to analyze as a unified portfolio. | ||
| </p> | ||
| {error && <p style={{ color: 'var(--red)', fontSize: 12, marginTop: 8 }}>{error}</p>} | ||
| {showSuggestion && suggestions.length > 0 && ( | ||
| <div style={{ | ||
| position: 'relative', | ||
| width: '100%', | ||
| }}> | ||
| <div style={{ | ||
| position: 'absolute', | ||
| top: 6, | ||
| left: 0, | ||
| right: 0, | ||
| background: 'var(--surface)', | ||
| border: '1px solid var(--border)', | ||
| borderRadius: 8, | ||
| zIndex: 20, | ||
| maxHeight: 220, | ||
| overflowY: 'auto' | ||
| }}> | ||
| {suggestions.map(org => ( | ||
| <div | ||
| key={org.login} | ||
| onMouseDown={() => selectSuggestion(org.login)} | ||
| style={{ | ||
| padding: '10px 12px', | ||
| cursor: 'pointer', | ||
| fontSize: 13, | ||
| display: 'flex', | ||
| justifyContent: 'space-between' | ||
| }} | ||
| > | ||
| <span>{org.login}</span> | ||
| <span style={{ color: 'var(--text3)', fontSize: 11 }}> | ||
| org | ||
| </span> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| )} | ||
|
Comment on lines
+117
to
+154
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift Enhance accessibility and keyboard navigation. The autocomplete dropdown has several gaps:
♻️ Suggested improvements1. Add semantic role and hover styles: {suggestions.map(org => (
<div
key={org.login}
+ role="button"
+ tabIndex={0}
onMouseDown={() => selectSuggestion(org.login)}
+ onKeyDown={(e) => e.key === 'Enter' && selectSuggestion(org.login)}
style={{
padding: '10px 12px',
cursor: 'pointer',
fontSize: 13,
display: 'flex',
justifyContent: 'space-between'
}}
+ onMouseEnter={(e) => e.currentTarget.style.background = 'var(--hover)'}
+ onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>2. Add keyboard navigation in useEffect: Add handling for ArrowDown, ArrowUp, and Escape keys to navigate through suggestions. 3. Externalize "org" string: As per path instructions, user-visible strings should be externalized to resource files for internationalization. As per path instructions, user-visible strings should be externalized and the code should adhere to React best practices and accessibility standards. 🧰 Tools🪛 ast-grep (0.44.0)[warning] 145-145: A list component should have a key to prevent re-rendering (list-component-needs-key) [warning] 146-148: A list component should have a key to prevent re-rendering (list-component-needs-key) 🪛 React Doctor (0.5.8)[warning] 135-135: Screen reader users can't tell this click handler is interactive because it has no Give clickable static elements a (no-static-element-interactions) 🤖 Prompt for AI AgentsSources: Path instructions, Linters/SAST tools |
||
|
|
||
| </div> | ||
| )} | ||
|
|
||
| {/* Quick explore */} | ||
| {!loading && ( | ||
| <div style={{ textAlign: 'center' }}> | ||
| <span style={{ ...C.label, display: 'block', marginBottom: 8 }}>Quick explore</span> | ||
| <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}> | ||
| {QUICK.map(q => ( | ||
| <button key={q} onClick={() => go([q])} style={{ ...C.btn('secondary'), fontSize: 12, padding: '5px 14px' }}> | ||
| {q} | ||
| </button> | ||
| ))} | ||
| {/* Loading */} | ||
| {loading && ( | ||
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}> | ||
| <Spinner /> | ||
| <p style={{ color: 'var(--text2)', fontSize: 13 }}>{loadMsg}</p> | ||
| </div> | ||
| </div> | ||
| )} | ||
| )} | ||
|
|
||
| {/* Recent */} | ||
| {recent.length > 0 && !loading && ( | ||
| <div style={{ textAlign: 'center' }}> | ||
| <span style={{ ...C.label, display: 'block', marginBottom: 8 }}>Recent searches</span> | ||
| <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}> | ||
| {recent.map(r => ( | ||
| <button key={r} onClick={() => go(r.split(',').map(s => s.trim()))} style={{ ...C.btn('ghost'), fontSize: 12, padding: '4px 12px' }}> | ||
| {r} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* Stats bar */} | ||
| <div style={{ display: 'flex', gap: 48, padding: '20px 40px', background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)' }}> | ||
| {[['5,000', 'req/hr with PAT', 'var(--green)'], ['1HR', 'intelligent cache', 'var(--green)'], ['ZERO', 'backend latency', 'var(--accent)']].map(([v, l, color]) => ( | ||
| <div key={l} style={{ textAlign: 'center' }}> | ||
| <div style={{ fontSize: 22, fontWeight: 800, color }}>{v}</div> | ||
| <div style={{ fontSize: 10, color: 'var(--text2)', letterSpacing: '.07em', textTransform: 'uppercase', marginTop: 2 }}>{l}</div> | ||
| {/* Quick explore */} | ||
| {!loading && ( | ||
| <div style={{ textAlign: 'center' }}> | ||
| <span style={{ ...C.label, display: 'block', marginBottom: 8 }}>Quick explore</span> | ||
| <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}> | ||
| {QUICK.map(q => ( | ||
| <button key={q} onClick={() => go([q])} style={{ ...C.btn('secondary'), fontSize: 12, padding: '5px 14px' }}> | ||
| {q} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ))} | ||
| )} | ||
|
|
||
| {/* Stats bar */} | ||
| <div style={{ display: 'flex', gap: 48, padding: '20px 40px', background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)' }}> | ||
| {[['5,000', 'req/hr with PAT', 'var(--green)'], ['1HR', 'intelligent cache', 'var(--green)'], ['ZERO', 'backend latency', 'var(--accent)']].map(([v, l, color]) => ( | ||
| <div key={l} style={{ textAlign: 'center' }}> | ||
| <div style={{ fontSize: 22, fontWeight: 800, color }}>{v}</div> | ||
| <div style={{ fontSize: 10, color: 'var(--text2)', letterSpacing: '.07em', textTransform: 'uppercase', marginTop: 2 }}>{l}</div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,6 +85,19 @@ export async function fetchRepos(org, pat) { | |
| return all | ||
| } | ||
|
|
||
| export const searchOrgs = async (query) => { | ||
| if (!query) return [] | ||
|
|
||
| const res = await fetch( | ||
| `https://api.github.com/search/users?q=${encodeURIComponent(query)}+type:org` | ||
| ) | ||
| if (!res.ok) return [] | ||
|
|
||
| const data = await res.json() | ||
| return data.items || [] | ||
| } | ||
|
Comment on lines
+88
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift Refactor to use the existing
♻️ Proposed refactor to reuse fetchWithCache-export const searchOrgs = async (query) => {
- if (!query) return []
-
- const res = await fetch(
- `https://api.github.com/search/users?q=${encodeURIComponent(query)}+type:org`
- )
- if (!res.ok) return []
-
- const data = await res.json()
- return data.items || []
-}
+export const searchOrgs = async (query, pat) => {
+ if (!query) return []
+
+ try {
+ const url = `https://api.github.com/search/users?q=${encodeURIComponent(query)}+type:org`
+ const data = await fetchWithCache(url, pat)
+ return data.items || []
+ } catch (err) {
+ // Return empty on errors (NOT_FOUND, RATE_LIMIT, etc.)
+ return []
+ }
+}This will require passing As per path instructions, the code should adhere to performance best practices. 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
|
|
||
| export async function fetchContributors(org, repo, pat) { | ||
| try { | ||
| return await fetchWithCache( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Add race condition protection and error handling.
The debounced search has two issues:
Race condition: Multiple async
searchOrgscalls can complete out of order. If a user types "face" → "faceb" → "faceboo" quickly, all three API calls execute, and whichever completes last wins—potentially showing stale results for "face" after the user has typed "faceboo".Silent failures: If
searchOrgsfails (rate limit, network error), the user sees no feedback.🔒 Proposed fix with cancellation and error handling
useEffect(() => { if (!input.trim()) { setSuggestion([]) + setShowSuggestions(false) return } + let cancelled = false const t = setTimeout(async () => { - const result = await searchOrgs(input.trim()) - setSuggestion(result.slice(0,6)) - setShowSuggestions(true) + try { + const result = await searchOrgs(input.trim()) + if (!cancelled) { + setSuggestion(result.slice(0,6)) + setShowSuggestions(result.length > 0) + } + } catch (err) { + if (!cancelled) { + setSuggestion([]) + setShowSuggestions(false) + } + } }, 300) - return () => clearTimeout(t) + return () => { + clearTimeout(t) + cancelled = true + } }, [input])The
cancelledflag ensures stale results from earlier searches don't overwrite newer ones.🤖 Prompt for AI Agents