Skip to content

Commit 8a4d3c4

Browse files
committed
Add Import & Export Functionality
1 parent 3451803 commit 8a4d3c4

File tree

11 files changed

+213
-12
lines changed

11 files changed

+213
-12
lines changed

src/app/actions.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import z from 'zod/v4'
77
import { db } from '@/lib/db/db'
88
import * as schema from '@/lib/db/schema'
99

10+
import { zImportSuiteFileSchema } from './types'
11+
1012
const zCreateSuite = z.object({
1113
name: z.string().min(1),
1214
})
@@ -27,3 +29,63 @@ export async function createSuiteAction(form: FormData): Promise<void> {
2729
revalidatePath('/')
2830
redirect(`/suite/${newSuite.id}`)
2931
}
32+
33+
const MAX_BYTES = 1024 * 1024 * 10 // 10MB
34+
35+
export async function importSuiteAction(form: FormData): Promise<void> {
36+
const file = form.get('file')
37+
if (!(file instanceof File)) {
38+
throw new Error('No file uploaded.')
39+
}
40+
if (file.size === 0) {
41+
throw new Error('File is empty.')
42+
}
43+
if (file.size > MAX_BYTES) {
44+
throw new Error(`File too large. Limit is ${MAX_BYTES} bytes.`)
45+
}
46+
47+
let raw: unknown
48+
try {
49+
const text = await file.text()
50+
raw = JSON.parse(text)
51+
} catch {
52+
throw new Error('Invalid JSON.')
53+
}
54+
55+
const importedSuiteData = zImportSuiteFileSchema.parse(raw)
56+
57+
const dbImportedSuite = await db.transaction(async (tx) => {
58+
const [newSuite] = await tx
59+
.insert(schema.suite)
60+
.values({
61+
name: importedSuiteData.name,
62+
cronCadence: importedSuiteData.cronCadence,
63+
notificationsEmailAddress: importedSuiteData.notificationsEmailAddress,
64+
})
65+
.returning()
66+
67+
for (const test of importedSuiteData.tests) {
68+
const [newTest] = await tx
69+
.insert(schema.test)
70+
.values({
71+
evaluation: test.evaluation,
72+
label: test.label,
73+
suiteId: newSuite.id,
74+
})
75+
.returning()
76+
77+
for (const step of test.steps) {
78+
await tx.insert(schema.testStep).values({
79+
description: step.description,
80+
order: step.order,
81+
testId: newTest.id,
82+
})
83+
}
84+
}
85+
86+
return newSuite
87+
})
88+
89+
revalidatePath('/')
90+
redirect(`/suite/${dbImportedSuite.id}`)
91+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { eq } from 'drizzle-orm'
2+
3+
import type { TImportSuiteFile } from '@/app/types'
4+
import { zImportSuiteFileSchema } from '@/app/types'
5+
import { db } from '@/lib/db/db'
6+
import * as schema from '@/lib/db/schema'
7+
8+
// https://nextjs.org/docs/app/api-reference/file-conventions/route
9+
export async function GET(request: Request, { params }: { params: Promise<{ suiteId: string }> }) {
10+
const { suiteId } = await params
11+
12+
const suiteIdNum = parseInt(suiteId, 10)
13+
if (isNaN(suiteIdNum)) {
14+
throw new Error(`Invalid suite ID: ${suiteId}`)
15+
}
16+
17+
const suite = await db.query.suite.findFirst({
18+
where: eq(schema.suite.id, suiteIdNum),
19+
with: {
20+
tests: {
21+
with: {
22+
steps: true,
23+
},
24+
},
25+
},
26+
})
27+
28+
if (!suite) {
29+
throw new Error(`Suite not found: ${suiteId}`)
30+
}
31+
32+
// NOTE: We use this to check the type of the suite.
33+
const json: TImportSuiteFile = suite
34+
35+
// Assuming you have a Zod schema for the suite export, e.g. import { suiteExportSchema } from '@/lib/validation/suiteExportSchema'
36+
// You can use .parse() or .safeParse() to filter out unwanted fields.
37+
// This will strip out fields not defined in the Zod schema (e.g. id, createdAt).
38+
39+
const filtered = zImportSuiteFileSchema.parse(json)
40+
const raw = JSON.stringify(filtered, null, 2)
41+
42+
const blob = new Blob([raw], { type: 'application/json' })
43+
44+
return new Response(blob, {
45+
headers: {
46+
'Content-Disposition': `attachment; filename="${suite.name}.json"`,
47+
'Content-Type': 'application/json',
48+
},
49+
})
50+
}

src/app/loader.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ const MOCK_SUITE: TestSuiteDefinition = BROWSERUSE_DOCS_TEST_SUITE
1010
export async function loader() {
1111
const suites = await db.query.suite.findMany({
1212
orderBy: [desc(schema.suite.createdAt)],
13-
with: { tests: true },
13+
with: {
14+
tests: true,
15+
runs: {
16+
orderBy: [desc(schema.suiteRun.createdAt)],
17+
limit: 1,
18+
},
19+
},
1420
})
1521

1622
// NOTE: We always seed the mock suite to make sure you can see something!
@@ -51,7 +57,13 @@ export async function loader() {
5157

5258
return await db.query.suite.findMany({
5359
orderBy: [desc(schema.suite.createdAt)],
54-
with: { tests: true },
60+
with: {
61+
tests: true,
62+
runs: {
63+
orderBy: [desc(schema.suiteRun.createdAt)],
64+
limit: 1,
65+
},
66+
},
5567
})
5668
}
5769

src/app/page.tsx

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
import { Input } from '@/components/ui/input'
1818
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
1919

20-
import { createSuiteAction } from './actions'
20+
import { createSuiteAction, importSuiteAction } from './actions'
2121
import { loader, type TSuite } from './loader'
2222

2323
export const dynamic = 'force-dynamic'
@@ -36,14 +36,21 @@ export default async function Page() {
3636

3737
{/* Suites List */}
3838

39-
<SectionHeader title="Test Suites" actions={[<CreateSuiteDialog key="create-suite-dialog" />]} />
39+
<SectionHeader
40+
title="Test Suites"
41+
actions={[
42+
//
43+
<CreateSuiteDialog key="create-suite-dialog" />,
44+
<ImportSuiteDialog key="import-suite-dialog" />,
45+
]}
46+
/>
4047

4148
<Table>
4249
<TableHeader>
4350
<TableRow>
4451
<TableHead>Name</TableHead>
4552
<TableHead>Tests</TableHead>
46-
<TableHead>Created</TableHead>
53+
<TableHead>Created At</TableHead>
4754
<TableHead>Cron</TableHead>
4855
<TableHead>Last Run</TableHead>
4956
<TableHead>{/* Actions */}</TableHead>
@@ -63,7 +70,7 @@ export default async function Page() {
6370
<TableCell>{suite.tests.length} tests</TableCell>
6471
<TableCell>{formatDate(suite.createdAt)}</TableCell>
6572
<TableCell>{getCronLabel(suite.cronCadence)}</TableCell>
66-
<TableCell>{suite.lastCronRunAt ? formatDate(suite.lastCronRunAt) : 'Never'}</TableCell>
73+
<TableCell>{getLastRunLabel(suite)}</TableCell>
6774
<TableCell className="text-right">
6875
<Link href={`/suite/${suite.id}`}>View</Link>
6976
</TableCell>
@@ -117,3 +124,43 @@ function getCronLabel(cronCadence: TSuite['cronCadence']) {
117124
return 'Never'
118125
}
119126
}
127+
128+
function ImportSuiteDialog() {
129+
return (
130+
<Dialog>
131+
<DialogTrigger asChild>
132+
<Button variant="outline" className="ml-auto">
133+
<Plus className="w-4 h-4" />
134+
Import Suite
135+
</Button>
136+
</DialogTrigger>
137+
<DialogContent>
138+
<DialogHeader>
139+
<DialogTitle>Import a test suite</DialogTitle>
140+
<DialogDescription>Import a test suite from a JSON file.</DialogDescription>
141+
142+
<form action={importSuiteAction} className="space-y-4">
143+
<div>
144+
<label htmlFor="file" className="block text-sm font-medium text-gray-700 mb-1">
145+
Suite File
146+
</label>
147+
<Input type="file" id="file" name="file" accept=".json" required />
148+
</div>
149+
150+
<div className="flex justify-end gap-2">
151+
<Button type="submit">Import</Button>
152+
</div>
153+
</form>
154+
</DialogHeader>
155+
</DialogContent>
156+
</Dialog>
157+
)
158+
}
159+
160+
function getLastRunLabel(suite: { runs: { createdAt: Date }[] }) {
161+
if (suite.runs.length === 0) {
162+
return 'Never'
163+
}
164+
165+
return formatDate(suite.runs[0].createdAt)
166+
}

src/app/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { z } from 'zod'
2+
3+
const zImportSuiteStepSchema = z.object({
4+
description: z.string(),
5+
order: z.number(),
6+
})
7+
8+
const zImportSuiteTestSchema = z.object({
9+
label: z.string(),
10+
evaluation: z.string(),
11+
steps: zImportSuiteStepSchema.array(),
12+
})
13+
14+
export const zImportSuiteFileSchema = z.object({
15+
name: z.string(),
16+
cronCadence: z.enum(['hourly', 'daily']).nullable(),
17+
notificationsEmailAddress: z.string().nullable(),
18+
tests: zImportSuiteTestSchema.array(),
19+
})
20+
21+
export type TImportSuiteFile = z.infer<typeof zImportSuiteFileSchema>

src/components/shared/SectionHeader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function SectionHeader({
1111
actions: React.ReactNode[]
1212
}) {
1313
return (
14-
<div className="flex items-center justify-between gap-3 py-3">
14+
<div className="flex items-center gap-3 py-3">
1515
<div className="flex flex-col gap-1 mr-auto">
1616
<SectionHeaderTitle title={title} />
1717
{subtitle && <SectionHeaderSubtitle subtitle={subtitle} />}

src/components/suite/HistoryTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function HistoryTab({ suite }: { suite: TSuite }) {
1515
<TableRow>
1616
<TableHead>Status</TableHead>
1717
<TableHead>Run</TableHead>
18-
<TableHead>Created At</TableHead>
18+
<TableHead>Performed At</TableHead>
1919
<TableHead>{/* Actions */}</TableHead>
2020
</TableRow>
2121
</TableHeader>

src/components/suite/SuiteDetails.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,13 @@ export function SuiteDetails({
7373
<Fragment>
7474
<PageHeader
7575
title={suite.name}
76-
subtitle={`${suite.tests.length} tests`}
7776
back={{ href: '/', label: 'All Suites' }}
77+
actions={[
78+
{
79+
label: 'Export Suite',
80+
link: `/api/export/${suite.id}`,
81+
},
82+
]}
7883
/>
7984

8085
{/* Body with Tabs */}
@@ -97,14 +102,15 @@ export function SuiteDetails({
97102
<Play className="w-4 h-4" />
98103
Run Suite
99104
</Button>
105+
,
100106
</form>,
101107
]}
102108
/>
103109
<HistoryTab suite={suite} />
104110
</div>
105111
</div>
106112

107-
<div className="mt-9" />
113+
<div className="my-9 h-px bg-gray-200 w-full" />
108114

109115
<SectionHeader
110116
title="Settings"

src/components/suite/TestsTab.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
77
import type { TSuite } from '../../app/suite/[suiteId]/loader'
88
import { formatDate } from '../shared/utils'
99

10+
/**
11+
* Shows a list of tests in a suite.
12+
*/
1013
export function TestsTab({ suite, suiteId }: { suite: TSuite; suiteId: number }) {
1114
return (
1215
<Table>

src/components/suite/run/SuiteRunDetails.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function SuiteRunDetails({ run }: { run: TSuiteRun }) {
4444
<TableHeader>
4545
<TableRow>
4646
<TableHead>Test</TableHead>
47-
<TableHead>Created At</TableHead>
47+
<TableHead>Ran At</TableHead>
4848
<TableHead>{/* Actions */}</TableHead>
4949
</TableRow>
5050
</TableHeader>

0 commit comments

Comments
 (0)