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