Files
lti-web-client/src/components/Table.tsx
T

518 lines
16 KiB
TypeScript

'use client';
import { Fragment, ReactNode, useCallback, useEffect, useState } from 'react';
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getExpandedRowModel,
getSortedRowModel,
TableOptions,
useReactTable,
ColumnDef,
FilterFn,
SortingState,
OnChangeFn,
Row,
HeaderContext,
ExpandedState,
} from '@tanstack/react-table';
import { rankItem } from '@tanstack/match-sorter-utils';
import { Icon } from '@iconify/react';
import Pagination from '@/components/Pagination';
import { cn } from '@/lib/helper';
interface TableClassNames {
containerClassName?: string;
tableWrapperClassName?: string;
tableClassName?: string;
tableHeaderClassName?: string;
headerRowClassName?: string;
headerColumnClassName?: string;
tableBodyClassName?: string;
bodyRowClassName?: string;
selectedBodyRowClassName?: string;
bodyColumnClassName?: string;
bodySubRowClassName?: (depth: number) => string;
selectedBodySubRowClassName?: (depth: number) => string;
bodySubRowColumnClassName?: (depth: number) => string;
tableFooterClassName?: string;
footerRowClassName?: string;
footerColumnClassName?: string;
paginationClassName?: string;
}
export interface TableProps<TData extends object> {
data: TData[];
columns: ColumnDef<TData, unknown>[];
pageSize?: number;
onPageSizeChange?: (pageSize: number) => void;
totalItems?: number;
page?: number;
onPageChange?: (page: number) => void;
isLoading?: boolean;
fuzzySearchValue?: string | null;
onFuzzySearchValueChange?: (value: string) => void;
className?: TableClassNames;
emptyContent?: ReactNode;
sorting?: SortingState;
setSorting?: OnChangeFn<SortingState>;
manualSorting?: boolean;
rowSelection?: Record<string, boolean>;
setRowSelection?: OnChangeFn<Record<string, boolean>>;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
renderFooter?: boolean;
withCheckbox?: boolean;
withPagination?: boolean;
rowOptions?: number[];
/**
* Custom row renderer. Should return a complete <tr> element or null.
* This gives full control over the row structure including colspan.
* Return null to render the default row.
*/
renderCustomRow?: (row: Row<TData>) => ReactNode | null;
getRowCanExpand?: (row: Row<TData>) => boolean;
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
expanded?: ExpandedState;
getSubRows?: (originalRow: TData, index: number) => TData[] | undefined;
}
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
const emptyContentDefaultValue = (
<div className='w-full p-5 text-center'>
<span className='text-lg opacity-50'>
Tidak ada data yang dapat ditampilkan...
</span>
</div>
);
export const TABLE_DEFAULT_STYLING = {
containerClassName: 'w-full mb-20',
tableWrapperClassName:
'overflow-x-auto border border-solid border-base-content/10 rounded-lg',
tableClassName: 'font-inter w-full table-auto text-sm font-medium',
tableHeaderClassName: '',
headerRowClassName: '',
headerColumnClassName:
'px-4 py-3 border-base-content/10 text-base-content/50 text-sm font-medium',
tableBodyClassName: '',
bodyRowClassName:
'transition-all duration-200 border-t border-base-content/10 bg-transparent',
selectedBodyRowClassName: 'bg-primary/5',
bodyColumnClassName: 'px-4 py-3 text-base-content font-medium',
bodySubRowClassName: (depth: number) =>
'transition-all duration-200 border-t border-base-content/10 bg-transparent',
selectedBodySubRowClassName: (depth: number) => 'bg-primary/5',
bodySubRowColumnClassName: (depth: number) =>
'px-4 py-3 text-base-content font-medium',
paginationClassName: 'px-3',
tableFooterClassName: 'font-semibold border-base-content/10',
footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10',
footerColumnClassName: 'p-4 text-base-content whitespace-nowrap',
};
const Table = <TData extends object>({
data = [],
columns = [],
pageSize = 10,
onPageSizeChange,
totalItems,
page,
onPageChange,
isLoading = false,
fuzzySearchValue,
onFuzzySearchValueChange,
className = TABLE_DEFAULT_STYLING,
emptyContent = emptyContentDefaultValue,
sorting,
setSorting,
manualSorting = false,
rowSelection,
setRowSelection,
enableRowSelection,
renderFooter = false,
withCheckbox = false,
withPagination = true,
rowOptions = [10, 20, 50, 100],
renderCustomRow,
getRowCanExpand,
renderSubComponent,
expanded = {},
getSubRows,
}: TableProps<TData>) => {
const isServerSideTable =
totalItems !== undefined &&
page !== undefined &&
onPageChange !== undefined;
const tableClassNames = {
...TABLE_DEFAULT_STYLING,
...className,
};
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: pageSize,
});
const fuzzyFilter: FilterFn<TData> = useCallback(
(row, columnId, value, addMeta) => {
const itemRank = rankItem(row.getValue(columnId), value);
addMeta({ itemRank });
return itemRank.passed;
},
[]
);
const tableOptions: TableOptions<TData> = {
columns,
data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand: getRowCanExpand ?? (getSubRows ? undefined : () => false),
getSubRows,
manualSorting,
state: {
pagination,
globalFilter: fuzzySearchValue,
expanded,
},
filterFns: {
fuzzy: fuzzyFilter,
},
globalFilterFn: fuzzyFilter,
};
if (fuzzySearchValue !== null) {
tableOptions.onGlobalFilterChange = onFuzzySearchValueChange;
tableOptions.getFilteredRowModel = getFilteredRowModel();
}
if (sorting && setSorting) {
tableOptions.onSortingChange = setSorting;
tableOptions.state = {
...tableOptions.state,
sorting,
};
}
if (rowSelection && setRowSelection) {
tableOptions.onRowSelectionChange = setRowSelection;
tableOptions.state = {
...tableOptions.state,
rowSelection,
};
tableOptions.getRowId = (row) => (row as { id: string }).id;
}
if (enableRowSelection !== undefined) {
tableOptions.enableRowSelection = enableRowSelection;
}
const table = useReactTable(tableOptions);
const { setPageSize } = table;
const prevPageClickHandler = () => {
table.previousPage();
if (isServerSideTable) {
onPageChange(page - 1);
}
};
const nextPageClickHandler = () => {
table.nextPage();
if (isServerSideTable) {
onPageChange(page + 1);
}
};
const pageChangeHandler = (pageNumber: number) => {
const currentPage = pageNumber - 1;
table.setPageIndex(pageNumber ? currentPage : 0);
if (isServerSideTable) {
onPageChange(pageNumber);
}
};
useEffect(() => {
setPageSize(pageSize);
}, [pageSize, setPageSize]);
return (
<div
className={cn(
TABLE_DEFAULT_STYLING.containerClassName,
tableClassNames.containerClassName,
{
'mb-0': !withPagination,
}
)}
>
<div
className={cn(
TABLE_DEFAULT_STYLING.tableWrapperClassName,
tableClassNames.tableWrapperClassName
)}
>
<table
className={cn(
TABLE_DEFAULT_STYLING.tableClassName,
tableClassNames.tableClassName
)}
>
<thead
className={cn(
TABLE_DEFAULT_STYLING.tableHeaderClassName,
tableClassNames.tableHeaderClassName
)}
>
{table.getHeaderGroups().map((headerGroup) => (
<tr
key={headerGroup.id}
className={cn(
TABLE_DEFAULT_STYLING.headerRowClassName,
tableClassNames.headerRowClassName
)}
>
{headerGroup.headers.map((header) => {
const columnRelativeDepth =
header.depth - header.column.depth;
if (
!header.isPlaceholder &&
columnRelativeDepth > 1 &&
header.id === header.column.id
) {
return null;
}
let rowSpan = 1;
if (header.isPlaceholder) {
const leafs = header.getLeafHeaders();
rowSpan = leafs[leafs.length - 1].depth - header.depth;
}
return (
<th
key={header.id}
colSpan={header.colSpan}
rowSpan={rowSpan}
onClick={header.column.getToggleSortingHandler()}
className={cn(
header.column.getCanSort()
? 'cursor-pointer select-none'
: '',
{
'first:w-9 first:pr-0': withCheckbox,
},
{
'border-b': header.colSpan > 1,
},
TABLE_DEFAULT_STYLING.headerColumnClassName,
tableClassNames.headerColumnClassName
)}
>
<div
className={cn('flex items-center gap-1 min-h-full', {
'justify-center': header.colSpan > 1,
})}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getCanSort() && (
<div className='w-4 h-4 relative flex flex-col items-center'>
<Icon
icon='heroicons:chevron-up-16-solid'
width={18}
height={18}
className={cn(
'absolute -top-1',
'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'asc'
? 'text-black'
: 'text-black/30'
)}
/>
<Icon
icon='heroicons:chevron-down-16-solid'
width={18}
height={18}
className={cn(
'absolute -bottom-1.5',
'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'desc'
? 'text-black'
: 'text-black/30'
)}
/>
</div>
)}
</div>
</th>
);
})}
</tr>
))}
</thead>
<tbody
className={cn(
TABLE_DEFAULT_STYLING.tableBodyClassName,
tableClassNames.tableBodyClassName
)}
>
{table.getRowModel().rows.map((row) => {
const customRowContent = renderCustomRow?.(row);
if (customRowContent) {
return renderCustomRow?.(row);
}
return (
<Fragment key={row.id}>
<tr
data-depth={row.depth}
className={cn(
row.depth > 0
? tableClassNames.bodySubRowClassName(row.depth)
: tableClassNames.bodyRowClassName,
{
[tableClassNames.selectedBodyRowClassName!]:
row.getIsSelected() && row.depth === 0,
[tableClassNames.selectedBodySubRowClassName(
row.depth
)!]: row.getIsSelected() && row.depth > 0,
}
)}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cn(
{ 'first:w-9 first:pr-0': withCheckbox },
TABLE_DEFAULT_STYLING.bodyColumnClassName,
row.depth > 0
? tableClassNames.bodySubRowColumnClassName(
row.depth
)
: tableClassNames.bodyColumnClassName
)}
>
{!isLoading &&
flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
{isLoading && <div className='skeleton w-full h-4' />}
</td>
))}
</tr>
{row.getIsExpanded() && (
<>
{renderSubComponent && (
<tr
className={cn(
TABLE_DEFAULT_STYLING.bodySubRowClassName(1),
tableClassNames.bodySubRowClassName(1),
{
[tableClassNames.selectedBodySubRowClassName(1)]:
row.getIsSelected(),
}
)}
>
<td colSpan={row.getVisibleCells().length}>
{renderSubComponent({ row })}
</td>
</tr>
)}
</>
)}
</Fragment>
);
})}
</tbody>
<tfoot
className={cn(
TABLE_DEFAULT_STYLING.tableFooterClassName,
tableClassNames.tableFooterClassName
)}
>
{renderFooter && (
<tr
className={cn(
TABLE_DEFAULT_STYLING.footerRowClassName,
tableClassNames.footerRowClassName
)}
>
{table.getAllLeafColumns().map((column) => (
<td
key={column.id}
className={cn(
{ 'first:w-9 first:pr-0': withCheckbox },
TABLE_DEFAULT_STYLING.footerColumnClassName,
tableClassNames.footerColumnClassName
)}
>
{column.columnDef.footer &&
flexRender(column.columnDef.footer, {
column,
header: column.columnDef,
table,
} as HeaderContext<TData, unknown>)}
</td>
))}
</tr>
)}
</tfoot>
</table>
</div>
{(data.length === 0 || table.getRowModel().rows.length === 0) &&
!isLoading &&
emptyContent}
{data.length > 0 &&
table.getRowModel().rows.length > 0 &&
!isLoading &&
withPagination && (
<div
className={cn(
'mt-5',
TABLE_DEFAULT_STYLING.paginationClassName,
tableClassNames.paginationClassName
)}
>
<Pagination
totalItems={isServerSideTable ? totalItems : table.getRowCount()}
itemsPerPage={table.getState().pagination.pageSize}
currentPage={
isServerSideTable
? page
: table.getState().pagination.pageIndex + 1
}
onPrevPage={prevPageClickHandler}
onNextPage={nextPageClickHandler}
onPageChange={pageChangeHandler}
rowOptions={rowOptions}
onRowChange={onPageSizeChange}
/>
</div>
)}
</div>
);
};
export default Table;