Skip to content

Commit 7a8a3f7

Browse files
fix: paginate skills index
1 parent ac0bdf0 commit 7a8a3f7

File tree

2 files changed

+139
-25
lines changed

2 files changed

+139
-25
lines changed

src/__tests__/skills-index.test.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SkillsIndex } from '../routes/skills/index'
66

77
const navigateMock = vi.fn()
88
const useQueryMock = vi.fn()
9+
const useActionMock = vi.fn()
910

1011
vi.mock('@tanstack/react-router', () => ({
1112
createFileRoute: () => (_config: { component: unknown; validateSearch: unknown }) => ({
@@ -16,18 +17,24 @@ vi.mock('@tanstack/react-router', () => ({
1617
}))
1718

1819
vi.mock('convex/react', () => ({
20+
useAction: (...args: unknown[]) => useActionMock(...args),
1921
useQuery: (...args: unknown[]) => useQueryMock(...args),
2022
}))
2123

2224
describe('SkillsIndex', () => {
2325
beforeEach(() => {
2426
useQueryMock.mockReset()
27+
useActionMock.mockReset()
2528
navigateMock.mockReset()
26-
useQueryMock.mockReturnValue([])
29+
useActionMock.mockReturnValue(() => Promise.resolve([]))
30+
useQueryMock.mockReturnValue({ items: [], nextCursor: null })
2731
})
2832

29-
it('caps listWithLatest query limit', () => {
33+
it('requests the first skills page', () => {
3034
render(<SkillsIndex />)
31-
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), { limit: 200 })
35+
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), {
36+
cursor: undefined,
37+
limit: 50,
38+
})
3239
})
3340
})

src/routes/skills/index.tsx

Lines changed: 129 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { createFileRoute, Link } from '@tanstack/react-router'
2-
import { useQuery } from 'convex/react'
3-
import { useEffect, useMemo, useState } from 'react'
2+
import { useAction, useQuery } from 'convex/react'
3+
import { useEffect, useMemo, useRef, useState } from 'react'
44
import { api } from '../../../convex/_generated/api'
55
import type { Doc } from '../../../convex/_generated/dataModel'
66
import { SkillCard } from '../../components/SkillCard'
77

88
const sortKeys = ['newest', 'downloads', 'installs', 'stars', 'name', 'updated'] as const
9+
const pageSize = 50
910
type SortKey = (typeof sortKeys)[number]
1011
type SortDir = 'asc' | 'desc'
1112

@@ -41,29 +42,107 @@ export function SkillsIndex() {
4142
const view = search.view ?? 'list'
4243
const highlightedOnly = search.highlighted ?? false
4344
const [query, setQuery] = useState(search.q ?? '')
45+
const searchSkills = useAction(api.search.searchSkills)
46+
const [pages, setPages] = useState<
47+
Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
48+
>([])
49+
const [cursor, setCursor] = useState<string | null>(null)
50+
const [nextCursor, setNextCursor] = useState<string | null>(null)
51+
const [searchResults, setSearchResults] = useState<
52+
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
53+
>([])
54+
const [searchLimit, setSearchLimit] = useState(pageSize)
55+
const [isSearching, setIsSearching] = useState(false)
56+
const searchRequest = useRef(0)
4457

45-
const items = useQuery(api.skills.listWithLatest, { limit: 200 }) as
46-
| Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
58+
const trimmedQuery = useMemo(() => query.trim(), [query])
59+
const hasQuery = trimmedQuery.length > 0
60+
61+
const listPage = useQuery(
62+
api.skills.listPublicPage,
63+
hasQuery ? 'skip' : { cursor: cursor ?? undefined, limit: pageSize },
64+
) as
65+
| {
66+
items: Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
67+
nextCursor: string | null
68+
}
4769
| undefined
48-
const isLoadingSkills = items === undefined
70+
const isLoadingList = !hasQuery && pages.length === 0 && listPage === undefined
4971

5072
useEffect(() => {
5173
setQuery(search.q ?? '')
5274
}, [search.q])
5375

54-
const filtered = useMemo(() => {
55-
const value = query.trim().toLowerCase()
56-
const all = (items ?? []).filter((entry) =>
57-
highlightedOnly ? entry.skill.batch === 'highlighted' : true,
58-
)
59-
if (!value) return all
60-
return all.filter((entry) => {
61-
const skill = entry.skill
62-
if (skill.slug.toLowerCase().includes(value)) return true
63-
if (skill.displayName.toLowerCase().includes(value)) return true
64-
return (skill.summary ?? '').toLowerCase().includes(value)
65-
})
66-
}, [highlightedOnly, query, items])
76+
useEffect(() => {
77+
if (hasQuery) return
78+
setPages([])
79+
setCursor(null)
80+
setNextCursor(null)
81+
}, [hasQuery])
82+
83+
useEffect(() => {
84+
if (hasQuery || !listPage) return
85+
setNextCursor(listPage.nextCursor)
86+
setPages((prev) => (cursor ? [...prev, ...listPage.items] : listPage.items))
87+
}, [cursor, hasQuery, listPage])
88+
89+
useEffect(() => {
90+
if (!hasQuery) {
91+
setSearchResults([])
92+
setIsSearching(false)
93+
return
94+
}
95+
setSearchResults([])
96+
setSearchLimit(pageSize)
97+
}, [hasQuery, highlightedOnly, trimmedQuery])
98+
99+
useEffect(() => {
100+
if (!hasQuery) return
101+
searchRequest.current += 1
102+
const requestId = searchRequest.current
103+
setIsSearching(true)
104+
const handle = window.setTimeout(() => {
105+
void (async () => {
106+
try {
107+
const data = (await searchSkills({
108+
query: trimmedQuery,
109+
highlightedOnly,
110+
limit: searchLimit,
111+
})) as Array<{
112+
skill: Doc<'skills'>
113+
version: Doc<'skillVersions'> | null
114+
score: number
115+
}>
116+
if (requestId === searchRequest.current) {
117+
setSearchResults(data)
118+
}
119+
} finally {
120+
if (requestId === searchRequest.current) {
121+
setIsSearching(false)
122+
}
123+
}
124+
})()
125+
}, 220)
126+
return () => window.clearTimeout(handle)
127+
}, [hasQuery, highlightedOnly, searchLimit, searchSkills, trimmedQuery])
128+
129+
const baseItems = useMemo(() => {
130+
if (hasQuery) {
131+
return searchResults.map((entry) => ({
132+
skill: entry.skill,
133+
latestVersion: entry.version,
134+
}))
135+
}
136+
return pages
137+
}, [hasQuery, pages, searchResults])
138+
139+
const filtered = useMemo(
140+
() =>
141+
baseItems.filter((entry) =>
142+
highlightedOnly ? entry.skill.batch === 'highlighted' : true,
143+
),
144+
[baseItems, highlightedOnly],
145+
)
67146

68147
const sorted = useMemo(() => {
69148
const multiplier = dir === 'asc' ? 1 : -1
@@ -94,9 +173,15 @@ export function SkillsIndex() {
94173
}, [dir, filtered, sort])
95174

96175
const showing = sorted.length
97-
const total = items?.filter((entry) =>
98-
highlightedOnly ? entry.skill.batch === 'highlighted' : true,
99-
).length
176+
const isLoadingSkills = hasQuery
177+
? isSearching && searchResults.length === 0
178+
: isLoadingList
179+
const canLoadMore = hasQuery
180+
? !isSearching && searchResults.length === searchLimit && searchResults.length > 0
181+
: nextCursor !== null
182+
const isLoadingMore = hasQuery
183+
? isSearching && searchResults.length > 0
184+
: listPage === undefined && pages.length > 0
100185

101186
return (
102187
<main className="section">
@@ -108,7 +193,7 @@ export function SkillsIndex() {
108193
<p className="section-subtitle" style={{ marginBottom: 0 }}>
109194
{isLoadingSkills
110195
? 'Loading skills…'
111-
: `${showing}${typeof total === 'number' ? ` of ${total}` : ''} skills${
196+
: `${showing} skill${showing === 1 ? '' : 's'}${
112197
highlightedOnly ? ' (highlighted)' : ''
113198
}.`}
114199
</p>
@@ -276,6 +361,28 @@ export function SkillsIndex() {
276361
})}
277362
</div>
278363
)}
364+
365+
{canLoadMore ? (
366+
<div
367+
className="card"
368+
style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}
369+
>
370+
<button
371+
className="btn"
372+
type="button"
373+
disabled={isLoadingMore}
374+
onClick={() => {
375+
if (hasQuery) {
376+
setSearchLimit((value) => value + pageSize)
377+
} else if (nextCursor) {
378+
setCursor(nextCursor)
379+
}
380+
}}
381+
>
382+
{isLoadingMore ? 'Loading…' : 'Load more'}
383+
</button>
384+
</div>
385+
) : null}
279386
</main>
280387
)
281388
}

0 commit comments

Comments
 (0)