From 0e9f6f6bc1fd00bc15cb9fac8362b78ad19e626d Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 25 May 2026 16:38:03 +0200 Subject: [PATCH 01/90] feat(templates): v1 read-only template detail page Add a per-template detail page reachable from the templates list at /templates/[templateId]/{overview,builds,tags}. v1 is read-only across all three tabs, except the visibility dropdown on the header which reuses the existing templates.updateTemplate mutation. Routing - New route group [templateId]/(detail-tabs) wraps overview, builds, and tags so the existing builds/[buildId] build-detail page is untouched (sibling, not inside the group). - [templateId]/page.tsx redirects to /overview. - Names: name-cell click on the list page navigates to /overview. Default (E2B) templates remain non-clickable. Copy moves to a hover-revealed icon button in the corner of the cell. Header - Reusable usePageTitle Zustand hook (~30 LOC) lets pages override the dashboard title bar with fetched data. Global header reads the override; falls back to pathname-derived layout config when none. - DetailsRow with 5 cells: Memory, CPU, Created, Updated, Visibility. - No-successful-build state renders '--' for cpu/memory. - Visibility dropdown reuses the existing updateTemplate mutation with optimistic updates across both list and detail caches. Builds tab - BuildsTable now accepts optional templateId prop; when set, the Template column is hidden and a scoped filter hook (useTemplateBuildsFilters, statuses-only URL state) is used. - BuildsHeader gains showSearchInput and scoped props; the all-team /templates/builds page is unchanged. Tags tab - New templates.getTags tRPC procedure + repository method backed by the existing GET /templates/{templateID}/tags infra endpoint. - Read-only table: tag pill | linked build ID + relative time. - Sortable on both columns; client-side search by name; info banner copy matches Figma; empty state. tRPC + repository - New templates.getTemplate procedure filters server-side over the existing getTeamTemplates result; throws NOT_FOUND when the templateID isn't in the team's list (catches stale links, wrong team, and default-template URL guessing). Telemetry - PostHog events: 'template detail opened' (mount), 'template detail tab switched' (tab change), 'template visibility changed from header' (mutation success). 'template detail opened' is also emitted on name-cell click from the list with fromTab=list. Co-Authored-By: Craft Agent --- .../(detail-tabs)/builds/page.tsx | 15 ++ .../[templateId]/(detail-tabs)/layout.tsx | 20 ++ .../[templateId]/(detail-tabs)/loading.tsx | 5 + .../(detail-tabs)/overview/page.tsx | 24 +++ .../[templateId]/(detail-tabs)/tags/page.tsx | 17 ++ .../templates/[templateId]/page.tsx | 10 + src/configs/layout.ts | 35 ++++ src/configs/urls.ts | 8 + src/core/modules/templates/models.ts | 2 + .../modules/templates/repository.server.ts | 55 +++++- src/core/server/api/routers/templates.ts | 24 +++ src/features/dashboard/layouts/header.tsx | 8 +- .../dashboard/templates/builds/header.tsx | 58 ++++-- .../dashboard/templates/builds/table.tsx | 70 +++++-- .../builds/use-template-builds-filters.tsx | 36 ++++ .../templates/detail/header-skeleton.tsx | 27 +++ .../dashboard/templates/detail/header.tsx | 108 +++++++++++ .../dashboard/templates/detail/tabs.tsx | 79 ++++++++ .../templates/detail/title-binder.tsx | 75 ++++++++ .../templates/detail/visibility-dropdown.tsx | 174 ++++++++++++++++++ .../dashboard/templates/list/table-cells.tsx | 79 ++++++-- .../dashboard/templates/tags/empty.tsx | 25 +++ .../dashboard/templates/tags/header.tsx | 51 +++++ .../templates/tags/sortable-header.tsx | 47 +++++ .../dashboard/templates/tags/table-cells.tsx | 83 +++++++++ .../dashboard/templates/tags/table.tsx | 135 ++++++++++++++ src/lib/hooks/use-page-title.ts | 65 +++++++ src/ui/primitives/icons.tsx | 19 ++ 28 files changed, 1302 insertions(+), 52 deletions(-) create mode 100644 src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/builds/page.tsx create mode 100644 src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/layout.tsx create mode 100644 src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/loading.tsx create mode 100644 src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/overview/page.tsx create mode 100644 src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/page.tsx create mode 100644 src/app/dashboard/[teamSlug]/templates/[templateId]/page.tsx create mode 100644 src/features/dashboard/templates/builds/use-template-builds-filters.tsx create mode 100644 src/features/dashboard/templates/detail/header-skeleton.tsx create mode 100644 src/features/dashboard/templates/detail/header.tsx create mode 100644 src/features/dashboard/templates/detail/tabs.tsx create mode 100644 src/features/dashboard/templates/detail/title-binder.tsx create mode 100644 src/features/dashboard/templates/detail/visibility-dropdown.tsx create mode 100644 src/features/dashboard/templates/tags/empty.tsx create mode 100644 src/features/dashboard/templates/tags/header.tsx create mode 100644 src/features/dashboard/templates/tags/sortable-header.tsx create mode 100644 src/features/dashboard/templates/tags/table-cells.tsx create mode 100644 src/features/dashboard/templates/tags/table.tsx create mode 100644 src/lib/hooks/use-page-title.ts diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/builds/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/builds/page.tsx new file mode 100644 index 000000000..9544e7657 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/builds/page.tsx @@ -0,0 +1,15 @@ +import BuildsHeader from '@/features/dashboard/templates/builds/header' +import BuildsTable from '@/features/dashboard/templates/builds/table' + +export default async function TemplateDetailBuildsPage({ + params, +}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) { + const { templateId } = await params + + return ( +
+ + +
+ ) +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/layout.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/layout.tsx new file mode 100644 index 000000000..9526e76b5 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/layout.tsx @@ -0,0 +1,20 @@ +import { Suspense } from 'react' +import TemplateDetailTabs from '@/features/dashboard/templates/detail/tabs' +import TemplateTitleBinder from '@/features/dashboard/templates/detail/title-binder' + +export default async function TemplateDetailLayout({ + children, + params, +}: LayoutProps<'/dashboard/[teamSlug]/templates/[templateId]'>) { + const { teamSlug, templateId } = await params + + return ( +
+ + + + + {children} +
+ ) +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/loading.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/loading.tsx new file mode 100644 index 000000000..2b9dc7302 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/loading.tsx @@ -0,0 +1,5 @@ +import LoadingLayout from '@/features/dashboard/loading-layout' + +export default function TemplateDetailLoading() { + return +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/overview/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/overview/page.tsx new file mode 100644 index 000000000..dcbf1a68a --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/overview/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from 'react' +import TemplateDetailHeader from '@/features/dashboard/templates/detail/header' +import TemplateDetailHeaderSkeleton from '@/features/dashboard/templates/detail/header-skeleton' + +/** + * Template detail \u2014 Overview tab. + * + * v1: the 5-cell DetailsRow is the only content. + * Future iterations will add: running-sandboxes chart, description / + * dockerfile-like view, and a versions section. See the v1 plan. + */ +export default async function TemplateOverviewPage({ + params, +}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) { + const { teamSlug, templateId } = await params + + return ( +
+ }> + + +
+ ) +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/page.tsx new file mode 100644 index 000000000..72459a9ff --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from 'react' +import LoadingLayout from '@/features/dashboard/loading-layout' +import TagsTable from '@/features/dashboard/templates/tags/table' + +export default async function TemplateTagsPage({ + params, +}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) { + const { teamSlug, templateId } = await params + + return ( +
+ }> + + +
+ ) +} diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/page.tsx new file mode 100644 index 000000000..8cf4ea965 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from 'next/navigation' +import { PROTECTED_URLS } from '@/configs/urls' + +export default async function TemplateDetailPage({ + params, +}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) { + const { teamSlug, templateId } = await params + + redirect(PROTECTED_URLS.TEMPLATE_OVERVIEW(teamSlug, templateId)) +} diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 0f29ab80c..d7078f60e 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -89,6 +89,17 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< }, } }, + // Template detail tabs: fallback title (UUID-derived) used until + // usePageTitle injects the friendly template name from fetched data. + '/dashboard/*/templates/*/overview': (pathname) => + templateDetailLayoutConfig(pathname), + '/dashboard/*/templates/*/tags': (pathname) => + templateDetailLayoutConfig(pathname), + // Template detail Builds tab — same fallback, but the path matches + // /templates/*/builds (no trailing buildId) so it doesn't collide + // with the build-detail glob above (/templates/*/builds/*). + '/dashboard/*/templates/*/builds': (pathname) => + templateDetailLayoutConfig(pathname), // integrations '/dashboard/*/webhooks': () => ({ @@ -163,6 +174,30 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< }), } +function templateDetailLayoutConfig(pathname: string): DashboardLayoutConfig { + const parts = pathname.split('/') + const teamSlug = parts[2]! + const templateId = parts[4]! + const templateIdSliced = + templateId.length > 14 + ? `${templateId.slice(0, 6)}...${templateId.slice(-6)}` + : templateId + + // Note: no `includeHeaderBottomStyles` — the tabs row sits flush + // below the global title bar, matching /templates/list and /sandboxes. + return { + title: [ + { + label: 'Templates', + href: PROTECTED_URLS.TEMPLATES_LIST(teamSlug), + }, + { label: templateIdSliced }, + ], + type: 'custom', + copyValue: templateId, + } +} + /** * Returns the layout config for a given dashboard pathname. * @param pathname - The current route pathname diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 9412b2a97..0056dfdfd 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -44,6 +44,14 @@ export const PROTECTED_URLS = { TEMPLATES_LIST: (teamSlug: string) => `/dashboard/${teamSlug}/templates/list`, TEMPLATES_BUILDS: (teamSlug: string) => `/dashboard/${teamSlug}/templates/builds`, + TEMPLATE_DETAIL: (teamSlug: string, templateId: string) => + `/dashboard/${teamSlug}/templates/${templateId}/overview`, + TEMPLATE_OVERVIEW: (teamSlug: string, templateId: string) => + `/dashboard/${teamSlug}/templates/${templateId}/overview`, + TEMPLATE_DETAIL_BUILDS: (teamSlug: string, templateId: string) => + `/dashboard/${teamSlug}/templates/${templateId}/builds`, + TEMPLATE_TAGS: (teamSlug: string, templateId: string) => + `/dashboard/${teamSlug}/templates/${templateId}/tags`, TEMPLATE_BUILD: (teamSlug: string, templateId: string, buildId: string) => `/dashboard/${teamSlug}/templates/${templateId}/builds/${buildId}`, diff --git a/src/core/modules/templates/models.ts b/src/core/modules/templates/models.ts index aa08bebab..62e6269b9 100644 --- a/src/core/modules/templates/models.ts +++ b/src/core/modules/templates/models.ts @@ -23,3 +23,5 @@ export type DefaultTemplate = Template & { isDefault: true defaultDescription?: string } + +export type TemplateTag = InfraComponents['schemas']['TemplateTag'] diff --git a/src/core/modules/templates/repository.server.ts b/src/core/modules/templates/repository.server.ts index 9d282af3e..c4d5bd47c 100644 --- a/src/core/modules/templates/repository.server.ts +++ b/src/core/modules/templates/repository.server.ts @@ -7,7 +7,11 @@ import { MOCK_DEFAULT_TEMPLATES_DATA, MOCK_TEMPLATES_DATA, } from '@/configs/mock-data' -import type { DefaultTemplate, Template } from '@/core/modules/templates/models' +import type { + DefaultTemplate, + Template, + TemplateTag, +} from '@/core/modules/templates/models' import { type AuthUserEmailResolver, getAuthUserEmailsById, @@ -30,6 +34,8 @@ type TemplatesRepositoryDeps = { export interface TeamTemplatesRepository { getTeamTemplates(): Promise> + getTemplate(templateId: string): Promise> + getTags(templateId: string): Promise> deleteTemplate(templateId: string): Promise> updateTemplateVisibility( templateId: string, @@ -53,6 +59,53 @@ export function createTemplatesRepository( } ): TeamTemplatesRepository { return { + async getTemplate(templateId) { + // v1: filter server-side over the existing list endpoint. No + // separate per-template fetch needed; this keeps the cache hot + // for cross-page reuse and avoids any new infra dependencies. + const listResult = await this.getTeamTemplates() + if (!listResult.ok) return listResult + + const template = listResult.data.templates.find( + (t) => t.templateID === templateId + ) + + if (!template) { + return err( + repoErrorFromHttp(404, 'Template not found in this team', undefined) + ) + } + + return ok({ template }) + }, + async getTags(templateId) { + if (USE_MOCK_DATA) { + return ok({ tags: [] }) + } + + const res = await deps.infraClient.GET('/templates/{templateID}/tags', { + params: { + path: { + templateID: templateId, + }, + }, + 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 template tags', + res.error + ) + ) + } + + return ok({ tags: res.data ?? [] }) + }, async getTeamTemplates() { if (USE_MOCK_DATA) { return ok({ diff --git a/src/core/server/api/routers/templates.ts b/src/core/server/api/routers/templates.ts index d063c251c..ccbeebf80 100644 --- a/src/core/server/api/routers/templates.ts +++ b/src/core/server/api/routers/templates.ts @@ -42,6 +42,30 @@ export const templatesRouter = createTRPCRouter({ return result.data }), + getTemplate: teamTemplatesRepositoryProcedure + .input( + z.object({ + templateId: z.string(), + }) + ) + .query(async ({ ctx, input }) => { + const result = await ctx.templatesRepository.getTemplate(input.templateId) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + + getTags: teamTemplatesRepositoryProcedure + .input( + z.object({ + templateId: z.string(), + }) + ) + .query(async ({ ctx, input }) => { + const result = await ctx.templatesRepository.getTags(input.templateId) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + }), + getDefaultTemplatesCached: templatesRepositoryProcedure.query( async ({ ctx }) => { const result = await ctx.templatesRepository.getDefaultTemplatesCached() diff --git a/src/features/dashboard/layouts/header.tsx b/src/features/dashboard/layouts/header.tsx index e1a780ee7..14efdff95 100644 --- a/src/features/dashboard/layouts/header.tsx +++ b/src/features/dashboard/layouts/header.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' import { Fragment } from 'react' import { getDashboardLayoutConfig, type TitleSegment } from '@/configs/layout' +import { usePageTitleStore } from '@/lib/hooks/use-page-title' import { cn } from '@/lib/utils' import ClientOnly from '@/ui/client-only' import CopyButton from '@/ui/copy-button' @@ -21,7 +22,10 @@ export default function DashboardLayoutHeader({ }: DashboardLayoutHeaderProps) { const pathname = usePathname() const config = getDashboardLayoutConfig(pathname) - const copyableValue = config.copyValue ?? null + const override = usePageTitleStore((state) => state.override) + + const title = override?.title ?? config.title + const copyableValue = override?.copyValue ?? config.copyValue ?? null return (

