add raw query editor and response table

This commit is contained in:
2024-07-08 12:47:15 +02:00
parent 655984115b
commit 7b9f93448b
14 changed files with 354 additions and 33 deletions

View File

@@ -6,7 +6,10 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "recommended": true,
"suspicious": {
"noExplicitAny": "warn"
}
} }
}, },
"formatter": { "formatter": {

Binary file not shown.

View File

@@ -29,6 +29,7 @@
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8", "zod": "^3.23.8",

View File

@@ -12,7 +12,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui"; } from "@/components/ui";
import { cn } from "@/lib/utils"; import { cn, isImageUrl, isUrl } from "@/lib/utils";
import { useTableColumnsQuery, useTableDataQuery } from "@/services/db"; import { useTableColumnsQuery, useTableDataQuery } from "@/services/db";
import { useSettingsStore } from "@/state"; import { useSettingsStore } from "@/state";
import { import {
@@ -27,19 +27,6 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { ArrowUp, Rows3 } from "lucide-react"; import { ArrowUp, Rows3 } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { z } from "zod";
function isUrl(value: string) {
return z.string().url().safeParse(value).success;
}
const imageUrlRegex = new RegExp(
/(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|svg|webp|bmp))/i,
);
function isImageUrl(value: string) {
return value.match(imageUrlRegex);
}
export const DataTable = ({ export const DataTable = ({
tableName, tableName,

View File

@@ -6,5 +6,8 @@ export * from "./input";
export * from "./label"; export * from "./label";
export * from "./mode-toggle"; export * from "./mode-toggle";
export * from "./select"; export * from "./select";
export * from "./sonner";
export * from "./sql-data-table";
export * from "./sql-data-table-cell";
export * from "./switch"; export * from "./switch";
export * from "./table"; export * from "./table";

View File

@@ -0,0 +1,29 @@
import { useUiStore } from "@/state";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const theme = useUiStore.use.theme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,55 @@
import { cn, isImageUrl, isUrl } from "@/lib/utils";
import { useSettingsStore } from "@/state";
import type { Row } from "@tanstack/react-table";
import { memo } from "react";
type Props<T> = {
row: Row<T>;
column_name: string;
isDate?: boolean;
isNumber?: boolean;
};
function RawSqlDataTableCell<T>({
isNumber,
row,
column_name,
isDate,
}: Props<T>) {
const formatDates = useSettingsStore.use.formatDates();
const showImagesPreview = useSettingsStore.use.showImagesPreview();
const value = row.getValue(column_name) as any;
let finalValue = value;
if (formatDates && isDate) {
finalValue = new Date(value as string).toLocaleString();
}
if (showImagesPreview && typeof value === "string" && isUrl(value)) {
const isImage = isImageUrl(value);
return (
<a
href={value}
target={"_blank"}
className={cn("hover:underline")}
rel="noreferrer"
>
<div className={"flex items-center justify-between break-all gap-4"}>
{value}
{isImage && (
<img src={value} alt={"preview"} className="size-20 object-cover" />
)}
</div>
</a>
);
}
return (
<div className={cn("break-all", isNumber && "text-right")}>
{finalValue}
</div>
);
}
const SqlDataTableCell = memo(RawSqlDataTableCell);
SqlDataTableCell.displayName = "SqlDataTableCell";
export { SqlDataTableCell };

View File

@@ -0,0 +1,123 @@
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { flexRender } from "@tanstack/react-table";
import type { Table as TableType } from "@tanstack/react-table";
import { ArrowUp } from "lucide-react";
import { type ComponentPropsWithoutRef, memo } from "react";
type Props<TData> = {
table: TableType<TData>;
columnsLength: number;
} & ComponentPropsWithoutRef<typeof Table>;
function RawSqlDataTable<TData>({
table,
columnsLength,
style,
...rest
}: Props<TData>) {
return (
<Table
style={{
width: table.getCenterTotalSize(),
...style,
}}
{...rest}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const sorted = header.column.getIsSorted();
return (
<TableHead
className={"p-0 relative"}
key={header.id}
style={{
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>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columnsLength} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}
const SqlDataTable = memo(RawSqlDataTable);
SqlDataTable.displayName = "SqlDataTable";
export { SqlDataTable };

View File

@@ -1,24 +1,44 @@
import { type ClassValue, clsx } from 'clsx' import { type ClassValue, clsx } from "clsx";
import { twMerge } from 'tailwind-merge' import { twMerge } from "tailwind-merge";
import { z } from "zod";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
type Valuable<T> = { [K in keyof T as T[K] extends null | undefined ? never : K]: T[K] } type Valuable<T> = {
[K in keyof T as T[K] extends null | undefined ? never : K]: T[K];
};
export function getValuable<T extends object, V = Valuable<T>>(obj: T): V { export function getValuable<T extends object, V = Valuable<T>>(obj: T): V {
return Object.fromEntries( return Object.fromEntries(
Object.entries(obj).filter( Object.entries(obj).filter(
([, v]) => !((typeof v === 'string' && !v.length) || v === null || typeof v === 'undefined') ([, v]) =>
) !(
) as V (typeof v === "string" && !v.length) ||
v === null ||
typeof v === "undefined"
),
),
) as V;
} }
export function prettyBytes(bytes: number): string { export function prettyBytes(bytes: number): string {
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'] const units = ["B", "kB", "MB", "GB", "TB", "PB"];
if (bytes === 0) return '0 B' if (bytes === 0) return "0 B";
const i = Math.floor(Math.log(bytes) / Math.log(1024)) const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i) const size = bytes / 1024 ** i;
return `${size.toFixed(2)} ${units[i]}` return `${size.toFixed(2)} ${units[i]}`;
}
export function isUrl(value: string) {
return z.string().url().safeParse(value).success;
}
const imageUrlRegex = new RegExp(
/(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|svg|webp|bmp))/i,
);
export function isImageUrl(value: string) {
return value.match(imageUrlRegex);
} }

View File

@@ -8,6 +8,7 @@ import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css"; import "@fontsource/inter/700.css";
import "@fontsource/inter/800.css"; import "@fontsource/inter/800.css";
import "./index.css"; import "./index.css";
import { Toaster } from "@/components/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
// Import the generated route tree // Import the generated route tree
@@ -35,6 +36,7 @@ if (rootElement && !rootElement.innerHTML) {
initialIsOpen={false} initialIsOpen={false}
buttonPosition={"bottom-left"} buttonPosition={"bottom-left"}
/> />
<Toaster richColors />
<RouterProvider router={router} /> <RouterProvider router={router} />
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,

View File

@@ -1,16 +1,77 @@
import { Button, SqlDataTable, SqlDataTableCell } from "@/components/ui";
import { useQueryRawSqlMutation } from "@/services/db";
import { useUiStore } from "@/state";
import Editor from "@monaco-editor/react"; import Editor from "@monaco-editor/react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ArrowRight } from "lucide-react";
import { useMemo, useState } from "react";
export const Route = createFileRoute("/raw/")({ export const Route = createFileRoute("/raw/")({
component: Component, component: Component,
}); });
function Component() { function Component() {
const [query, setQuery] = useState<string | undefined>("SELECT * FROM");
const { mutate, data } = useQueryRawSqlMutation();
const appTheme = useUiStore.use.theme();
const themeToUse = appTheme === "light" ? "light" : "vs-dark";
const handleSubmit = () => {
if (!query) return;
mutate({ query });
};
const columns = useMemo<ColumnDef<any>[]>(() => {
if (!data) return [] as ColumnDef<any>[];
return Object.keys(data[0]).map((key) => ({
header: key,
accessorKey: key,
cell: ({ row }) => <SqlDataTableCell row={row} column_name={key} />,
})) as ColumnDef<any>[];
}, [data]);
const table = useReactTable({
data: data ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
});
return ( return (
<Editor <div className={"h-layout w-layout p-3"}>
height="90vh" <div className={"flex flex-col gap-4 flex-1 h-full max-h-full pb-3"}>
defaultLanguage="sql" <Editor
defaultValue="SELECT * FROM table" theme={themeToUse}
/> value={query}
onChange={setQuery}
className={"h-full border"}
height={"35%"}
language="sql"
options={{
tabFocusMode: true,
detectIndentation: false,
minimap: { enabled: false },
automaticLayout: true,
scrollBeyondLastLine: false,
}}
/>
<div className={"flex gap-4 items-center justify-between"}>
{data && <p>Result: {data.length} rows</p>}
<Button disabled={!query} onClick={handleSubmit} className={"gap-2"}>
Execute Query <ArrowRight className={"size-4"} />
</Button>
</div>
{data && (
<div className="rounded-md border min-h-0 h-full overflow-auto w-full min-w-0">
<SqlDataTable table={table} columnsLength={columns.length} />
</div>
)}
</div>
</div>
); );
} }

View File

@@ -1,4 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { HTTPError } from "ky";
import { toast } from "sonner";
import { DB_QUERY_KEYS } from "./db.query-keys"; import { DB_QUERY_KEYS } from "./db.query-keys";
import { dbService } from "./db.service"; import { dbService } from "./db.service";
import type { import type {
@@ -7,6 +9,7 @@ import type {
GetTableForeignKeysArgs, GetTableForeignKeysArgs,
GetTableIndexesArgs, GetTableIndexesArgs,
GetTablesListArgs, GetTablesListArgs,
QueryRawSqlArgs,
} from "./db.types"; } from "./db.types";
export const useDatabasesListQuery = () => { export const useDatabasesListQuery = () => {
@@ -75,3 +78,19 @@ export const useTableForeignKeysQuery = (args: GetTableForeignKeysArgs) => {
enabled: !!args.tableName && !!args.dbName, enabled: !!args.tableName && !!args.dbName,
}); });
}; };
export const useQueryRawSqlMutation = () => {
return useMutation({
onError: async (error) => {
if (error instanceof HTTPError) {
const errorJson = await error.response.json();
console.log(errorJson);
toast.error(errorJson.message);
return;
}
toast.error(error.message);
},
mutationFn: ({ query }: QueryRawSqlArgs) =>
dbService.queryRawSql({ query }),
});
};

View File

@@ -9,6 +9,8 @@ import type {
GetTableIndexesArgs, GetTableIndexesArgs,
GetTablesListArgs, GetTablesListArgs,
GetTablesListResponse, GetTablesListResponse,
QueryRawSqlArgs,
QueryRawSqlResponse,
TableColumns, TableColumns,
TableForeignKeys, TableForeignKeys,
TableIndexes, TableIndexes,
@@ -59,6 +61,14 @@ class DbService {
.get(`api/databases/${dbName}/tables/${tableName}/foreign-keys`) .get(`api/databases/${dbName}/tables/${tableName}/foreign-keys`)
.json<TableForeignKeys>(); .json<TableForeignKeys>();
} }
queryRawSql({ query }: QueryRawSqlArgs) {
return dbInstance
.post("api/raw", {
json: { query },
})
.json<QueryRawSqlResponse>();
}
} }
export const dbService = new DbService(); export const dbService = new DbService();

View File

@@ -69,6 +69,13 @@ export type GetTableForeignKeysArgs = {
tableName: string; tableName: string;
dbName: string; dbName: string;
}; };
export type QueryRawSqlArgs = {
query: string;
};
export type QueryRawSqlResponse = Array<Record<string, any>>;
export type TableForeignKey = { export type TableForeignKey = {
conname: string; conname: string;
deferrable: boolean; deferrable: boolean;
@@ -80,4 +87,5 @@ export type TableForeignKey = {
on_delete: string; on_delete: string;
on_update: string; on_update: string;
}; };
export type TableForeignKeys = TableForeignKey[]; export type TableForeignKeys = TableForeignKey[];