Skip to content

Commit 3c4dfe7

Browse files
Refactor: Templates api layer to trpc (#189)
<!-- CURSOR_SUMMARY --> > [!NOTE] > Move Templates from server actions to tRPC (queries/mutations) with React Query, add prefetch/hydration, and refine telemetry and prefetch utilities. > > - **Templates data layer (tRPC + React Query)** > - Add `templatesRouter` with `getTemplates`, `getDefaultTemplatesCached`, `updateTemplate`, `deleteTemplate`; register in `trpcAppRouter`. > - Update `TemplatesTable` and cells to use `useTRPC` + `useSuspenseQuery`/`useMutation` with optimistic updates and error toasts; remove server actions and old fetching modules. > - Convert templates page to RSC prefetch + `HydrateClient` + `Suspense` with `LoadingLayout` and client-side `ErrorBoundary` handling. > - **Prefetch utilities** > - Introduce non-blocking `prefetch(...)` and keep `prefetchAsync(...)`; update Sandboxes/Templates pages to use non-awaited `prefetch`. > - **Telemetry middleware** > - Simplify handler signature; adjust logging/status behavior for internal errors (log serialized error, obfuscate only when `cause` exists); tweak success log message. > - **Removals/Cleanup** > - Remove legacy templates server actions and helpers (`server/templates/*`, templates route `loading.tsx`). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bb321c8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d3de1ca commit 3c4dfe7

File tree

13 files changed

+551
-429
lines changed

13 files changed

+551
-429
lines changed

src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@list/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default async function ListPage({
88
}: PageProps<'/dashboard/[teamIdOrSlug]/sandboxes'>) {
99
const { teamIdOrSlug } = await params
1010

11-
await prefetch(
11+
prefetch(
1212
trpc.sandboxes.getSandboxes.queryOptions({
1313
teamIdOrSlug,
1414
})

src/app/dashboard/[teamIdOrSlug]/templates/loading.tsx

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,25 @@
1+
import LoadingLayout from '@/features/dashboard/loading-layout'
12
import TemplatesTable from '@/features/dashboard/templates/table'
2-
import {
3-
getDefaultTemplates,
4-
getTeamTemplates,
5-
} from '@/server/templates/get-team-templates'
6-
import ErrorBoundary from '@/ui/error'
3+
import { HydrateClient, prefetch, trpc } from '@/trpc/server'
4+
import { Suspense } from 'react'
75

86
export default async function Page({
97
params,
108
}: PageProps<'/dashboard/[teamIdOrSlug]/templates'>) {
119
const { teamIdOrSlug } = await params
1210

13-
const res = await getTeamTemplates({
14-
teamIdOrSlug,
15-
})
11+
prefetch(
12+
trpc.templates.getTemplates.queryOptions({
13+
teamIdOrSlug,
14+
})
15+
)
16+
prefetch(trpc.templates.getDefaultTemplatesCached.queryOptions())
1617

17-
const defaultRes = await getDefaultTemplates()
18-
19-
if (!res?.data?.templates || res?.serverError) {
20-
return (
21-
<ErrorBoundary
22-
error={
23-
{
24-
name: 'Templates Error',
25-
message: res?.serverError ?? 'Unknown error',
26-
} satisfies Error
27-
}
28-
description={'Could not load templates'}
29-
/>
30-
)
31-
}
32-
33-
const templates = [
34-
...res.data.templates,
35-
...(defaultRes?.data?.templates ? defaultRes.data.templates : []),
36-
]
37-
38-
return <TemplatesTable templates={templates} />
18+
return (
19+
<HydrateClient>
20+
<Suspense fallback={<LoadingLayout />}>
21+
<TemplatesTable />
22+
</Suspense>
23+
</HydrateClient>
24+
)
3925
}

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

Lines changed: 126 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,7 @@ import {
77
} from '@/lib/hooks/use-toast'
88
import { cn } from '@/lib/utils'
99
import { isVersionCompatible } from '@/lib/utils/version'
10-
import {
11-
deleteTemplateAction,
12-
updateTemplateAction,
13-
} from '@/server/templates/templates-actions'
10+
import { useTRPC } from '@/trpc/client'
1411
import { DefaultTemplate, Template } from '@/types/api.types'
1512
import { AlertDialog } from '@/ui/alert-dialog'
1613
import { E2BBadge } from '@/ui/brand'
@@ -26,9 +23,10 @@ import {
2623
DropdownMenuTrigger,
2724
} from '@/ui/primitives/dropdown-menu'
2825
import { Loader } from '@/ui/primitives/loader_d'
26+
import { useMutation, useQueryClient } from '@tanstack/react-query'
2927
import { CellContext } from '@tanstack/react-table'
3028
import { Lock, LockOpen, MoreVertical } from 'lucide-react'
31-
import { useAction } from 'next-safe-action/hooks'
29+
import { useParams } from 'next/navigation'
3230
import { useMemo, useState } from 'react'
3331
import ResourceUsage from '../common/resource-usage'
3432
import { useDashboard } from '../context'
@@ -49,60 +47,144 @@ export function ActionsCell({
4947
}: CellContext<Template | DefaultTemplate, unknown>) {
5048
const template = row.original
5149
const { team } = useDashboard()
50+
const { teamIdOrSlug } =
51+
useParams<
52+
Awaited<PageProps<'/dashboard/[teamIdOrSlug]/templates'>['params']>
53+
>()
54+
5255
const { toast } = useToast()
56+
const trpc = useTRPC()
57+
const queryClient = useQueryClient()
5358
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
5459

55-
const { execute: executeUpdateTemplate, isExecuting: isUpdating } = useAction(
56-
updateTemplateAction,
57-
{
58-
onSuccess: ({ input }) => {
60+
const updateTemplateMutation = useMutation(
61+
trpc.templates.updateTemplate.mutationOptions({
62+
onSuccess: async (data, variables) => {
63+
const templateName = template.aliases[0] || template.templateID
64+
5965
toast(
6066
defaultSuccessToast(
61-
`Template is now ${input.props.Public ? 'public' : 'private'}.`
67+
<>
68+
Template{' '}
69+
<span className="prose-body-highlight">{templateName}</span> is
70+
now {data.public ? 'public' : 'private'}.
71+
</>
6272
)
6373
)
74+
75+
await queryClient.cancelQueries({
76+
queryKey: trpc.templates.getTemplates.queryKey({
77+
teamIdOrSlug,
78+
}),
79+
})
80+
81+
queryClient.setQueryData(
82+
trpc.templates.getTemplates.queryKey({
83+
teamIdOrSlug,
84+
}),
85+
(old) => {
86+
if (!old?.templates) return old
87+
88+
return {
89+
...old,
90+
templates: old.templates.map((t: Template) =>
91+
t.templateID === variables.templateId
92+
? { ...t, public: variables.public }
93+
: t
94+
),
95+
}
96+
}
97+
)
6498
},
6599
onError: (error) => {
100+
const templateName = template.aliases[0] || template.templateID
66101
toast(
67102
defaultErrorToast(
68-
error.error.serverError || 'Failed to update template.'
103+
error.message || `Failed to update template ${templateName}.`
69104
)
70105
)
71106
},
72-
}
107+
onSettled: () => {
108+
queryClient.invalidateQueries({
109+
queryKey: trpc.templates.getTemplates.queryKey({
110+
teamIdOrSlug,
111+
}),
112+
})
113+
},
114+
})
73115
)
74116

75-
const { execute: executeDeleteTemplate, isExecuting: isDeleting } = useAction(
76-
deleteTemplateAction,
77-
{
78-
onSuccess: () => {
79-
toast(defaultSuccessToast('Template has been deleted.'))
117+
const deleteTemplateMutation = useMutation(
118+
trpc.templates.deleteTemplate.mutationOptions({
119+
onSuccess: async (_, variables) => {
120+
const templateName = template.aliases[0] || template.templateID
121+
toast(
122+
defaultSuccessToast(
123+
<>
124+
Template{' '}
125+
<span className="prose-body-highlight">{templateName}</span> has
126+
been deleted.
127+
</>
128+
)
129+
)
130+
131+
// stop ongoing invlaidations and remove template from state while refetch is going in the background
132+
133+
await queryClient.cancelQueries({
134+
queryKey: trpc.templates.getTemplates.queryKey({
135+
teamIdOrSlug,
136+
}),
137+
})
138+
139+
queryClient.setQueryData(
140+
trpc.templates.getTemplates.queryKey({
141+
teamIdOrSlug,
142+
}),
143+
144+
(old) => {
145+
if (!old?.templates) return old
146+
return {
147+
...old,
148+
templates: old.templates.filter(
149+
(t: Template) => t.templateID !== variables.templateId
150+
),
151+
}
152+
}
153+
)
80154
},
81-
onError: (error) => {
155+
onError: (error, _variables) => {
156+
const templateName = template.aliases[0] || template.templateID
82157
toast(
83158
defaultErrorToast(
84-
error.error.serverError || 'Failed to delete template.'
159+
error.message || `Failed to delete template ${templateName}.`
85160
)
86161
)
87162
},
88163
onSettled: () => {
89164
setIsDeleteDialogOpen(false)
165+
166+
queryClient.invalidateQueries({
167+
queryKey: trpc.templates.getTemplates.queryKey({
168+
teamIdOrSlug,
169+
}),
170+
})
90171
},
91-
}
172+
})
92173
)
93174

94-
const togglePublish = async () => {
95-
executeUpdateTemplate({
175+
const isUpdating = updateTemplateMutation.isPending
176+
const isDeleting = deleteTemplateMutation.isPending
177+
178+
const togglePublish = () => {
179+
updateTemplateMutation.mutate({
96180
teamIdOrSlug: team.slug ?? team.id,
97181
templateId: template.templateID,
98-
props: {
99-
Public: !template.public,
100-
},
182+
public: !template.public,
101183
})
102184
}
103185

104-
const deleteTemplate = async () => {
105-
executeDeleteTemplate({
186+
const deleteTemplate = () => {
187+
deleteTemplateMutation.mutate({
106188
teamIdOrSlug: team.slug ?? team.id,
107189
templateId: template.templateID,
108190
})
@@ -114,7 +196,23 @@ export function ActionsCell({
114196
open={isDeleteDialogOpen}
115197
onOpenChange={setIsDeleteDialogOpen}
116198
title="Delete Template"
117-
description="Are you sure you want to delete this template? This action cannot be undone."
199+
description={
200+
<>
201+
You are about to delete the template{' '}
202+
{template.aliases[0] && (
203+
<>
204+
<span className="prose-body-highlight">
205+
{template.aliases[0]}
206+
</span>{' '}
207+
(
208+
</>
209+
)}
210+
<code className="text-fg-tertiary font-mono">
211+
{template.templateID}
212+
</code>
213+
{template.aliases[0] && <>)</>}. This action cannot be undone.
214+
</>
215+
}
118216
confirm="Delete"
119217
onConfirm={() => deleteTemplate()}
120218
confirmProps={{

src/features/dashboard/templates/table.tsx

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,73 @@
33
import { useColumnSizeVars } from '@/lib/hooks/use-column-size-vars'
44
import { useVirtualRows } from '@/lib/hooks/use-virtual-rows'
55
import { cn } from '@/lib/utils'
6-
import { DefaultTemplate, Template } from '@/types/api.types'
6+
import { useTRPC } from '@/trpc/client'
7+
import { Template } from '@/types/api.types'
78
import ClientOnly from '@/ui/client-only'
89
import {
910
DataTable,
1011
DataTableHead,
1112
DataTableHeader,
1213
DataTableRow,
1314
} from '@/ui/data-table'
15+
import ErrorBoundary from '@/ui/error'
1416
import HelpTooltip from '@/ui/help-tooltip'
1517
import { SIDEBAR_TRANSITION_CLASSNAMES } from '@/ui/primitives/sidebar'
18+
import { useSuspenseQuery } from '@tanstack/react-query'
1619
import {
1720
ColumnFiltersState,
1821
ColumnSizingState,
1922
flexRender,
2023
TableOptions,
2124
useReactTable,
2225
} from '@tanstack/react-table'
23-
import { useEffect, useRef, useState } from 'react'
26+
import { useParams } from 'next/navigation'
27+
import { useEffect, useMemo, useRef, useState } from 'react'
2428
import { useLocalStorage } from 'usehooks-ts'
2529
import TemplatesHeader from './header'
2630
import { useTemplateTableStore } from './stores/table-store'
2731
import { TemplatesTableBody as TableBody } from './table-body'
2832
import { fallbackData, templatesTableConfig, useColumns } from './table-config'
2933

30-
interface TemplatesTableProps {
31-
templates: (Template | DefaultTemplate)[]
32-
}
33-
3434
const ROW_HEIGHT_PX = 32
3535
const VIRTUAL_OVERSCAN = 8
3636

37-
export default function TemplatesTable({ templates }: TemplatesTableProps) {
37+
export default function TemplatesTable() {
3838
'use no memo'
3939

40+
const trpc = useTRPC()
41+
const { teamIdOrSlug } =
42+
useParams<
43+
Awaited<PageProps<'/dashboard/[teamIdOrSlug]/templates'>['params']>
44+
>()
45+
46+
const { data: templatesData, error: templatesError } = useSuspenseQuery(
47+
trpc.templates.getTemplates.queryOptions(
48+
{ teamIdOrSlug },
49+
{
50+
refetchOnMount: false,
51+
refetchOnWindowFocus: false,
52+
refetchOnReconnect: false,
53+
}
54+
)
55+
)
56+
57+
const { data: defaultTemplatesData } = useSuspenseQuery(
58+
trpc.templates.getDefaultTemplatesCached.queryOptions(undefined, {
59+
refetchOnMount: false,
60+
refetchOnWindowFocus: false,
61+
refetchOnReconnect: false,
62+
})
63+
)
64+
65+
const templates = useMemo(
66+
() => [
67+
...(defaultTemplatesData?.templates ?? []),
68+
...(templatesData?.templates ?? []),
69+
],
70+
[templatesData, defaultTemplatesData]
71+
)
72+
4073
const scrollRef = useRef<HTMLDivElement>(null)
4174

4275
const { sorting, setSorting, globalFilter, setGlobalFilter } =
@@ -126,6 +159,18 @@ export default function TemplatesTable({ templates }: TemplatesTableProps) {
126159
overscan: VIRTUAL_OVERSCAN,
127160
})
128161

162+
if (templatesError) {
163+
return (
164+
<ErrorBoundary
165+
error={{
166+
name: 'Templates Error',
167+
message: templatesError?.message ?? 'Failed to load templates',
168+
}}
169+
description="Could not load templates"
170+
/>
171+
)
172+
}
173+
129174
return (
130175
<ClientOnly className="flex h-full min-h-0 flex-col md:max-w-[calc(100svw-var(--sidebar-width-active))] p-3 md:p-6">
131176
<TemplatesHeader table={table} />

0 commit comments

Comments
 (0)