Skip to content

Connection creation form #175

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "yarn generate:schemas && next dev",
"build": "yarn generate:schemas && next build",
"start": "next start",
"lint": "next lint",
"test": "vitest"
"test": "vitest",
"generate:schemas": "tsx tools/generateSchemas.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.7.4",
Expand Down Expand Up @@ -66,12 +67,14 @@
"@uiw/react-codemirror": "^4.23.0",
"@viz-js/lang-dot": "^1.0.4",
"@xiechao/codemirror-lang-handlebars": "^1.0.4",
"ajv": "^8.17.1",
"class-variance-authority": "^0.7.0",
"client-only": "^0.0.1",
"clsx": "^2.1.1",
"cm6-graphql": "^0.2.0",
"cmdk": "1.0.0",
"codemirror": "^5.65.3",
"codemirror-json-schema": "^0.8.0",
"codemirror-lang-brainfuck": "^0.1.0",
"codemirror-lang-elixir": "^4.0.0",
"codemirror-lang-hcl": "^0.0.0-beta.2",
Expand Down Expand Up @@ -112,6 +115,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@apidevtools/json-schema-ref-parser": "^11.7.3",
"@sourcebot/db": "^0.1.0",
"@types/node": "^20",
"@types/react": "^18",
Expand All @@ -126,6 +130,7 @@
"npm-run-all": "^4.1.5",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tsx": "^4.19.2",
"typescript": "^5",
"vite-tsconfig-paths": "^5.1.3",
"vitest": "^2.1.5"
Expand Down
76 changes: 73 additions & 3 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
'use server';

import Ajv from "ajv";
import { getUser } from "./data/user";
import { auth } from "./auth";
import { notAuthenticated, notFound } from "./lib/serviceError";
import { notAuthenticated, notFound, ServiceError, unexpectedError } from "./lib/serviceError";
import { prisma } from "@/prisma";
import { githubSchema } from "./schemas/github.schema";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "./lib/errorCodes";

const ajv = new Ajv({
validateFormats: false,
});

export const createOrg = async (name: string) => {
export const createOrg = async (name: string): Promise<{ id: number } | ServiceError> => {
const session = await auth();
if (!session) {
return notAuthenticated();
Expand All @@ -29,13 +37,14 @@ export const createOrg = async (name: string) => {
}
}

export const switchActiveOrg = async (orgId: number) => {
export const switchActiveOrg = async (orgId: number): Promise<{ id: number } | ServiceError> => {
const session = await auth();
if (!session) {
return notAuthenticated();
}

// Check to see if the user is a member of the org
// @todo: refactor this into a shared function
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
Expand All @@ -61,4 +70,65 @@ export const switchActiveOrg = async (orgId: number) => {
return {
id: orgId,
}
}

export const createConnection = async (config: string): Promise<{ id: number } | ServiceError> => {
const session = await auth();
if (!session) {
return notAuthenticated();
}

const user = await getUser(session.user.id);
if (!user) {
return unexpectedError("User not found");
}
const orgId = user.activeOrgId;
if (!orgId) {
return unexpectedError("User has no active org");
}

// @todo: refactor this into a shared function
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: session.user.id,
orgId,
}
},
});
if (!membership) {
return notFound();
}

let parsedConfig;
try {
parsedConfig = JSON.parse(config);
} catch (e) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "config must be a valid JSON object."
} satisfies ServiceError;
}