- +

{copyableValue && ( = [ { value: 'failed', label: 'Failed' }, ] -export default function BuildsHeader() { - const { statuses, setStatuses, buildIdOrTemplate, setBuildIdOrTemplate } = - useFilters() +interface BuildsHeaderProps { + /** + * When false, hides the build/template search input. Used on the + * per-template detail page where the table is already scoped via the + * `templateId` prop on `BuildsTable`. Defaults to true. + */ + showSearchInput?: boolean + /** + * When provided, the header uses the template-scoped filters hook + * (statuses-only URL state) instead of the shared `useFilters` hook. + * Pair this with `BuildsTable templateId={...}` so both surfaces read + * from the same URL state. + */ + scoped?: boolean +} + +export default function BuildsHeader({ + showSearchInput = true, + scoped = false, +}: BuildsHeaderProps = {}) { + // Both hooks must be called unconditionally to satisfy React's rules. + // `scoped` only changes via route navigation — the component is + // unmounted between transitions, so hook order remains consistent. + const sharedFilters = useFilters() + const scopedFilters = useTemplateBuildsFilters() + + const statuses = scoped ? scopedFilters.statuses : sharedFilters.statuses + const setStatuses = scoped + ? scopedFilters.setStatuses + : sharedFilters.setStatuses + const buildIdOrTemplate = scoped ? undefined : sharedFilters.buildIdOrTemplate + const setBuildIdOrTemplate = scoped + ? undefined + : sharedFilters.setBuildIdOrTemplate const [localBuildIdOrTemplate, setLocalBuildIdOrTemplate] = useState( buildIdOrTemplate ?? '' @@ -103,15 +135,17 @@ export default function BuildsHeader() { return (
- { - setLocalBuildIdOrTemplate(e.target.value) - setBuildIdOrTemplate(e.target.value) - }} - /> + {showSearchInput && setBuildIdOrTemplate && ( + { + setLocalBuildIdOrTemplate(e.target.value) + setBuildIdOrTemplate(e.target.value) + }} + /> + )} diff --git a/src/features/dashboard/templates/builds/table.tsx b/src/features/dashboard/templates/builds/table.tsx index 493280d3e..e320d02e8 100644 --- a/src/features/dashboard/templates/builds/table.tsx +++ b/src/features/dashboard/templates/builds/table.tsx @@ -38,6 +38,7 @@ import { Template, } from './table-cells' import useFilters from './use-filters' +import useTemplateBuildsFilters from './use-template-builds-filters' const BUILDS_REFETCH_INTERVAL_MS = 15_000 const RUNNING_BUILD_POLL_INTERVAL_MS = 3_000 @@ -51,14 +52,40 @@ const COLUMN_WIDTHS = { duration: 96, } as const -const BuildsTable = () => { +interface BuildsTableProps { + /** + * When provided, the table is scoped to a single template: + * - filters are read from `useTemplateBuildsFilters` (statuses only, + * no search input affects the list) + * - the Template column is hidden + * - the `buildIdOrTemplate` query param is set to this UUID + */ + templateId?: string +} + +const BuildsTable = ({ templateId }: BuildsTableProps = {}) => { const trpc = useTRPC() const queryClient = useQueryClient() const router = useRouter() const scrollContainerRef = useRef(null) const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/templates'>() - const { statuses, buildIdOrTemplate } = useFilters() + + const isTemplateScoped = templateId !== undefined + + // Two filter hooks live here, but only one is active per render. React + // requires consistent hook order across renders — `templateId` only + // changes via route navigation (which unmounts the component), so it is + // safe to call both unconditionally and pick which result to use. + const sharedFilters = useFilters() + const scopedFilters = useTemplateBuildsFilters() + + const statuses = isTemplateScoped + ? scopedFilters.statuses + : sharedFilters.statuses + const buildIdOrTemplate = isTemplateScoped + ? templateId + : sharedFilters.buildIdOrTemplate const { isFilterRefetching, clearFilterRefetching } = useFilterChangeTracking( statuses, buildIdOrTemplate @@ -154,6 +181,11 @@ const BuildsTable = () => { const showEmpty = !isInitialLoad && !isFetchingBuilds && !hasData const showFilterRefetchingOverlay = isFilterRefetching && hasData + // colSpan must match the visible column count (status + [template] + + // started + duration + id + reason filler) when the Template column is + // conditionally hidden in template-scoped mode. + const colSpan = isTemplateScoped ? 5 : 6 + return (
{ - + {!isTemplateScoped && ( + + )} @@ -173,7 +207,7 @@ const BuildsTable = () => { Status - Template + {!isTemplateScoped && Template} Started @@ -193,7 +227,7 @@ const BuildsTable = () => { > {showLoader && ( - +
@@ -203,7 +237,7 @@ const BuildsTable = () => { {showEmpty && ( - + @@ -214,7 +248,7 @@ const BuildsTable = () => { {hasScrolledPastInitialPages && ( @@ -250,15 +284,17 @@ const BuildsTable = () => { > - -