add table
This commit is contained in:
parent
d0801a5155
commit
8e8fde67bc
10
package.json
10
package.json
@ -7,7 +7,9 @@
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
@ -19,10 +21,16 @@
|
||||
"react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react-icons": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@tanstack/react-table": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chakra-ui/react": "^2.0.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"axios": "^1.17.0",
|
||||
"keycloak-js": "^26.2.4",
|
||||
|
||||
688
src/components/table/DataTable.jsx
Normal file
688
src/components/table/DataTable.jsx
Normal file
@ -0,0 +1,688 @@
|
||||
// ----- Imports -----
|
||||
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
HStack,
|
||||
Spacer as ChakraSpacer,
|
||||
Table as ChakraTable,
|
||||
Tbody,
|
||||
Td,
|
||||
Text,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
VStack,
|
||||
} from "@chakra-ui/react";
|
||||
import { Pagination as UIKitPagination } from "../pagination/Pagination.jsx";
|
||||
import { toFaDigits } from "../../utils/numbers.js";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
// ----- Context -----
|
||||
|
||||
const DataTableContext = createContext(null);
|
||||
|
||||
function useDataTable() {
|
||||
const context = useContext(DataTableContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("DataTable components must be used inside <DataTable>");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// ----- Root Component -----
|
||||
|
||||
function Root({
|
||||
children,
|
||||
|
||||
data = [],
|
||||
columns = [],
|
||||
|
||||
page = 1,
|
||||
size = 10,
|
||||
|
||||
pagination,
|
||||
onPageChange,
|
||||
|
||||
getRowId = (row) => String(row?.id),
|
||||
|
||||
enableSelection = false,
|
||||
showIndex = true,
|
||||
|
||||
defaultSelectedIds = [],
|
||||
onSelectionChange,
|
||||
|
||||
...props
|
||||
}) {
|
||||
// ----- Normalized Pagination Props -----
|
||||
|
||||
const normalizedPage = Number(page) || 1;
|
||||
const normalizedSize = Number(size) || 10;
|
||||
|
||||
const totalCount = pagination?.total ?? pagination?.totalCount ?? 0;
|
||||
|
||||
const totalPages =
|
||||
pagination?.total_pages ??
|
||||
pagination?.totalPages ??
|
||||
(totalCount > 0 ? Math.ceil(totalCount / normalizedSize) : 0);
|
||||
|
||||
// ----- TanStack Pagination State -----
|
||||
|
||||
const paginationState = useMemo(() => {
|
||||
return {
|
||||
pageIndex: Math.max(normalizedPage - 1, 0),
|
||||
pageSize: normalizedSize,
|
||||
};
|
||||
}, [normalizedPage, normalizedSize]);
|
||||
|
||||
const handlePaginationChange = useCallback(
|
||||
(updater) => {
|
||||
const next =
|
||||
typeof updater === "function" ? updater(paginationState) : updater;
|
||||
|
||||
const nextPage = next.pageIndex + 1;
|
||||
|
||||
if (nextPage !== normalizedPage) {
|
||||
onPageChange?.(nextPage);
|
||||
}
|
||||
},
|
||||
[paginationState, normalizedPage, onPageChange],
|
||||
);
|
||||
|
||||
// ----- Selection State -----
|
||||
|
||||
const [selectedIds, setSelectedIdsState] = useState(defaultSelectedIds);
|
||||
|
||||
const setSelectedIds = useCallback(
|
||||
(updater) => {
|
||||
setSelectedIdsState((prev) => {
|
||||
const next = typeof updater === "function" ? updater(prev) : updater;
|
||||
|
||||
onSelectionChange?.(next);
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[onSelectionChange],
|
||||
);
|
||||
|
||||
// ----- Reset Selection On Page Change -----
|
||||
|
||||
const previousPageRef = useRef({
|
||||
page: normalizedPage,
|
||||
size: normalizedSize,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = previousPageRef.current;
|
||||
|
||||
const pageChanged =
|
||||
previous.page !== normalizedPage || previous.size !== normalizedSize;
|
||||
|
||||
previousPageRef.current = {
|
||||
page: normalizedPage,
|
||||
size: normalizedSize,
|
||||
};
|
||||
|
||||
if (pageChanged) {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
}, [enableSelection, normalizedPage, normalizedSize, setSelectedIds]);
|
||||
|
||||
// ----- Page Row Ids -----
|
||||
|
||||
const allPageIds = useMemo(() => {
|
||||
return data
|
||||
.map((item) => getRowId(item))
|
||||
.filter((id) => id !== undefined && id !== null && id !== "");
|
||||
}, [data, getRowId]);
|
||||
|
||||
const pageSelectedIds = useMemo(() => {
|
||||
return selectedIds.filter((id) => allPageIds.includes(id));
|
||||
}, [allPageIds, selectedIds]);
|
||||
|
||||
// ----- Selection Status -----
|
||||
|
||||
const isAllSelected =
|
||||
allPageIds.length > 0 && pageSelectedIds.length === allPageIds.length;
|
||||
|
||||
const isIndeterminate =
|
||||
pageSelectedIds.length > 0 && pageSelectedIds.length < allPageIds.length;
|
||||
|
||||
// ----- Selection Handlers -----
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedIds([]);
|
||||
}, [setSelectedIds]);
|
||||
|
||||
const onSelectionChangedId = useCallback(
|
||||
(id) => {
|
||||
setSelectedIds((prev) => {
|
||||
if (!allPageIds.includes(id)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (prev.includes(id)) {
|
||||
return prev.filter((item) => item !== id);
|
||||
}
|
||||
|
||||
return [...prev, id];
|
||||
});
|
||||
},
|
||||
[allPageIds, setSelectedIds],
|
||||
);
|
||||
|
||||
const toggleRowSelection = onSelectionChangedId;
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedIds(allPageIds);
|
||||
}, [allPageIds, setSelectedIds]);
|
||||
|
||||
const unselectAll = useCallback(() => {
|
||||
setSelectedIds([]);
|
||||
}, [setSelectedIds]);
|
||||
|
||||
const toggleSelectAll = useCallback(
|
||||
(checked) => {
|
||||
if (checked) {
|
||||
selectAll();
|
||||
return;
|
||||
}
|
||||
|
||||
unselectAll();
|
||||
},
|
||||
[selectAll, unselectAll],
|
||||
);
|
||||
|
||||
// ----- Table Columns -----
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
const result = [];
|
||||
|
||||
if (enableSelection) {
|
||||
result.push({
|
||||
id: "__selection",
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
isChecked={meta.isAllSelected}
|
||||
isIndeterminate={meta.isIndeterminate}
|
||||
onChange={(e) => meta.toggleSelectAll(e.target.checked)}
|
||||
colorScheme="purple"
|
||||
size="md"
|
||||
/>
|
||||
);
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta;
|
||||
const rowId = meta.getRowId(row.original);
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
isChecked={meta.selectedIds.includes(rowId)}
|
||||
onChange={() => meta.onSelectionChangedId(rowId)}
|
||||
colorScheme="purple"
|
||||
size="md"
|
||||
/>
|
||||
);
|
||||
},
|
||||
meta: { isCentered: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (showIndex) {
|
||||
result.push({
|
||||
id: "__index",
|
||||
header: "ردیف",
|
||||
cell: ({ row, table }) => {
|
||||
const { pageIndex, pageSize } = table.getState().pagination;
|
||||
const rowNumber = pageIndex * pageSize + row.index + 1;
|
||||
|
||||
return (
|
||||
<Text fontWeight="bold" sx={{ fontVariantNumeric: "normal" }}>
|
||||
{toFaDigits(rowNumber)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
meta: { isCentered: true },
|
||||
});
|
||||
}
|
||||
|
||||
return [...result, ...columns];
|
||||
}, [columns, enableSelection, showIndex]);
|
||||
|
||||
// ----- Table Instance -----
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId,
|
||||
|
||||
manualPagination: true,
|
||||
pageCount: totalPages,
|
||||
rowCount: totalCount,
|
||||
|
||||
state: {
|
||||
pagination: paginationState,
|
||||
},
|
||||
|
||||
onPaginationChange: handlePaginationChange,
|
||||
|
||||
meta: {
|
||||
selectedIds,
|
||||
onSelectionChangedId,
|
||||
|
||||
getRowId,
|
||||
|
||||
allPageIds,
|
||||
pageSelectedIds,
|
||||
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
|
||||
selectAll,
|
||||
unselectAll,
|
||||
toggleSelectAll,
|
||||
clearSelection,
|
||||
|
||||
totalCount,
|
||||
totalPages,
|
||||
},
|
||||
});
|
||||
|
||||
// ----- Provider Value -----
|
||||
|
||||
const value = {
|
||||
table,
|
||||
|
||||
data,
|
||||
columns: tableColumns,
|
||||
|
||||
page: normalizedPage,
|
||||
size: normalizedSize,
|
||||
|
||||
pagination,
|
||||
onPageChange,
|
||||
|
||||
paginationState,
|
||||
totalCount,
|
||||
totalPages,
|
||||
|
||||
getRowId,
|
||||
|
||||
enableSelection,
|
||||
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
clearSelection,
|
||||
|
||||
allPageIds,
|
||||
pageSelectedIds,
|
||||
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
|
||||
selectAll,
|
||||
unselectAll,
|
||||
toggleSelectAll,
|
||||
|
||||
onSelectionChangedId,
|
||||
toggleRowSelection,
|
||||
};
|
||||
|
||||
// ----- Root Render -----
|
||||
|
||||
return (
|
||||
<DataTableContext.Provider value={value}>
|
||||
<VStack spacing={4} w="full" {...props}>
|
||||
{children}
|
||||
</VStack>
|
||||
</DataTableContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Topbar Components -----
|
||||
|
||||
function Topbar({ children, ...props }) {
|
||||
return (
|
||||
<HStack w="full" px={2} spacing={4} {...props}>
|
||||
{children}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Title({ children, ...props }) {
|
||||
return (
|
||||
<Text fontSize="lg" textAlign="start" fontWeight="bold" {...props}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function Spacer(props) {
|
||||
return <ChakraSpacer {...props} />;
|
||||
}
|
||||
|
||||
function Actions({ children, ...props }) {
|
||||
const context = useDataTable();
|
||||
|
||||
return (
|
||||
<HStack spacing={4} {...props}>
|
||||
{typeof children === "function" ? children(context) : children}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Selection Components -----
|
||||
|
||||
function SelectionActions({ children }) {
|
||||
const context = useDataTable();
|
||||
|
||||
if (context.selectedIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof children === "function" ? children(context) : children;
|
||||
}
|
||||
|
||||
function SelectAll({ children = "انتخاب همه", ...props }) {
|
||||
const {
|
||||
data,
|
||||
enableSelection,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
toggleSelectAll,
|
||||
} = useDataTable();
|
||||
|
||||
if (!enableSelection || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
isChecked={isAllSelected}
|
||||
isIndeterminate={isIndeterminate}
|
||||
onChange={(e) => toggleSelectAll(e.target.checked)}
|
||||
colorScheme="purple"
|
||||
size="lg"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedCount({ children, ...props }) {
|
||||
const { selectedIds } = useDataTable();
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof children === "function") {
|
||||
return children({ count: selectedIds.length, selectedIds });
|
||||
}
|
||||
|
||||
return (
|
||||
<Text fontSize="sm" color="gray.600" {...props}>
|
||||
{toFaDigits(selectedIds.length)} مورد انتخاب شده
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Content Wrapper -----
|
||||
|
||||
function Content({ children, ...props }) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
w="full"
|
||||
h="100%"
|
||||
minH={0}
|
||||
gap={4}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Table Container -----
|
||||
|
||||
function Table({
|
||||
children,
|
||||
minW = "1200px",
|
||||
heightOffset = 355,
|
||||
containerProps,
|
||||
tableProps,
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
flex="1"
|
||||
minH={`calc(100vh - ${heightOffset}px)`}
|
||||
maxH={`calc(100vh - ${heightOffset}px)`}
|
||||
overflowX="auto"
|
||||
overflowY="auto"
|
||||
bg="white"
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
sx={{
|
||||
"::-webkit-scrollbar": {
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
},
|
||||
"::-webkit-scrollbar-thumb": {
|
||||
background: "#CBD5E0",
|
||||
borderRadius: "8px",
|
||||
},
|
||||
"::-webkit-scrollbar-track": {
|
||||
background: "transparent",
|
||||
},
|
||||
}}
|
||||
{...containerProps}
|
||||
>
|
||||
<ChakraTable
|
||||
variant="simple"
|
||||
minW={minW}
|
||||
sx={{
|
||||
"th, td": {
|
||||
borderColor: "gray.200",
|
||||
whiteSpace: "nowrap",
|
||||
px: 2,
|
||||
},
|
||||
"tbody tr:last-child td": {
|
||||
borderBottom: "none",
|
||||
},
|
||||
}}
|
||||
{...tableProps}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<DataTable.Head />
|
||||
<DataTable.Body />
|
||||
</>
|
||||
)}
|
||||
</ChakraTable>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Table Head -----
|
||||
|
||||
function Head() {
|
||||
const { table } = useDataTable();
|
||||
|
||||
return (
|
||||
<Thead position="sticky" top={0} bg="white" zIndex={1}>
|
||||
{table.getHeaderGroups().map((group) => (
|
||||
<Tr key={group.id} borderBottomWidth="1px" borderColor="gray.200">
|
||||
{group.headers.map((header) => (
|
||||
<Th
|
||||
key={header.id}
|
||||
py={4}
|
||||
borderRightWidth="1px"
|
||||
borderRightColor="gray.200"
|
||||
fontSize="md"
|
||||
color="gray.600"
|
||||
textAlign={
|
||||
header.column.columnDef.meta?.isCentered ? "center" : "start"
|
||||
}
|
||||
verticalAlign="middle"
|
||||
textTransform="none"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Table Body -----
|
||||
|
||||
function Body({ emptyText = "موردی وجود ندارد" }) {
|
||||
const { table, columns } = useDataTable();
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
return (
|
||||
<Tbody>
|
||||
{rows.length > 0 ? (
|
||||
rows.map((row) => (
|
||||
<Tr
|
||||
key={row.id}
|
||||
borderBottomWidth="1px"
|
||||
borderColor="gray.200"
|
||||
_hover={{ bg: "blue.50" }}
|
||||
transition="0.2s"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Td
|
||||
key={cell.id}
|
||||
py={3}
|
||||
fontSize="md"
|
||||
color="gray.700"
|
||||
borderRightWidth="1px"
|
||||
borderRightColor="gray.200"
|
||||
textAlign={
|
||||
cell.column.columnDef.meta?.isCentered ? "center" : "start"
|
||||
}
|
||||
verticalAlign="middle"
|
||||
>
|
||||
{cell.column.columnDef.cell
|
||||
? flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
: cell.getValue()}
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
))
|
||||
) : (
|
||||
<Tr>
|
||||
<Td colSpan={columns.length} py={8} textAlign="center">
|
||||
<Text color="gray.500">{emptyText}</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</Tbody>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Pagination -----
|
||||
|
||||
function Pagination({
|
||||
pagination: paginationProp,
|
||||
onPageChange: onPageChangeProp,
|
||||
}) {
|
||||
const context = useDataTable();
|
||||
|
||||
const { table } = context;
|
||||
|
||||
const pagination = paginationProp ?? context.pagination;
|
||||
|
||||
const totalCount =
|
||||
pagination?.total ?? pagination?.totalCount ?? context.totalCount ?? 0;
|
||||
|
||||
const totalPages =
|
||||
pagination?.total_pages ??
|
||||
pagination?.totalPages ??
|
||||
context.totalPages ??
|
||||
table.getPageCount() ??
|
||||
0;
|
||||
|
||||
const currentPage = table.getState().pagination.pageIndex + 1;
|
||||
|
||||
const handlePageChange = (nextPage) => {
|
||||
if (onPageChangeProp) {
|
||||
onPageChangeProp(nextPage);
|
||||
return;
|
||||
}
|
||||
|
||||
table.setPageIndex(nextPage - 1);
|
||||
};
|
||||
|
||||
if (!context.onPageChange && !onPageChangeProp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (totalPages <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mt={1} flexShrink={0}>
|
||||
<UIKitPagination
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
totalCount={totalCount}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Compound Export -----
|
||||
|
||||
export const DataTable = Object.assign(Root, {
|
||||
Topbar,
|
||||
Title,
|
||||
Spacer,
|
||||
Actions,
|
||||
SelectionActions,
|
||||
SelectAll,
|
||||
SelectedCount,
|
||||
Content,
|
||||
Table,
|
||||
Head,
|
||||
Body,
|
||||
Pagination,
|
||||
});
|
||||
1
src/components/table/index.js
Normal file
1
src/components/table/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { DataTable } from "./DataTable.jsx";
|
||||
12
yarn.lock
12
yarn.lock
@ -609,6 +609,18 @@
|
||||
dependencies:
|
||||
"@tanstack/query-core" "5.101.0"
|
||||
|
||||
"@tanstack/react-table@^8.21.3":
|
||||
version "8.21.3"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.3.tgz#2c38c747a5731c1a07174fda764b9c2b1fb5e91b"
|
||||
integrity sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==
|
||||
dependencies:
|
||||
"@tanstack/table-core" "8.21.3"
|
||||
|
||||
"@tanstack/table-core@8.21.3":
|
||||
version "8.21.3"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.21.3.tgz#2977727d8fc8dfa079112d9f4d4c019110f1732c"
|
||||
integrity sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==
|
||||
|
||||
"@types/babel__core@^7.20.5":
|
||||
version "7.20.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user