Merge branch 'refs/heads/maybe-rollback-to-remove-primeng'

# Conflicts:
#	frontend/bun.lockb
This commit is contained in:
2024-07-14 12:55:00 +02:00
24 changed files with 663 additions and 417 deletions

13
TODO.md Normal file
View 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)

View File

@@ -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>[];

View File

@@ -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();

View File

@@ -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.

View File

@@ -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",

View File

@@ -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>
); );

View File

@@ -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>

View 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>
);
}

View 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} />;
};

View 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} />;
};

View File

@@ -0,0 +1,2 @@
export * from "./form-select";
export * from "./form-input";

View File

@@ -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";

View File

@@ -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 };

View File

@@ -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}

View File

@@ -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}

View 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 };

View File

@@ -0,0 +1 @@
export * from "./use-auto-id";

View File

@@ -0,0 +1,6 @@
import { useId } from 'react'
export const useAutoId = (id?: string) => {
const generatedId = useId()
return id ?? generatedId
}

View File

@@ -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;
} }

View File

@@ -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 />

View File

@@ -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>
);
}

View File

@@ -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) {

View File

@@ -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 = {