mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 21:41:57 +00:00
518 lines
16 KiB
TypeScript
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;
|