// @todo: we will need to validate the config against different schemas based on the type of connection.
const isValidConfig = ajv.validate(githubSchema, parsedConfig);
if (!isValidConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`,
} satisfies ServiceError;
}

const connection = await prisma.config.create({
data: {
orgId: orgId,
data: parsedConfig,
}
});

return {
id: connection.id,
}
}
2 changes: 1 addition & 1 deletion packages/web/src/app/components/orgSelector/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { auth } from "@/auth";
import { getUser, getUserOrgs } from "../../data/user";
import { getUser, getUserOrgs } from "../../../data/user";
import { OrgSelectorDropdown } from "./orgSelectorDropdown";

export const OrgSelector = async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ export const OrgSelectorDropdown = ({
))}
</CommandGroup>
</CommandList>

</Command>
</DropdownMenuGroup>
{searchFilter.length === 0 && (
Expand Down
17 changes: 17 additions & 0 deletions packages/web/src/app/connections/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NavigationMenu } from "../components/navigationMenu";

export default function Layout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {

return (
<div className="min-h-screen flex flex-col">
<NavigationMenu />
<main className="flex-grow flex justify-center p-4">
<div className="w-full max-w-5xl rounded-lg border p-6">{children}</div>
</main>
</div>
)
}
171 changes: 171 additions & 0 deletions packages/web/src/app/connections/new/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@

'use client';

import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
import { json, jsonLanguage, jsonParseLinter } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import { EditorView, hoverTooltip } from "@codemirror/view";
import { zodResolver } from "@hookform/resolvers/zod";
import CodeMirror, { ReactCodeMirrorRef } from "@uiw/react-codemirror";
import Ajv from "ajv";
import {
handleRefresh,
jsonCompletion,
jsonSchemaHover,
jsonSchemaLinter,
stateExtensions
} from "codemirror-json-schema";
import { useCallback, useRef } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { githubSchema } from "@/schemas/github.schema";
import { Input } from "@/components/ui/input";
import { createConnection } from "@/actions";
import { useToast } from "@/components/hooks/use-toast";
import { isServiceError } from "@/lib/utils";
import { useRouter } from "next/navigation";

const ajv = new Ajv({
validateFormats: false,
});

// @todo: we will need to validate the config against different schemas based on the type of connection.
const validate = ajv.compile(githubSchema);

const formSchema = z.object({
name: z.string().min(1),
config: z
.string()
.superRefine((data, ctx) => {
const addIssue = (message: string) => {
return ctx.addIssue({
code: "custom",
message: `Schema validation error: ${message}`
});
}

let parsed;
try {
parsed = JSON.parse(data);
} catch {
addIssue("Invalid JSON");
return;
}

const valid = validate(parsed);
if (!valid) {
addIssue(ajv.errorsText(validate.errors));
}
}),
});

// Add this theme extension to your extensions array
const customAutocompleteStyle = EditorView.baseTheme({
".cm-tooltip.cm-completionInfo": {
padding: "8px",
fontSize: "12px",
fontFamily: "monospace",
},
".cm-tooltip-hover.cm-tooltip": {
padding: "8px",
fontSize: "12px",
fontFamily: "monospace",
}
})

export default function NewConnectionPage() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
config: JSON.stringify({ type: "github" }, null, 2),
},
});

const editorRef = useRef<ReactCodeMirrorRef>(null);
const keymapExtension = useKeymapExtension(editorRef.current?.view);
const { theme } = useThemeNormalized();
const { toast } = useToast();
const router = useRouter();

const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
createConnection(data.config)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to create connection. Reason: ${response.message}`
});
} else {
toast({
description: `✅ Connection created successfully!`
});
router.push('/');
}
});
}, [router, toast]);

return (
<div>
<h1 className="text-2xl font-bold mb-4">Create a connection</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config"
render={({ field: { value, onChange } }) => (
<FormItem>
<FormLabel>Configuration</FormLabel>
<FormControl>
<CodeMirror
ref={editorRef}
value={value}
onChange={onChange}
extensions={[
keymapExtension,
json(),
linter(jsonParseLinter(), {
delay: 300,
}),
linter(jsonSchemaLinter(), {
needsRefresh: handleRefresh,
}),
jsonLanguage.data.of({
autocomplete: jsonCompletion(),
}),
hoverTooltip(jsonSchemaHover()),
// @todo: we will need to validate the config against different schemas based on the type of connection.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stateExtensions(githubSchema as any),
customAutocompleteStyle,
]}
theme={theme === "dark" ? "dark" : "light"}
>
</CodeMirror>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button className="mt-5" type="submit">Submit</Button>
</form>
</Form>
</div>
)
}
File renamed without changes.
Loading