revamp template
This commit is contained in:
67
src/components/data-grid/DataGrid.tsx
Normal file
67
src/components/data-grid/DataGrid.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { DataGridInner, DataGridProvider } from '.';
|
||||
import {
|
||||
ColumnFiltersState,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
Table,
|
||||
TableOptions
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
export type TDataGridLayoutCellSpacing = 'xs' | 'md' | 'sm' | 'lg';
|
||||
|
||||
export type TDataGridSelectedRowIds = Set<string>;
|
||||
|
||||
export type TDataGridRequestParams = {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
sorting?: SortingState;
|
||||
columnFilters?: ColumnFiltersState;
|
||||
};
|
||||
|
||||
export interface TDataGridProps<TData extends object> {
|
||||
columns: any[];
|
||||
data?: TData[];
|
||||
rowSelection?: boolean;
|
||||
getRowId?: TableOptions<TData>['getRowId'];
|
||||
onRowSelectionChange?: (state: RowSelectionState, table?: Table<TData>) => void;
|
||||
messages?: {
|
||||
loading?: ReactNode | string;
|
||||
empty?: ReactNode | string;
|
||||
};
|
||||
layout?: {
|
||||
cellSpacing?: TDataGridLayoutCellSpacing;
|
||||
cellBorder?: boolean;
|
||||
card?: boolean;
|
||||
classes?: {
|
||||
table?: '';
|
||||
container?: '';
|
||||
root?: '';
|
||||
};
|
||||
};
|
||||
pagination?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sizes?: number[];
|
||||
sizesInfo?: string;
|
||||
sizesLabel?: string;
|
||||
sizesDescription?: string;
|
||||
more?: boolean;
|
||||
moreLimit?: number;
|
||||
info?: string;
|
||||
};
|
||||
sorting?: { id: string; desc?: boolean }[];
|
||||
toolbar?: ReactNode;
|
||||
filters?: { id: string; value: unknown }[];
|
||||
serverSide?: boolean;
|
||||
onFetchData?: (params: TDataGridRequestParams) => Promise<any>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const DataGrid = <TData extends object>(props: TDataGridProps<TData>) => {
|
||||
return (
|
||||
<DataGridProvider {...props}>
|
||||
<DataGridInner />
|
||||
</DataGridProvider>
|
||||
);
|
||||
};
|
||||
132
src/components/data-grid/DataGridColumnFilter.tsx
Normal file
132
src/components/data-grid/DataGridColumnFilter.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import * as React from 'react';
|
||||
import { Check, CirclePlus } from 'lucide-react';
|
||||
import { Column } from '@tanstack/react-table';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
interface IDataGridColumnFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>;
|
||||
title?: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function DataGridColumnFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options
|
||||
}: IDataGridColumnFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues();
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="light" size="sm">
|
||||
<CirclePlus className="size-4" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden">
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value);
|
||||
} else {
|
||||
selectedValues.add(option.value);
|
||||
}
|
||||
const filterValues = Array.from(selectedValues);
|
||||
column?.setFilterValue(filterValues.length ? filterValues : undefined);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'me-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<Check className={cn('h-4 w-4')} />
|
||||
</div>
|
||||
{option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => column?.setFilterValue(undefined)}
|
||||
className="justify-center text-center"
|
||||
>
|
||||
Clear filters
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
130
src/components/data-grid/DataGridColumnHeader.tsx
Normal file
130
src/components/data-grid/DataGridColumnHeader.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { HTMLAttributes, ReactNode } from 'react';
|
||||
import { ChevronsUpDown, ArrowUp, ArrowDown, EyeOff, Check } from 'lucide-react';
|
||||
import { Column } from '@tanstack/react-table';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface IDataGridColumnHeader<TData, TValue> extends HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>;
|
||||
title?: string;
|
||||
filter?: ReactNode;
|
||||
show?: string;
|
||||
}
|
||||
|
||||
export function DataGridColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title = '',
|
||||
className,
|
||||
filter,
|
||||
show = 'true'
|
||||
}: IDataGridColumnHeader<TData, TValue>) {
|
||||
if (show == 'true') {
|
||||
if (!filter && !column.getCanSort() && !column.getCanHide()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
|
||||
if (!filter && !column.getCanHide() && column.getCanSort()) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('-ms-3 h-8 data-[state=open]:bg-accent !ring-0 !ring-offset-0', className)}
|
||||
onClick={() => {
|
||||
// Determine the current sorting state
|
||||
const isSorted = column.getIsSorted();
|
||||
if (isSorted === 'asc') {
|
||||
column.toggleSorting(true); // Switch to desc
|
||||
} else if (isSorted === 'desc') {
|
||||
column.clearSorting(); // Clear to unsorted
|
||||
} else {
|
||||
column.toggleSorting(false); // Switch to asc
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDown className="!size-[0.825rem]" />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUp className="!size-[0.825rem]" />
|
||||
) : (
|
||||
<ChevronsUpDown className="!size-[0.825rem]" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'-ms-3 h-8 data-[state=open]:bg-accent !ring-0 !ring-offset-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDown className="!size-[0.825rem]" />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUp className="!size-[0.825rem]" />
|
||||
) : (
|
||||
<ChevronsUpDown className="!size-[0.825rem]" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{filter && (
|
||||
<>
|
||||
<DropdownMenuLabel>{filter}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{column.getCanSort() && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUp className="!size-[0.825rem] text-muted-foreground/90" />
|
||||
<span className="grow">Asc</span>
|
||||
{column.getIsSorted() === 'asc' && (
|
||||
<Check className="size-4 text-muted-foreground/90" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDown className="!size-[0.825rem] text-muted-foreground/90" />
|
||||
<span className="grow">Desc</span>
|
||||
{column.getIsSorted() === 'desc' && (
|
||||
<Check className="size-4 text-muted-foreground/90" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{column.getCanHide() && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeOff className="!size-[0.825rem] text-muted-foreground/90" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
51
src/components/data-grid/DataGridColumnVisibility.tsx
Normal file
51
src/components/data-grid/DataGridColumnVisibility.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { Table } from '@tanstack/react-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { KeenIcon } from '@/components/keenicons';
|
||||
|
||||
interface IDataGridColumnVisibilityProps<TData> {
|
||||
table: Table<TData>;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function DataGridColumnVisibility<TData>({
|
||||
table,
|
||||
hideTitle = false
|
||||
}: IDataGridColumnVisibilityProps<TData>) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="light" size="sm">
|
||||
<KeenIcon icon="setting-4" />
|
||||
{!hideTitle && 'Columns'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[150px]">
|
||||
<DropdownMenuLabel className="font-medium">Toggle Columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.columnDef.meta?.headerTitle || column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
182
src/components/data-grid/DataGridContext.tsx
Normal file
182
src/components/data-grid/DataGridContext.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
ColumnFiltersState,
|
||||
VisibilityState,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
RowSelectionState,
|
||||
OnChangeFn,
|
||||
Table,
|
||||
SortingState
|
||||
} from '@tanstack/react-table';
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { DataGridInner } from './DataGridInner';
|
||||
import { TDataGridProps, TDataGridRequestParams } from './DataGrid';
|
||||
import { deepMerge, debounce } from '@/lib/helpers';
|
||||
|
||||
export interface IDataGridContextProps<TData extends object> {
|
||||
props: TDataGridProps<TData>;
|
||||
table: Table<TData>;
|
||||
totalRows: number;
|
||||
loading: boolean;
|
||||
setLoading: (state: boolean) => void;
|
||||
reload: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DataGridContext = createContext<IDataGridContextProps<any> | undefined>(undefined);
|
||||
|
||||
export const useDataGrid = () => {
|
||||
const context = useContext(DataGridContext);
|
||||
if (!context) {
|
||||
throw new Error('useDataGrid must be used within a DataGridProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const DataGridProvider = <TData extends object>(props: TDataGridProps<TData>) => {
|
||||
const defaultValues: Partial<TDataGridProps<TData>> = {
|
||||
messages: {
|
||||
empty: 'No data available',
|
||||
loading: 'Loading...'
|
||||
},
|
||||
layout: {
|
||||
cellSpacing: 'xs',
|
||||
cellBorder: true,
|
||||
card: false
|
||||
},
|
||||
pagination: {
|
||||
info: '{from} - {to} of {count}',
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesLabel: 'Show',
|
||||
sizesDescription: 'per page',
|
||||
size: 5,
|
||||
page: 0,
|
||||
moreLimit: 5,
|
||||
more: false
|
||||
},
|
||||
rowSelection: false,
|
||||
serverSide: false
|
||||
};
|
||||
|
||||
const mergedProps = deepMerge(defaultValues, props);
|
||||
|
||||
const [data, setData] = useState<TData[]>(mergedProps.data || []);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [totalRows, setTotalRows] = useState<number>(
|
||||
mergedProps.data ? mergedProps.data.length : 0
|
||||
);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: props.pagination?.page ?? 0,
|
||||
pageSize: props.pagination?.size ?? 5
|
||||
});
|
||||
const [rowSelection, setRowSelection] = useState(mergedProps.rowSelection);
|
||||
const [sorting, setSorting] = useState<SortingState>(mergedProps.sorting ?? []);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
const fetchServerSideData = useCallback(async () => {
|
||||
if (loading || !mergedProps.onFetchData) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const requestParams: TDataGridRequestParams = {
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
sorting,
|
||||
columnFilters
|
||||
};
|
||||
|
||||
const { data, totalCount } = await mergedProps.onFetchData(requestParams);
|
||||
|
||||
setData(data || []);
|
||||
setTotalRows(totalCount || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loading, pagination, sorting, columnFilters, mergedProps.onFetchData]);
|
||||
|
||||
const debouncedFetchData = debounce(fetchServerSideData, 100);
|
||||
|
||||
const loadData = () => {
|
||||
if (mergedProps.serverSide) {
|
||||
debouncedFetchData();
|
||||
} else {
|
||||
setLoading(true); // Show loading bar for local data
|
||||
setData(mergedProps.data || []);
|
||||
setTotalRows(mergedProps.data ? mergedProps.data.length : 0);
|
||||
setLoading(false); // Hide loading bar after data is set
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger debounced fetch for server-side data; load local data if serverSide is false
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [pagination, sorting, columnFilters, mergedProps.data, mergedProps.serverSide]);
|
||||
|
||||
const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updaterOrValue) => {
|
||||
setRowSelection((prev: RowSelectionState) =>
|
||||
typeof updaterOrValue === 'function' ? updaterOrValue(prev) : updaterOrValue
|
||||
);
|
||||
|
||||
if (mergedProps.onRowSelectionChange) {
|
||||
const newSelection =
|
||||
typeof updaterOrValue === 'function' ? updaterOrValue(rowSelection) : updaterOrValue;
|
||||
mergedProps.onRowSelectionChange(newSelection, table);
|
||||
}
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: mergedProps.columns,
|
||||
pageCount: mergedProps.serverSide ? Math.ceil(totalRows / pagination.pageSize) : undefined,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination
|
||||
},
|
||||
getRowId: mergedProps.getRowId || ((row, index) => String(index)),
|
||||
enableRowSelection: mergedProps.rowSelection,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
onSortingChange: (newSorting) => !loading && setSorting(newSorting),
|
||||
onColumnFiltersChange: (newFilters) => !loading && setColumnFilters(newFilters),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: (newPagination) => !loading && setPagination(newPagination),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
manualPagination: mergedProps.serverSide,
|
||||
manualSorting: mergedProps.serverSide,
|
||||
manualFiltering: mergedProps.serverSide
|
||||
});
|
||||
|
||||
return (
|
||||
<DataGridContext.Provider
|
||||
value={{
|
||||
props: mergedProps,
|
||||
table,
|
||||
totalRows,
|
||||
loading,
|
||||
setLoading,
|
||||
reload: loadData
|
||||
}}
|
||||
>
|
||||
{props.children ? props.children : <DataGridInner />}
|
||||
</DataGridContext.Provider>
|
||||
);
|
||||
};
|
||||
16
src/components/data-grid/DataGridEmpty.tsx
Normal file
16
src/components/data-grid/DataGridEmpty.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { useDataGrid } from '.';
|
||||
|
||||
const DataGridEmpty = () => {
|
||||
const { table, props } = useDataGrid();
|
||||
const totalColumns = table.getAllColumns().length + (props.rowSelection ? 1 : 0);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={totalColumns} className="text-center text-muted-foreground py-6">
|
||||
{props.messages?.empty || 'No data available'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export { DataGridEmpty };
|
||||
41
src/components/data-grid/DataGridInner.tsx
Normal file
41
src/components/data-grid/DataGridInner.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDataGrid, DataGridLoader, DataGridTable, DataGridPagination } from '.';
|
||||
|
||||
const DataGridInner = () => {
|
||||
const { props, table, loading } = useDataGrid();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid',
|
||||
props.layout?.card &&
|
||||
`
|
||||
card
|
||||
[&>[data-container]]:border-x-0
|
||||
[&>[data-container]]:rounded-none
|
||||
[&>[data-container]>[data-table]>thead>tr>th:first-child]:px-5
|
||||
[&>[data-container]>[data-table]>tbody>tr>td:first-child]:px-5
|
||||
[&>[data-toolbar]]:p-5
|
||||
[&>[data-pagination]]:px-5
|
||||
[&>[data-pagination]]:py-3
|
||||
`,
|
||||
props.layout?.classes?.root
|
||||
)}
|
||||
>
|
||||
{props.toolbar && props.toolbar}
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full scrollable-x-auto border rounded-md',
|
||||
props.layout?.classes?.container
|
||||
)}
|
||||
data-container
|
||||
>
|
||||
<DataGridTable />
|
||||
{loading && <DataGridLoader />}
|
||||
</div>
|
||||
{table.getRowModel().rows.length > 0 && <DataGridPagination />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { DataGridInner };
|
||||
41
src/components/data-grid/DataGridInnerCard.tsx
Normal file
41
src/components/data-grid/DataGridInnerCard.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDataGrid, DataGridLoader, DataGridTable, DataGridPagination } from '.';
|
||||
|
||||
const DataGridInnerCard = () => {
|
||||
const { props, table, loading } = useDataGrid();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid',
|
||||
props.layout?.card &&
|
||||
`
|
||||
card border-0 shadow-none
|
||||
[&>[data-container]]:border-x-0
|
||||
[&>[data-container]]:rounded-none
|
||||
[&>[data-container]>[data-table]>thead>tr>th:first-child]:px-0
|
||||
[&>[data-container]>[data-table]>tbody>tr>td:first-child]:px-0
|
||||
[&>[data-toolbar]]:p-0
|
||||
[&>[data-pagination]]:px-0
|
||||
[&>[data-pagination]]:py-2
|
||||
`,
|
||||
props.layout?.classes?.root
|
||||
)}
|
||||
>
|
||||
{props.toolbar && props.toolbar}
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full scrollable-x-auto border rounded-md',
|
||||
props.layout?.classes?.container
|
||||
)}
|
||||
data-container
|
||||
>
|
||||
<DataGridTable />
|
||||
{loading && <DataGridLoader />}
|
||||
</div>
|
||||
{table.getRowModel().rows.length > 0 && <DataGridPagination />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { DataGridInnerCard };
|
||||
34
src/components/data-grid/DataGridLoader.tsx
Normal file
34
src/components/data-grid/DataGridLoader.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { useDataGrid } from '.';
|
||||
|
||||
export const DataGridLoader = () => {
|
||||
const { props } = useDataGrid();
|
||||
|
||||
return (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="text-muted-foreground bg-card flex items-center gap-2 px-4 py-2 font-medium leading-none text-sm border shadow-sm rounded-md">
|
||||
<svg
|
||||
className="animate-spin -ml-1 h-5 w-5 text-muted-foreground"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{props.messages?.loading}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
150
src/components/data-grid/DataGridPagination.tsx
Normal file
150
src/components/data-grid/DataGridPagination.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import { useDataGrid } from '.';
|
||||
import { ChevronRightIcon, ChevronLeftIcon } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DataGridPagination = () => {
|
||||
const { table, totalRows, props } = useDataGrid();
|
||||
const btnBaseClasses = 'size-7 p-0 text-[13px]';
|
||||
const btnArrowClasses = btnBaseClasses + ' rtl:transform rtl:rotate-180';
|
||||
const pageIndex = table.getState().pagination.pageIndex;
|
||||
const pageSize = table.getState().pagination.pageSize;
|
||||
const from = pageIndex * pageSize + 1;
|
||||
const to = Math.min((pageIndex + 1) * pageSize, totalRows);
|
||||
|
||||
// Replace placeholders in paginationInfo
|
||||
const paginationInfo = props.pagination?.info
|
||||
? props.pagination.info
|
||||
.replace('{from}', from.toString())
|
||||
.replace('{to}', to.toString())
|
||||
.replace('{count}', totalRows.toString())
|
||||
: `${from} - ${to} of ${totalRows}`;
|
||||
|
||||
// Pagination limit logic
|
||||
const pageCount = table.getPageCount();
|
||||
const paginationMoreLimit = props.pagination?.moreLimit || 5;
|
||||
|
||||
// Determine the start and end of the pagination group
|
||||
const currentGroupStart = Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit;
|
||||
const currentGroupEnd = Math.min(currentGroupStart + paginationMoreLimit, pageCount);
|
||||
|
||||
// Render page buttons based on the current group
|
||||
const renderPageButtons = () => {
|
||||
const buttons = [];
|
||||
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key={i}
|
||||
variant="ghost"
|
||||
className={cn(btnBaseClasses, 'text-muted-foreground', {
|
||||
'bg-accent text-accent-foreground': pageIndex === i
|
||||
})}
|
||||
onClick={() => table.setPageIndex(i)}
|
||||
>
|
||||
{i + 1}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return buttons;
|
||||
};
|
||||
|
||||
// Render a "previous" ellipsis button if there are previous pages to show
|
||||
const renderEllipsisPrevButton = () => {
|
||||
if (currentGroupStart > 0) {
|
||||
return (
|
||||
<Button
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
||||
>
|
||||
...
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null; // No ellipsis needed if we're in the first group
|
||||
};
|
||||
|
||||
// Render a "next" ellipsis button if there are more pages to show after the current group
|
||||
const renderEllipsisNextButton = () => {
|
||||
if (currentGroupEnd < pageCount) {
|
||||
return (
|
||||
<Button
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
||||
>
|
||||
...
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col md:flex-row justify-between items-center gap-5 md:gap-4"
|
||||
data-pagination
|
||||
>
|
||||
<div className="flex items-center space-x-2 order-2 md:order-1 pb-2 md:pb-0">
|
||||
<div className="text-sm text-muted-foreground">Rows per page</div>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[70px]" size="sm">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{props.pagination?.sizes?.map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 order-1 md:order-2 pt-2 md:pt-0">
|
||||
<div className="text-sm text-muted-foreground">{paginationInfo}</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
{renderEllipsisPrevButton()}
|
||||
|
||||
<>{renderPageButtons()}</>
|
||||
|
||||
{renderEllipsisNextButton()}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { DataGridPagination };
|
||||
20
src/components/data-grid/DataGridRowSelect.tsx
Normal file
20
src/components/data-grid/DataGridRowSelect.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Row } from '@tanstack/react-table';
|
||||
|
||||
export interface IDataGridRowSelectProps<TData> {
|
||||
row: Row<TData>;
|
||||
}
|
||||
|
||||
const DataGridRowSelect = <TData,>({ row }: IDataGridRowSelectProps<TData>) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
className="align-[inherit]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { DataGridRowSelect };
|
||||
20
src/components/data-grid/DataGridRowSelectAll.tsx
Normal file
20
src/components/data-grid/DataGridRowSelectAll.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useDataGrid } from '.';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
const DataGridRowSelectAll = () => {
|
||||
const { table } = useDataGrid();
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
className="align-[inherit]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { DataGridRowSelectAll };
|
||||
109
src/components/data-grid/DataGridTable.tsx
Normal file
109
src/components/data-grid/DataGridTable.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { DataGridEmpty, TDataGridLayoutCellSpacing } from '.';
|
||||
import { flexRender, HeaderGroup, Row, Cell } from '@tanstack/react-table';
|
||||
import { useDataGrid } from '.';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DataGridTableProps {
|
||||
show?: boolean; // Menambahkan properti 'show' untuk kontrol visibilitas header
|
||||
}
|
||||
|
||||
const DataGridTable = <TData,>({ show = true }: DataGridTableProps) => {
|
||||
const { table, props } = useDataGrid();
|
||||
const headCellSpacingOptions: Record<TDataGridLayoutCellSpacing, string> = {
|
||||
xs: 'px-2.5',
|
||||
sm: 'px-3',
|
||||
md: 'px-4',
|
||||
lg: 'px-6'
|
||||
};
|
||||
const bodyCellSpacingOptions: Record<TDataGridLayoutCellSpacing, string> = {
|
||||
xs: 'p-2.5',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6'
|
||||
};
|
||||
|
||||
const headCellSpacing = props.layout?.cellSpacing
|
||||
? headCellSpacingOptions[props.layout?.cellSpacing]
|
||||
: headCellSpacingOptions['md'];
|
||||
const bodyCellSpacing = props.layout?.cellSpacing
|
||||
? bodyCellSpacingOptions[props.layout?.cellSpacing]
|
||||
: bodyCellSpacingOptions['md'];
|
||||
const cellBorder = props.layout?.cellBorder ?? false;
|
||||
|
||||
return (
|
||||
<table
|
||||
className={cn(
|
||||
'w-full align-middle text-left rtl:text-right caption-bottom text-sm',
|
||||
props.layout?.classes?.table
|
||||
)}
|
||||
data-table
|
||||
>
|
||||
{/* Header tabel hanya dirender jika show=true */}
|
||||
{show && (
|
||||
<thead className="[&_tr]:border-b">
|
||||
{table.getHeaderGroups().map((headerGroup: HeaderGroup<TData>) => (
|
||||
<tr
|
||||
key={headerGroup.id}
|
||||
className={cn(
|
||||
'border-b bg-muted/30 data-[state=selected]:bg-muted',
|
||||
cellBorder && '[&_>:last-child]:border-e-0'
|
||||
)}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={cn(
|
||||
headCellSpacing,
|
||||
cellBorder && 'border-e',
|
||||
'h-12 text-left rtl:text-right align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0',
|
||||
header.column.columnDef.meta?.headerClassName
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
)}
|
||||
|
||||
{/* Body tabel */}
|
||||
<tbody className="[&_tr:last-child]:border-0">
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row: Row<TData>) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className={cn(
|
||||
'border-b hover:bg-muted/30 data-[state=selected]:bg-muted/50',
|
||||
cellBorder && '[&_>:last-child]:border-e-0'
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
bodyCellSpacing,
|
||||
cellBorder && 'border-e',
|
||||
'align-middle [&:has([role=checkbox])]:pe-0',
|
||||
cell.column.columnDef.meta?.cellClassName
|
||||
)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<DataGridEmpty />
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export { DataGridTable };
|
||||
17
src/components/data-grid/DataGridToolbar.tsx
Normal file
17
src/components/data-grid/DataGridToolbar.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface IDataGridToolbarProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DataGridToolbar = ({ children, className }: IDataGridToolbarProps) => {
|
||||
return (
|
||||
<div data-toolbar className={cn('flex items-center gap-2 justify-between', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { DataGridToolbar };
|
||||
14
src/components/data-grid/index.ts
Normal file
14
src/components/data-grid/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export * from './DataGrid';
|
||||
export * from './DataGridContext';
|
||||
export * from './DataGridInner';
|
||||
export * from './DataGridInnerCard';
|
||||
export * from './DataGridToolbar';
|
||||
export * from './DataGridTable';
|
||||
export * from './DataGridPagination';
|
||||
export * from './DataGridLoader';
|
||||
export * from './DataGridEmpty';
|
||||
export * from './DataGridColumnHeader';
|
||||
export * from './DataGridColumnFilter';
|
||||
export * from './DataGridColumnVisibility';
|
||||
export * from './DataGridRowSelectAll';
|
||||
export * from './DataGridRowSelect';
|
||||
10
src/components/data-grid/types.d.ts
vendored
Normal file
10
src/components/data-grid/types.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
import { RowData } from '@tanstack/react-table';
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
headerTitle?: string;
|
||||
headerClassName?: string;
|
||||
cellClassName?: string;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user