Skip to content

Commit a4e056a

Browse files
authored
feat: template details page (#351)
Add template detail page with Overview, Builds, and Tags tabs. **Changes:** - Overview tab: latest build, specs, envd version, visibility, started-sandboxes count. - Tags tab: assign, reassign, and rollback against any build, with a searchable build picker and tag history view. - Builds tab: server-side search and status filtering per template.
1 parent 08c6aaa commit a4e056a

84 files changed

Lines changed: 6608 additions & 380 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

spec/openapi.dashboard-api.yaml

Lines changed: 427 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
import BuildsHeader from '@/features/dashboard/templates/builds/header'
1+
'use client'
2+
3+
import { AllBuildsHeader } from '@/features/dashboard/templates/builds/all-builds-header'
24
import BuildsTable from '@/features/dashboard/templates/builds/table'
5+
import useFilters from '@/features/dashboard/templates/builds/use-filters'
36

47
export default function TemplateBuildsPage() {
8+
const { statuses, buildIdOrTemplate } = useFilters()
9+
510
return (
6-
<div className="h-full min-h-0 flex-1 p-3 md:p-6 flex flex-col gap-3">
7-
<BuildsHeader />
8-
<BuildsTable />
11+
<div className="h-full min-h-0 flex-1 p-3 md:p-6 flex flex-col gap-4">
12+
<AllBuildsHeader />
13+
<BuildsTable filters={{ statuses, buildIdOrTemplate }} />
914
</div>
1015
)
1116
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client'
2+
3+
import { use, useCallback } from 'react'
4+
import type { ListedBuildModel } from '@/core/modules/builds/models'
5+
import BuildsTable from '@/features/dashboard/templates/builds/table'
6+
import { TemplateBuildsHeader } from '@/features/dashboard/templates/builds/template-builds-header'
7+
import useTemplateBuildsFilters from '@/features/dashboard/templates/builds/use-template-builds-filters'
8+
import { isValidUuid } from '@/features/dashboard/templates/tags/helpers'
9+
10+
export default function TemplateDetailBuildsPage({
11+
params,
12+
}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) {
13+
const { templateId } = use(params)
14+
const { statuses, q } = useTemplateBuildsFilters()
15+
16+
const trimmed = q?.trim() ?? ''
17+
const isSearching = trimmed.length > 0
18+
const isValidSearch = !isSearching || isValidUuid(trimmed)
19+
20+
const postFilter = useCallback(
21+
(build: ListedBuildModel) => build.templateId === templateId,
22+
[templateId]
23+
)
24+
25+
return (
26+
<div className="h-full min-h-0 flex-1 p-3 md:p-6 flex flex-col gap-4">
27+
<TemplateBuildsHeader />
28+
<BuildsTable
29+
filters={{
30+
statuses,
31+
buildIdOrTemplate:
32+
isSearching && isValidSearch ? trimmed : templateId,
33+
}}
34+
postFilter={isSearching && isValidSearch ? postFilter : undefined}
35+
disabled={isSearching && !isValidSearch}
36+
showTemplateColumn={false}
37+
/>
38+
</div>
39+
)
40+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Suspense } from 'react'
2+
import TemplateDetailTabs from '@/features/dashboard/templates/detail/tabs'
3+
import TemplateTitleBinder from '@/features/dashboard/templates/detail/title-binder'
4+
import { HydrateClient, prefetch, trpc } from '@/trpc/server'
5+
6+
export default async function TemplateDetailLayout({
7+
children,
8+
params,
9+
}: LayoutProps<'/dashboard/[teamSlug]/templates/[templateId]'>) {
10+
const { teamSlug, templateId } = await params
11+
12+
prefetch(trpc.templates.getTemplate.queryOptions({ teamSlug, templateId }))
13+
14+
return (
15+
<HydrateClient>
16+
<div className="pt-2 flex-1 md:pt-3 min-h-0 h-full flex flex-col">
17+
<Suspense fallback={null}>
18+
<TemplateTitleBinder teamSlug={teamSlug} templateId={templateId} />
19+
</Suspense>
20+
<TemplateDetailTabs teamSlug={teamSlug} templateId={templateId} />
21+
{children}
22+
</div>
23+
</HydrateClient>
24+
)
25+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import LoadingLayout from '@/features/dashboard/loading-layout'
2+
3+
export default function TemplateDetailLoading() {
4+
return <LoadingLayout />
5+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Suspense } from 'react'
2+
import TemplateOverview from '@/features/dashboard/templates/detail/overview'
3+
import { TemplateOverviewSkeleton } from '@/features/dashboard/templates/detail/overview/skeleton'
4+
import { HydrateClient, prefetch, trpc } from '@/trpc/server'
5+
6+
export default async function TemplateOverviewPage({
7+
params,
8+
}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) {
9+
const { teamSlug, templateId } = await params
10+
11+
prefetch(trpc.templates.getTemplate.queryOptions({ teamSlug, templateId }))
12+
13+
return (
14+
<HydrateClient>
15+
<div className="p-6 md:p-10 flex flex-col gap-6 w-full max-w-[600px] mx-auto">
16+
<Suspense fallback={<TemplateOverviewSkeleton />}>
17+
<TemplateOverview teamSlug={teamSlug} templateId={templateId} />
18+
</Suspense>
19+
</div>
20+
</HydrateClient>
21+
)
22+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Suspense } from 'react'
2+
import LoadingLayout from '@/features/dashboard/loading-layout'
3+
import { TAG_HISTORY_PAGE_LIMIT } from '@/features/dashboard/templates/tags/constants'
4+
import TagHistoryView from '@/features/dashboard/templates/tags/history/tag-history-view'
5+
import { HydrateClient, prefetch, trpc } from '@/trpc/server'
6+
7+
export default async function TemplateTagHistoryPage({
8+
params,
9+
}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]/tags/[tag]'>) {
10+
const { teamSlug, templateId, tag } = await params
11+
const decodedTag = decodeURIComponent(tag)
12+
13+
prefetch(
14+
trpc.templates.getTagAssignments.infiniteQueryOptions({
15+
teamSlug,
16+
templateId,
17+
tag: decodedTag,
18+
limit: TAG_HISTORY_PAGE_LIMIT,
19+
})
20+
)
21+
22+
return (
23+
<HydrateClient>
24+
<div className="h-full min-h-0 flex-1 py-6 px-8 md:px-11 flex flex-col gap-3 max-w-[924px] mx-auto w-full">
25+
<Suspense fallback={<LoadingLayout />}>
26+
<TagHistoryView
27+
teamSlug={teamSlug}
28+
templateId={templateId}
29+
tag={decodedTag}
30+
/>
31+
</Suspense>
32+
</div>
33+
</HydrateClient>
34+
)
35+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Suspense } from 'react'
2+
import LoadingLayout from '@/features/dashboard/loading-layout'
3+
import { TAGS_PAGE_LIMIT } from '@/features/dashboard/templates/tags/constants'
4+
import TagsTable from '@/features/dashboard/templates/tags/table'
5+
import { HydrateClient, prefetch, trpc } from '@/trpc/server'
6+
7+
export default async function TemplateTagsPage({
8+
params,
9+
}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) {
10+
const { teamSlug, templateId } = await params
11+
12+
prefetch(
13+
trpc.templates.getTagGroups.infiniteQueryOptions({
14+
teamSlug,
15+
templateId,
16+
limit: TAGS_PAGE_LIMIT,
17+
search: undefined,
18+
sort: undefined,
19+
})
20+
)
21+
prefetch(trpc.templates.getTagCount.queryOptions({ teamSlug, templateId }))
22+
23+
return (
24+
<HydrateClient>
25+
<div className="h-full min-h-0 flex-1 pt-6 pb-2 md:pt-10 md:pb-4 px-8 md:px-11 flex flex-col gap-3 max-w-[924px] mx-auto w-full">
26+
<Suspense fallback={<LoadingLayout />}>
27+
<TagsTable teamSlug={teamSlug} templateId={templateId} />
28+
</Suspense>
29+
</div>
30+
</HydrateClient>
31+
)
32+
}

src/app/dashboard/[teamSlug]/templates/[templateId]/error.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use client'
22

3+
import { TRPCClientError } from '@trpc/client'
4+
import { notFound } from 'next/navigation'
35
import { DashboardRouteError } from '@/features/dashboard/shared/route-error'
46

57
export default function TemplateDetailsError({
@@ -9,5 +11,9 @@ export default function TemplateDetailsError({
911
error: Error & { digest?: string }
1012
reset: () => void
1113
}) {
14+
if (error instanceof TRPCClientError && error.data?.code === 'NOT_FOUND') {
15+
notFound()
16+
}
17+
1218
return <DashboardRouteError error={error} reset={reset} />
1319
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client'
2+
3+
import { Button } from '@/ui/primitives/button'
4+
import {
5+
Card,
6+
CardContent,
7+
CardDescription,
8+
CardFooter,
9+
CardHeader,
10+
} from '@/ui/primitives/card'
11+
import { ArrowLeftIcon } from '@/ui/primitives/icons'
12+
13+
export default function TemplateNotFound() {
14+
return (
15+
<div className="flex min-h-[60vh] items-center justify-center">
16+
<Card className="w-full max-w-md border border-stroke bg-bg-1/40 backdrop-blur-lg">
17+
<CardHeader className="text-center">
18+
<span className="prose-value-big">404</span>
19+
<CardDescription>Template not found</CardDescription>
20+
</CardHeader>
21+
<CardContent className="text-center text-fg-secondary">
22+
<p>We couldn’t find this template in your team.</p>
23+
</CardContent>
24+
<CardFooter>
25+
<Button
26+
variant="secondary"
27+
onClick={() => window.history.back()}
28+
className="w-full"
29+
>
30+
<ArrowLeftIcon />
31+
Go Back
32+
</Button>
33+
</CardFooter>
34+
</Card>
35+
</div>
36+
)
37+
}

0 commit comments

Comments
 (0)