Skip to content
Merged
27 changes: 23 additions & 4 deletions src/app/dashboard/[teamSlug]/templates/(tabs)/list/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { Suspense } from 'react'
import LoadingLayout from '@/features/dashboard/loading-layout'
import {
TEMPLATES_DEFAULT_SORT,
TEMPLATES_PAGE_SIZE,
} from '@/features/dashboard/templates/list/constants'
import TemplatesTable from '@/features/dashboard/templates/list/table'
import { HydrateClient, prefetch, trpc } from '@/trpc/server'

export default async function TemplatesListPage({
params,
}: PageProps<'/dashboard/[teamSlug]/templates/list'>) {
const { teamSlug } = await params

prefetch(
trpc.templates.getTemplates.infiniteQueryOptions({
teamSlug,
limit: TEMPLATES_PAGE_SIZE,
sort: TEMPLATES_DEFAULT_SORT,
})
)

export default function TemplatesListPage() {
return (
<Suspense fallback={<LoadingLayout />}>
<TemplatesTable />
</Suspense>
<HydrateClient>
<Suspense fallback={<LoadingLayout />}>
<TemplatesTable />
</Suspense>
</HydrateClient>
)
}
15 changes: 15 additions & 0 deletions src/core/modules/templates/models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type {
components as DashboardComponents,
paths as DashboardPaths,
} from '@/contracts/dashboard-api'
import type { components as InfraComponents } from '@/contracts/infra-api'

export type Template = Pick<
Expand All @@ -23,3 +27,14 @@ export type DefaultTemplate = Template & {
isDefault: true
defaultDescription?: string
}

export type TemplatesSort = DashboardComponents['parameters']['templates_sort']

export type ListTeamTemplatesOptions = NonNullable<
DashboardPaths['/templates']['get']['parameters']['query']
>

export interface ListTeamTemplatesResult {
data: Array<Template | DefaultTemplate>
nextCursor: string | null
}
66 changes: 65 additions & 1 deletion src/core/modules/templates/repository.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {
MOCK_DEFAULT_TEMPLATES_DATA,
MOCK_TEMPLATES_DATA,
} from '@/configs/mock-data'
import type { DefaultTemplate, Template } from '@/core/modules/templates/models'
import type {
DefaultTemplate,
ListTeamTemplatesOptions,
ListTeamTemplatesResult,
Template,
} from '@/core/modules/templates/models'
import {
type AuthUserEmailResolver,
getAuthUserEmailsById,
Expand All @@ -30,6 +35,9 @@ type TemplatesRepositoryDeps = {

export interface TeamTemplatesRepository {
getTeamTemplates(): Promise<RepoResult<{ templates: Template[] }>>
listTeamTemplates(
options: ListTeamTemplatesOptions
): Promise<RepoResult<ListTeamTemplatesResult>>
deleteTemplate(templateId: string): Promise<RepoResult<{ success: true }>>
updateTemplateVisibility(
templateId: string,
Expand Down Expand Up @@ -87,6 +95,62 @@ export function createTemplatesRepository(
),
})
},
async listTeamTemplates(options) {
if (USE_MOCK_DATA) {
return ok({ data: MOCK_TEMPLATES_DATA, nextCursor: null })
}

const res = await deps.apiClient.GET('/templates', {
params: {
query: options,
},
headers: {
...deps.authHeaders(scope.accessToken, scope.teamId),
},
})

if (!res.response.ok || res.error) {
return err(
repoErrorFromHttp(
res.response.status,
res.error?.message ?? 'Failed to fetch templates',
res.error
)
)
}

if (!res.data?.data?.length) {
return ok({ data: [], nextCursor: res.data?.nextCursor ?? null })
}

const data = res.data.data.map((t): Template | DefaultTemplate => ({
templateID: t.templateID,
buildID: t.buildID,
cpuCount: t.cpuCount,
memoryMB: t.memoryMB,
diskSizeMB: t.diskSizeMB ?? 0,
public: t.public,
aliases: t.aliases,
names: t.names,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
// Email resolution is deferred while the Supabase auth migration is
// in progress; the endpoint returns only the creator id for now.
createdBy: t.createdBy
? { id: t.createdBy.id, email: t.createdBy.email ?? '' }
: null,
lastSpawnedAt: t.lastSpawnedAt ?? null,
spawnCount: t.spawnCount,
buildCount: t.buildCount,
envdVersion: t.envdVersion ?? '',
...(t.isDefault && {
isDefault: true as const,
defaultDescription: t.defaultDescription ?? undefined,
}),
}))

return ok({ data, nextCursor: res.data.nextCursor ?? null })
},
async deleteTemplate(templateId) {
const res = await deps.infraClient.DELETE('/templates/{templateID}', {
params: {
Expand Down
43 changes: 38 additions & 5 deletions src/core/server/api/routers/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,44 @@ const teamTemplatesRepositoryProcedure = protectedTeamProcedure.use(
export const templatesRouter = createTRPCRouter({
// QUERIES

getTemplates: teamTemplatesRepositoryProcedure.query(async ({ ctx }) => {
const result = await ctx.templatesRepository.getTeamTemplates()
if (!result.ok) throwTRPCErrorFromRepoError(result.error)
return result.data
}),
getTemplates: teamTemplatesRepositoryProcedure
.input(
z.object({
cursor: z.string().optional(),
limit: z.number().int().min(1).max(100).default(50),
cpuCount: z.number().int().positive().optional(),
memoryMB: z.number().int().positive().optional(),
public: z.boolean().optional(),
search: z.string().optional(),
sort: z
.enum([
'name_asc',
'name_desc',
'cpu_count_asc',
'cpu_count_desc',
'memory_mb_asc',
'memory_mb_desc',
'created_at_asc',
'created_at_desc',
'updated_at_asc',
'updated_at_desc',
])
.default('updated_at_desc'),
})
)
.query(async ({ ctx, input }) => {
const result = await ctx.templatesRepository.listTeamTemplates({
cursor: input.cursor,
limit: input.limit,
cpuCount: input.cpuCount,
memoryMB: input.memoryMB,
public: input.public,
search: input.search,
sort: input.sort,
})
if (!result.ok) throwTRPCErrorFromRepoError(result.error)
return result.data
}),

getDefaultTemplatesCached: templatesRepositoryProcedure.query(
async ({ ctx }) => {
Expand Down
37 changes: 0 additions & 37 deletions src/features/dashboard/templates/builds/table-cells.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import CopyButtonInline from '@/ui/copy-button-inline'
import { Badge } from '@/ui/primitives/badge'
import { Button } from '@/ui/primitives/button'
import { CheckIcon, CloseIcon } from '@/ui/primitives/icons'
import { Loader } from '@/ui/primitives/loader'

export function BuildId({ id }: { id: string }) {
return (
Expand Down Expand Up @@ -61,42 +60,6 @@ export function Template({
)
}

export function LoadMoreButton({
isLoading,
onLoadMore,
}: {
isLoading: boolean
onLoadMore: () => void
}) {
if (isLoading) {
return (
<span className="inline-flex items-center gap-1">
Loading
<Loader variant="dots" />
</span>
)
}
return (
<button
onClick={onLoadMore}
className="underline text-fg-secondary hover:text-accent-main-highlight transition-colors"
>
Load more
</button>
)
}

export function BackToTopButton({ onBackToTop }: { onBackToTop: () => void }) {
return (
<button
onClick={onBackToTop}
className="underline text-fg-secondary hover:text-accent-main-highlight transition-colors"
>
Back to top
</button>
)
}

export function Duration({
createdAt,
finishedAt,
Expand Down
3 changes: 1 addition & 2 deletions src/features/dashboard/templates/builds/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { cn } from '@/lib/utils/ui'
import { useTRPC } from '@/trpc/client'
import { BackToTopButton, LoadMoreButton } from '@/ui/pagination-buttons'
import { ArrowDownIcon } from '@/ui/primitives/icons'
import { Loader } from '@/ui/primitives/loader'
import {
Expand All @@ -28,10 +29,8 @@ import {
} from '@/ui/primitives/table'
import BuildsEmpty from './empty'
import {
BackToTopButton,
BuildId,
Duration,
LoadMoreButton,
Reason,
StartedAt,
Status,
Expand Down
16 changes: 16 additions & 0 deletions src/features/dashboard/templates/list/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { SortingState } from '@tanstack/react-table'
import type { TemplatesSort } from '@/core/modules/templates/models'

export const TEMPLATES_PAGE_SIZE = 50

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have some default page size for everything instead of having it specific to templates?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep it as-is for now since page size could depend on row item size and API performance


export const TEMPLATES_DEFAULT_SORT_COLUMN_ID = 'createdAt'
export const TEMPLATES_DEFAULT_SORT_BASE = 'created_at'
export const TEMPLATES_DEFAULT_SORT_DESC = true

export const TEMPLATES_DEFAULT_SORT: TemplatesSort = `${TEMPLATES_DEFAULT_SORT_BASE}_${
TEMPLATES_DEFAULT_SORT_DESC ? 'desc' : 'asc'
}`

export const TEMPLATES_DEFAULT_SORTING: SortingState = [
{ id: TEMPLATES_DEFAULT_SORT_COLUMN_ID, desc: TEMPLATES_DEFAULT_SORT_DESC },
]
33 changes: 16 additions & 17 deletions src/features/dashboard/templates/list/header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Table } from '@tanstack/react-table'
import { Suspense } from 'react'
import type { Template } from '@/core/modules/templates/models'
import { useTemplateTableStore } from './stores/table-store'
import TemplatesTableFilters from './table-filters'
import { SearchInput } from './table-search'

Expand All @@ -11,13 +12,16 @@ interface TemplatesHeaderProps {
export default function TemplatesHeader({ table }: TemplatesHeaderProps) {
'use no memo'

const { columnFilters, globalFilter } = table.getState()
const showFilteredRowCount = columnFilters.length > 0 || Boolean(globalFilter)
const { globalFilter, cpuCount, memoryMB, isPublic } = useTemplateTableStore()
const isFiltered =
Boolean(globalFilter) ||
cpuCount !== undefined ||
memoryMB !== undefined ||
isPublic !== undefined

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

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

<span className="prose-label-highlight h-9 flex w-full min-w-0 items-center gap-1 uppercase sm:w-auto">
{showFilteredRowCount ? (
<>
<span className="text-fg">
{filteredCount} {filteredCount === 1 ? 'result' : 'results'}
</span>
<span className="text-fg-tertiary"> · </span>
<span className="text-fg-tertiary">{totalCount} total</span>
</>
) : (
<span className="text-fg-tertiary">{totalCount} total</span>
)}
<span className="text-fg">
{loadedCount} {loadedCount === 1 ? 'template' : 'templates'}
</span>
{isFiltered ? (
<span className="text-fg-tertiary"> · filtered</span>
) : null}
</span>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { OnChangeFn, SortingState } from '@tanstack/react-table'
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import { createHashStorage } from '@/lib/utils/store'
import { TEMPLATES_DEFAULT_SORTING } from '../constants'
import { trackTemplateTableInteraction } from '../table-config'

interface TemplateTableState {
Expand Down Expand Up @@ -33,7 +34,7 @@ type Store = TemplateTableState & TemplateTableActions

const initialState: TemplateTableState = {
// Table state
sorting: [{ id: 'updatedAt', desc: true }],
sorting: TEMPLATES_DEFAULT_SORTING,
globalFilter: '',
// Filter state
cpuCount: undefined,
Expand Down
Loading
Loading