revert fb8e77bb68
Local Build and Deploy / deploy (push) Successful in 1m11s
Local Build and Deploy / deploy (push) Successful in 1m11s
revert style: clean up code with prettier and eslint
This commit is contained in:
1 parent
27de078f54
commit
e85e4d74ea
378 files changed
+20710
-22428
No files matched your search
+141
-111
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -11,164 +11,181 @@ import {
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
} from 'lucide-react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useEffect, useRef, useState, useTransition } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { PER_PAGE_OPTIONS } from '@/lib/admin-helpers'
|
||||
import type { DataTableColumn, PaginatedResult } from '@/types'
|
||||
|
||||
/**
|
||||
* One-click filter preset. The chip applies/clears the listed query-string
|
||||
* keys at once; the preset is "active" when every key matches the URL state.
|
||||
*/
|
||||
export interface DataTablePreset {
|
||||
label: string;
|
||||
params: Record<string, string>;
|
||||
label: string
|
||||
params: Record<string, string>
|
||||
}
|
||||
|
||||
interface DataTableProps<T extends { id: number | string }> {
|
||||
data: PaginatedResult<T>;
|
||||
columns: DataTableColumn<T>[];
|
||||
searchPlaceholder?: string;
|
||||
bulkActions?: { label: string; value: string; variant?: "destructive" | "default" }[];
|
||||
onBulkAction?: (action: string, ids: (number | string)[]) => Promise<void>;
|
||||
exportUrl?: string;
|
||||
presets?: DataTablePreset[];
|
||||
data: PaginatedResult<T>
|
||||
columns: DataTableColumn<T>[]
|
||||
searchPlaceholder?: string
|
||||
bulkActions?: { label: string; value: string; variant?: 'destructive' | 'default' }[]
|
||||
onBulkAction?: (action: string, ids: (number | string)[]) => Promise<void>
|
||||
exportUrl?: string
|
||||
presets?: DataTablePreset[]
|
||||
}
|
||||
|
||||
export function DataTable<T extends { id: number | string }>({
|
||||
data,
|
||||
columns,
|
||||
searchPlaceholder = "Search...",
|
||||
searchPlaceholder = 'Search...',
|
||||
bulkActions,
|
||||
onBulkAction,
|
||||
exportUrl,
|
||||
presets,
|
||||
}: DataTableProps<T>) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(new Set());
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get("search") || "");
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(new Set())
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get('search') || '')
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set())
|
||||
|
||||
const currentSort = searchParams.get("sort") || "";
|
||||
const currentOrder = searchParams.get("order") || "desc";
|
||||
const currentSort = searchParams.get('sort') || ''
|
||||
const currentOrder = searchParams.get('order') || 'desc'
|
||||
|
||||
const debouncedSearch = useDebounce(searchValue, 300);
|
||||
const isFirstRender = useRef(true);
|
||||
const debouncedSearch = useDebounce(searchValue, 300)
|
||||
const isFirstRender = useRef(true)
|
||||
|
||||
// Collect active filters from URL
|
||||
const filterColumns = columns.filter((col) => col.filterOptions && col.filterOptions.length > 0);
|
||||
const activeFilters: Record<string, string> = {};
|
||||
const filterColumns = columns.filter((col) => col.filterOptions && col.filterOptions.length > 0)
|
||||
const activeFilters: Record<string, string> = {}
|
||||
for (const col of filterColumns) {
|
||||
const key = col.filterKey || `filter_${col.key}`;
|
||||
const val = searchParams.get(key);
|
||||
if (val) activeFilters[key] = val;
|
||||
const key = col.filterKey || `filter_${col.key}`
|
||||
const val = searchParams.get(key)
|
||||
if (val) activeFilters[key] = val
|
||||
}
|
||||
const activeFilterCount = Object.keys(activeFilters).length;
|
||||
const activeFilterCount = Object.keys(activeFilters).length
|
||||
|
||||
function updateParams(updates: Record<string, string>) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
if (value) params.set(key, value)
|
||||
else params.delete(key)
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
});
|
||||
router.push(`?${params.toString()}`)
|
||||
})
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
isFirstRender.current = false
|
||||
return
|
||||
}
|
||||
updateParams({ search: debouncedSearch, page: "1" });
|
||||
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
updateParams({ search: debouncedSearch, page: '1' })
|
||||
}, [debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleSearch(value: string) {
|
||||
setSearchValue(value);
|
||||
setSearchValue(value)
|
||||
}
|
||||
|
||||
function handleSort(key: string) {
|
||||
const newOrder = currentSort === key && currentOrder === "asc" ? "desc" : "asc";
|
||||
updateParams({ sort: key, order: newOrder });
|
||||
const newOrder = currentSort === key && currentOrder === 'asc' ? 'desc' : 'asc'
|
||||
updateParams({ sort: key, order: newOrder })
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
updateParams({ page: String(page) });
|
||||
updateParams({ page: String(page) })
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: string) {
|
||||
updateParams({ perPage, page: "1" });
|
||||
updateParams({ perPage, page: '1' })
|
||||
}
|
||||
|
||||
function handleFilterChange(col: DataTableColumn<T>, value: string) {
|
||||
const key = col.filterKey || `filter_${col.key}`;
|
||||
updateParams({ [key]: value === "__all__" ? "" : value, page: "1" });
|
||||
const key = col.filterKey || `filter_${col.key}`
|
||||
updateParams({ [key]: value === '__all__' ? '' : value, page: '1' })
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
const updates: Record<string, string> = { page: "1" };
|
||||
const updates: Record<string, string> = { page: '1' }
|
||||
for (const col of filterColumns) {
|
||||
const key = col.filterKey || `filter_${col.key}`;
|
||||
updates[key] = "";
|
||||
const key = col.filterKey || `filter_${col.key}`
|
||||
updates[key] = ''
|
||||
}
|
||||
updateParams(updates);
|
||||
updateParams(updates)
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selectedIds.size === data.rows.length) {
|
||||
setSelectedIds(new Set());
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(data.rows.map((r) => r.id)));
|
||||
setSelectedIds(new Set(data.rows.map((r) => r.id)))
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id: number | string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleBulkAction(action: string) {
|
||||
if (selectedIds.size === 0 || !onBulkAction) return;
|
||||
await onBulkAction(action, Array.from(selectedIds));
|
||||
setSelectedIds(new Set());
|
||||
router.refresh();
|
||||
if (selectedIds.size === 0 || !onBulkAction) return
|
||||
await onBulkAction(action, Array.from(selectedIds))
|
||||
setSelectedIds(new Set())
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
function getSortIcon(key: string) {
|
||||
if (currentSort !== key) return <ArrowUpDown className="h-3 w-3" />;
|
||||
return currentOrder === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />;
|
||||
if (currentSort !== key) return <ArrowUpDown className="h-3 w-3" />
|
||||
return currentOrder === 'asc' ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
)
|
||||
}
|
||||
|
||||
function isPresetActive(preset: DataTablePreset): boolean {
|
||||
return Object.entries(preset.params).every(([k, v]) => (searchParams.get(k) ?? "") === v);
|
||||
return Object.entries(preset.params).every(([k, v]) => (searchParams.get(k) ?? '') === v)
|
||||
}
|
||||
|
||||
function applyPreset(preset: DataTablePreset) {
|
||||
// Apply (or toggle off) the preset by overwriting only its keys + reset page.
|
||||
const updates: Record<string, string> = { page: "1" };
|
||||
const updates: Record<string, string> = { page: '1' }
|
||||
if (isPresetActive(preset)) {
|
||||
for (const k of Object.keys(preset.params)) updates[k] = "";
|
||||
for (const k of Object.keys(preset.params)) updates[k] = ''
|
||||
} else {
|
||||
Object.assign(updates, preset.params);
|
||||
Object.assign(updates, preset.params)
|
||||
}
|
||||
updateParams(updates);
|
||||
updateParams(updates)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -177,7 +194,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
{presets && presets.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{presets.map((preset) => {
|
||||
const active = isPresetActive(preset);
|
||||
const active = isPresetActive(preset)
|
||||
return (
|
||||
<button
|
||||
key={preset.label}
|
||||
@@ -185,14 +202,14 @@ export function DataTable<T extends { id: number | string }>({
|
||||
onClick={() => applyPreset(preset)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background hover:bg-accent"
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-background hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
{active && <X className="h-3 w-3" />}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -231,18 +248,26 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">Filters</h4>
|
||||
{activeFilterCount > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={clearAllFilters}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{filterColumns.map((col) => {
|
||||
const key = col.filterKey || `filter_${col.key}`;
|
||||
const currentValue = searchParams.get(key) || "__all__";
|
||||
const key = col.filterKey || `filter_${col.key}`
|
||||
const currentValue = searchParams.get(key) || '__all__'
|
||||
return (
|
||||
<div key={col.key} className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{col.label}</span>
|
||||
<Select value={currentValue} onValueChange={(v) => handleFilterChange(col, v)}>
|
||||
<Select
|
||||
value={currentValue}
|
||||
onValueChange={(v) => handleFilterChange(col, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -256,7 +281,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -267,22 +292,22 @@ export function DataTable<T extends { id: number | string }>({
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{filterColumns.map((col) => {
|
||||
const key = col.filterKey || `filter_${col.key}`;
|
||||
const val = searchParams.get(key);
|
||||
if (!val) return null;
|
||||
const opt = col.filterOptions!.find((o) => o.value === val);
|
||||
const key = col.filterKey || `filter_${col.key}`
|
||||
const val = searchParams.get(key)
|
||||
if (!val) return null
|
||||
const opt = col.filterOptions!.find((o) => o.value === val)
|
||||
return (
|
||||
<Badge key={key} variant="secondary" className="gap-1 pr-1">
|
||||
{col.label}: {opt?.label || val}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFilterChange(col, "__all__")}
|
||||
onClick={() => handleFilterChange(col, '__all__')}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-accent"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -299,16 +324,19 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium mb-2">Toggle columns</h4>
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} className="flex items-center gap-2 py-1 text-sm hover:text-foreground">
|
||||
<div
|
||||
key={col.key}
|
||||
className="flex items-center gap-2 py-1 text-sm hover:text-foreground"
|
||||
>
|
||||
<Checkbox
|
||||
checked={!hiddenColumns.has(col.key)}
|
||||
onCheckedChange={(checked) => {
|
||||
setHiddenColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.delete(col.key);
|
||||
else next.add(col.key);
|
||||
return next;
|
||||
});
|
||||
const next = new Set(prev)
|
||||
if (checked) next.delete(col.key)
|
||||
else next.add(col.key)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{col.label}
|
||||
@@ -346,7 +374,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
{bulkActions.map((action) => (
|
||||
<Button
|
||||
key={action.value}
|
||||
variant={action.variant || "default"}
|
||||
variant={action.variant || 'default'}
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction(action.value)}
|
||||
>
|
||||
@@ -396,7 +424,9 @@ export function DataTable<T extends { id: number | string }>({
|
||||
{data.rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.filter((c) => !hiddenColumns.has(c.key)).length + (bulkActions ? 1 : 0)}
|
||||
colSpan={
|
||||
columns.filter((c) => !hiddenColumns.has(c.key)).length + (bulkActions ? 1 : 0)
|
||||
}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No results found.
|
||||
@@ -406,7 +436,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
data.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={isPending ? "opacity-50" : undefined}
|
||||
className={isPending ? 'opacity-50' : undefined}
|
||||
data-selected={selectedIds.has(row.id) || undefined}
|
||||
>
|
||||
{bulkActions && (
|
||||
@@ -423,7 +453,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<TableCell key={col.key}>
|
||||
{col.render
|
||||
? col.render((row as Record<string, unknown>)[col.key] as T[keyof T], row)
|
||||
: String((row as Record<string, unknown>)[col.key] ?? "")}
|
||||
: String((row as Record<string, unknown>)[col.key] ?? '')}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -449,26 +479,26 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{Array.from({ length: Math.min(data.lastPage, 7) }, (_, i) => {
|
||||
let pageNum: number;
|
||||
let pageNum: number
|
||||
if (data.lastPage <= 7) {
|
||||
pageNum = i + 1;
|
||||
pageNum = i + 1
|
||||
} else if (data.page <= 4) {
|
||||
pageNum = i + 1;
|
||||
pageNum = i + 1
|
||||
} else if (data.page >= data.lastPage - 3) {
|
||||
pageNum = data.lastPage - 6 + i;
|
||||
pageNum = data.lastPage - 6 + i
|
||||
} else {
|
||||
pageNum = data.page - 3 + i;
|
||||
pageNum = data.page - 3 + i
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={pageNum === data.page ? "default" : "outline"}
|
||||
variant={pageNum === data.page ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -482,5 +512,5 @@ export function DataTable<T extends { id: number | string }>({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user