Skip to content

Commit

Permalink
feat: Added custom connection creator/ file uploader
Browse files Browse the repository at this point in the history
  • Loading branch information
RamiAwar committed Apr 5, 2024
1 parent 3ddc569 commit 316fd65
Show file tree
Hide file tree
Showing 4 changed files with 258 additions and 67 deletions.
17 changes: 10 additions & 7 deletions text2sql-frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,16 @@ const createConnection = async (
return response.data;
};

const createTestConnection = async (dsn: string): Promise<ConnectResult> => {
const response = await axios.post<ConnectResult>(
`${baseUrl}/create-sample-db`,
{ dsn }
);
const createFileConnection = async (
file: File,
name: string
): Promise<ConnectResult> => {
const formData = new FormData();
formData.append("file", file);
formData.append("name", name);
const response = await axios.post<ConnectResult>(`${baseUrl}/connect/file`, formData);
return response.data;
};
}

export type ListConnectionsResult = ApiResponse<{
connections: ConnectionResult[];
Expand Down Expand Up @@ -352,7 +355,7 @@ export const api = {
updateTableSchemaDescription,
updateTableSchemaFieldDescription,
createConnection,
createTestConnection,
createFileConnection,
updateConnection,
deleteConnection,
listConnections,
Expand Down
247 changes: 215 additions & 32 deletions text2sql-frontend/src/components/Connection/ConnectionCreator.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,229 @@
import { Description, Fieldset, Label, Legend } from '@catalyst/fieldset'
import { Field, Fieldset, Label, Legend } from '@catalyst/fieldset'
import { Radio, RadioField, RadioGroup } from '@catalyst/radio'
import { Text } from '@catalyst/text'
import { useEffect, useState } from 'react'
import {api} from "../../api"
import { useEffect, useRef, useState } from 'react'
import { api } from "@/api"
import { Input } from '@catalyst/input'
import { Button } from '@catalyst/button'
import { enqueueSnackbar } from 'notistack'
import { Routes } from '@/router'
import { useNavigate } from 'react-router-dom'
import { useConnectionList } from '@components/Providers/ConnectionListProvider'
import { isAxiosError } from 'axios'
import { CloudArrowUpIcon, DocumentCheckIcon } from '@heroicons/react/24/solid'
import { XCircleIcon, XMarkIcon } from '@heroicons/react/24/outline'


function classNames(...classes: string[]) {
return classes.filter(Boolean).join(" ");
}


const ConnectionCreator = ({ name = null }: { name: string | null }) => {

type RadioValue = "database" | "file" | null;
const [selectedRadio, setSelectedRadio] = useState<RadioValue>(null);
const [dsn, setDsn] = useState<string | null>(null);
const [file, setFile] = useState<File>();
const [, , fetchConnections] = useConnectionList();

const fileInputRef = useRef<HTMLInputElement>(null);

const ConnectionCreator = () => {
const handleFileClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};

const navigate = useNavigate();

const [samples, setSamples] = useState<string[]>([])

// Get samples from API
useEffect(() => {
const fetchSamples = async () => {
const handleCustomCreate = async () => {
// Call api with name and dsn
if (name === null || name === "" || dsn === null || dsn === "") {
enqueueSnackbar({
variant: "info",
message: "Please enter a name and dsn for this connection",
});
} else if (name && dsn) {
try {
const res = await api.getSamples()
setSamples(res.data.map((sample) => sample.file));
await api.createConnection(dsn, name, false);
fetchConnections();
enqueueSnackbar({
variant: "success",
message: "Connection created",
});
navigate(Routes.Root);
} catch (exception) {
console.error(exception)
if (isAxiosError(exception) && exception.response?.status === 409) {
enqueueSnackbar({
variant: "info",
message: "Connection already exists, skipping creation",
});
} else if (isAxiosError(exception) && exception.response?.status === 422) {
enqueueSnackbar({
variant: "error",
message: exception.response?.data.detail[0].msg,
});
} else if (isAxiosError(exception) && exception.response?.status === 400) {
enqueueSnackbar({
variant: "error",
message: exception.response?.data.detail,
});
} else {
if (isAxiosError(exception) && exception.response?.data?.detail) {
enqueueSnackbar({
variant: "error",
message: exception.response?.data?.detail
});
} else {
enqueueSnackbar({
variant: "error",
message: "Error creating connection, please check your DSN.",
});
}
}
}
}
fetchSamples()
}, [])
}

const handleFileCreate = async () => {
if (!file) {
enqueueSnackbar({
variant: "info",
message: "Please add a file",
});
return
}

if (name === null || name === "") {
enqueueSnackbar({
variant: "info",
message: "Please add a name",
});
return
}

// Limit file size to 500MB
if (file.size > 1024 * 1024 * 500) {
enqueueSnackbar({
variant: "info",
message: "File size exceeds 500MB limit",
});
return
}

try {
await api.createFileConnection(file, name);
fetchConnections();
enqueueSnackbar({
variant: "success",
message: "Connection created",
});
navigate(Routes.Root);
} catch (exception) {
if (isAxiosError(exception) && exception.response?.status === 409) {
enqueueSnackbar({
variant: "info",
message: "Connection already exists, skipping creation",
});
} else if (isAxiosError(exception) && exception.response?.status === 422) {
enqueueSnackbar({
variant: "error",
message: exception.response?.data.detail[0].msg,
});
} else if (isAxiosError(exception) && exception.response?.status === 400) {
enqueueSnackbar({
variant: "error",
message: exception.response?.data.detail,
});
} else {
if (isAxiosError(exception) && exception.response?.data?.detail) {
enqueueSnackbar({
variant: "error",
message: exception.response?.data?.detail
});
} else {
enqueueSnackbar({
variant: "error",
message: "Error creating connection, please check your DSN.",
});
}
}
}
}

const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
console.log("her");
const files = event.target.files;
console.log(files);
if (files && files.length > 0) {
setFile(files[0]);
}
}

return (
<Fieldset>
<Legend>Resale and transfers</Legend>
<Text>Decide if people buy tickets from you or from scalpers.</Text>
<RadioGroup name="resale" defaultValue="permit">
<RadioField>
<Radio value="permit" />
<Label>Allow tickets to be resold</Label>
<Description>Customers can resell or transfer their tickets if they can’t make it to the event.</Description>
</RadioField>
<RadioField>
<Radio value="forbid" />
<Label>Don’t allow tickets to be resold</Label>
<Description>Tickets cannot be resold or transferred to another person.</Description>
</RadioField>
</RadioGroup>
</Fieldset>
)
<div>
<Fieldset>
<Legend>Create a custom connection</Legend>
<RadioGroup defaultValue="" onChange={(selection: string) => setSelectedRadio(selection as RadioValue)}>
<RadioField>
<Radio value="database" color="white" />
<Label className="cursor-pointer">Postgres, MySQL connection</Label>
</RadioField>
<RadioField>
<Radio value="file" color="white" />
<Label className="cursor-pointer">SQLite file</Label>
</RadioField>
</RadioGroup>
</Fieldset>
<div className="mt-10 max-w-2xl">
{selectedRadio === "database" && (
<div>
<Field>
<Label>Connection DSN</Label>
<Input type="text" placeholder="postgres://myuser:mypassword@localhost:5432/mydatabase" onChange={(e) => setDsn(e.target.value)} />
</Field>
<Button className="cursor-pointer mt-4" onClick={handleCustomCreate}>Create connection</Button>
</div>
)}
{selectedRadio === "file" && (
<div>
<Field>
<Label>SQLite data file</Label>
<div className="mt-2 flex justify-center rounded-lg border border-dashed border-white/60 px-6 py-10">
<div className={classNames(file ? "" : "hidden", "text-center")}>
<div className="relative inline-block">
<DocumentCheckIcon className="h-12 w-12 text-gray-300" aria-hidden="true" />
<div onClick={() => setFile(undefined)} className="absolute -right-1 -top-1 cursor-pointer block h-3 w-3 rounded-full bg-red-500 ring-4 ring-red-500">
<XMarkIcon className="h-3 w-3 text-white [&>path]:stroke-[4]" aria-hidden="true" />
</div>
</div>
<p className="mt-2 text-sm leading-6 text-gray-400">{file && file.name}</p>
</div>
<div className={classNames(file ? "hidden" : "", "text-center")}>
<CloudArrowUpIcon onClick={handleFileClick} className="cursor-pointer mx-auto h-12 w-12 text-gray-300" aria-hidden="true" />
<div className="mt-4 flex text-sm leading-6 text-gray-400">
<label
htmlFor="file-upload"
className="px-1 relative cursor-pointer rounded-md bg-gray-900 font-semibold text-white focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-indigo-500"
>
<span>Upload a file</span>
{/** Set a key so that the input is re-rendered and cleared when the file is removed */}
<input ref={fileInputRef} id="file-upload" name="file-upload" type="file" className="sr-only" onChange={handleFileChange} key={file?.name} />
</label>
<p>or drag and drop</p>
</div>
<p className="text-xs leading-5 text-gray-400">SQLite file</p>
</div>
</div>
</Field>
<Button className="cursor-pointer mt-4" onClick={handleFileCreate}>Create connection</Button>
</div>
)
}
</div >
</div>
);
}


export default ConnectionCreator;
50 changes: 24 additions & 26 deletions text2sql-frontend/src/components/Connection/NewConnection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,32 +40,30 @@ export const NewConnection = () => {
Add a new database connection
</p>

<div className="mt-5 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div className="sm:col-span-3">
<label
htmlFor="name"
className="block text-sm font-medium leading-6 text-white"
>
Name
</label>
<div className="mt-2">
<input
type="text"
name="name"
id="name"
disabled={isLoading}
autoComplete="one-time-code"
value={connectionName}
onChange={handleNameChange}
placeholder="Postgres Prod"
className={classNames(
isLoading
? "animate-pulse bg-gray-900 text-gray-400"
: "bg-white/5 text-white",
"block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-indigo-500 sm:text-sm sm:leading-6"
)}
/>
</div>
<div className="mt-5 max-w-2xl">
<label
htmlFor="name"
className="block text-sm font-medium leading-6 text-white"
>
Name
</label>
<div className="mt-2">
<input
type="text"
name="name"
id="name"
disabled={isLoading}
autoComplete="one-time-code"
value={connectionName}
onChange={handleNameChange}
placeholder="Postgres Prod"
className={classNames(
isLoading
? "animate-pulse bg-gray-900 text-gray-400"
: "bg-white/5 text-white",
"block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-indigo-500 sm:text-sm sm:leading-6"
)}
/>
</div>
</div>

Expand Down
11 changes: 9 additions & 2 deletions text2sql-frontend/src/components/Connection/SampleSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@ export const SampleSelector = ({ name = null }: { name: string | null }) => {
const res = await api.getSamples()
setSamples(res.data);
} catch (exception) {
if (isAxiosError(exception)) {
// Connection already exists, skip creation but don't close or clear modal
// If connection already exists, skip creation
if (isAxiosError(exception) && exception.response?.status === 409) {
enqueueSnackbar({
variant: "info",
message: "Connection already exists, skipping creation",
});
return;
}
else if (isAxiosError(exception)) {
enqueueSnackbar({
variant: "error",
message: "Error fetching samples",
Expand Down

0 comments on commit 316fd65

Please sign in to comment.