mirror of
https://github.com/ershisan99/db-studio.git
synced 2025-12-16 05:09:26 +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,
|
||||
TableView,
|
||||
} from "@/components/db-table-view/table";
|
||||
import { DataTablePagination } from "@/components/ui";
|
||||
import { useTableDataQuery } from "@/services/db";
|
||||
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 PaginationState,
|
||||
type SortingState,
|
||||
@@ -24,9 +12,14 @@ import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Info, Rows3, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Rows3 } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useColumns } from "./columns";
|
||||
import { ColumnsDropdown } from "./columns-dropdown";
|
||||
import {
|
||||
WhereClauseForm,
|
||||
type WhereClauseFormValues,
|
||||
} from "./where-clause-form";
|
||||
|
||||
export const DataTable = ({
|
||||
tableName,
|
||||
@@ -43,14 +36,11 @@ export const DataTable = ({
|
||||
onPageIndexChange: (pageIndex: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
}) => {
|
||||
const whereQueryForm = useForm<{ whereQuery: string }>();
|
||||
const formatDates = useSettingsStore.use.formatDates();
|
||||
const showImagesPreview = useSettingsStore.use.showImagesPreview();
|
||||
const columns = useColumns({ dbName, tableName });
|
||||
const [whereQuery, setWhereQuery] = useState("");
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
const { data: details } = useTableColumnsQuery({ dbName, tableName });
|
||||
const { data, refetch } = useTableDataQuery({
|
||||
whereQuery,
|
||||
tableName,
|
||||
@@ -60,6 +50,18 @@ export const DataTable = ({
|
||||
sortDesc: sorting[0]?.desc,
|
||||
sortField: sorting[0]?.id,
|
||||
});
|
||||
|
||||
const handleWhereClauseFormSubmit = useCallback(
|
||||
({ whereClause }: WhereClauseFormValues) => {
|
||||
if (whereClause === whereQuery) {
|
||||
void refetch();
|
||||
return;
|
||||
}
|
||||
setWhereQuery(whereClause);
|
||||
},
|
||||
[whereQuery, refetch],
|
||||
);
|
||||
|
||||
const paginationUpdater: OnChangeFn<PaginationState> = (args) => {
|
||||
if (typeof args === "function") {
|
||||
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({
|
||||
data: data?.data ?? [],
|
||||
columns,
|
||||
@@ -171,62 +105,9 @@ export const DataTable = ({
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<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>
|
||||
<WhereClauseForm onSubmit={handleWhereClauseFormSubmit} />
|
||||
</h1>
|
||||
<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>
|
||||
<ColumnsDropdown table={table} />
|
||||
<p>
|
||||
Rows: <strong>{data?.count}</strong>
|
||||
</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 { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
@@ -42,3 +43,29 @@ const imageUrlRegex = new RegExp(
|
||||
export function isImageUrl(value: string) {
|
||||
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