Skip to content

Commit 0322cd8

Browse files
Connection creation form (#175)
1 parent a5006c5 commit 0322cd8

File tree

12 files changed

+1292
-26
lines changed

12 files changed

+1292
-26
lines changed

packages/web/package.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
"version": "0.1.0",
44
"private": true,
55
"scripts": {
6-
"dev": "next dev",
7-
"build": "next build",
6+
"dev": "yarn generate:schemas && next dev",
7+
"build": "yarn generate:schemas && next build",
88
"start": "next start",
99
"lint": "next lint",
10-
"test": "vitest"
10+
"test": "vitest",
11+
"generate:schemas": "tsx tools/generateSchemas.ts"
1112
},
1213
"dependencies": {
1314
"@auth/prisma-adapter": "^2.7.4",
@@ -66,12 +67,14 @@
6667
"@uiw/react-codemirror": "^4.23.0",
6768
"@viz-js/lang-dot": "^1.0.4",
6869
"@xiechao/codemirror-lang-handlebars": "^1.0.4",
70+
"ajv": "^8.17.1",
6971
"class-variance-authority": "^0.7.0",
7072
"client-only": "^0.0.1",
7173
"clsx": "^2.1.1",
7274
"cm6-graphql": "^0.2.0",
7375
"cmdk": "1.0.0",
7476
"codemirror": "^5.65.3",
77+
"codemirror-json-schema": "^0.8.0",
7578
"codemirror-lang-brainfuck": "^0.1.0",
7679
"codemirror-lang-elixir": "^4.0.0",
7780
"codemirror-lang-hcl": "^0.0.0-beta.2",
@@ -112,6 +115,7 @@
112115
"zod": "^3.23.8"
113116
},
114117
"devDependencies": {
118+
"@apidevtools/json-schema-ref-parser": "^11.7.3",
115119
"@sourcebot/db": "^0.1.0",
116120
"@types/node": "^20",
117121
"@types/react": "^18",
@@ -126,6 +130,7 @@
126130
"npm-run-all": "^4.1.5",
127131
"postcss": "^8",
128132
"tailwindcss": "^3.4.1",
133+
"tsx": "^4.19.2",
129134
"typescript": "^5",
130135
"vite-tsconfig-paths": "^5.1.3",
131136
"vitest": "^2.1.5"

packages/web/src/actions.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
'use server';
22

3+
import Ajv from "ajv";
4+
import { getUser } from "./data/user";
35
import { auth } from "./auth";
4-
import { notAuthenticated, notFound } from "./lib/serviceError";
6+
import { notAuthenticated, notFound, ServiceError, unexpectedError } from "./lib/serviceError";
57
import { prisma } from "@/prisma";
8+
import { githubSchema } from "./schemas/github.schema";
9+
import { StatusCodes } from "http-status-codes";
10+
import { ErrorCode } from "./lib/errorCodes";
611

12+
const ajv = new Ajv({
13+
validateFormats: false,
14+
});
715

8-
export const createOrg = async (name: string) => {
16+
export const createOrg = async (name: string): Promise<{ id: number } | ServiceError> => {
917
const session = await auth();
1018
if (!session) {
1119
return notAuthenticated();
@@ -29,13 +37,14 @@ export const createOrg = async (name: string) => {
2937
}
3038
}
3139

32-
export const switchActiveOrg = async (orgId: number) => {
40+
export const switchActiveOrg = async (orgId: number): Promise<{ id: number } | ServiceError> => {
3341
const session = await auth();
3442
if (!session) {
3543
return notAuthenticated();
3644
}
3745

3846
// Check to see if the user is a member of the org
47+
// @todo: refactor this into a shared function
3948
const membership = await prisma.userToOrg.findUnique({
4049
where: {
4150
orgId_userId: {
@@ -61,4 +70,65 @@ export const switchActiveOrg = async (orgId: number) => {
6170
return {
6271
id: orgId,
6372
}
73+
}
74+
75+
export const createConnection = async (config: string): Promise<{ id: number } | ServiceError> => {
76+
const session = await auth();
77+
if (!session) {
78+
return notAuthenticated();
79+
}
80+
81+
const user = await getUser(session.user.id);
82+
if (!user) {
83+
return unexpectedError("User not found");
84+
}
85+
const orgId = user.activeOrgId;
86+
if (!orgId) {
87+
return unexpectedError("User has no active org");
88+
}
89+
90+
// @todo: refactor this into a shared function
91+
const membership = await prisma.userToOrg.findUnique({
92+
where: {
93+
orgId_userId: {
94+
userId: session.user.id,
95+
orgId,
96+
}
97+
},
98+
});
99+
if (!membership) {
100+
return notFound();
101+
}
102+
103+
let parsedConfig;
104+
try {
105+
parsedConfig = JSON.parse(config);
106+
} catch (e) {
107+
return {
108+
statusCode: StatusCodes.BAD_REQUEST,
109+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
110+
message: "config must be a valid JSON object."
111+
} satisfies ServiceError;
112+
}
113+
114+
// @todo: we will need to validate the config against different schemas based on the type of connection.
115+
const isValidConfig = ajv.validate(githubSchema, parsedConfig);
116+
if (!isValidConfig) {
117+
return {
118+
statusCode: StatusCodes.BAD_REQUEST,
119+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
120+
message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`,
121+
} satisfies ServiceError;
122+
}
123+
124+
const connection = await prisma.config.create({
125+
data: {
126+
orgId: orgId,
127+
data: parsedConfig,
128+
}
129+
});
130+
131+
return {
132+
id: connection.id,
133+
}
64134
}

packages/web/src/app/components/orgSelector/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { auth } from "@/auth";
2-
import { getUser, getUserOrgs } from "../../data/user";
2+
import { getUser, getUserOrgs } from "../../../data/user";
33
import { OrgSelectorDropdown } from "./orgSelectorDropdown";
44

55
export const OrgSelector = async () => {

packages/web/src/app/components/orgSelector/orgSelectorDropdown.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,6 @@ export const OrgSelectorDropdown = ({
157157
))}
158158
</CommandGroup>
159159
</CommandList>
160-
161160
</Command>
162161
</DropdownMenuGroup>
163162
{searchFilter.length === 0 && (
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { NavigationMenu } from "../components/navigationMenu";
2+
3+
export default function Layout({
4+
children,
5+
}: Readonly<{
6+
children: React.ReactNode;
7+
}>) {
8+
9+
return (
10+
<div className="min-h-screen flex flex-col">
11+
<NavigationMenu />
12+
<main className="flex-grow flex justify-center p-4">
13+
<div className="w-full max-w-5xl rounded-lg border p-6">{children}</div>
14+
</main>
15+
</div>
16+
)
17+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
2+
'use client';
3+
4+
import { Button } from "@/components/ui/button";
5+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
6+
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
7+
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
8+
import { json, jsonLanguage, jsonParseLinter } from "@codemirror/lang-json";
9+
import { linter } from "@codemirror/lint";
10+
import { EditorView, hoverTooltip } from "@codemirror/view";
11+
import { zodResolver } from "@hookform/resolvers/zod";
12+
import CodeMirror, { ReactCodeMirrorRef } from "@uiw/react-codemirror";
13+
import Ajv from "ajv";
14+
import {
15+
handleRefresh,
16+
jsonCompletion,
17+
jsonSchemaHover,
18+
jsonSchemaLinter,
19+
stateExtensions
20+
} from "codemirror-json-schema";
21+
import { useCallback, useRef } from "react";
22+
import { useForm } from "react-hook-form";
23+
import { z } from "zod";
24+
import { githubSchema } from "@/schemas/github.schema";
25+
import { Input } from "@/components/ui/input";
26+
import { createConnection } from "@/actions";
27+
import { useToast } from "@/components/hooks/use-toast";
28+
import { isServiceError } from "@/lib/utils";
29+
import { useRouter } from "next/navigation";
30+
31+
const ajv = new Ajv({
32+
validateFormats: false,
33+
});
34+
35+
// @todo: we will need to validate the config against different schemas based on the type of connection.
36+
const validate = ajv.compile(githubSchema);
37+
38+
const formSchema = z.object({
39+
name: z.string().min(1),
40+
config: z
41+
.string()
42+
.superRefine((data, ctx) => {
43+
const addIssue = (message: string) => {
44+
return ctx.addIssue({
45+
code: "custom",
46+
message: `Schema validation error: ${message}`
47+
});
48+
}
49+
50+
let parsed;
51+
try {
52+
parsed = JSON.parse(data);
53+
} catch {
54+
addIssue("Invalid JSON");
55+
return;
56+
}
57+
58+
const valid = validate(parsed);
59+
if (!valid) {
60+
addIssue(ajv.errorsText(validate.errors));
61+
}
62+
}),
63+
});
64+
65+
// Add this theme extension to your extensions array
66+
const customAutocompleteStyle = EditorView.baseTheme({
67+
".cm-tooltip.cm-completionInfo": {
68+
padding: "8px",
69+
fontSize: "12px",
70+
fontFamily: "monospace",
71+
},
72+
".cm-tooltip-hover.cm-tooltip": {
73+
padding: "8px",
74+
fontSize: "12px",
75+
fontFamily: "monospace",
76+
}
77+
})
78+
79+
export default function NewConnectionPage() {
80+
const form = useForm<z.infer<typeof formSchema>>({
81+
resolver: zodResolver(formSchema),
82+
defaultValues: {
83+
config: JSON.stringify({ type: "github" }, null, 2),
84+
},
85+
});
86+
87+
const editorRef = useRef<ReactCodeMirrorRef>(null);
88+
const keymapExtension = useKeymapExtension(editorRef.current?.view);
89+
const { theme } = useThemeNormalized();
90+
const { toast } = useToast();
91+
const router = useRouter();
92+
93+
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
94+
createConnection(data.config)
95+
.then((response) => {
96+
if (isServiceError(response)) {
97+
toast({
98+
description: `❌ Failed to create connection. Reason: ${response.message}`
99+
});
100+
} else {
101+
toast({
102+
description: `✅ Connection created successfully!`
103+
});
104+
router.push('/');
105+
}
106+
});
107+
}, [router, toast]);
108+
109+
return (
110+
<div>
111+
<h1 className="text-2xl font-bold mb-4">Create a connection</h1>
112+
<Form {...form}>
113+
<form onSubmit={form.handleSubmit(onSubmit)}>
114+
<div className="flex flex-col gap-4">
115+
<FormField
116+
control={form.control}
117+
name="name"
118+
render={({ field }) => (
119+
<FormItem>
120+
<FormLabel>Display Name</FormLabel>
121+
<FormControl>
122+
<Input {...field} />
123+
</FormControl>
124+
<FormMessage />
125+
</FormItem>
126+
)}
127+
/>
128+
<FormField
129+
control={form.control}
130+
name="config"
131+
render={({ field: { value, onChange } }) => (
132+
<FormItem>
133+
<FormLabel>Configuration</FormLabel>
134+
<FormControl>
135+
<CodeMirror
136+
ref={editorRef}
137+
value={value}
138+
onChange={onChange}
139+
extensions={[
140+
keymapExtension,
141+
json(),
142+
linter(jsonParseLinter(), {
143+
delay: 300,
144+
}),
145+
linter(jsonSchemaLinter(), {
146+
needsRefresh: handleRefresh,
147+
}),
148+
jsonLanguage.data.of({
149+
autocomplete: jsonCompletion(),
150+
}),
151+
hoverTooltip(jsonSchemaHover()),
152+
// @todo: we will need to validate the config against different schemas based on the type of connection.
153+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
154+
stateExtensions(githubSchema as any),
155+
customAutocompleteStyle,
156+
]}
157+
theme={theme === "dark" ? "dark" : "light"}
158+
>
159+
</CodeMirror>
160+
</FormControl>
161+
<FormMessage />
162+
</FormItem>
163+
)}
164+
/>
165+
</div>
166+
<Button className="mt-5" type="submit">Submit</Button>
167+
</form>
168+
</Form>
169+
</div>
170+
)
171+
}

0 commit comments

Comments
 (0)