diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..b5d4b10 --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +# TODO + +## Databases + +- [ ] Fully support PostgreSQL and MySQL +- [ ] Add support for SQLite +- [ ] Add support for MSSQL +- [ ] Add support for MongoDB +- [ ] Add support for Redis + +## Nice to have + +- [ ] Seed database with fake data (something like mockaroo, but taking advantage of db introspection) \ No newline at end of file diff --git a/api/src/drivers/driver.interface.ts b/api/src/drivers/driver.interface.ts index cc60795..9a22ecb 100644 --- a/api/src/drivers/driver.interface.ts +++ b/api/src/drivers/driver.interface.ts @@ -24,7 +24,11 @@ export interface Driver { ): Promise; getTableData( credentials: Credentials, - args: WithSortPagination<{ tableName: string; dbName: string }>, + args: WithSortPagination<{ + tableName: string; + dbName: string; + whereQuery?: string; + }>, ): Promise<{ count: number; data: Record[]; diff --git a/api/src/drivers/postgres.ts b/api/src/drivers/postgres.ts index 6865dae..37cead4 100644 --- a/api/src/drivers/postgres.ts +++ b/api/src/drivers/postgres.ts @@ -159,22 +159,31 @@ export class PostgresDriver implements Driver { page, sortDesc, sortField, - }: WithSortPagination<{ tableName: string; dbName: string }>, + whereQuery, + }: WithSortPagination<{ + tableName: string; + dbName: string; + whereQuery?: string; + }>, ) { const sql = await this.queryRunner(credentials); const offset = (perPage * page).toString(); - const rows = sql` - SELECT COUNT(*) - FROM ${sql(dbName)}.${sql(tableName)}`; - const tables = sql` + const rowsQuery = ` + SELECT COUNT(*) + FROM "${dbName}"."${tableName}" + ${whereQuery ? `WHERE ${whereQuery}` : ""}`; + + const tablesQuery = ` SELECT * - FROM ${sql(dbName)}.${sql(tableName)} - ${sortField ? sql`ORDER BY ${sql(sortField)} ${sortDesc ? sql`DESC` : sql`ASC`}` : sql``} + FROM "${dbName}"."${tableName}" + ${whereQuery ? `WHERE ${whereQuery}` : ""} + ${sortField ? `ORDER BY "${sortField}" ${sortDesc ? "DESC" : "ASC"}` : ""} LIMIT ${perPage} OFFSET ${offset} `; - + const rows = sql.unsafe(rowsQuery); + const tables = sql.unsafe(tablesQuery); const [[count], data] = await Promise.all([rows, tables]); void sql.end(); diff --git a/api/src/index.ts b/api/src/index.ts index 6d84db6..adc2889 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -95,7 +95,13 @@ const app = new Elysia({ prefix: "/api" }) "/databases/:dbName/tables/:tableName/data", async ({ query, params, jwt, set, cookie: { auth } }) => { const { tableName, dbName } = params; - const { perPage = "50", page = "0", sortField, sortDesc } = query; + const { + perPage = "50", + page = "0", + sortField, + sortDesc, + whereQuery, + } = query; const credentials = await jwt.verify(auth.value); if (!credentials) { @@ -111,6 +117,7 @@ const app = new Elysia({ prefix: "/api" }) page: Number.parseInt(page, 10), sortField, sortDesc: sortDesc === "true", + whereQuery, }); }, ) diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 7e6c3a1..1e717ae 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index 20005bc..4655721 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@fontsource/inter": "^5.0.19", + "@hookform/resolvers": "^3.9.0", "@it-incubator/prettier-config": "^0.1.2", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-dialog": "^1.1.1", @@ -34,6 +35,7 @@ "primereact": "^10.7.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.52.1", "sonner": "^1.5.0", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", diff --git a/frontend/src/components/db-table-view/data-table.tsx b/frontend/src/components/db-table-view/data-table.tsx index 9421b9d..cb7b91c 100644 --- a/frontend/src/components/db-table-view/data-table.tsx +++ b/frontend/src/components/db-table-view/data-table.tsx @@ -5,13 +5,14 @@ import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, - ScrollArea, + FormInput, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, + Tooltip, } from "@/components/ui"; import { cn, isImageUrl, isUrl } from "@/lib/utils"; import { useTableColumnsQuery, useTableDataQuery } from "@/services/db"; @@ -26,8 +27,9 @@ import { getCoreRowModel, useReactTable, } from "@tanstack/react-table"; -import { ArrowUp, Rows3 } from "lucide-react"; +import { ArrowUp, Info, Rows3, Search } from "lucide-react"; import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; export const DataTable = ({ tableName, @@ -44,14 +46,16 @@ export const DataTable = ({ onPageIndexChange: (pageIndex: number) => void; onPageSizeChange: (pageSize: number) => void; }) => { + const whereQueryForm = useForm<{ whereQuery: string }>(); const formatDates = useSettingsStore.use.formatDates(); const showImagesPreview = useSettingsStore.use.showImagesPreview(); - + const [whereQuery, setWhereQuery] = useState(""); const [sorting, setSorting] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); const { data: details } = useTableColumnsQuery({ dbName, tableName }); - const { data } = useTableDataQuery({ + const { data, refetch } = useTableDataQuery({ + whereQuery, tableName, dbName, perPage: pageSize, @@ -59,7 +63,6 @@ export const DataTable = ({ sortDesc: sorting[0]?.desc, sortField: sorting[0]?.id, }); - const paginationUpdater: OnChangeFn = (args) => { if (typeof args === "function") { const newArgs = args({ @@ -88,7 +91,7 @@ export const DataTable = ({ return (
@@ -167,10 +170,41 @@ export const DataTable = ({ }); return ( -
+

{tableName} +
{ + if (data.whereQuery === whereQuery) { + void refetch(); + return; + } + setWhereQuery(data.whereQuery); + })} + > + + + + Your input will be prefixed with WHERE and appended to the raw + SQL query. +
You can use AND, OR, NOT, BETWEEN, LIKE, =, etc.
+
To remove the WHERE clause, submit an empty input.
+

