BugListPage.tsx

  1import { useState, useEffect } from 'react'
  2import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from 'lucide-react'
  3import { Button } from '@/components/ui/button'
  4import { Skeleton } from '@/components/ui/skeleton'
  5import { BugRow } from '@/components/bugs/BugRow'
  6import { IssueFilters } from '@/components/bugs/IssueFilters'
  7import { QueryInput } from '@/components/bugs/QueryInput'
  8import { useBugListQuery } from '@/__generated__/graphql'
  9import { cn } from '@/lib/utils'
 10import { useRepo } from '@/lib/repo'
 11
 12const PAGE_SIZE = 25
 13
 14type StatusFilter = 'open' | 'closed'
 15
 16// Issue list page (/:repo/issues). Search bar with structured query, open/closed toggle,
 17// label+author filter dropdowns, and paginated bug rows.
 18export function BugListPage() {
 19  const repo = useRepo()
 20  const [statusFilter, setStatusFilter] = useState<StatusFilter>('open')
 21  const [selectedLabels, setSelectedLabels] = useState<string[]>([])
 22  // humanId — uniquely identifies the selection for the dropdown UI
 23  const [selectedAuthorId, setSelectedAuthorId] = useState<string | null>(null)
 24  // query value (login/name) — what goes into author:... in the query string
 25  const [selectedAuthorQuery, setSelectedAuthorQuery] = useState<string | null>(null)
 26  const [freeText, setFreeText] = useState('')
 27  const [draft, setDraft] = useState(() => buildQueryString('open', [], null, ''))
 28
 29  // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
 30  // cursors[0] is always undefined (first page needs no cursor).
 31  const [cursors, setCursors] = useState<(string | undefined)[]>([undefined])
 32  const page = cursors.length - 1  // 0-indexed current page
 33
 34  // Build separate query strings: two for the always-visible counts (open/closed),
 35  // one for the paginated list. The count queries share all filters except status.
 36  const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, freeText)
 37  const openQuery = `status:open ${baseQuery}`.trim()
 38  const closedQuery = `status:closed ${baseQuery}`.trim()
 39  const listQuery = buildQueryString(statusFilter, selectedLabels, selectedAuthorQuery, freeText)
 40
 41  const { data, loading, error } = useBugListQuery({
 42    variables: { ref: repo, openQuery, closedQuery, listQuery, first: PAGE_SIZE, after: cursors[page] },
 43  })
 44
 45  const openCount = data?.repository?.openCount.totalCount ?? 0
 46  const closedCount = data?.repository?.closedCount.totalCount ?? 0
 47  const bugs = data?.repository?.bugs
 48  const totalCount = bugs?.totalCount ?? 0
 49  const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
 50  const hasNext = bugs?.pageInfo.hasNextPage ?? false
 51  const hasPrev = page > 0
 52
 53  // Reset to page 1 whenever the list query changes.
 54  useEffect(() => { setCursors([undefined]) }, [listQuery])
 55
 56  // Apply all filters at once, keeping draft in sync with the structured state.
 57  function applyFilters(
 58    status: StatusFilter,
 59    labels: string[],
 60    authorId: string | null,
 61    authorQuery: string | null,
 62    text: string,
 63  ) {
 64    setStatusFilter(status)
 65    setSelectedLabels(labels)
 66    setSelectedAuthorId(authorId)
 67    setSelectedAuthorQuery(authorQuery)
 68    setFreeText(text)
 69    setDraft(buildQueryString(status, labels, authorQuery, text))
 70  }
 71
 72  // Parse the draft text box on submit so manual edits update the dropdowns too.
 73  // When parsing we don't know the humanId — clear it so the dropdown resets.
 74  // Called both from the <form> onSubmit (with event) and from QueryInput's
 75  // Enter-key handler (without event), so e is optional.
 76  function handleSearch(e?: React.FormEvent) {
 77    e?.preventDefault()
 78    const p = parseQueryString(draft)
 79    applyFilters(p.status, p.labels, null, p.author, p.freeText)
 80  }
 81
 82  function goNext() {
 83    const endCursor = bugs?.pageInfo.endCursor
 84    if (!endCursor) return
 85    setCursors((prev) => [...prev, endCursor])
 86  }
 87
 88  function goPrev() {
 89    setCursors((prev) => prev.slice(0, -1))
 90  }
 91
 92  return (
 93    <div>
 94      {/* Search bar */}
 95      <form onSubmit={handleSearch} className="mb-4 flex gap-2">
 96        <QueryInput
 97          value={draft}
 98          onChange={setDraft}
 99          onSubmit={handleSearch}
100          placeholder="status:open author:… label:…"
101        />
102        <Button type="submit">
103          Search
104        </Button>
105      </form>
106
107      {/* List container */}
108      <div className="rounded-md border border-border">
109        {/* Open / Closed toggle + filter dropdowns */}
110        <div className="flex items-center border-b border-border px-4 py-2">
111          <div className="flex items-center gap-1">
112            <button
113              onClick={() => applyFilters('open', selectedLabels, selectedAuthorId, selectedAuthorQuery, freeText)}
114              className={cn(
115                'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
116                statusFilter === 'open'
117                  ? 'bg-accent text-accent-foreground'
118                  : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
119              )}
120            >
121              <CircleDot className={cn('size-4', statusFilter === 'open' && 'text-green-600 dark:text-green-400')} />
122              Open
123              <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none tabular-nums">
124                {openCount}
125              </span>
126            </button>
127
128            <button
129              onClick={() => applyFilters('closed', selectedLabels, selectedAuthorId, selectedAuthorQuery, freeText)}
130              className={cn(
131                'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
132                statusFilter === 'closed'
133                  ? 'bg-accent text-accent-foreground'
134                  : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
135              )}
136            >
137              <CircleCheck className={cn('size-4', statusFilter === 'closed' && 'text-purple-600 dark:text-purple-400')} />
138              Closed
139              <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none tabular-nums">
140                {closedCount}
141              </span>
142            </button>
143          </div>
144
145          <div className="ml-auto">
146            <IssueFilters
147              selectedLabels={selectedLabels}
148              onLabelsChange={(labels) => applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)}
149              selectedAuthorId={selectedAuthorId}
150              onAuthorChange={(id, qv) => applyFilters(statusFilter, selectedLabels, id, qv, freeText)}
151              recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
152            />
153          </div>
154        </div>
155
156        {/* Bug rows */}
157        {error && (
158          <p className="px-4 py-8 text-center text-sm text-destructive">
159            Failed to load issues: {error.message}
160          </p>
161        )}
162
163        {loading && !data && <BugListSkeleton />}
164
165        {bugs?.nodes.length === 0 && (
166          <p className="px-4 py-8 text-center text-sm text-muted-foreground">
167            No {statusFilter} issues found.
168          </p>
169        )}
170
171        {bugs?.nodes.map((bug) => (
172          <BugRow
173            key={bug.id}
174            id={bug.id}
175            humanId={bug.humanId}
176            status={bug.status}
177            title={bug.title}
178            labels={bug.labels}
179            author={bug.author}
180            createdAt={bug.createdAt}
181            commentCount={bug.comments.totalCount}
182            repo={repo}
183            onLabelClick={(name) => {
184              if (!selectedLabels.includes(name)) {
185                applyFilters(statusFilter, [...selectedLabels, name], selectedAuthorId, selectedAuthorQuery, freeText)
186              }
187            }}
188          />
189        ))}
190
191        {totalPages > 1 && (
192          <div className="flex items-center justify-center gap-2 border-t border-border px-4 py-2">
193            <Button
194              variant="ghost"
195              size="sm"
196              onClick={goPrev}
197              disabled={!hasPrev || loading}
198              className="gap-1 text-muted-foreground"
199            >
200              <ChevronLeft className="size-4" />
201              Previous
202            </Button>
203            <span className="text-sm text-muted-foreground">
204              Page {page + 1} of {totalPages}
205            </span>
206            <Button
207              variant="ghost"
208              size="sm"
209              onClick={goNext}
210              disabled={!hasNext || loading}
211              className="gap-1 text-muted-foreground"
212            >
213              Next
214              <ChevronRight className="size-4" />
215            </Button>
216          </div>
217        )}
218      </div>
219    </div>
220  )
221}
222
223// buildBaseQuery returns the filter parts (labels, author, freeText) without
224// the status prefix, so it can be combined with "status:open" / "status:closed".
225function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
226  const parts: string[] = []
227  for (const label of labels) {
228    parts.push(label.includes(' ') ? `label:"${label}"` : `label:${label}`)
229  }
230  if (author) {
231    parts.push(author.includes(' ') ? `author:"${author}"` : `author:${author}`)
232  }
233  if (freeText.trim()) parts.push(freeText.trim())
234  return parts.join(' ')
235}
236
237// Build the structured query string sent to the GraphQL allBugs(query:) argument.
238// Multi-word label/author values are wrapped in quotes so the backend parser
239// treats them as a single token (e.g. label:"my label" vs label:my label).
240function buildQueryString(
241  status: StatusFilter,
242  labels: string[],
243  author: string | null,
244  freeText: string,
245): string {
246  return `status:${status} ${buildBaseQuery(labels, author, freeText)}`.trim()
247}
248
249// Tokenize a query string, keeping quoted spans (e.g. author:"René Descartes")
250// as single tokens. Quotes are preserved in the output so callers can strip them
251// when extracting values.
252function tokenizeQuery(input: string): string[] {
253  const tokens: string[] = []
254  let current = ''
255  let inQuote = false
256  for (const ch of input.trim()) {
257    if (ch === '"') { inQuote = !inQuote; current += ch }
258    else if (ch === ' ' && !inQuote) { if (current) { tokens.push(current); current = '' } }
259    else current += ch
260  }
261  if (current) tokens.push(current)
262  return tokens
263}
264
265// Parse a free-text query string back into structured filter state so that
266// manual edits to the search box are reflected in the dropdown UI on submit.
267// Strips surrounding quotes from values (they're an encoding detail, not part
268// of the value itself). Unknown tokens fall through to freeText.
269function parseQueryString(input: string): {
270  status: StatusFilter
271  labels: string[]
272  author: string | null
273  freeText: string
274} {
275  let status: StatusFilter = 'open'
276  const labels: string[] = []
277  let author: string | null = null
278  const free: string[] = []
279
280  for (const token of tokenizeQuery(input)) {
281    if (token === 'status:open') status = 'open'
282    else if (token === 'status:closed') status = 'closed'
283    else if (token.startsWith('label:')) labels.push(token.slice(6))
284    else if (token.startsWith('author:')) author = token.slice(7).replace(/^"|"$/g, '')
285    else free.push(token)
286  }
287
288  return { status, labels, author, freeText: free.join(' ') }
289}
290
291function BugListSkeleton() {
292  return (
293    <div className="divide-y divide-border">
294      {Array.from({ length: 8 }).map((_, i) => (
295        <div key={i} className="flex items-start gap-3 px-4 py-3">
296          <Skeleton className="mt-0.5 size-4 rounded-full" />
297          <div className="flex-1 space-y-2">
298            <Skeleton className="h-4 w-2/3" />
299            <Skeleton className="h-3 w-1/3" />
300          </div>
301        </div>
302      ))}
303    </div>
304  )
305}