🎉 chore(init): project initialization
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@shadcn/ui/input';
|
||||
import { Table } from '@tanstack/react-table';
|
||||
import { Combobox } from '../combobox';
|
||||
|
||||
export interface IParams {
|
||||
key: string;
|
||||
placeholder?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
}
|
||||
interface ColumnFilterProps<TData> {
|
||||
table: Table<TData>;
|
||||
params: IParams[];
|
||||
filters?: any;
|
||||
}
|
||||
|
||||
export function ColumnFilter<TData>({ table, params, filters }: ColumnFilterProps<TData>) {
|
||||
const updateFilter = (key: string, value: any) => {
|
||||
table.setColumnFilters((prev) => {
|
||||
const newFilters = prev.filter((filter) => filter.id !== key);
|
||||
if (value) {
|
||||
newFilters.push({ id: key, value });
|
||||
}
|
||||
return newFilters;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
{params.map((param) => {
|
||||
if (param.options) {
|
||||
return (
|
||||
<Combobox
|
||||
key={param.key}
|
||||
className='w-32'
|
||||
placeholder={param.placeholder || 'Choose...'}
|
||||
value={filters[param.key] || ''}
|
||||
onChange={(value) => {
|
||||
updateFilter(param.key, value);
|
||||
}}
|
||||
options={param.options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
key={param.key}
|
||||
className='w-32'
|
||||
placeholder={param.placeholder || 'Search...'}
|
||||
value={filters[param.key] || ''}
|
||||
onChange={(event) => updateFilter(param.key, event.target.value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProList, type ProListActions, type ProListProps } from './pro-list';
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@shadcn/ui/select';
|
||||
import { Table } from '@tanstack/react-table';
|
||||
|
||||
interface PaginationText {
|
||||
textRowsPerPage?: string;
|
||||
textPageOf?: (pageIndex: number, pageCount: number) => string;
|
||||
}
|
||||
interface PaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
text?: PaginationText;
|
||||
}
|
||||
|
||||
export function Pagination<TData>({ table, text }: PaginationProps<TData>) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-between gap-2 sm:flex-row'>
|
||||
<div className='text-muted-foreground flex-1 whitespace-nowrap text-center text-sm sm:text-left'>
|
||||
{text?.textPageOf?.(table.getState().pagination.pageIndex + 1, table.getPageCount()) ||
|
||||
`Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`}
|
||||
</div>
|
||||
<div className='m-auto flex items-center gap-2'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<p className='font-medium'>{text?.textRowsPerPage || 'Rows per page'}</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[70px]'>
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 50, 100, 200].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='hidden h-8 w-8 p-2 lg:flex'
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to first page</span>
|
||||
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-8 w-8 p-2'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to previous page</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageIndex + 1}`}
|
||||
onValueChange={(value) => table.setPageIndex(Number(value) - 1)}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[70px]'>
|
||||
<SelectValue placeholder='Select page number' />
|
||||
</SelectTrigger>
|
||||
<SelectContent className='w-12'>
|
||||
{Array.from({ length: table.getPageCount() }, (_, i) => (
|
||||
<SelectItem key={i} value={`${i + 1}`}>
|
||||
{i + 1}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-8 w-8 p-2'
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to next page</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='hidden h-8 w-8 p-2 lg:flex'
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to last page</span>
|
||||
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@shadcn/ui/alert';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Checkbox } from '@shadcn/ui/checkbox';
|
||||
import {
|
||||
ColumnFiltersState,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { ListRestart, Loader, RefreshCcw } from 'lucide-react';
|
||||
import React, { useEffect, useImperativeHandle, useState } from 'react';
|
||||
import Empty from '../empty';
|
||||
import { ColumnFilter, IParams } from './column-filter';
|
||||
import { Pagination } from './pagination';
|
||||
|
||||
export interface ProListProps<TData, TValue> {
|
||||
request: (
|
||||
pagination: {
|
||||
page: number;
|
||||
size: number;
|
||||
},
|
||||
filter: TValue,
|
||||
) => Promise<{ list: TData[]; total: number }>;
|
||||
params?: IParams[];
|
||||
header?: {
|
||||
title?: React.ReactNode;
|
||||
toolbar?: React.ReactNode | React.ReactNode[];
|
||||
};
|
||||
batchRender?: (rows: TData[]) => React.ReactNode[];
|
||||
renderItem: (item: TData, checkbox: React.ReactNode) => React.ReactNode;
|
||||
action?: React.Ref<ProListActions | undefined>;
|
||||
texts?: Partial<{
|
||||
textRowsPerPage: string;
|
||||
textPageOf: (current: number, total: number) => string;
|
||||
selectedRowsText: (total: number) => string;
|
||||
}>;
|
||||
}
|
||||
export interface ProListActions {
|
||||
refresh: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function ProList<TData, TValue extends Record<string, unknown>>({
|
||||
request,
|
||||
params,
|
||||
header,
|
||||
batchRender,
|
||||
renderItem,
|
||||
action,
|
||||
texts,
|
||||
}: ProListProps<TData, TValue>) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<{ [key: number]: boolean }>({});
|
||||
const [data, setData] = useState<TData[]>([]);
|
||||
const [rowCount, setRowCount] = useState<number>(0);
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: [],
|
||||
onPaginationChange: setPagination,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
columnFilters,
|
||||
rowSelection,
|
||||
pagination,
|
||||
},
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
rowCount: rowCount,
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await request(
|
||||
{
|
||||
page: pagination.pageIndex + 1,
|
||||
size: pagination.pageSize,
|
||||
},
|
||||
Object.fromEntries(columnFilters.map((item) => [item.id, item.value])) as TValue,
|
||||
);
|
||||
setData(response.list);
|
||||
setRowCount(response.total);
|
||||
} catch (error) {
|
||||
console.log('Fetch data error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
table.resetColumnFilters();
|
||||
table.resetGlobalFilter(true);
|
||||
table.resetColumnVisibility();
|
||||
setRowSelection({});
|
||||
table.resetPagination();
|
||||
};
|
||||
|
||||
useImperativeHandle(action, () => ({
|
||||
refresh: fetchData,
|
||||
reset,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [pagination.pageIndex, pagination.pageSize, columnFilters]);
|
||||
|
||||
const handleSelectionChange = (index: number, isSelected: boolean) => {
|
||||
setRowSelection((prevSelection) => ({
|
||||
...prevSelection,
|
||||
[index]: isSelected,
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedRows = data.filter((_, index) => rowSelection[index]);
|
||||
const selectedCount = selectedRows.length;
|
||||
|
||||
return (
|
||||
<div className='flex max-w-full flex-col gap-4 overflow-hidden'>
|
||||
<div className='flex flex-wrap-reverse items-center justify-between gap-4'>
|
||||
<div>
|
||||
{params ? (
|
||||
<ColumnFilter
|
||||
table={table}
|
||||
params={params}
|
||||
filters={Object.fromEntries(columnFilters.map((item) => [item.id, item.value]))}
|
||||
/>
|
||||
) : (
|
||||
header?.title
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-1 items-center justify-end gap-2'>
|
||||
<Button variant='outline' className='h-8 w-8 p-2' onClick={fetchData}>
|
||||
<RefreshCcw className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button variant='outline' className='h-8 w-8 p-2' onClick={reset}>
|
||||
<ListRestart className='h-4 w-4' />
|
||||
</Button>
|
||||
{header?.toolbar}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCount > 0 && batchRender && (
|
||||
<Alert className='flex items-center justify-between'>
|
||||
<AlertTitle className='m-0'>
|
||||
{texts?.selectedRowsText?.(selectedCount) || `Selected ${selectedCount} rows`}
|
||||
</AlertTitle>
|
||||
<AlertDescription className='flex gap-2'>{batchRender(selectedRows)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className='relative overflow-x-auto'>
|
||||
<div className='grid grid-cols-1 gap-4'>
|
||||
{data.length ? (
|
||||
data.map((item, index) => {
|
||||
const isSelected = !!rowSelection[index];
|
||||
|
||||
const checkbox = (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(value) => handleSelectionChange(index, !!value)}
|
||||
aria-label='Select row'
|
||||
/>
|
||||
);
|
||||
|
||||
return <div key={index}>{renderItem(item, checkbox)}</div>;
|
||||
})
|
||||
) : (
|
||||
<div className='flex items-center justify-center py-24'>
|
||||
<Empty />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className='bg-muted/80 absolute top-0 z-20 flex h-full w-full items-center justify-center'>
|
||||
<Loader className='h-4 w-4 animate-spin' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rowCount > 0 && (
|
||||
<Pagination
|
||||
table={table}
|
||||
text={{
|
||||
textRowsPerPage: texts?.textRowsPerPage,
|
||||
textPageOf: texts?.textPageOf,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user