first
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
import type { Column } from './DataTable';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { DataTable, useDataControl } from './DataTable';
|
||||
|
||||
type TestItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
status: 'active' | 'inactive';
|
||||
value: number;
|
||||
};
|
||||
|
||||
const mockData: TestItem[] = [
|
||||
{ id: 1, name: 'Alpha', status: 'active', value: 100 },
|
||||
{ id: 2, name: 'Beta', status: 'inactive', value: 200 },
|
||||
{ id: 3, name: 'Gamma', status: 'active', value: 150 },
|
||||
{ id: 4, name: 'Delta', status: 'inactive', value: 50 },
|
||||
{ id: 5, name: 'Epsilon', status: 'active', value: 300 },
|
||||
{ id: 6, name: 'Zeta', status: 'inactive', value: 250 },
|
||||
{ id: 7, name: 'Eta', status: 'active', value: 175 },
|
||||
];
|
||||
|
||||
const columns: Column<TestItem>[] = [
|
||||
{ field: 'id', label: 'ID', sortKey: 'id' },
|
||||
{ field: 'name', label: 'Name', sortKey: 'name' },
|
||||
{ field: 'status', label: 'Status', sortKey: 'status' },
|
||||
{ field: 'value', label: 'Value', sortKey: 'value' },
|
||||
];
|
||||
|
||||
const TestWrapper = ({
|
||||
data = mockData,
|
||||
cols = columns,
|
||||
pageSize,
|
||||
noDataMessage,
|
||||
noDataCTA,
|
||||
}: {
|
||||
data?: TestItem[] | null;
|
||||
cols?: Column<TestItem>[];
|
||||
pageSize?: number;
|
||||
noDataMessage?: string;
|
||||
noDataCTA?: React.ReactNode;
|
||||
}) => {
|
||||
const controller = useDataControl<TestItem>(data || []);
|
||||
return (
|
||||
<DataTable
|
||||
dataController={controller}
|
||||
columns={cols}
|
||||
pageSize={pageSize}
|
||||
noDataMessage={noDataMessage}
|
||||
noDataCTA={noDataCTA}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('DataTable', () => {
|
||||
describe('rendering', () => {
|
||||
test('renders table with data', () => {
|
||||
render(<TestWrapper />);
|
||||
|
||||
expect(screen.getByRole('table')).toBeDefined();
|
||||
expect(screen.getByText('Alpha')).toBeDefined();
|
||||
expect(screen.getByText('Beta')).toBeDefined();
|
||||
});
|
||||
|
||||
test('renders column headers', () => {
|
||||
render(<TestWrapper />);
|
||||
|
||||
// Component renders both desktop and mobile versions (hidden via CSS)
|
||||
// so we use getAllByText and verify at least one exists
|
||||
expect(screen.getAllByText('ID').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Value').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('renders correct number of rows based on page size', () => {
|
||||
render(<TestWrapper pageSize={3} />);
|
||||
|
||||
const rows = screen.getAllByRole('row');
|
||||
// 1 header row + 3 data rows
|
||||
expect(rows.length).toBe(4);
|
||||
});
|
||||
|
||||
test('shows no data message when data is empty', () => {
|
||||
render(<TestWrapper data={[]} noDataMessage="No items found" />);
|
||||
|
||||
expect(screen.getByText('No items found')).toBeDefined();
|
||||
expect(screen.queryByRole('table')).toBeNull();
|
||||
});
|
||||
|
||||
test('shows default no data message', () => {
|
||||
render(<TestWrapper data={[]} />);
|
||||
|
||||
expect(screen.getByText('No Data Found.')).toBeDefined();
|
||||
});
|
||||
|
||||
test('renders noDataCTA when provided', () => {
|
||||
render(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined();
|
||||
});
|
||||
|
||||
test('returns null when data is null', () => {
|
||||
const NullWrapper = () => {
|
||||
const controller = useDataControl<TestItem>(null);
|
||||
return <DataTable dataController={controller} columns={columns} />;
|
||||
};
|
||||
|
||||
const { container } = render(<NullWrapper />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-generated columns', () => {
|
||||
test('generates columns from data keys when columns not provided', () => {
|
||||
const AutoColumnsWrapper = () => {
|
||||
const controller = useDataControl(mockData);
|
||||
return <DataTable dataController={controller} />;
|
||||
};
|
||||
|
||||
render(<AutoColumnsWrapper />);
|
||||
|
||||
// Should generate columns from object keys (rendered for both mobile/desktop)
|
||||
expect(screen.getAllByText('Id').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Value').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
test('clicking sortable column header triggers sort', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
|
||||
// Get the first "Name" element (desktop version)
|
||||
const nameHeaders = screen.getAllByText('Name');
|
||||
fireEvent.click(nameHeaders[0]!);
|
||||
|
||||
// After sorting by name ascending, Alpha should still be first
|
||||
const rows = screen.getAllByRole('row');
|
||||
const firstDataRow = rows[1]!;
|
||||
expect(within(firstDataRow).getByText('Alpha')).toBeDefined();
|
||||
});
|
||||
|
||||
test('clicking same column twice reverses sort order', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
|
||||
const nameHeaders = screen.getAllByText('Name');
|
||||
fireEvent.click(nameHeaders[0]!); // asc
|
||||
fireEvent.click(nameHeaders[0]!); // desc
|
||||
|
||||
// After sorting by name descending, Zeta should be first
|
||||
const rows = screen.getAllByRole('row');
|
||||
const firstDataRow = rows[1]!;
|
||||
expect(within(firstDataRow).getByText('Zeta')).toBeDefined();
|
||||
});
|
||||
|
||||
test('sort icon is displayed for sortable columns', () => {
|
||||
render(<TestWrapper />);
|
||||
|
||||
// Each sortable column should have the ArrowUpDown icon
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
headers.forEach((header) => {
|
||||
const svg = header.querySelector('svg');
|
||||
expect(svg).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
test('shows pagination bar when data exceeds page size', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
|
||||
expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
|
||||
});
|
||||
|
||||
test('does not show pagination when all data fits on one page', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
|
||||
expect(screen.queryByText(/Showing page/)).toBeNull();
|
||||
});
|
||||
|
||||
test('next button navigates to next page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
expect(screen.getByText(/Showing page 2 of 2/)).toBeDefined();
|
||||
// Zeta (id 6) and Eta (id 7) should now be visible
|
||||
expect(screen.getByText('Zeta')).toBeDefined();
|
||||
});
|
||||
|
||||
test('previous button navigates to previous page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
const prevButton = screen.getByRole('button', { name: 'Prev' });
|
||||
fireEvent.click(prevButton);
|
||||
|
||||
expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
|
||||
expect(screen.getByText('Alpha')).toBeDefined();
|
||||
});
|
||||
|
||||
test('first button goes to first page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
const firstButton = screen.getByRole('button', { name: 'First' });
|
||||
fireEvent.click(firstButton);
|
||||
|
||||
expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
|
||||
});
|
||||
|
||||
test('last button goes to last page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
|
||||
const lastButton = screen.getByRole('button', { name: 'Last' });
|
||||
fireEvent.click(lastButton);
|
||||
|
||||
expect(screen.getByText(/Showing page 2 of 2/)).toBeDefined();
|
||||
});
|
||||
|
||||
test('navigation buttons are disabled at boundaries', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
|
||||
const firstBtn = screen.getByRole('button', { name: 'First' });
|
||||
const prevBtn = screen.getByRole('button', { name: 'Prev' });
|
||||
const nextBtn = screen.getByRole('button', { name: 'Next' });
|
||||
const lastBtn = screen.getByRole('button', { name: 'Last' });
|
||||
|
||||
// On first page, prev and first should be disabled
|
||||
expect(firstBtn.hasAttribute('disabled')).toBe(true);
|
||||
expect(prevBtn.hasAttribute('disabled')).toBe(true);
|
||||
expect(nextBtn.hasAttribute('disabled')).toBe(false);
|
||||
expect(lastBtn.hasAttribute('disabled')).toBe(false);
|
||||
|
||||
// Go to last page
|
||||
fireEvent.click(lastBtn);
|
||||
|
||||
// On last page, next and last should be disabled
|
||||
expect(firstBtn.hasAttribute('disabled')).toBe(false);
|
||||
expect(prevBtn.hasAttribute('disabled')).toBe(false);
|
||||
expect(nextBtn.hasAttribute('disabled')).toBe(true);
|
||||
expect(lastBtn.hasAttribute('disabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditional columns', () => {
|
||||
test('hides column when condition is false', () => {
|
||||
const conditionalColumns: Column<TestItem>[] = [
|
||||
{ field: 'id', label: 'ID' },
|
||||
{ field: 'name', label: 'Name' },
|
||||
{ field: 'status', label: 'Status', condition: false },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={conditionalColumns} />);
|
||||
|
||||
expect(screen.getAllByText('ID').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Status')).toBeNull();
|
||||
});
|
||||
|
||||
test('shows column when condition is true', () => {
|
||||
const conditionalColumns: Column<TestItem>[] = [
|
||||
{ field: 'id', label: 'ID' },
|
||||
{ field: 'status', label: 'Status', condition: true },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={conditionalColumns} />);
|
||||
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('evaluates function condition', () => {
|
||||
let showStatus = false;
|
||||
const conditionalColumns: Column<TestItem>[] = [
|
||||
{ field: 'id', label: 'ID' },
|
||||
{ field: 'status', label: 'Status', condition: () => showStatus },
|
||||
];
|
||||
|
||||
const { rerender } = render(<TestWrapper cols={conditionalColumns} />);
|
||||
expect(screen.queryByText('Status')).toBeNull();
|
||||
|
||||
showStatus = true;
|
||||
rerender(<TestWrapper cols={conditionalColumns} />);
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom formatting', () => {
|
||||
test('uses format function to render cell content', () => {
|
||||
const formattedColumns: Column<TestItem>[] = [
|
||||
{ field: 'id', label: 'ID' },
|
||||
{
|
||||
field: 'name',
|
||||
label: 'Name',
|
||||
format: ({ value }) => <strong data-testid="formatted-name">{String(value)}</strong>,
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={formattedColumns} />);
|
||||
|
||||
const formattedNames = screen.getAllByTestId('formatted-name');
|
||||
expect(formattedNames.length).toBeGreaterThan(0);
|
||||
expect(formattedNames[0]?.tagName).toBe('STRONG');
|
||||
});
|
||||
|
||||
test('format function receives item and idx', () => {
|
||||
const formattedColumns: Column<TestItem>[] = [
|
||||
{
|
||||
field: 'name',
|
||||
label: 'Name',
|
||||
format: ({ item, idx }) => <span data-testid={`item-${item.id}`}>{`${item.name}-${idx}`}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={formattedColumns} />);
|
||||
|
||||
// Each row should have a unique test ID based on item.id
|
||||
expect(screen.getByTestId('item-1')).toBeDefined();
|
||||
expect(screen.getByTestId('item-2')).toBeDefined();
|
||||
// First item (Alpha, id=1) should have idx=0
|
||||
expect(screen.getByText('Alpha-0')).toBeDefined();
|
||||
});
|
||||
|
||||
test('format function can render without field', () => {
|
||||
const actionColumns: Column<TestItem>[] = [
|
||||
{ field: 'id', label: 'ID' },
|
||||
{
|
||||
label: 'Actions',
|
||||
format: ({ item }) => <button data-testid={`action-${item.id}`}>Edit {item.name}</button>,
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={actionColumns} />);
|
||||
|
||||
expect(screen.getByTestId('action-1')).toBeDefined();
|
||||
expect(screen.getByText('Edit Alpha')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltips', () => {
|
||||
test('renders tooltip indicator when tooltip is provided', () => {
|
||||
const tooltipColumns: Column<TestItem>[] = [
|
||||
{ field: 'id', label: 'ID', tooltip: 'Unique identifier' },
|
||||
{ field: 'name', label: 'Name' },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={tooltipColumns} />);
|
||||
|
||||
// The tooltip indicator (ⓘ) should be present in the ID column headers
|
||||
const idHeaders = screen.getAllByText(/ID/);
|
||||
const hasTooltipIndicator = idHeaders.some((header) => header.textContent?.includes('ⓘ'));
|
||||
expect(hasTooltipIndicator).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile labels', () => {
|
||||
test('renders labelMobile when provided', () => {
|
||||
const mobileColumns: Column<TestItem>[] = [
|
||||
{ field: 'id', label: 'Identifier', labelMobile: 'ID' },
|
||||
{ field: 'name', label: 'Full Name', labelMobile: 'Name' },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={mobileColumns} />);
|
||||
|
||||
// Both desktop and mobile labels should be in the DOM (hidden/shown via CSS)
|
||||
expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('ID').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Full Name').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with useDataControl', () => {
|
||||
test('search filters table data', () => {
|
||||
const SearchableTable = () => {
|
||||
const controller = useDataControl(mockData);
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
data-testid="search-input"
|
||||
value={controller.searchQuery}
|
||||
onChange={(ev) => {
|
||||
controller.setSearchKeys(['name']);
|
||||
controller.setSearchQuery(ev.target.value);
|
||||
}}
|
||||
/>
|
||||
<DataTable dataController={controller} columns={columns} pageSize={10} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<SearchableTable />);
|
||||
|
||||
const searchInput = screen.getByTestId('search-input');
|
||||
fireEvent.change(searchInput, { target: { value: 'Alpha' } });
|
||||
|
||||
// Only Alpha should be visible
|
||||
expect(screen.getByText('Alpha')).toBeDefined();
|
||||
expect(screen.queryByText('Beta')).toBeNull();
|
||||
expect(screen.queryByText('Gamma')).toBeNull();
|
||||
});
|
||||
|
||||
test('external sort control works', () => {
|
||||
const ExternalSortTable = () => {
|
||||
const controller = useDataControl(mockData);
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="sort-btn" onClick={() => controller.setSortedBy('value-desc')}>
|
||||
Sort by Value Desc
|
||||
</button>
|
||||
<DataTable dataController={controller} columns={columns} pageSize={10} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<ExternalSortTable />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('sort-btn'));
|
||||
|
||||
// After sorting by value descending, Epsilon (300) should be first
|
||||
const rows = screen.getAllByRole('row');
|
||||
const firstDataRow = rows[1]!;
|
||||
expect(within(firstDataRow).getByText('Epsilon')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { SortKey } from 'hooks/useDataControl';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { ArrowUpDown } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl';
|
||||
import { PaginationBar } from './PaginationBar';
|
||||
import { cn } from 'helpers/cn';
|
||||
|
||||
export const DataTable = <T,>(props: DataTableProps<T>) => {
|
||||
const [columns, setColumns] = useState<Column<T>[]>([]);
|
||||
const { dataController, uniqueKey, noDataCTA, noDataMessage } = props;
|
||||
const theUniqueKey = uniqueKey || 'id';
|
||||
const { rawData, data, pageSize, sortBy, sortedBy, setCustomSort } = dataController;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.columns) {
|
||||
setColumns(props.columns);
|
||||
} else if (!!rawData && rawData.length > 0) {
|
||||
const cols: Column<T>[] = Object.keys(rawData[0] as object).map((key) => ({
|
||||
field: key as keyof T,
|
||||
label: toLabel(key),
|
||||
sortKey: key as SortKey<T>,
|
||||
}));
|
||||
setColumns(cols);
|
||||
} else {
|
||||
setColumns([]);
|
||||
}
|
||||
}, [props.columns, rawData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof props.pageSize === 'number') {
|
||||
dataController.setPageSize(props.pageSize);
|
||||
}
|
||||
}, [props.pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!props.customSort) {
|
||||
setCustomSort(() => props.customSort!);
|
||||
}
|
||||
}, [props.customSort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.searchKeys) {
|
||||
const propsSearchKeys = JSON.stringify(props.searchKeys);
|
||||
const controllerSearchKeys = JSON.stringify(dataController.searchKeys);
|
||||
if (propsSearchKeys !== controllerSearchKeys) {
|
||||
dataController.setSearchKeys(props.searchKeys);
|
||||
}
|
||||
} else if (columns.length > 0 && !dataController.searchKeys) {
|
||||
const colFields = columns.map((col) => col.field).filter((f): f is keyof T => !!f);
|
||||
dataController.setSearchKeys(colFields);
|
||||
}
|
||||
}, [props.searchKeys, columns.length]);
|
||||
|
||||
if (!rawData) return null;
|
||||
|
||||
if (rawData.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground">{noDataMessage || 'No Data Found.'}</div>
|
||||
{noDataCTA && <>{noDataCTA}</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card overflow-hidden rounded-xl border">
|
||||
<div>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
{columns.map((column: Column<T>, idx: number) => {
|
||||
const { label = '', sortKey, field, condition = true } = column;
|
||||
const { tooltip, headerClassName, labelMobile } = column;
|
||||
const renderColumn = typeof condition === 'function' ? condition() : condition;
|
||||
return (
|
||||
renderColumn && (
|
||||
<TableHead className={cn('whitespace-nowrap max-[499px]:px-2', headerClassName)} key={idx}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center',
|
||||
sortKey && 'cursor-pointer hover:text-black',
|
||||
sortedBy?.startsWith(`${String(sortKey)}-`) && 'text-black',
|
||||
)}
|
||||
onClick={() => sortBy(sortKey as Extract<keyof T, string> | undefined)}
|
||||
>
|
||||
<span className="hidden min-[500px]:inline">
|
||||
{String(label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
|
||||
</span>
|
||||
<span className="min-[500px]:hidden">
|
||||
{String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
|
||||
</span>
|
||||
{sortKey && <ArrowUpDown className={cn('ml-2 h-4 w-4')} />}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{tooltip && (
|
||||
<TooltipContent>
|
||||
<p className="whitespace-pre-line">{tooltip}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableHead>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((item: T, idx: number) => {
|
||||
return (
|
||||
<TableRow key={String(item[theUniqueKey as keyof T] || idx)} className="group/tablerow">
|
||||
{columns.map((column, idx) => {
|
||||
const value = column.field !== undefined ? item[column.field] : undefined;
|
||||
const { format, condition = true, cellClassName } = column;
|
||||
const renderColumn = typeof condition === 'function' ? condition() : condition;
|
||||
return (
|
||||
renderColumn && (
|
||||
<TableCell className={cn('whitespace-nowrap max-[499px]:px-2', cellClassName)} key={idx}>
|
||||
<>
|
||||
{format
|
||||
? column.field !== undefined
|
||||
? format({
|
||||
value: item[column.field],
|
||||
item,
|
||||
data,
|
||||
idx,
|
||||
})
|
||||
: format({
|
||||
value: undefined as unknown as T[keyof T],
|
||||
item,
|
||||
data,
|
||||
idx,
|
||||
})
|
||||
: value}
|
||||
</>
|
||||
</TableCell>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{!!pageSize && pageSize < rawData?.length && (
|
||||
<PaginationBar dataManager={dataController as UseDataControlReturn<T>} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { useDataControl };
|
||||
export type { UseDataControlReturn };
|
||||
|
||||
type DataTableProps<T> = {
|
||||
dataController: UseDataControlReturn<T>;
|
||||
columns?: Column<T>[];
|
||||
uniqueKey?: keyof T;
|
||||
searchKeys?: (keyof T)[];
|
||||
pageSize?: number;
|
||||
noDataCTA?: React.ReactNode;
|
||||
customSort?: (a: T, b: T, originalSort: (a: T, b: T) => number) => number;
|
||||
noDataMessage?: string;
|
||||
};
|
||||
|
||||
export type DataTableFormatArgs<T> = {
|
||||
value: T[keyof T];
|
||||
item: T;
|
||||
data: T[];
|
||||
idx: number;
|
||||
};
|
||||
|
||||
export type Column<T> = {
|
||||
field?: keyof T;
|
||||
label?: string;
|
||||
labelMobile?: string;
|
||||
headerClassName?: string;
|
||||
cellClassName?: string;
|
||||
format?: (args: DataTableFormatArgs<T>) => React.ReactNode;
|
||||
condition?: boolean | (() => boolean);
|
||||
sortKey?: SortKey<T>;
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
function toLabel(key: string): string {
|
||||
return key
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2') // insert space before capitals
|
||||
.replace(/_/g, ' ') // replace underscores with spaces
|
||||
.replace(/\s+/g, ' ') // collapse multiple spaces
|
||||
.trim()
|
||||
.replace(/\w\S*/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// PaginationBar.tsx
|
||||
import type { UseDataControlReturn } from 'hooks/useDataControl';
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
type PaginationBarProps<T> = {
|
||||
dataManager: UseDataControlReturn<T>;
|
||||
};
|
||||
|
||||
export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager;
|
||||
|
||||
const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []);
|
||||
|
||||
const handlePageSizeChange = (value: string) => {
|
||||
const next = Number(value);
|
||||
if (!Number.isFinite(next) || next <= 0) return;
|
||||
setPageSize(next);
|
||||
changePage(1);
|
||||
};
|
||||
|
||||
const buttonClass =
|
||||
'h-8 w-8 p-0 hover:bg-white text-muted-foreground disabled:opacity-100 disabled:pointer-events-none';
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 flex w-full items-center justify-between border-t p-4">
|
||||
{/* LEFT */}
|
||||
<div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap">
|
||||
<span className="hidden min-[500px]:inline">
|
||||
Showing page {currentPage} of {pageCount}
|
||||
</span>
|
||||
<span className="min-[500px]:hidden">
|
||||
p {currentPage}/{pageCount}
|
||||
</span>
|
||||
|
||||
<span className="bg-border h-5 w-px" aria-hidden="true" />
|
||||
|
||||
<Select value={String(pageSize)} onValueChange={handlePageSizeChange}>
|
||||
<SelectTrigger
|
||||
className="
|
||||
h-8
|
||||
w-[80px] min-[500px]:w-[110px]
|
||||
justify-between
|
||||
bg-transparent
|
||||
text-muted-foreground
|
||||
hover:bg-white
|
||||
data-[state=open]:bg-muted
|
||||
"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
<SelectValue className="text-muted-foreground">
|
||||
<span className="hidden min-[500px]:inline">{pageSize} / page</span>
|
||||
<span className="min-[500px]:hidden">{pageSize}/p</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent align="start">
|
||||
{pageSizeOptions.map((n) => (
|
||||
<SelectItem
|
||||
key={n}
|
||||
value={String(n)}
|
||||
className="
|
||||
text-muted-foreground
|
||||
data-[state=checked]:bg-muted
|
||||
data-[state=checked]:text-muted-foreground
|
||||
"
|
||||
>
|
||||
<span className="hidden min-[500px]:inline">{n} / page</span>
|
||||
<span className="min-[500px]:hidden">{n}/p</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* RIGHT */}
|
||||
<div className="flex items-center gap-0.5 min-[500px]:gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="First"
|
||||
>
|
||||
«
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Prev"
|
||||
>
|
||||
‹
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(currentPage + 1)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Next"
|
||||
>
|
||||
›
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(pageCount)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Last"
|
||||
>
|
||||
»
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from './DataTable';
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const useFixedHeightPagination = (data: any, pageSize: number) => {
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tableRef.current) {
|
||||
const height = tableRef.current.clientHeight;
|
||||
if (height > 0) {
|
||||
tableRef.current.style.height = height + 'px';
|
||||
}
|
||||
}
|
||||
}, [data, pageSize]);
|
||||
|
||||
return { tableRef };
|
||||
};
|
||||
Reference in New Issue
Block a user