Skip to content

Commit e25f182

Browse files
authored
feat: templates list pagination (#406)
## Summary Re-lands #352 (previously reverted in deca10d) with follow-ups. The templates list is now paginated server-side (keyset cursor) instead of fetching everything and sorting/filtering in memory. ## Changes - Templates list uses cursor pagination: first page prefetched on the server, more rows via "Load more"; search and visibility filter are handled server-side. - Sorting is limited to Created and Updated — name/CPU/memory sorting dropped. Default sort is now `updated_at_desc` (recently built templates shown at the top) - Removed the CPU/memory resource filter (was in-memory only, not supported by the backend). - Old URLs with removed sort/filter state fall back to defaults.
1 parent 09f86da commit e25f182

16 files changed

Lines changed: 371 additions & 295 deletions

File tree

spec/openapi.dashboard-api.yaml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
11
import { Suspense } from 'react'
22
import LoadingLayout from '@/features/dashboard/loading-layout'
3+
import {
4+
TEMPLATES_DEFAULT_SORT,
5+
TEMPLATES_PAGE_SIZE,
6+
} from '@/features/dashboard/templates/list/constants'
37
import TemplatesTable from '@/features/dashboard/templates/list/table'
8+
import { HydrateClient, prefetch, trpc } from '@/trpc/server'
9+
10+
export default async function TemplatesListPage({
11+
params,
12+
}: PageProps<'/dashboard/[teamSlug]/templates/list'>) {
13+
const { teamSlug } = await params
14+
15+
prefetch(
16+
trpc.templates.getTemplates.infiniteQueryOptions({
17+
teamSlug,
18+
limit: TEMPLATES_PAGE_SIZE,
19+
sort: TEMPLATES_DEFAULT_SORT,
20+
})
21+
)
422

5-
export default function TemplatesListPage() {
623
return (
7-
<Suspense fallback={<LoadingLayout />}>
8-
<TemplatesTable />
9-
</Suspense>
24+
<HydrateClient>
25+
<Suspense fallback={<LoadingLayout />}>
26+
<TemplatesTable />
27+
</Suspense>
28+
</HydrateClient>
1029
)
1130
}

src/core/modules/templates/models.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import type {
2+
components as DashboardComponents,
3+
paths as DashboardPaths,
4+
} from '@/contracts/dashboard-api'
15
import type { components as InfraComponents } from '@/contracts/infra-api'
26

37
export type Template = Pick<
@@ -23,3 +27,14 @@ export type DefaultTemplate = Template & {
2327
isDefault: true
2428
defaultDescription?: string
2529
}
30+
31+
export type TemplatesSort = DashboardComponents['parameters']['templates_sort']
32+
33+
export type ListTeamTemplatesOptions = NonNullable<
34+
DashboardPaths['/templates']['get']['parameters']['query']
35+
>
36+
37+
export interface ListTeamTemplatesResult {
38+
data: Array<Template | DefaultTemplate>
39+
nextCursor: string | null
40+
}

src/core/modules/templates/repository.server.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import {
77
MOCK_DEFAULT_TEMPLATES_DATA,
88
MOCK_TEMPLATES_DATA,
99
} from '@/configs/mock-data'
10-
import type { DefaultTemplate, Template } from '@/core/modules/templates/models'
10+
import type {
11+
DefaultTemplate,
12+
ListTeamTemplatesOptions,
13+
ListTeamTemplatesResult,
14+
Template,
15+
} from '@/core/modules/templates/models'
1116
import {
1217
type AuthUserEmailResolver,
1318
getAuthUserEmailsById,
@@ -30,6 +35,9 @@ type TemplatesRepositoryDeps = {
3035

3136
export interface TeamTemplatesRepository {
3237
getTeamTemplates(): Promise<RepoResult<{ templates: Template[] }>>
38+
listTeamTemplates(
39+
options: ListTeamTemplatesOptions
40+
): Promise<RepoResult<ListTeamTemplatesResult>>
3341
deleteTemplate(templateId: string): Promise<RepoResult<{ success: true }>>
3442
updateTemplateVisibility(
3543
templateId: string,
@@ -87,6 +95,62 @@ export function createTemplatesRepository(
8795
),
8896
})
8997
},
98+
async listTeamTemplates(options) {
99+
if (USE_MOCK_DATA) {
100+
return ok({ data: MOCK_TEMPLATES_DATA, nextCursor: null })
101+
}
102+
103+
const res = await deps.apiClient.GET('/templates', {
104+
params: {
105+
query: options,
106+
},
107+
headers: {
108+
...deps.authHeaders(scope.accessToken, scope.teamId),
109+
},
110+
})
111+
112+
if (!res.response.ok || res.error) {
113+
return err(
114+
repoErrorFromHttp(
115+
res.response.status,
116+
res.error?.message ?? 'Failed to fetch templates',
117+
res.error
118+
)
119+
)
120+
}
121+
122+
if (!res.data?.data?.length) {
123+
return ok({ data: [], nextCursor: res.data?.nextCursor ?? null })
124+
}
125+
126+
const data = res.data.data.map((t): Template | DefaultTemplate => ({
127+
templateID: t.templateID,
128+
buildID: t.buildID,
129+
cpuCount: t.cpuCount,
130+
memoryMB: t.memoryMB,
131+
diskSizeMB: t.diskSizeMB ?? 0,
132+
public: t.public,
133+
aliases: t.aliases,
134+
names: t.names,
135+
createdAt: t.createdAt,
136+
updatedAt: t.updatedAt,
137+
// Email resolution is deferred while the Supabase auth migration is
138+
// in progress; the endpoint returns only the creator id for now.
139+
createdBy: t.createdBy
140+
? { id: t.createdBy.id, email: t.createdBy.email ?? '' }
141+
: null,
142+
lastSpawnedAt: t.lastSpawnedAt ?? null,
143+
spawnCount: t.spawnCount,
144+
buildCount: t.buildCount,
145+
envdVersion: t.envdVersion ?? '',
146+
...(t.isDefault && {
147+
isDefault: true as const,
148+
defaultDescription: t.defaultDescription ?? undefined,
149+
}),
150+
}))
151+
152+
return ok({ data, nextCursor: res.data.nextCursor ?? null })
153+
},
90154
async deleteTemplate(templateId) {
91155
const res = await deps.infraClient.DELETE('/templates/{templateID}', {
92156
params: {

src/core/server/api/routers/templates.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,34 @@ const teamTemplatesRepositoryProcedure = protectedTeamProcedure.use(
3636
export const templatesRouter = createTRPCRouter({
3737
// QUERIES
3838

39-
getTemplates: teamTemplatesRepositoryProcedure.query(async ({ ctx }) => {
40-
const result = await ctx.templatesRepository.getTeamTemplates()
41-
if (!result.ok) throwTRPCErrorFromRepoError(result.error)
42-
return result.data
43-
}),
39+
getTemplates: teamTemplatesRepositoryProcedure
40+
.input(
41+
z.object({
42+
cursor: z.string().optional(),
43+
limit: z.number().int().min(1).max(100).default(50),
44+
public: z.boolean().optional(),
45+
search: z.string().optional(),
46+
sort: z
47+
.enum([
48+
'created_at_asc',
49+
'created_at_desc',
50+
'updated_at_asc',
51+
'updated_at_desc',
52+
])
53+
.default('updated_at_desc'),
54+
})
55+
)
56+
.query(async ({ ctx, input }) => {
57+
const result = await ctx.templatesRepository.listTeamTemplates({
58+
cursor: input.cursor,
59+
limit: input.limit,
60+
public: input.public,
61+
search: input.search,
62+
sort: input.sort,
63+
})
64+
if (!result.ok) throwTRPCErrorFromRepoError(result.error)
65+
return result.data
66+
}),
4467

4568
getDefaultTemplatesCached: templatesRepositoryProcedure.query(
4669
async ({ ctx }) => {

src/features/dashboard/templates/builds/table-cells.tsx

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -61,42 +61,6 @@ export function Template({
6161
)
6262
}
6363

64-
export function LoadMoreButton({
65-
isLoading,
66-
onLoadMore,
67-
}: {
68-
isLoading: boolean
69-
onLoadMore: () => void
70-
}) {
71-
if (isLoading) {
72-
return (
73-
<span className="inline-flex items-center gap-1">
74-
Loading
75-
<Loader variant="dots" />
76-
</span>
77-
)
78-
}
79-
return (
80-
<button
81-
onClick={onLoadMore}
82-
className="underline text-fg-secondary hover:text-accent-main-highlight transition-colors"
83-
>
84-
Load more
85-
</button>
86-
)
87-
}
88-
89-
export function BackToTopButton({ onBackToTop }: { onBackToTop: () => void }) {
90-
return (
91-
<button
92-
onClick={onBackToTop}
93-
className="underline text-fg-secondary hover:text-accent-main-highlight transition-colors"
94-
>
95-
Back to top
96-
</button>
97-
)
98-
}
99-
10064
export function Duration({
10165
createdAt,
10266
finishedAt,

src/features/dashboard/templates/builds/table.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
import { useRouteParams } from '@/lib/hooks/use-route-params'
1717
import { cn } from '@/lib/utils/ui'
1818
import { useTRPC } from '@/trpc/client'
19+
import { BackToTopButton, LoadMoreButton } from '@/ui/pagination-buttons'
1920
import { ArrowDownIcon } from '@/ui/primitives/icons'
2021
import { Loader } from '@/ui/primitives/loader'
2122
import {
@@ -28,10 +29,8 @@ import {
2829
} from '@/ui/primitives/table'
2930
import BuildsEmpty from './empty'
3031
import {
31-
BackToTopButton,
3232
BuildId,
3333
Duration,
34-
LoadMoreButton,
3534
Reason,
3635
StartedAt,
3736
Status,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { SortingState } from '@tanstack/react-table'
2+
import type { TemplatesSort } from '@/core/modules/templates/models'
3+
4+
export const TEMPLATES_PAGE_SIZE = 50
5+
6+
export const TEMPLATES_DEFAULT_SORT_COLUMN_ID = 'updatedAt'
7+
export const TEMPLATES_DEFAULT_SORT_BASE = 'updated_at'
8+
export const TEMPLATES_DEFAULT_SORT_DESC = true
9+
10+
export const TEMPLATES_DEFAULT_SORT: TemplatesSort = `${TEMPLATES_DEFAULT_SORT_BASE}_${
11+
TEMPLATES_DEFAULT_SORT_DESC ? 'desc' : 'asc'
12+
}`
13+
14+
export const TEMPLATES_DEFAULT_SORTING: SortingState = [
15+
{ id: TEMPLATES_DEFAULT_SORT_COLUMN_ID, desc: TEMPLATES_DEFAULT_SORT_DESC },
16+
]

src/features/dashboard/templates/list/header.tsx

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Table } from '@tanstack/react-table'
22
import { Suspense } from 'react'
33
import type { Template } from '@/core/modules/templates/models'
4+
import { useTemplateTableStore } from './stores/table-store'
45
import TemplatesTableFilters from './table-filters'
56
import { SearchInput } from './table-search'
67

@@ -11,13 +12,12 @@ interface TemplatesHeaderProps {
1112
export default function TemplatesHeader({ table }: TemplatesHeaderProps) {
1213
'use no memo'
1314

14-
const { columnFilters, globalFilter } = table.getState()
15-
const showFilteredRowCount = columnFilters.length > 0 || Boolean(globalFilter)
15+
const { globalFilter, isPublic } = useTemplateTableStore()
16+
const isFiltered = Boolean(globalFilter) || isPublic !== undefined
1617

17-
const totalCount = table.options.data.length
18-
const filteredCount = showFilteredRowCount
19-
? table.getFilteredRowModel().rows.length
20-
: totalCount
18+
// With server-side pagination we only know how many rows are currently
19+
// loaded, not the grand total.
20+
const loadedCount = table.options.data.length
2121

2222
return (
2323
<div className="flex min-w-0 flex-wrap items-start gap-1 sm:items-center">
@@ -33,17 +33,12 @@ export default function TemplatesHeader({ table }: TemplatesHeaderProps) {
3333
<div className="hidden w-2 shrink-0 sm:block" aria-hidden="true" />
3434

3535
<span className="prose-label-highlight h-9 flex w-full min-w-0 items-center gap-1 uppercase sm:w-auto">
36-
{showFilteredRowCount ? (
37-
<>
38-
<span className="text-fg">
39-
{filteredCount} {filteredCount === 1 ? 'result' : 'results'}
40-
</span>
41-
<span className="text-fg-tertiary"> · </span>
42-
<span className="text-fg-tertiary">{totalCount} total</span>
43-
</>
44-
) : (
45-
<span className="text-fg-tertiary">{totalCount} total</span>
46-
)}
36+
<span className="text-fg">
37+
{loadedCount} {loadedCount === 1 ? 'template' : 'templates'}
38+
</span>
39+
{isFiltered ? (
40+
<span className="text-fg-tertiary"> · filtered</span>
41+
) : null}
4742
</span>
4843
</div>
4944
)

src/features/dashboard/templates/list/stores/table-store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { OnChangeFn, SortingState } from '@tanstack/react-table'
22
import { create } from 'zustand'
33
import { createJSONStorage, persist } from 'zustand/middleware'
44
import { createHashStorage } from '@/lib/utils/store'
5+
import { TEMPLATES_DEFAULT_SORTING } from '../constants'
56
import { trackTemplateTableInteraction } from '../table-config'
67

78
interface TemplateTableState {
@@ -33,7 +34,7 @@ type Store = TemplateTableState & TemplateTableActions
3334

3435
const initialState: TemplateTableState = {
3536
// Table state
36-
sorting: [{ id: 'updatedAt', desc: true }],
37+
sorting: TEMPLATES_DEFAULT_SORTING,
3738
globalFilter: '',
3839
// Filter state
3940
cpuCount: undefined,

0 commit comments

Comments
 (0)