Skip to content
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>
)
}
27 changes: 27 additions & 0 deletions src/core/modules/templates/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,30 @@ export type DefaultTemplate = Template & {
isDefault: true
defaultDescription?: string
}

export type TemplatesSort =
| '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'

export interface ListTeamTemplatesOptions {
cursor?: string
limit?: number
cpuCount?: number
memoryMB?: number
public?: boolean
search?: string
sort?: TemplatesSort
}

export interface ListTeamTemplatesResult {
data: Array<Template | DefaultTemplate>
nextCursor: string | null
}
80 changes: 79 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,76 @@ 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: {
cursor: options.cursor,
limit: options.limit,
cpuCount: options.cpuCount,
memoryMB: options.memoryMB,
public: options.public,
search: options.search,
sort: options.sort,
},
},
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
)
)
}

const data = (res.data?.data ?? []).map(
(t): Template | DefaultTemplate => {
const base: Template = {
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 ?? '',
}

if (t.isDefault) {
return {
...base,
isDefault: true,
defaultDescription: t.defaultDescription ?? undefined,
}
}

return base
}
)

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

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