Skip to content

Commit 33a7cec

Browse files
authored
Merge pull request #955 from sethconvex/feat/listpublic-v4-staged-release
feat: V4 staged release — /skillsv4 test route
2 parents ecf71e8 + 81e734b commit 33a7cec

File tree

5 files changed

+365
-30
lines changed

5 files changed

+365
-30
lines changed

convex/skills.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getAuthUserId } from '@convex-dev/auth/server'
22
import { getPage, type IndexKey } from 'convex-helpers/server/pagination'
3+
import schema from './schema'
34
import { paginationOptsValidator } from 'convex/server'
45
import { ConvexError, v, type Value } from 'convex/values'
56
import type { Doc, Id } from './_generated/dataModel'
@@ -330,22 +331,6 @@ const NONSUSPICIOUS_SORT_INDEXES = {
330331
installs: 'by_nonsuspicious_installs',
331332
} as const
332333

333-
// Index fields for getPage (avoids importing schema.ts which pulls in auth deps)
334-
const DIGEST_INDEX_FIELDS: Record<string, string[]> = {
335-
by_active_created: ['softDeletedAt', 'createdAt'],
336-
by_active_updated: ['softDeletedAt', 'updatedAt'],
337-
by_active_name: ['softDeletedAt', 'displayName'],
338-
by_active_stats_downloads: ['softDeletedAt', 'statsDownloads', 'updatedAt'],
339-
by_active_stats_stars: ['softDeletedAt', 'statsStars', 'updatedAt'],
340-
by_active_stats_installs_all_time: ['softDeletedAt', 'statsInstallsAllTime', 'updatedAt'],
341-
by_nonsuspicious_created: ['softDeletedAt', 'isSuspicious', 'createdAt'],
342-
by_nonsuspicious_updated: ['softDeletedAt', 'isSuspicious', 'updatedAt'],
343-
by_nonsuspicious_name: ['softDeletedAt', 'isSuspicious', 'displayName'],
344-
by_nonsuspicious_downloads: ['softDeletedAt', 'isSuspicious', 'statsDownloads', 'updatedAt'],
345-
by_nonsuspicious_stars: ['softDeletedAt', 'isSuspicious', 'statsStars', 'updatedAt'],
346-
by_nonsuspicious_installs: ['softDeletedAt', 'isSuspicious', 'statsInstallsAllTime', 'updatedAt'],
347-
}
348-
349334
function isSkillVersionId(
350335
value: Id<'skillVersions'> | null | undefined,
351336
): value is Id<'skillVersions'> {
@@ -2760,10 +2745,11 @@ export const listPublicPageV4 = query({
27602745
startInclusive: lastFetchInclusive,
27612746
endIndexKey: eqPrefix,
27622747
endInclusive: true,
2763-
targetMaxRows: fetchSize,
2748+
// endIndexKey causes targetMaxRows to be ignored, so use absoluteMaxRows
2749+
absoluteMaxRows: fetchSize,
27642750
order: dir,
27652751
index: indexName,
2766-
indexFields: DIGEST_INDEX_FIELDS[indexName],
2752+
schema,
27672753
})
27682754

27692755
// Pair digests with their index keys, then filter

src/routeTree.gen.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111
import { Route as rootRouteImport } from './routes/__root'
1212
import { Route as UploadRouteImport } from './routes/upload'
13+
import { Route as TestV4RouteImport } from './routes/test-v4'
1314
import { Route as StarsRouteImport } from './routes/stars'
15+
import { Route as Skillsv4RouteImport } from './routes/skillsv4'
1416
import { Route as SettingsRouteImport } from './routes/settings'
1517
import { Route as SearchRouteImport } from './routes/search'
1618
import { Route as ManagementRouteImport } from './routes/management'
@@ -30,11 +32,21 @@ const UploadRoute = UploadRouteImport.update({
3032
path: '/upload',
3133
getParentRoute: () => rootRouteImport,
3234
} as any)
35+
const TestV4Route = TestV4RouteImport.update({
36+
id: '/test-v4',
37+
path: '/test-v4',
38+
getParentRoute: () => rootRouteImport,
39+
} as any)
3340
const StarsRoute = StarsRouteImport.update({
3441
id: '/stars',
3542
path: '/stars',
3643
getParentRoute: () => rootRouteImport,
3744
} as any)
45+
const Skillsv4Route = Skillsv4RouteImport.update({
46+
id: '/skillsv4',
47+
path: '/skillsv4',
48+
getParentRoute: () => rootRouteImport,
49+
} as any)
3850
const SettingsRoute = SettingsRouteImport.update({
3951
id: '/settings',
4052
path: '/settings',
@@ -109,7 +121,9 @@ export interface FileRoutesByFullPath {
109121
'/management': typeof ManagementRoute
110122
'/search': typeof SearchRoute
111123
'/settings': typeof SettingsRoute
124+
'/skillsv4': typeof Skillsv4Route
112125
'/stars': typeof StarsRoute
126+
'/test-v4': typeof TestV4Route
113127
'/upload': typeof UploadRoute
114128
'/$owner/$slug': typeof OwnerSlugRoute
115129
'/cli/auth': typeof CliAuthRoute
@@ -126,7 +140,9 @@ export interface FileRoutesByTo {
126140
'/management': typeof ManagementRoute
127141
'/search': typeof SearchRoute
128142
'/settings': typeof SettingsRoute
143+
'/skillsv4': typeof Skillsv4Route
129144
'/stars': typeof StarsRoute
145+
'/test-v4': typeof TestV4Route
130146
'/upload': typeof UploadRoute
131147
'/$owner/$slug': typeof OwnerSlugRoute
132148
'/cli/auth': typeof CliAuthRoute
@@ -144,7 +160,9 @@ export interface FileRoutesById {
144160
'/management': typeof ManagementRoute
145161
'/search': typeof SearchRoute
146162
'/settings': typeof SettingsRoute
163+
'/skillsv4': typeof Skillsv4Route
147164
'/stars': typeof StarsRoute
165+
'/test-v4': typeof TestV4Route
148166
'/upload': typeof UploadRoute
149167
'/$owner/$slug': typeof OwnerSlugRoute
150168
'/cli/auth': typeof CliAuthRoute
@@ -163,7 +181,9 @@ export interface FileRouteTypes {
163181
| '/management'
164182
| '/search'
165183
| '/settings'
184+
| '/skillsv4'
166185
| '/stars'
186+
| '/test-v4'
167187
| '/upload'
168188
| '/$owner/$slug'
169189
| '/cli/auth'
@@ -180,7 +200,9 @@ export interface FileRouteTypes {
180200
| '/management'
181201
| '/search'
182202
| '/settings'
203+
| '/skillsv4'
183204
| '/stars'
205+
| '/test-v4'
184206
| '/upload'
185207
| '/$owner/$slug'
186208
| '/cli/auth'
@@ -197,7 +219,9 @@ export interface FileRouteTypes {
197219
| '/management'
198220
| '/search'
199221
| '/settings'
222+
| '/skillsv4'
200223
| '/stars'
224+
| '/test-v4'
201225
| '/upload'
202226
| '/$owner/$slug'
203227
| '/cli/auth'
@@ -215,7 +239,9 @@ export interface RootRouteChildren {
215239
ManagementRoute: typeof ManagementRoute
216240
SearchRoute: typeof SearchRoute
217241
SettingsRoute: typeof SettingsRoute
242+
Skillsv4Route: typeof Skillsv4Route
218243
StarsRoute: typeof StarsRoute
244+
TestV4Route: typeof TestV4Route
219245
UploadRoute: typeof UploadRoute
220246
OwnerSlugRoute: typeof OwnerSlugRoute
221247
CliAuthRoute: typeof CliAuthRoute
@@ -234,13 +260,27 @@ declare module '@tanstack/react-router' {
234260
preLoaderRoute: typeof UploadRouteImport
235261
parentRoute: typeof rootRouteImport
236262
}
263+
'/test-v4': {
264+
id: '/test-v4'
265+
path: '/test-v4'
266+
fullPath: '/test-v4'
267+
preLoaderRoute: typeof TestV4RouteImport
268+
parentRoute: typeof rootRouteImport
269+
}
237270
'/stars': {
238271
id: '/stars'
239272
path: '/stars'
240273
fullPath: '/stars'
241274
preLoaderRoute: typeof StarsRouteImport
242275
parentRoute: typeof rootRouteImport
243276
}
277+
'/skillsv4': {
278+
id: '/skillsv4'
279+
path: '/skillsv4'
280+
fullPath: '/skillsv4'
281+
preLoaderRoute: typeof Skillsv4RouteImport
282+
parentRoute: typeof rootRouteImport
283+
}
244284
'/settings': {
245285
id: '/settings'
246286
path: '/settings'
@@ -343,7 +383,9 @@ const rootRouteChildren: RootRouteChildren = {
343383
ManagementRoute: ManagementRoute,
344384
SearchRoute: SearchRoute,
345385
SettingsRoute: SettingsRoute,
386+
Skillsv4Route: Skillsv4Route,
346387
StarsRoute: StarsRoute,
388+
TestV4Route: TestV4Route,
347389
UploadRoute: UploadRoute,
348390
OwnerSlugRoute: OwnerSlugRoute,
349391
CliAuthRoute: CliAuthRoute,

src/routes/skills/-useSkillsBrowseModel.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ export function useSkillsBrowseModel({
3030
search,
3131
navigate,
3232
searchInputRef,
33+
useV4 = false,
3334
}: {
3435
search: SkillsSearchState
3536
navigate: SkillsNavigate
3637
searchInputRef: RefObject<HTMLInputElement | null>
38+
useV4?: boolean
3739
}) {
3840
const [query, setQuery] = useState(search.q ?? '')
3941
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>([])
@@ -70,25 +72,41 @@ export function useSkillsBrowseModel({
7072
const fetchPage = useCallback(
7173
async (cursor: string | null, generation: number) => {
7274
try {
73-
const result = await convexHttp.query(api.skills.listPublicPageV3, {
74-
paginationOpts: { cursor, numItems: pageSize },
75-
sort: listSort,
76-
dir,
77-
highlightedOnly,
78-
nonSuspiciousOnly,
79-
})
80-
if (generation !== fetchGeneration.current) return
81-
setListResults((prev) => (cursor ? [...prev, ...result.page] : result.page))
82-
setListCursor(result.isDone ? null : result.continueCursor)
83-
setListStatus(result.isDone ? 'done' : 'idle')
75+
if (useV4) {
76+
const result = await convexHttp.query(api.skills.listPublicPageV4, {
77+
cursor: cursor ?? undefined,
78+
numItems: pageSize,
79+
sort: listSort,
80+
dir,
81+
highlightedOnly,
82+
nonSuspiciousOnly,
83+
})
84+
if (generation !== fetchGeneration.current) return
85+
setListResults((prev) => (cursor ? [...prev, ...result.page] : result.page))
86+
const canAdvance = result.hasMore && result.nextCursor != null
87+
setListCursor(canAdvance ? result.nextCursor : null)
88+
setListStatus(canAdvance ? 'idle' : 'done')
89+
} else {
90+
const result = await convexHttp.query(api.skills.listPublicPageV3, {
91+
paginationOpts: { cursor, numItems: pageSize },
92+
sort: listSort,
93+
dir,
94+
highlightedOnly,
95+
nonSuspiciousOnly,
96+
})
97+
if (generation !== fetchGeneration.current) return
98+
setListResults((prev) => (cursor ? [...prev, ...result.page] : result.page))
99+
setListCursor(result.isDone ? null : result.continueCursor)
100+
setListStatus(result.isDone ? 'done' : 'idle')
101+
}
84102
} catch (err) {
85103
if (generation !== fetchGeneration.current) return
86104
console.error('Failed to fetch skills page:', err)
87105
// Reset to idle so the user can retry via "Load more"
88106
setListStatus(cursor ? 'idle' : 'done')
89107
}
90108
},
91-
[listSort, dir, highlightedOnly, nonSuspiciousOnly],
109+
[listSort, dir, highlightedOnly, nonSuspiciousOnly, useV4],
92110
)
93111

94112
// Reset and fetch first page when sort/dir/filters change

src/routes/skillsv4.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { createFileRoute, redirect } from '@tanstack/react-router'
2+
import { useQuery } from 'convex/react'
3+
import { useRef } from 'react'
4+
import { api } from '../../convex/_generated/api'
5+
import { parseSort } from './skills/-params'
6+
import { SkillsResults } from './skills/-SkillsResults'
7+
import { SkillsToolbar } from './skills/-SkillsToolbar'
8+
import { useSkillsBrowseModel, type SkillsSearchState } from './skills/-useSkillsBrowseModel'
9+
10+
export const Route = createFileRoute('/skillsv4')({
11+
validateSearch: (search): SkillsSearchState => {
12+
return {
13+
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
14+
sort: typeof search.sort === 'string' ? parseSort(search.sort) : undefined,
15+
dir: search.dir === 'asc' || search.dir === 'desc' ? search.dir : undefined,
16+
highlighted:
17+
search.highlighted === '1' || search.highlighted === 'true' || search.highlighted === true
18+
? true
19+
: undefined,
20+
nonSuspicious:
21+
search.nonSuspicious === '1' ||
22+
search.nonSuspicious === 'true' ||
23+
search.nonSuspicious === true
24+
? true
25+
: undefined,
26+
view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
27+
focus: search.focus === 'search' ? 'search' : undefined,
28+
}
29+
},
30+
beforeLoad: ({ search }) => {
31+
const hasQuery = Boolean(search.q?.trim())
32+
if (hasQuery || search.sort) return
33+
throw redirect({
34+
to: '/skillsv4',
35+
search: {
36+
q: search.q || undefined,
37+
sort: 'downloads',
38+
dir: search.dir || undefined,
39+
highlighted: search.highlighted || undefined,
40+
nonSuspicious: search.nonSuspicious || undefined,
41+
view: search.view || undefined,
42+
focus: search.focus || undefined,
43+
},
44+
replace: true,
45+
})
46+
},
47+
component: SkillsV4Index,
48+
})
49+
50+
function SkillsV4Index() {
51+
const navigate = Route.useNavigate()
52+
const search = Route.useSearch()
53+
const searchInputRef = useRef<HTMLInputElement>(null)
54+
const totalSkills = useQuery(api.skills.countPublicSkills)
55+
const totalSkillsText =
56+
typeof totalSkills === 'number' ? totalSkills.toLocaleString('en-US') : null
57+
58+
const model = useSkillsBrowseModel({
59+
navigate,
60+
search,
61+
searchInputRef,
62+
useV4: true,
63+
})
64+
65+
return (
66+
<main className="section">
67+
<header className="skills-header-top">
68+
<h1 className="section-title" style={{ marginBottom: 8 }}>
69+
Skills (V4 test)
70+
{totalSkillsText && <span style={{ opacity: 0.55 }}>{` (${totalSkillsText})`}</span>}
71+
</h1>
72+
<p className="section-subtitle" style={{ marginBottom: 0 }}>
73+
{model.isLoadingSkills
74+
? 'Loading skills…'
75+
: `Browse the skill library${model.activeFilters.length ? ` (${model.activeFilters.join(', ')})` : ''}.`}
76+
</p>
77+
</header>
78+
<div className="skills-container">
79+
<SkillsToolbar
80+
searchInputRef={searchInputRef}
81+
query={model.query}
82+
hasQuery={model.hasQuery}
83+
sort={model.sort}
84+
dir={model.dir}
85+
view={model.view}
86+
highlightedOnly={model.highlightedOnly}
87+
nonSuspiciousOnly={model.nonSuspiciousOnly}
88+
onQueryChange={model.onQueryChange}
89+
onToggleHighlighted={model.onToggleHighlighted}
90+
onToggleNonSuspicious={model.onToggleNonSuspicious}
91+
onSortChange={model.onSortChange}
92+
onToggleDir={model.onToggleDir}
93+
onToggleView={model.onToggleView}
94+
/>
95+
<SkillsResults
96+
isLoadingSkills={model.isLoadingSkills}
97+
sorted={model.sorted}
98+
view={model.view}
99+
listDoneLoading={!model.isLoadingSkills && !model.canLoadMore && !model.isLoadingMore}
100+
hasQuery={model.hasQuery}
101+
canLoadMore={model.canLoadMore}
102+
isLoadingMore={model.isLoadingMore}
103+
canAutoLoad={model.canAutoLoad}
104+
loadMoreRef={model.loadMoreRef}
105+
loadMore={model.loadMore}
106+
/>
107+
</div>
108+
</main>
109+
)
110+
}

0 commit comments

Comments
 (0)