mirror of
https://github.com/ershisan99/db-studio.git
synced 2025-12-16 12:33:05 +00:00
Merge branch 'refs/heads/maybe-rollback-to-remove-primeng'
# Conflicts: # frontend/bun.lockb
This commit is contained in:
13
TODO.md
Normal file
13
TODO.md
Normal file
@@ -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)
|
||||||
@@ -24,7 +24,11 @@ export interface Driver {
|
|||||||
): Promise<any[]>;
|
): Promise<any[]>;
|
||||||
getTableData(
|
getTableData(
|
||||||
credentials: Credentials,
|
credentials: Credentials,
|
||||||
args: WithSortPagination<{ tableName: string; dbName: string }>,
|
args: WithSortPagination<{
|
||||||
|
tableName: string;
|
||||||
|
dbName: string;
|
||||||
|
whereQuery?: string;
|
||||||
|
}>,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
count: number;
|
count: number;
|
||||||
data: Record<string, any>[];
|
data: Record<string, any>[];
|
||||||
|
|||||||
@@ -159,22 +159,31 @@ export class PostgresDriver implements Driver {
|
|||||||
page,
|
page,
|
||||||
sortDesc,
|
sortDesc,
|
||||||
sortField,
|
sortField,
|
||||||
}: WithSortPagination<{ tableName: string; dbName: string }>,
|
whereQuery,
|
||||||
|
}: WithSortPagination<{
|
||||||
|
tableName: string;
|
||||||
|
dbName: string;
|
||||||
|
whereQuery?: string;
|
||||||
|
}>,
|
||||||
) {
|
) {
|
||||||
const sql = await this.queryRunner(credentials);
|
const sql = await this.queryRunner(credentials);
|
||||||
|
|
||||||
const offset = (perPage * page).toString();
|
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 *
|
SELECT *
|
||||||
FROM ${sql(dbName)}.${sql(tableName)}
|
FROM "${dbName}"."${tableName}"
|
||||||
${sortField ? sql`ORDER BY ${sql(sortField)} ${sortDesc ? sql`DESC` : sql`ASC`}` : sql``}
|
${whereQuery ? `WHERE ${whereQuery}` : ""}
|
||||||
|
${sortField ? `ORDER BY "${sortField}" ${sortDesc ? "DESC" : "ASC"}` : ""}
|
||||||
LIMIT ${perPage} OFFSET ${offset}
|
LIMIT ${perPage} OFFSET ${offset}
|
||||||
`;
|
`;
|
||||||
|
const rows = sql.unsafe(rowsQuery);
|
||||||
|
const tables = sql.unsafe(tablesQuery);
|
||||||
const [[count], data] = await Promise.all([rows, tables]);
|
const [[count], data] = await Promise.all([rows, tables]);
|
||||||
|
|
||||||
void sql.end();
|
void sql.end();
|
||||||
|
|||||||
@@ -95,7 +95,13 @@ const app = new Elysia({ prefix: "/api" })
|
|||||||
"/databases/:dbName/tables/:tableName/data",
|
"/databases/:dbName/tables/:tableName/data",
|
||||||
async ({ query, params, jwt, set, cookie: { auth } }) => {
|
async ({ query, params, jwt, set, cookie: { auth } }) => {
|
||||||
const { tableName, dbName } = params;
|
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);
|
const credentials = await jwt.verify(auth.value);
|
||||||
|
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
@@ -111,6 +117,7 @@ const app = new Elysia({ prefix: "/api" })
|
|||||||
page: Number.parseInt(page, 10),
|
page: Number.parseInt(page, 10),
|
||||||
sortField,
|
sortField,
|
||||||
sortDesc: sortDesc === "true",
|
sortDesc: sortDesc === "true",
|
||||||
|
whereQuery,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.0.19",
|
"@fontsource/inter": "^5.0.19",
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@it-incubator/prettier-config": "^0.1.2",
|
"@it-incubator/prettier-config": "^0.1.2",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"primereact": "^10.7.0",
|
"primereact": "^10.7.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.52.1",
|
||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
"tailwind-merge": "^2.3.0",
|
"tailwind-merge": "^2.3.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import {
|
|||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
ScrollArea,
|
FormInput,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { cn, isImageUrl, isUrl } from "@/lib/utils";
|
import { cn, isImageUrl, isUrl } from "@/lib/utils";
|
||||||
import { useTableColumnsQuery, useTableDataQuery } from "@/services/db";
|
import { useTableColumnsQuery, useTableDataQuery } from "@/services/db";
|
||||||
@@ -26,8 +27,9 @@ import {
|
|||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { ArrowUp, Rows3 } from "lucide-react";
|
import { ArrowUp, Info, Rows3, Search } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
export const DataTable = ({
|
export const DataTable = ({
|
||||||
tableName,
|
tableName,
|
||||||
@@ -44,14 +46,16 @@ export const DataTable = ({
|
|||||||
onPageIndexChange: (pageIndex: number) => void;
|
onPageIndexChange: (pageIndex: number) => void;
|
||||||
onPageSizeChange: (pageSize: number) => void;
|
onPageSizeChange: (pageSize: number) => void;
|
||||||
}) => {
|
}) => {
|
||||||
|
const whereQueryForm = useForm<{ whereQuery: string }>();
|
||||||
const formatDates = useSettingsStore.use.formatDates();
|
const formatDates = useSettingsStore.use.formatDates();
|
||||||
const showImagesPreview = useSettingsStore.use.showImagesPreview();
|
const showImagesPreview = useSettingsStore.use.showImagesPreview();
|
||||||
|
const [whereQuery, setWhereQuery] = useState("");
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
|
|
||||||
const { data: details } = useTableColumnsQuery({ dbName, tableName });
|
const { data: details } = useTableColumnsQuery({ dbName, tableName });
|
||||||
const { data } = useTableDataQuery({
|
const { data, refetch } = useTableDataQuery({
|
||||||
|
whereQuery,
|
||||||
tableName,
|
tableName,
|
||||||
dbName,
|
dbName,
|
||||||
perPage: pageSize,
|
perPage: pageSize,
|
||||||
@@ -59,7 +63,6 @@ export const DataTable = ({
|
|||||||
sortDesc: sorting[0]?.desc,
|
sortDesc: sorting[0]?.desc,
|
||||||
sortField: sorting[0]?.id,
|
sortField: sorting[0]?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const paginationUpdater: OnChangeFn<PaginationState> = (args) => {
|
const paginationUpdater: OnChangeFn<PaginationState> = (args) => {
|
||||||
if (typeof args === "function") {
|
if (typeof args === "function") {
|
||||||
const newArgs = args({
|
const newArgs = args({
|
||||||
@@ -88,7 +91,7 @@ export const DataTable = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"whitespace-nowrap",
|
"whitespace-nowrap ",
|
||||||
data_type === "integer" && "text-right",
|
data_type === "integer" && "text-right",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -167,10 +170,41 @@ export const DataTable = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-col gap-4 flex-1 max-h-full h-full pb-3"}>
|
<div className={"flex flex-col gap-4 flex-1 max-h-full pb-3"}>
|
||||||
<div className={"flex gap-4 items-center justify-between"}>
|
<div className={"flex gap-4 items-center justify-between"}>
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
<Rows3 /> {tableName}
|
<Rows3 /> {tableName}
|
||||||
|
<form
|
||||||
|
className={"flex gap-2"}
|
||||||
|
onSubmit={whereQueryForm.handleSubmit((data) => {
|
||||||
|
if (data.whereQuery === whereQuery) {
|
||||||
|
void refetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWhereQuery(data.whereQuery);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormInput
|
||||||
|
name={"whereQuery"}
|
||||||
|
control={whereQueryForm.control}
|
||||||
|
placeholder="WHERE clause"
|
||||||
|
/>
|
||||||
|
<Button variant={"outline"} size={"icon"} type={"submit"}>
|
||||||
|
<Search className={"size-[1.2rem] shrink-0"} />
|
||||||
|
</Button>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
Your input will be prefixed with WHERE and appended to the raw
|
||||||
|
SQL query.
|
||||||
|
<div>You can use AND, OR, NOT, BETWEEN, LIKE, =, etc.</div>
|
||||||
|
<div>To remove the WHERE clause, submit an empty input.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Info className={"size-4 shrink-0 cursor-help"} />
|
||||||
|
</Tooltip>
|
||||||
|
</form>
|
||||||
</h1>
|
</h1>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -201,103 +235,106 @@ export const DataTable = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="rounded-md border min-h-0 h-full w-full min-w-0">
|
<div className="rounded-md border min-h-0 h-full w-full min-w-0 flex flex-col">
|
||||||
<Table
|
<div className={"flex flex-col flex-1 overflow-auto relative"}>
|
||||||
className={"table-fixed min-w-full"}
|
<Table
|
||||||
{...{
|
className={"table-fixed min-w-full"}
|
||||||
style: {
|
{...{
|
||||||
width: table.getCenterTotalSize(),
|
style: {
|
||||||
},
|
width: table.getCenterTotalSize(),
|
||||||
}}
|
},
|
||||||
>
|
}}
|
||||||
<TableHeader>
|
>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<TableHeader>
|
||||||
<TableRow key={headerGroup.id}>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
{headerGroup.headers.map((header) => {
|
<TableRow key={headerGroup.id} className={"sticky top-0"}>
|
||||||
const sorted = header.column.getIsSorted();
|
{headerGroup.headers.map((header) => {
|
||||||
|
const sorted = header.column.getIsSorted();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableHead
|
<TableHead
|
||||||
className={"p-0 relative"}
|
className={"p-0 relative"}
|
||||||
key={header.id}
|
key={header.id}
|
||||||
style={{
|
style={{
|
||||||
width: header.getSize(),
|
width: header.getSize(),
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
|
||||||
title={
|
|
||||||
header.column.getNextSortingOrder() === "asc"
|
|
||||||
? "Sort ascending"
|
|
||||||
: header.column.getNextSortingOrder() === "desc"
|
|
||||||
? "Sort descending"
|
|
||||||
: "Clear sort"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
<ArrowUp
|
|
||||||
className={cn(
|
|
||||||
"ml-2 size-4 opacity-0 transition-transform",
|
|
||||||
sorted && "opacity-100",
|
|
||||||
(sorted as string) === "desc" && "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<div
|
|
||||||
{...{
|
|
||||||
onDoubleClick: () => header.column.resetSize(),
|
|
||||||
onMouseDown: header.getResizeHandler(),
|
|
||||||
onTouchStart: header.getResizeHandler(),
|
|
||||||
className: `resizer ${header.column.getIsResizing() ? "isResizing" : ""}`,
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</TableHead>
|
<Button
|
||||||
);
|
variant="ghost"
|
||||||
})}
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
</TableRow>
|
className={"select-text"}
|
||||||
))}
|
title={
|
||||||
</TableHeader>
|
header.column.getNextSortingOrder() === "asc"
|
||||||
<TableBody>
|
? "Sort ascending"
|
||||||
{table.getRowModel().rows?.length ? (
|
: header.column.getNextSortingOrder() === "desc"
|
||||||
table.getRowModel().rows.map((row) => (
|
? "Sort descending"
|
||||||
<TableRow
|
: "Clear sort"
|
||||||
key={row.id}
|
}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
>
|
||||||
>
|
{header.isPlaceholder
|
||||||
{row.getVisibleCells().map((cell) => (
|
? null
|
||||||
<TableCell
|
: flexRender(
|
||||||
key={cell.id}
|
header.column.columnDef.header,
|
||||||
style={{
|
header.getContext(),
|
||||||
width: cell.column.getSize(),
|
)}
|
||||||
}}
|
<ArrowUp
|
||||||
>
|
className={cn(
|
||||||
{flexRender(
|
"ml-2 size-4 opacity-0 transition-transform",
|
||||||
cell.column.columnDef.cell,
|
sorted && "opacity-100",
|
||||||
cell.getContext(),
|
(sorted as string) === "desc" && "rotate-180",
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
/>
|
||||||
))}
|
</Button>
|
||||||
|
<div
|
||||||
|
{...{
|
||||||
|
onDoubleClick: () => header.column.resetSize(),
|
||||||
|
onMouseDown: header.getResizeHandler(),
|
||||||
|
onTouchStart: header.getResizeHandler(),
|
||||||
|
className: `resizer ${header.column.getIsResizing() ? "isResizing" : ""}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))}
|
||||||
) : (
|
</TableHeader>
|
||||||
<TableRow>
|
<TableBody>
|
||||||
<TableCell
|
{table.getRowModel().rows?.length ? (
|
||||||
colSpan={columns.length}
|
table.getRowModel().rows.map((row) => (
|
||||||
className="h-24 text-center"
|
<TableRow
|
||||||
>
|
key={row.id}
|
||||||
No results.
|
data-state={row.getIsSelected() && "selected"}
|
||||||
</TableCell>
|
>
|
||||||
</TableRow>
|
{row.getVisibleCells().map((cell) => (
|
||||||
)}
|
<TableCell
|
||||||
</TableBody>
|
key={cell.id}
|
||||||
</Table>
|
style={{
|
||||||
</ScrollArea>
|
width: cell.column.getSize(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<DataTablePagination table={table} />
|
<DataTablePagination table={table} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ function RawSessionSelector() {
|
|||||||
value={currentSessionId ? currentSessionId.toString() : ""}
|
value={currentSessionId ? currentSessionId.toString() : ""}
|
||||||
onValueChange={handleSessionSelected}
|
onValueChange={handleSessionSelected}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="max-w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Select a Database" />
|
<span className={"truncate max-w-[calc(100%-1.5rem)]"}>
|
||||||
|
<SelectValue placeholder="Select a Database" />
|
||||||
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>{mappedSessions}</SelectContent>
|
<SelectContent>{mappedSessions}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
119
frontend/src/components/sidebar/sidebar.tsx
Normal file
119
frontend/src/components/sidebar/sidebar.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<SessionSelector />
|
||||||
|
<Select value={dbName} onValueChange={handleSelectedDb}>
|
||||||
|
<SelectTrigger className="w-full mt-4">
|
||||||
|
<SelectValue placeholder="Select a Database" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{data?.map((db) => {
|
||||||
|
return (
|
||||||
|
<SelectItem value={db} key={db}>
|
||||||
|
{db}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<nav className="flex flex-col gap-1 mt-4">
|
||||||
|
{dbName && (
|
||||||
|
<Link
|
||||||
|
to={"/db/$dbName/tables"}
|
||||||
|
params={{ dbName }}
|
||||||
|
activeOptions={{ exact: true }}
|
||||||
|
title={dbName}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded py-1.5 pl-1.5",
|
||||||
|
"hover:bg-muted",
|
||||||
|
"[&.active]:bg-muted [&.active]:font-semibold",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Database className={"size-4"} />
|
||||||
|
<span className={"max-w-full inline-block truncate"}>{dbName}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{tables?.map((table) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={table.table_name}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-2.5 rounded py-1.5 justify-between w-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className={cn(
|
||||||
|
"max-w-full inline-block truncate",
|
||||||
|
"hover:underline",
|
||||||
|
"[&.active]:font-medium",
|
||||||
|
)}
|
||||||
|
title={table.table_name}
|
||||||
|
to={"/db/$dbName/tables/$tableName"}
|
||||||
|
params={{ tableName: table.table_name, dbName: dbName }}
|
||||||
|
>
|
||||||
|
{table.table_name}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={cn(
|
||||||
|
"shrink-0",
|
||||||
|
"hover:underline",
|
||||||
|
buttonVariants({ variant: "ghost", size: "iconSm" }),
|
||||||
|
"[&.active]:bg-muted",
|
||||||
|
)}
|
||||||
|
title={"Explore Data"}
|
||||||
|
aria-label={"Explore Data"}
|
||||||
|
to={"/db/$dbName/tables/$tableName/data"}
|
||||||
|
params={{ tableName: table.table_name, dbName: dbName }}
|
||||||
|
search={{ pageIndex: 0, pageSize: 10 }}
|
||||||
|
>
|
||||||
|
<Rows3 className={"size-4 shrink-0"} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContainer({ children }: PropsWithChildren) {
|
||||||
|
return <aside className={"p-3"}>{children}</aside>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
return (
|
||||||
|
<SidebarContainer>
|
||||||
|
<SidebarContent />
|
||||||
|
</SidebarContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/ui/form/form-input.tsx
Normal file
32
frontend/src/components/ui/form/form-input.tsx
Normal file
@@ -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<T extends FieldValues> = Omit<
|
||||||
|
UseControllerProps<T>,
|
||||||
|
"control" | "defaultValue" | "rules"
|
||||||
|
> &
|
||||||
|
Omit<InputProps, "value" | "onChange"> & { control: Control<T> };
|
||||||
|
|
||||||
|
export const FormInput = <T extends FieldValues>({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
shouldUnregister,
|
||||||
|
...rest
|
||||||
|
}: Props<T>) => {
|
||||||
|
const {
|
||||||
|
field,
|
||||||
|
fieldState: { error },
|
||||||
|
} = useController({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
shouldUnregister,
|
||||||
|
});
|
||||||
|
return <Input errorMessage={error?.message} {...field} {...rest} />;
|
||||||
|
};
|
||||||
34
frontend/src/components/ui/form/form-select.tsx
Normal file
34
frontend/src/components/ui/form/form-select.tsx
Normal file
@@ -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<T extends FieldValues> = Omit<
|
||||||
|
UseControllerProps<T>,
|
||||||
|
"control" | "defaultValue" | "rules"
|
||||||
|
> &
|
||||||
|
Omit<ComponentPropsWithoutRef<typeof Select>, "value" | "onValueChange"> & {
|
||||||
|
control: Control<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormSelect = <T extends FieldValues>({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
shouldUnregister,
|
||||||
|
...rest
|
||||||
|
}: Props<T>) => {
|
||||||
|
const {
|
||||||
|
field: { onChange, ...field },
|
||||||
|
} = useController({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
shouldUnregister,
|
||||||
|
});
|
||||||
|
return <Select {...field} {...rest} onValueChange={onChange} />;
|
||||||
|
};
|
||||||
2
frontend/src/components/ui/form/index.ts
Normal file
2
frontend/src/components/ui/form/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./form-select";
|
||||||
|
export * from "./form-input";
|
||||||
@@ -4,6 +4,7 @@ export * from "./data-table-pagination";
|
|||||||
export * from "./dialog";
|
export * from "./dialog";
|
||||||
export * from "./dropdown-menu";
|
export * from "./dropdown-menu";
|
||||||
export * from "./input";
|
export * from "./input";
|
||||||
|
export * from "./form";
|
||||||
export * from "./label";
|
export * from "./label";
|
||||||
export * from "./mode-toggle";
|
export * from "./mode-toggle";
|
||||||
export * from "./select";
|
export * from "./select";
|
||||||
@@ -14,5 +15,6 @@ export * from "./sql-data-table-cell";
|
|||||||
export * from "./switch";
|
export * from "./switch";
|
||||||
export * from "./table";
|
export * from "./table";
|
||||||
export * from "./tabs";
|
export * from "./tabs";
|
||||||
|
export * from "./tooltip";
|
||||||
export * from "./toggle";
|
export * from "./toggle";
|
||||||
export * from "./toggle-group";
|
export * from "./toggle-group";
|
||||||
|
|||||||
@@ -1,24 +1,57 @@
|
|||||||
import * as React from "react";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useAutoId } from "@/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
type ChangeEvent,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
forwardRef,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
label?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RawInput = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
(
|
||||||
|
{ className, label, id, onValueChange, onChange, errorMessage, ...props },
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const finalId = useAutoId(id);
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange?.(e);
|
||||||
|
onValueChange?.(e.target.value);
|
||||||
|
},
|
||||||
|
[onValueChange, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ className, ...props }, ref) => {
|
|
||||||
return (
|
return (
|
||||||
<input
|
<div className={"grid gap-1.5"}>
|
||||||
className={cn(
|
{!!label && <Label htmlFor={finalId}>{label}</Label>}
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
<input
|
||||||
className,
|
onChange={handleChange}
|
||||||
|
id={finalId}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{!!errorMessage && (
|
||||||
|
<p className="text-red-500 text-xs">{errorMessage}</p>
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
</div>
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Input.displayName = "Input";
|
RawInput.displayName = "Input";
|
||||||
|
|
||||||
|
const Input = memo(RawInput);
|
||||||
|
Input.displayName = "MemoizedInput";
|
||||||
|
|
||||||
export { Input };
|
export { Input };
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const SelectTrigger = forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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 [&>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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
|
|
||||||
const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
|
const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div className="relative w-full overflow-auto">
|
<div className="relative w-full overflow-auto rounded-md">
|
||||||
<table
|
<table
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
@@ -23,7 +23,11 @@ const TableHeader = forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
HTMLAttributes<HTMLTableSectionElement>
|
HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
<thead
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr]:border-b [&_tr]:bg-background", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
TableHeader.displayName = "TableHeader";
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
@@ -61,7 +65,7 @@ const TableRow = forwardRef<
|
|||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
"border-b transition-colors data-[state=selected]:bg-muted",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
58
frontend/src/components/ui/tooltip.tsx
Normal file
58
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const Tooltip = ({
|
||||||
|
children,
|
||||||
|
content,
|
||||||
|
open,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
delayDuration = 100,
|
||||||
|
disableHoverableContent,
|
||||||
|
...props
|
||||||
|
}: Omit<
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> &
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Root>,
|
||||||
|
"content"
|
||||||
|
> & {
|
||||||
|
content: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider>
|
||||||
|
<TooltipPrimitive.Root
|
||||||
|
open={open}
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
disableHoverableContent={disableHoverableContent}
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||||
|
<TooltipContent {...props}>{content}</TooltipContent>
|
||||||
|
</TooltipPrimitive.Root>
|
||||||
|
</TooltipPrimitive.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
1
frontend/src/hooks/index.ts
Normal file
1
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./use-auto-id";
|
||||||
6
frontend/src/hooks/use-auto-id.ts
Normal file
6
frontend/src/hooks/use-auto-id.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useId } from 'react'
|
||||||
|
|
||||||
|
export const useAutoId = (id?: string) => {
|
||||||
|
const generatedId = useId()
|
||||||
|
return id ?? generatedId
|
||||||
|
}
|
||||||
@@ -75,11 +75,11 @@
|
|||||||
text-underline-position: under;
|
text-underline-position: under;
|
||||||
--sidebar-width: 264px;
|
--sidebar-width: 264px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-closed {
|
.sidebar-closed {
|
||||||
--sidebar-width: 0;
|
--sidebar-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-rows-layout {
|
.grid-rows-layout {
|
||||||
grid-template-rows: 60px 1fr;
|
grid-template-rows: 60px 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,11 @@
|
|||||||
import { SessionSelector } from "@/components/session-selector";
|
|
||||||
import { SettingsDialog } from "@/components/settings-dialog";
|
import { SettingsDialog } from "@/components/settings-dialog";
|
||||||
import {
|
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||||
Button,
|
import { Button, ModeToggle } from "@/components/ui";
|
||||||
ModeToggle,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
buttonVariants,
|
|
||||||
} from "@/components/ui";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useDatabasesListQuery, useTablesListQuery } from "@/services/db";
|
|
||||||
import { useUiStore } from "@/state";
|
import { useUiStore } from "@/state";
|
||||||
import {
|
import { Link, Outlet, createRootRoute } from "@tanstack/react-router";
|
||||||
Link,
|
|
||||||
Outlet,
|
|
||||||
createRootRoute,
|
|
||||||
useNavigate,
|
|
||||||
useParams,
|
|
||||||
} from "@tanstack/react-router";
|
|
||||||
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
||||||
import { Database, PanelLeft, PanelLeftClose, Rows3 } from "lucide-react";
|
import { PanelLeft, PanelLeftClose } from "lucide-react";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: Root,
|
component: Root,
|
||||||
@@ -31,16 +15,6 @@ function Root() {
|
|||||||
const showSidebar = useUiStore.use.showSidebar();
|
const showSidebar = useUiStore.use.showSidebar();
|
||||||
const toggleSidebar = useUiStore.use.toggleSidebar();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -70,86 +44,7 @@ function Root() {
|
|||||||
<SettingsDialog />
|
<SettingsDialog />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<Sidebar />
|
||||||
<aside className={"p-3"}>
|
|
||||||
{showSidebar && (
|
|
||||||
<>
|
|
||||||
<SessionSelector />
|
|
||||||
<Select value={dbName} onValueChange={handleSelectedDb}>
|
|
||||||
<SelectTrigger className="w-full mt-4">
|
|
||||||
<SelectValue placeholder="Select a Database" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{data?.map((db) => {
|
|
||||||
return (
|
|
||||||
<SelectItem value={db} key={db}>
|
|
||||||
{db}
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<nav className="flex flex-col gap-1 mt-4">
|
|
||||||
{dbName && (
|
|
||||||
<Link
|
|
||||||
to={"/db/$dbName/tables"}
|
|
||||||
params={{ dbName }}
|
|
||||||
activeOptions={{ exact: true }}
|
|
||||||
title={dbName}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 rounded py-1.5 pl-1.5",
|
|
||||||
"hover:bg-muted",
|
|
||||||
"[&.active]:bg-muted [&.active]:font-semibold",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Database className={"size-4"} />
|
|
||||||
<span className={"max-w-full inline-block truncate"}>
|
|
||||||
{dbName}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{tables?.map((table) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={table.table_name}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-2.5 rounded py-1.5 justify-between w-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
className={cn(
|
|
||||||
"max-w-full inline-block truncate",
|
|
||||||
"hover:underline",
|
|
||||||
"[&.active]:font-medium",
|
|
||||||
)}
|
|
||||||
title={table.table_name}
|
|
||||||
to={"/db/$dbName/tables/$tableName"}
|
|
||||||
params={{ tableName: table.table_name, dbName: dbName }}
|
|
||||||
>
|
|
||||||
{table.table_name}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
className={cn(
|
|
||||||
"shrink-0",
|
|
||||||
"hover:underline",
|
|
||||||
buttonVariants({ variant: "ghost", size: "iconSm" }),
|
|
||||||
"[&.active]:bg-muted",
|
|
||||||
)}
|
|
||||||
title={"Explore Data"}
|
|
||||||
aria-label={"Explore Data"}
|
|
||||||
to={"/db/$dbName/tables/$tableName/data"}
|
|
||||||
params={{ tableName: table.table_name, dbName: dbName }}
|
|
||||||
search={{ pageIndex: 0, pageSize: 10 }}
|
|
||||||
>
|
|
||||||
<Rows3 className={"size-4 shrink-0"} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
<TanStackRouterDevtools />
|
<TanStackRouterDevtools />
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Input,
|
FormInput,
|
||||||
|
FormSelect,
|
||||||
Label,
|
Label,
|
||||||
Select,
|
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
@@ -16,30 +16,122 @@ import {
|
|||||||
ToggleGroup,
|
ToggleGroup,
|
||||||
ToggleGroupItem,
|
ToggleGroupItem,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { useLoginMutation } from "@/services/db";
|
import { type LoginArgs, useLoginMutation } from "@/services/db";
|
||||||
import { useSessionStore } from "@/state/db-session-store";
|
import { useSessionStore } from "@/state/db-session-store";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { type FormEventHandler, useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { type Control, useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/login")({
|
export const Route = createFileRoute("/auth/login")({
|
||||||
component: LoginForm,
|
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<LoginWithConnectionStringFields>({
|
||||||
|
resolver: zodResolver(loginWithConnectionStringSchema),
|
||||||
|
defaultValues: {
|
||||||
|
type: "postgres",
|
||||||
|
connectionString: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2">
|
<form
|
||||||
<Label htmlFor="dbType">Database type</Label>
|
className="grid gap-2"
|
||||||
<Select defaultValue={"postgres"} name={"type"}>
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
<SelectTrigger className="w-full" id={"dbType"}>
|
id={"login-form"}
|
||||||
<SelectValue />
|
>
|
||||||
</SelectTrigger>
|
<DatabaseTypeSelector control={control} />
|
||||||
<SelectContent>
|
<FormInput
|
||||||
<SelectItem value="postgres">Postgres</SelectItem>
|
label={"Connection string"}
|
||||||
<SelectItem value="mysql">MySQL</SelectItem>
|
name={"connectionString"}
|
||||||
</SelectContent>
|
control={control}
|
||||||
</Select>
|
placeholder={"postgres://postgres:postgres@localhost:5432/postgres"}
|
||||||
</div>
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LoginWithConnectionFields>({
|
||||||
|
resolver: zodResolver(loginWithConnectionFieldsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
type: "postgres",
|
||||||
|
host: "",
|
||||||
|
port: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
ssl: "prefer",
|
||||||
|
database: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="grid gap-3"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
id={"login-form"}
|
||||||
|
>
|
||||||
|
<DatabaseTypeSelector control={control} />
|
||||||
|
<FormInput
|
||||||
|
name={"host"}
|
||||||
|
control={control}
|
||||||
|
label={"Host"}
|
||||||
|
placeholder={"127.0.0.1"}
|
||||||
|
/>
|
||||||
|
<FormInput name={"port"} control={control} label={"Port"} />
|
||||||
|
<FormInput name={"username"} control={control} label={"User"} />
|
||||||
|
<FormInput name={"password"} control={control} label={"Password"} />
|
||||||
|
<FormInput name={"database"} control={control} label={"Database"} />
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="ssl">SSL mode</Label>
|
||||||
|
<FormSelect control={control} name={"ssl"}>
|
||||||
|
<SelectTrigger className="w-full" id={"ssl"}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="false">false</SelectItem>
|
||||||
|
<SelectItem value="true">true</SelectItem>
|
||||||
|
<SelectItem value="require">require</SelectItem>
|
||||||
|
<SelectItem value="allow">allow</SelectItem>
|
||||||
|
<SelectItem value="prefer">prefer</SelectItem>
|
||||||
|
<SelectItem value="verify-full">verify-full</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</FormSelect>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,78 +141,9 @@ function LoginForm() {
|
|||||||
const { mutateAsync } = useLoginMutation();
|
const { mutateAsync } = useLoginMutation();
|
||||||
const addSession = useSessionStore.use.addSession();
|
const addSession = useSessionStore.use.addSession();
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
const onSubmit = async (args: LoginArgs) => {
|
||||||
e.preventDefault();
|
await mutateAsync(args);
|
||||||
const formData = new FormData(e.currentTarget);
|
addSession(args);
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,11 +156,7 @@ function LoginForm() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form
|
<div className="grid gap-4">
|
||||||
className="grid gap-4"
|
|
||||||
id={"login-form"}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
className="w-full border gap-0.5 rounded-md"
|
className="w-full border gap-0.5 rounded-md"
|
||||||
@@ -158,85 +177,11 @@ function LoginForm() {
|
|||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
{connectionMethod === "fields" ? (
|
{connectionMethod === "fields" ? (
|
||||||
<>
|
<ConnectionFieldsForm onSubmit={onSubmit} />
|
||||||
<DatabaseTypeSelector />
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="host">Host</Label>
|
|
||||||
<Input
|
|
||||||
id="host"
|
|
||||||
name="host"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder={"127.0.0.1"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="port">Port</Label>
|
|
||||||
<Input
|
|
||||||
id="port"
|
|
||||||
name="port"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
defaultValue={"5432"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="username">User</Label>
|
|
||||||
<Input id="username" name="username" type="text" required />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
name="password"
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
placeholder={"********"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="database">Database</Label>
|
|
||||||
<Input
|
|
||||||
name="database"
|
|
||||||
id="database"
|
|
||||||
type="text"
|
|
||||||
defaultValue={"postgres"}
|
|
||||||
placeholder={"postgres"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="ssl">SSL mode</Label>
|
|
||||||
<Select defaultValue={"false"} name={"ssl"}>
|
|
||||||
<SelectTrigger className="w-full" id={"ssl"}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="false">false</SelectItem>
|
|
||||||
<SelectItem value="true">true</SelectItem>
|
|
||||||
<SelectItem value="require">require</SelectItem>
|
|
||||||
<SelectItem value="allow">allow</SelectItem>
|
|
||||||
<SelectItem value="prefer">prefer</SelectItem>
|
|
||||||
<SelectItem value="verify-full">verify-full</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2">
|
<ConnectionStringForm onSubmit={onSubmit} />
|
||||||
<DatabaseTypeSelector />
|
|
||||||
<Label htmlFor="connectionString">Connection string</Label>
|
|
||||||
<Input
|
|
||||||
name="connectionString"
|
|
||||||
id="connectionString"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder={
|
|
||||||
"postgres://postgres:postgres@localhost:5432/postgres"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button className="w-full" form={"login-form"}>
|
<Button className="w-full" form={"login-form"}>
|
||||||
@@ -247,3 +192,24 @@ function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DatabaseTypeSelector({
|
||||||
|
control,
|
||||||
|
}: {
|
||||||
|
control: Control<any>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="dbType">Database type</Label>
|
||||||
|
<FormSelect control={control} name={"type"}>
|
||||||
|
<SelectTrigger className="w-full" id={"dbType"}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="postgres">Postgres</SelectItem>
|
||||||
|
<SelectItem value="mysql">MySQL</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</FormSelect>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import type {
|
|||||||
TableForeignKeys,
|
TableForeignKeys,
|
||||||
TableIndexes,
|
TableIndexes,
|
||||||
} from "@/services/db/db.types";
|
} from "@/services/db/db.types";
|
||||||
|
import { HTTPError } from "ky";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
class DbService {
|
class DbService {
|
||||||
login(data: LoginArgs) {
|
login(data: LoginArgs) {
|
||||||
@@ -44,12 +46,29 @@ class DbService {
|
|||||||
perPage,
|
perPage,
|
||||||
sortField,
|
sortField,
|
||||||
sortDesc,
|
sortDesc,
|
||||||
|
whereQuery,
|
||||||
}: GetTableDataArgs) {
|
}: GetTableDataArgs) {
|
||||||
return dbInstance
|
return dbInstance
|
||||||
.get(`api/databases/${dbName}/tables/${tableName}/data`, {
|
.get(`api/databases/${dbName}/tables/${tableName}/data`, {
|
||||||
searchParams: getValuable({ perPage, page, sortField, sortDesc }),
|
searchParams: getValuable({
|
||||||
|
perPage,
|
||||||
|
page,
|
||||||
|
sortField,
|
||||||
|
sortDesc,
|
||||||
|
whereQuery,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.json<GetTableDataResponse>();
|
.json<GetTableDataResponse>()
|
||||||
|
.catch(async (error) => {
|
||||||
|
if (error instanceof HTTPError) {
|
||||||
|
const errorJson = await error.response.json();
|
||||||
|
console.log(errorJson);
|
||||||
|
toast.error(errorJson.message);
|
||||||
|
} else {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
return { count: 0, data: [] };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getTableColumns({ dbName, tableName }: GetTableColumnsArgs) {
|
getTableColumns({ dbName, tableName }: GetTableColumnsArgs) {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type GetTableDataArgs = {
|
|||||||
page?: number;
|
page?: number;
|
||||||
sortField?: string;
|
sortField?: string;
|
||||||
sortDesc?: boolean;
|
sortDesc?: boolean;
|
||||||
|
whereQuery?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetTableDataResponse = {
|
export type GetTableDataResponse = {
|
||||||
|
|||||||
Reference in New Issue
Block a user