+ } + > + + + @@ -201,103 +235,106 @@ export const DataTable = ({

- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const sorted = header.column.getIsSorted(); +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const sorted = header.column.getIsSorted(); - return ( - - -
header.column.resetSize(), - onMouseDown: header.getResizeHandler(), - onTouchStart: header.getResizeHandler(), - className: `resizer ${header.column.getIsResizing() ? "isResizing" : ""}`, + return ( + - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} + > + +
header.column.resetSize(), + onMouseDown: header.getResizeHandler(), + onTouchStart: header.getResizeHandler(), + className: `resizer ${header.column.getIsResizing() ? "isResizing" : ""}`, + }} + /> + + ); + })} - )) - ) : ( - - - No results. - - - )} - -
-
+ ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + + +
+
); diff --git a/frontend/src/components/session-selector.tsx b/frontend/src/components/sidebar/session-selector.tsx similarity index 90% rename from frontend/src/components/session-selector.tsx rename to frontend/src/components/sidebar/session-selector.tsx index 7a648e8..54966f9 100644 --- a/frontend/src/components/session-selector.tsx +++ b/frontend/src/components/sidebar/session-selector.tsx @@ -49,8 +49,10 @@ function RawSessionSelector() { value={currentSessionId ? currentSessionId.toString() : ""} onValueChange={handleSessionSelected} > - - + + + + {mappedSessions} diff --git a/frontend/src/components/sidebar/sidebar.tsx b/frontend/src/components/sidebar/sidebar.tsx new file mode 100644 index 0000000..ae3bbb0 --- /dev/null +++ b/frontend/src/components/sidebar/sidebar.tsx @@ -0,0 +1,119 @@ +import { SessionSelector } from "@/components/sidebar/session-selector"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + buttonVariants, +} from "@/components/ui"; +import { cn } from "@/lib/utils"; +import { Route } from "@/routes/__root"; +import { useDatabasesListQuery, useTablesListQuery } from "@/services/db"; +import { useUiStore } from "@/state"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import { Database, Rows3 } from "lucide-react"; +import type { PropsWithChildren } from "react"; + +function SidebarContent() { + const { data } = useDatabasesListQuery(); + + const showSidebar = useUiStore.use.showSidebar(); + const params = useParams({ strict: false }); + const dbName = params.dbName ?? ""; + const { data: tables } = useTablesListQuery({ dbName }); + const navigate = useNavigate({ from: Route.fullPath }); + + const handleSelectedDb = (dbName: string) => { + void navigate({ to: "/db/$dbName/tables", params: { dbName } }); + }; + if (!showSidebar) return null; + + return ( + <> + + + + + ); +} + +function SidebarContainer({ children }: PropsWithChildren) { + return ; +} + +export function Sidebar() { + return ( + + + + ); +} diff --git a/frontend/src/components/ui/form/form-input.tsx b/frontend/src/components/ui/form/form-input.tsx new file mode 100644 index 0000000..4fa0d6d --- /dev/null +++ b/frontend/src/components/ui/form/form-input.tsx @@ -0,0 +1,32 @@ +import { Input, type InputProps } from "@/components/ui"; +import { + type Control, + type FieldValues, + type UseControllerProps, + useController, +} from "react-hook-form"; + +type Props = Omit< + UseControllerProps, + "control" | "defaultValue" | "rules" +> & + Omit & { control: Control }; + +export const FormInput = ({ + control, + name, + disabled, + shouldUnregister, + ...rest +}: Props) => { + const { + field, + fieldState: { error }, + } = useController({ + control, + name, + disabled, + shouldUnregister, + }); + return ; +}; diff --git a/frontend/src/components/ui/form/form-select.tsx b/frontend/src/components/ui/form/form-select.tsx new file mode 100644 index 0000000..64776c0 --- /dev/null +++ b/frontend/src/components/ui/form/form-select.tsx @@ -0,0 +1,34 @@ +import { Select } from "@/components/ui"; +import type { ComponentPropsWithoutRef } from "react"; +import { + type Control, + type FieldValues, + type UseControllerProps, + useController, +} from "react-hook-form"; + +type Props = Omit< + UseControllerProps, + "control" | "defaultValue" | "rules" +> & + Omit, "value" | "onValueChange"> & { + control: Control; + }; + +export const FormSelect = ({ + control, + name, + disabled, + shouldUnregister, + ...rest +}: Props) => { + const { + field: { onChange, ...field }, + } = useController({ + control, + name, + disabled, + shouldUnregister, + }); + return + {!!label && } + + {!!errorMessage && ( +

{errorMessage}

)} - ref={ref} - {...props} - /> + ); }, ); -Input.displayName = "Input"; +RawInput.displayName = "Input"; + +const Input = memo(RawInput); +Input.displayName = "MemoizedInput"; export { Input }; diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index 3cfdb52..95ec012 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -21,7 +21,7 @@ const SelectTrigger = forwardRef< span]:line-clamp-1", + "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", className, )} {...props} diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx index 2ad2e0b..61e986e 100644 --- a/frontend/src/components/ui/table.tsx +++ b/frontend/src/components/ui/table.tsx @@ -8,7 +8,7 @@ import { const Table = forwardRef>( ({ className, ...props }, ref) => ( -
+
>(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -61,7 +65,7 @@ const TableRow = forwardRef< , + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +const Tooltip = ({ + children, + content, + open, + defaultOpen, + onOpenChange, + delayDuration = 100, + disableHoverableContent, + ...props +}: Omit< + React.ComponentPropsWithoutRef & + React.ComponentPropsWithoutRef, + "content" +> & { + content: React.ReactNode; +}) => { + return ( + + + {children} + {content} + + + ); +}; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts new file mode 100644 index 0000000..8d8add4 --- /dev/null +++ b/frontend/src/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-auto-id"; diff --git a/frontend/src/hooks/use-auto-id.ts b/frontend/src/hooks/use-auto-id.ts new file mode 100644 index 0000000..8d0c60d --- /dev/null +++ b/frontend/src/hooks/use-auto-id.ts @@ -0,0 +1,6 @@ +import { useId } from 'react' + +export const useAutoId = (id?: string) => { + const generatedId = useId() + return id ?? generatedId +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 1316ae5..1d06773 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -75,11 +75,11 @@ text-underline-position: under; --sidebar-width: 264px; } - + .sidebar-closed { --sidebar-width: 0; } - + .grid-rows-layout { grid-template-rows: 60px 1fr; } diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 8e8271d..9869dfd 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -1,27 +1,11 @@ -import { SessionSelector } from "@/components/session-selector"; import { SettingsDialog } from "@/components/settings-dialog"; -import { - Button, - ModeToggle, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - buttonVariants, -} from "@/components/ui"; +import { Sidebar } from "@/components/sidebar/sidebar"; +import { Button, ModeToggle } from "@/components/ui"; import { cn } from "@/lib/utils"; -import { useDatabasesListQuery, useTablesListQuery } from "@/services/db"; import { useUiStore } from "@/state"; -import { - Link, - Outlet, - createRootRoute, - useNavigate, - useParams, -} from "@tanstack/react-router"; +import { Link, Outlet, createRootRoute } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/router-devtools"; -import { Database, PanelLeft, PanelLeftClose, Rows3 } from "lucide-react"; +import { PanelLeft, PanelLeftClose } from "lucide-react"; export const Route = createRootRoute({ component: Root, @@ -31,16 +15,6 @@ function Root() { const showSidebar = useUiStore.use.showSidebar(); const toggleSidebar = useUiStore.use.toggleSidebar(); - const { data } = useDatabasesListQuery(); - const params = useParams({ strict: false }); - const dbName = params.dbName ?? ""; - const navigate = useNavigate({ from: Route.fullPath }); - - const handleSelectedDb = (dbName: string) => { - void navigate({ to: "/db/$dbName/tables", params: { dbName } }); - }; - - const { data: tables } = useTablesListQuery({ dbName }); return ( <>
- - + diff --git a/frontend/src/routes/auth/login.tsx b/frontend/src/routes/auth/login.tsx index 47e3eaf..3ab04a3 100644 --- a/frontend/src/routes/auth/login.tsx +++ b/frontend/src/routes/auth/login.tsx @@ -6,9 +6,9 @@ import { CardFooter, CardHeader, CardTitle, - Input, + FormInput, + FormSelect, Label, - Select, SelectContent, SelectItem, SelectTrigger, @@ -16,30 +16,122 @@ import { ToggleGroup, ToggleGroupItem, } from "@/components/ui"; -import { useLoginMutation } from "@/services/db"; +import { type LoginArgs, useLoginMutation } from "@/services/db"; import { useSessionStore } from "@/state/db-session-store"; +import { zodResolver } from "@hookform/resolvers/zod"; import { createFileRoute } from "@tanstack/react-router"; -import { type FormEventHandler, useState } from "react"; -import { toast } from "sonner"; +import { useState } from "react"; +import { type Control, useForm } from "react-hook-form"; +import { z } from "zod"; export const Route = createFileRoute("/auth/login")({ component: LoginForm, }); -function DatabaseTypeSelector() { +const loginWithConnectionStringSchema = z.object({ + type: z.enum(["mysql", "postgres"]), + connectionString: z.string().trim().min(1, "Connection string is required"), +}); + +type LoginWithConnectionStringFields = z.infer< + typeof loginWithConnectionStringSchema +>; + +function ConnectionStringForm({ + onSubmit, +}: { + onSubmit: (values: LoginWithConnectionStringFields) => void; +}) { + const { control, handleSubmit } = useForm({ + resolver: zodResolver(loginWithConnectionStringSchema), + defaultValues: { + type: "postgres", + connectionString: "", + }, + }); + return ( -
- - -
+
+ + + + ); +} + +const loginWithConnectionFieldsSchema = z.object({ + type: z.enum(["mysql", "postgres"]), + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), + host: z.string().min(1, "Host is required"), + port: z.string().min(1, "Port is required"), + database: z.string().min(1, "Database is required"), + ssl: z.enum(["false", "true", "require", "allow", "prefer", "verify-full"]), +}); +type LoginWithConnectionFields = z.infer< + typeof loginWithConnectionFieldsSchema +>; + +function ConnectionFieldsForm({ + onSubmit, +}: { + onSubmit: (values: LoginWithConnectionFields) => void; +}) { + const { control, handleSubmit } = useForm({ + resolver: zodResolver(loginWithConnectionFieldsSchema), + defaultValues: { + type: "postgres", + host: "", + port: "", + username: "", + password: "", + ssl: "prefer", + database: "", + }, + }); + + return ( +
+ + + + + + +
+ + + + + + + false + true + require + allow + prefer + verify-full + + +
+ ); } @@ -49,78 +141,9 @@ function LoginForm() { const { mutateAsync } = useLoginMutation(); const addSession = useSessionStore.use.addSession(); - const handleSubmit: FormEventHandler = async (e) => { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const connectionString = formData.get("connectionString"); - const type = formData.get("type"); - if (connectionMethod === "connectionString") { - if ( - connectionString != null && - typeof connectionString === "string" && - type != null && - typeof type === "string" - ) { - try { - await mutateAsync({ connectionString, type }); - addSession({ connectionString, type }); - } catch (error) { - console.log(error); - toast.error("Invalid connection string"); - return; - } - } else { - toast.error("Please fill all fields"); - } - return; - } - - const username = formData.get("username"); - const password = formData.get("password"); - const host = formData.get("host"); - const port = formData.get("port"); - const database = formData.get("database"); - const ssl = formData.get("ssl"); - - if ( - database == null || - host == null || - password == null || - port == null || - ssl == null || - type == null || - username == null - ) { - toast.error("Please fill all fields"); - return; - } - if ( - typeof database !== "string" || - typeof host !== "string" || - typeof password !== "string" || - typeof port !== "string" || - typeof ssl !== "string" || - typeof type !== "string" || - typeof username !== "string" - ) { - return; - } - try { - await mutateAsync({ - username, - password, - host, - type, - port, - database, - ssl, - }); - addSession({ username, password, host, type, port, database, ssl }); - } catch (error) { - console.log(error); - toast.error("Invalid connection string"); - return; - } + const onSubmit = async (args: LoginArgs) => { + await mutateAsync(args); + addSession(args); }; return ( @@ -133,11 +156,7 @@ function LoginForm() { -
+
{connectionMethod === "fields" ? ( - <> - -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- + ) : ( -
- - - -
+ )} - +