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