mirror of
https://github.com/ershisan99/db-studio.git
synced 2025-12-16 12:33:05 +00:00
move things around
This commit is contained in:
41
frontend/src/components/db-table-view/columns-dropdown.tsx
Normal file
41
frontend/src/components/db-table-view/columns-dropdown.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import type { Table } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
export function ColumnsDropdown<T>({
|
||||||
|
table,
|
||||||
|
}: {
|
||||||
|
table: Table<T>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="ml-auto">
|
||||||
|
Columns
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={column.toggleVisibility}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
frontend/src/components/db-table-view/columns.tsx
Normal file
99
frontend/src/components/db-table-view/columns.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { cn, isImageUrl, isUrl } from "@/lib/utils";
|
||||||
|
import { type TableColumns, useTableColumnsQuery } from "@/services/db";
|
||||||
|
import { useSettingsStore } from "@/state";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
const buildColumns = ({
|
||||||
|
columns,
|
||||||
|
formatDates,
|
||||||
|
showImagesPreview,
|
||||||
|
}: {
|
||||||
|
columns?: TableColumns;
|
||||||
|
|
||||||
|
formatDates: boolean;
|
||||||
|
showImagesPreview: boolean;
|
||||||
|
}): ColumnDef<any>[] => {
|
||||||
|
if (!columns) return [] as ColumnDef<any>[];
|
||||||
|
|
||||||
|
return columns.map(({ column_name, udt_name, data_type }) => ({
|
||||||
|
accessorKey: column_name,
|
||||||
|
title: column_name,
|
||||||
|
size: 300,
|
||||||
|
header: () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap ",
|
||||||
|
data_type === "integer" && "text-right",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{`${column_name} [${udt_name.toUpperCase()}]`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const value = row.getValue(column_name) as any;
|
||||||
|
let finalValue = value;
|
||||||
|
if (
|
||||||
|
formatDates &&
|
||||||
|
["timestamp", "datetime"].includes(udt_name.toLowerCase())
|
||||||
|
) {
|
||||||
|
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",
|
||||||
|
["integer", "int", "tinyint", "double"].includes(data_type) &&
|
||||||
|
"text-right",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{finalValue}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})) as ColumnDef<any>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useColumns = ({
|
||||||
|
dbName,
|
||||||
|
tableName,
|
||||||
|
}: { dbName: string; tableName: string }) => {
|
||||||
|
const { data: details } = useTableColumnsQuery({ dbName, tableName });
|
||||||
|
const formatDates = useSettingsStore.use.formatDates();
|
||||||
|
const showImagesPreview = useSettingsStore.use.showImagesPreview();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return buildColumns({
|
||||||
|
columns: details,
|
||||||
|
formatDates,
|
||||||
|
showImagesPreview,
|
||||||
|
});
|
||||||
|
}, [details, formatDates, showImagesPreview]);
|
||||||
|
};
|
||||||
@@ -2,21 +2,9 @@ import {
|
|||||||
TableScrollContainer,
|
TableScrollContainer,
|
||||||
TableView,
|
TableView,
|
||||||
} from "@/components/db-table-view/table";
|
} from "@/components/db-table-view/table";
|
||||||
|
import { DataTablePagination } from "@/components/ui";
|
||||||
|
import { useTableDataQuery } from "@/services/db";
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
DataTablePagination,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
FormInput,
|
|
||||||
Tooltip,
|
|
||||||
} from "@/components/ui";
|
|
||||||
import { cn, isImageUrl, isUrl } from "@/lib/utils";
|
|
||||||
import { useTableColumnsQuery, useTableDataQuery } from "@/services/db";
|
|
||||||
import { useSettingsStore } from "@/state";
|
|
||||||
import {
|
|
||||||
type ColumnDef,
|
|
||||||
type OnChangeFn,
|
type OnChangeFn,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
@@ -24,9 +12,14 @@ import {
|
|||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { Info, Rows3, Search } from "lucide-react";
|
import { Rows3 } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useColumns } from "./columns";
|
||||||
|
import { ColumnsDropdown } from "./columns-dropdown";
|
||||||
|
import {
|
||||||
|
WhereClauseForm,
|
||||||
|
type WhereClauseFormValues,
|
||||||
|
} from "./where-clause-form";
|
||||||
|
|
||||||
export const DataTable = ({
|
export const DataTable = ({
|
||||||
tableName,
|
tableName,
|
||||||
@@ -43,14 +36,11 @@ 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 columns = useColumns({ dbName, tableName });
|
||||||
const formatDates = useSettingsStore.use.formatDates();
|
|
||||||
const showImagesPreview = useSettingsStore.use.showImagesPreview();
|
|
||||||
const [whereQuery, setWhereQuery] = useState("");
|
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, refetch } = useTableDataQuery({
|
const { data, refetch } = useTableDataQuery({
|
||||||
whereQuery,
|
whereQuery,
|
||||||
tableName,
|
tableName,
|
||||||
@@ -60,6 +50,18 @@ export const DataTable = ({
|
|||||||
sortDesc: sorting[0]?.desc,
|
sortDesc: sorting[0]?.desc,
|
||||||
sortField: sorting[0]?.id,
|
sortField: sorting[0]?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleWhereClauseFormSubmit = useCallback(
|
||||||
|
({ whereClause }: WhereClauseFormValues) => {
|
||||||
|
if (whereClause === whereQuery) {
|
||||||
|
void refetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWhereQuery(whereClause);
|
||||||
|
},
|
||||||
|
[whereQuery, refetch],
|
||||||
|
);
|
||||||
|
|
||||||
const paginationUpdater: OnChangeFn<PaginationState> = (args) => {
|
const paginationUpdater: OnChangeFn<PaginationState> = (args) => {
|
||||||
if (typeof args === "function") {
|
if (typeof args === "function") {
|
||||||
const newArgs = args({
|
const newArgs = args({
|
||||||
@@ -77,74 +79,6 @@ export const DataTable = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
if (!details) return [] as ColumnDef<any>[];
|
|
||||||
|
|
||||||
return details.map(({ column_name, udt_name, data_type }) => ({
|
|
||||||
accessorKey: column_name,
|
|
||||||
title: column_name,
|
|
||||||
size: 300,
|
|
||||||
header: () => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"whitespace-nowrap ",
|
|
||||||
data_type === "integer" && "text-right",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{`${column_name} [${udt_name.toUpperCase()}]`}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
enableSorting: true,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = row.getValue(column_name) as any;
|
|
||||||
let finalValue = value;
|
|
||||||
if (
|
|
||||||
formatDates &&
|
|
||||||
["timestamp", "datetime"].includes(udt_name.toLowerCase())
|
|
||||||
) {
|
|
||||||
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",
|
|
||||||
["integer", "int", "tinyint", "double"].includes(data_type) &&
|
|
||||||
"text-right",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{finalValue}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})) as ColumnDef<any>[];
|
|
||||||
}, [details, formatDates, showImagesPreview]);
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: data?.data ?? [],
|
data: data?.data ?? [],
|
||||||
columns,
|
columns,
|
||||||
@@ -171,62 +105,9 @@ export const DataTable = ({
|
|||||||
<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
|
<WhereClauseForm onSubmit={handleWhereClauseFormSubmit} />
|
||||||
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>
|
<ColumnsDropdown table={table} />
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" className="ml-auto">
|
|
||||||
Columns
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter((column) => column.getCanHide())
|
|
||||||
.map((column) => {
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={column.id}
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
checked={column.getIsVisible()}
|
|
||||||
onCheckedChange={column.toggleVisibility}
|
|
||||||
>
|
|
||||||
{column.id}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<p>
|
<p>
|
||||||
Rows: <strong>{data?.count}</strong>
|
Rows: <strong>{data?.count}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
44
frontend/src/components/db-table-view/where-clause-form.tsx
Normal file
44
frontend/src/components/db-table-view/where-clause-form.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Button, FormInput, Tooltip } from "@/components/ui";
|
||||||
|
import { Info, Search } from "lucide-react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
export interface WhereClauseFormValues {
|
||||||
|
whereClause: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhereClauseForm({
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
onSubmit: (values: WhereClauseFormValues) => void;
|
||||||
|
}) {
|
||||||
|
const { control, handleSubmit } = useForm<WhereClauseFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
whereClause: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex gap-2 "
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
id={"where-clause-form"}
|
||||||
|
>
|
||||||
|
<FormInput name={"whereClause"} control={control} />
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/hooks/use-filters.ts
Normal file
23
frontend/src/hooks/use-filters.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { cleanEmptyParams } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
type RegisteredRouter,
|
||||||
|
type RouteIds,
|
||||||
|
getRouteApi,
|
||||||
|
useNavigate,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export function useFilters<T extends RouteIds<RegisteredRouter["routeTree"]>>(
|
||||||
|
routeId: T,
|
||||||
|
) {
|
||||||
|
const routeApi = getRouteApi<T>(routeId);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const filters = routeApi.useSearch();
|
||||||
|
|
||||||
|
const setFilters = (partialFilters: Partial<typeof filters>) =>
|
||||||
|
navigate({
|
||||||
|
search: (prev) => cleanEmptyParams({ ...prev, ...partialFilters }),
|
||||||
|
});
|
||||||
|
const resetFilters = () => navigate({ search: {} });
|
||||||
|
|
||||||
|
return { filters, setFilters, resetFilters };
|
||||||
|
}
|
||||||
2
frontend/src/lib/constants.ts
Normal file
2
frontend/src/lib/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const DEFAULT_PAGE_INDEX = 0;
|
||||||
|
export const DEFAULT_PAGE_SIZE = 10;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from "@/lib/constants";
|
||||||
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";
|
import { z } from "zod";
|
||||||
@@ -42,3 +43,29 @@ const imageUrlRegex = new RegExp(
|
|||||||
export function isImageUrl(value: string) {
|
export function isImageUrl(value: string) {
|
||||||
return value.match(imageUrlRegex);
|
return value.match(imageUrlRegex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const cleanEmptyParams = <T extends Record<string, unknown>>(
|
||||||
|
search: T,
|
||||||
|
) => {
|
||||||
|
const newSearch = { ...search };
|
||||||
|
for (const key of Object.keys(search)) {
|
||||||
|
const value = newSearch[key];
|
||||||
|
if (
|
||||||
|
value === undefined ||
|
||||||
|
value === "" ||
|
||||||
|
(typeof value === "number" && Number.isNaN(value))
|
||||||
|
)
|
||||||
|
delete newSearch[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search.pageIndex === DEFAULT_PAGE_INDEX) {
|
||||||
|
// @ts-ignore
|
||||||
|
newSearch.pageIndex = undefined;
|
||||||
|
}
|
||||||
|
if (search.pageSize === DEFAULT_PAGE_SIZE) {
|
||||||
|
// @ts-ignore
|
||||||
|
newSearch.pageSize = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSearch;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user