diff --git a/frontend/src/components/db-table-view/columns-dropdown.tsx b/frontend/src/components/db-table-view/columns-dropdown.tsx new file mode 100644 index 0000000..9b64861 --- /dev/null +++ b/frontend/src/components/db-table-view/columns-dropdown.tsx @@ -0,0 +1,41 @@ +import { + Button, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui"; +import type { Table } from "@tanstack/react-table"; + +export function ColumnsDropdown({ + table, +}: { + table: Table; +}) { + return ( + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + e.preventDefault()} + checked={column.getIsVisible()} + onCheckedChange={column.toggleVisibility} + > + {column.id} + + ); + })} + + + ); +} diff --git a/frontend/src/components/db-table-view/columns.tsx b/frontend/src/components/db-table-view/columns.tsx new file mode 100644 index 0000000..8a7ef85 --- /dev/null +++ b/frontend/src/components/db-table-view/columns.tsx @@ -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[] => { + if (!columns) return [] as ColumnDef[]; + + return columns.map(({ column_name, udt_name, data_type }) => ({ + accessorKey: column_name, + title: column_name, + size: 300, + header: () => { + return ( +
+ {`${column_name} [${udt_name.toUpperCase()}]`} +
+ ); + }, + 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 ( + +
+ {value} + {isImage && ( + {"preview"} + )} +
+
+ ); + } + return ( +
+ {finalValue} +
+ ); + }, + })) as ColumnDef[]; +}; + +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]); +}; diff --git a/frontend/src/components/db-table-view/data-table.tsx b/frontend/src/components/db-table-view/data-table.tsx index b2407d0..a9c3c97 100644 --- a/frontend/src/components/db-table-view/data-table.tsx +++ b/frontend/src/components/db-table-view/data-table.tsx @@ -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([]); const [columnVisibility, setColumnVisibility] = useState({}); - 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 = (args) => { if (typeof args === "function") { const newArgs = args({ @@ -77,74 +79,6 @@ export const DataTable = ({ } }; - const columns = useMemo(() => { - if (!details) return [] as ColumnDef[]; - - return details.map(({ column_name, udt_name, data_type }) => ({ - accessorKey: column_name, - title: column_name, - size: 300, - header: () => { - return ( -
- {`${column_name} [${udt_name.toUpperCase()}]`} -
- ); - }, - 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 ( - -
- {value} - {isImage && ( - {"preview"} - )} -
-
- ); - } - return ( -
- {finalValue} -
- ); - }, - })) as ColumnDef[]; - }, [details, formatDates, showImagesPreview]); - const table = useReactTable({ data: data?.data ?? [], columns, @@ -171,62 +105,9 @@ export const DataTable = ({

{tableName} -
{ - if (data.whereQuery === whereQuery) { - void refetch(); - return; - } - setWhereQuery(data.whereQuery); - })} - > - - - - Your input will be prefixed with WHERE and appended to the raw - SQL query. -
You can use AND, OR, NOT, BETWEEN, LIKE, =, etc.
-
To remove the WHERE clause, submit an empty input.
-

- } - > - - - + - - - - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - e.preventDefault()} - checked={column.getIsVisible()} - onCheckedChange={column.toggleVisibility} - > - {column.id} - - ); - })} - - +

Rows: {data?.count}

diff --git a/frontend/src/components/db-table-view/where-clause-form.tsx b/frontend/src/components/db-table-view/where-clause-form.tsx new file mode 100644 index 0000000..9883b2d --- /dev/null +++ b/frontend/src/components/db-table-view/where-clause-form.tsx @@ -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({ + defaultValues: { + whereClause: "", + }, + }); + + return ( +
+ + + + Your input will be prefixed with WHERE and appended to the raw SQL + query. +
You can use AND, OR, NOT, BETWEEN, LIKE, =, etc.
+
To remove the WHERE clause, submit an empty input.
+ + } + > + +
+ + ); +} diff --git a/frontend/src/hooks/use-filters.ts b/frontend/src/hooks/use-filters.ts new file mode 100644 index 0000000..06acbcc --- /dev/null +++ b/frontend/src/hooks/use-filters.ts @@ -0,0 +1,23 @@ +import { cleanEmptyParams } from "@/lib/utils"; +import { + type RegisteredRouter, + type RouteIds, + getRouteApi, + useNavigate, +} from "@tanstack/react-router"; + +export function useFilters>( + routeId: T, +) { + const routeApi = getRouteApi(routeId); + const navigate = useNavigate(); + const filters = routeApi.useSearch(); + + const setFilters = (partialFilters: Partial) => + navigate({ + search: (prev) => cleanEmptyParams({ ...prev, ...partialFilters }), + }); + const resetFilters = () => navigate({ search: {} }); + + return { filters, setFilters, resetFilters }; +} diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts new file mode 100644 index 0000000..9aca9a7 --- /dev/null +++ b/frontend/src/lib/constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_PAGE_INDEX = 0; +export const DEFAULT_PAGE_SIZE = 10; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 27c565d..7ba021b 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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 = >( + 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; +};