here's the entire component
import {
ColumnDef,
flexRender,
SortingState,
useReactTable,
getCoreRowModel,
} from "@tanstack/react-table";
import { useIntersectionObserver } from "@/hooks";
import {
Box,
Flex,
Text,
Paper,
Table,
Skeleton,
BoxProps,
useMantineTheme,
} from "@mantine/core";
import {
forwardRef,
ReactNode,
useEffect,
useState,
useMemo,
useRef,
memo,
} from "react";
import { QueryFunction, useInfiniteQuery } from "@tanstack/react-query";
import { RiDatabase2Line } from "react-icons/ri";
import { css } from "@emotion/css";
export type FetchFn<T> = (props: { pageParam: number }) => Promise<T[]>;
interface TableComponentProps<T> {
queryFn: QueryFunction<T[], string[], number>;
columns: ColumnDef<T, any>[];
queryKey: any[];
}
const TableComponent: <T>(props: TableComponentProps<T>) => ReactNode = memo(
({ queryKey, queryFn, columns }) => {
const skullComponentRef = useRef<HTMLDivElement>(null);
const [sortingState, setSortingState] = useState<SortingState>([]);
const entry = useIntersectionObserver(skullComponentRef, {});
const isVisible = !!entry?.isIntersecting;
const { colors } = useMantineTheme();
const {
data,
isLoading,
fetchStatus,
hasNextPage,
isRefetching,
fetchNextPage,
} = useInfiniteQuery({
queryFn,
initialPageParam: 0,
refetchOnWindowFocus: false,
staleTime: 1 * 60 * 1000, // 1 min
queryKey: [...queryKey, sortingState],
getNextPageParam: (lastPage, allpages) =>
lastPage.length ? allpages.length : null,
});
const scrollPingPong = () => {
const tableContainer = document.querySelector<HTMLDivElement>(
".mantine-ScrollArea-viewport"
);
if (tableContainer && skullComponentRef.current) {
tableContainer.scrollTop =
tableContainer.scrollHeight -
tableContainer.clientHeight -
skullComponentRef.current.offsetHeight;
tableContainer.scrollTop =
tableContainer.scrollHeight - tableContainer.clientHeight;
}
};
const flatData = useMemo(
() => data?.pages?.flatMap((row) => row) ?? [],
[data]
);
const table = useReactTable({
columns,
data: flatData,
state: { sorting: sortingState },
onSortingChange: setSortingState,
getCoreRowModel: getCoreRowModel(),
});
useEffect(() => {
if (isVisible && hasNextPage) {
scrollPingPong();
fetchNextPage();
}
}, [isVisible, hasNextPage, fetchNextPage]);
return (
<Paper
mt="md"
bg="white"
withBorder
radius={"lg"}
style={{ overflow: "hidden" }}
>
<Table.ScrollContainer
minWidth={800}
h={`calc(100vh - 80px - 4rem)`}
className={css({
".mantine-ScrollArea-viewport": { paddingBottom: 0 },
})}
>
<Table
className={css({
"tbody tr:nth-child(even)": {
background: colors.tailwind_gray[0],
},
})}
verticalSpacing="md"
horizontalSpacing="xl"
withRowBorders={false}
>
<Table.Thead
h="4rem"
c="white"
bg={colors.tailwind_indigo[6]}
style={{ position: "sticky", top: 0, zIndex: 1 }}
>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.Th
style={{ cursor: "pointer" }}
key={header.id}
onClick={() => {
header.column.toggleSorting();
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: " 🔼",
desc: " 🔽",
}[header.column.getIsSorted() as string] ?? null}
</Table.Th>
))}
</Table.Tr>
))}
</Table.Thead>
<Table.Tbody
style={{
maxHeight: "calc(100vh - 134px)",
overflowY: "auto",
}}
className={css({
"& tr:hover": {
transition: "background .1s ease",
background: `${colors.tailwind_gray[1]} !important`,
},
})}
>
{table.getRowModel().rows.map((row) => (
<Table.Tr style={{ cursor: "pointer" }} key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Td
key={cell.id}
onClick={() => {
cell.column.id !== "action" && row.toggleSelected();
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
{(hasNextPage || isRefetching || isLoading) && (
<Skull
skullAmount={isLoading ? 10 : 3}
ref={skullComponentRef}
px="xs"
/>
)}
{fetchStatus === "idle" && !hasNextPage && (
<Flex
py="lg"
gap="xs"
h="100%"
align="center"
tt="capitalize"
justify="center"
c="tailwind_submarine.9"
bg="tailwind_submarine.0"
>
<RiDatabase2Line size={25} />
<Text>no further records found.</Text>
</Flex>
)}
</Table.ScrollContainer>
</Paper>
);
}
);
const Skull = forwardRef<HTMLDivElement, BoxProps & { skullAmount: number }>(
({ skullAmount, ...props }, ref) => {
return (
<Box ref={ref} {...props}>
{Array(skullAmount)
.fill(null)
.flatMap((_, key) => {
return <Skeleton h="3.5rem" mt="xs" key={key} />;
})}
</Box>
);
}
);
export default TableComponent;
here it's being used
import {
Menu,
Modal,
Flex,
Title,
Stack,
Button,
Checkbox,
ActionIcon,
useMantineTheme,
} from "@mantine/core";
import { useQueryClient, useMutation } from "@tanstack/react-query";
import { IRetailer } from "@/providers/onboardingformprovider";
import { RiDeleteBin6Line, RiEdit2Line } from "react-icons/ri";
import { createColumnHelper } from "@tanstack/react-table";
import { BsThreeDotsVertical } from "react-icons/bs";
import TableComponent, { FetchFn } from "./table";
import { useDisclosure } from "@mantine/hooks";
import { useState } from "react";
interface Product
extends Pick<
IRetailer,
| "productname"
| "priceperunit"
| "productquantity"
| "productcategory"
| "productdescription"
> {
check?: any;
action?: any;
}
const columnHelper = createColumnHelper<Product>();
const ProductsList = () => {
const [selectedProduct, setSelectedProduct] = useState<Product>();
const [opened, { open, close }] = useDisclosure(false);
const [products, setProducts] = useState<Product[]>([
{
productname: "one",
priceperunit: 10,
productcategory: "test 1",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "two",
priceperunit: 10,
productcategory: "test 2",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "three",
priceperunit: 10,
productcategory: "test 3",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "four",
priceperunit: 10,
productcategory: "test 4",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "five",
priceperunit: 10,
productcategory: "test 5",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "six",
priceperunit: 10,
productcategory: "test 6",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "seven",
priceperunit: 10,
productcategory: "test 7",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "eight",
priceperunit: 10,
productcategory: "test 8",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "nine",
priceperunit: 10,
productcategory: "test 9",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "ten",
priceperunit: 10,
productcategory: "test 10",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "eleven",
priceperunit: 10,
productcategory: "test 1",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "twelve",
priceperunit: 10,
productcategory: "test 2",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "thirteen",
priceperunit: 10,
productcategory: "test 3",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "fourteen",
priceperunit: 10,
productcategory: "test 4",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "fifteen",
priceperunit: 10,
productcategory: "test 5",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "sixteen",
priceperunit: 10,
productcategory: "test 6",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "seventeen",
priceperunit: 10,
productcategory: "test 7",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "eighteen",
priceperunit: 10,
productcategory: "test 8",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "nineteen",
priceperunit: 10,
productcategory: "test 9",
productdescription: "test 1",
productquantity: 10,
},
{
productname: "twenty",
priceperunit: 10,
productcategory: "test 10",
productdescription: "test 1",
productquantity: 10,
},
]);
const { colors } = useMantineTheme();
const client = useQueryClient();
const DeleteProductsMuation = useMutation({
mutationFn: () =>
Promise.resolve(
setProducts((products) => {
return products.filter(
(product) => selectedProduct?.productname !== product.productname
);
})
),
onSuccess: () => {
client.resetQueries({
queryKey: ["products"],
});
},
});
const columns = [
columnHelper.accessor("check", {
header: ({ table }) => (
<Checkbox
color="tailwind_indigo.4"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={() => {
table.toggleAllPageRowsSelected();
}}
/>
),
cell: ({ row }) => <Checkbox checked={row.getIsSelected()} readOnly />,
}),
columnHelper.accessor("productname", {
cell: (info) => info.getValue(),
header: "Product Name",
}),
columnHelper.accessor("productcategory", {
cell: (info) => info.getValue(),
header: "Product Category",
enableSorting: true,
}),
columnHelper.accessor("productquantity", {
cell: (info) => info.getValue(),
header: "Product Quantity",
}),
columnHelper.accessor("priceperunit", {
cell: (info) => info.getValue(),
header: "Price",
}),
columnHelper.accessor("action", {
header: "Action",
cell: ({ row }) => (
<Menu
shadow="xl"
radius="md"
width={220}
position="bottom-end"
styles={{
dropdown: {
border: "1px solid",
borderColor: colors.tailwind_gray[0],
},
item: {
padding: ".5rem",
},
}}
>
<Menu.Target>
<ActionIcon
color="tailwind_gray.2"
variant="outline"
size="2.5rem"
radius="xl"
c="black"
>
<BsThreeDotsVertical />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
<RiEdit2Line
size={16}
color="var(--mantine-color-tailwind_gray-6)"
/>
}
>
Edit Product
</Menu.Item>
<Menu.Item
color="red"
leftSection={<RiDeleteBin6Line size={15} />}
onClick={() => {
setSelectedProduct(row.original);
open();
}}
>
Delete Product
</Menu.Item>
</Menu.Dropdown>
</Menu>
),
}),
];
const fetchProducts: FetchFn<Product> = async ({ pageParam }) => {
return await new Promise((resolve) => {
// !add offset and limit/length should be statically type like (20 or 7), offset should be the pageParam when fetching from database multiplying the two should do it such as(offset(pageParam * size) and limit(size))
const size = 7;
const start = pageParam * size;
const end = start + size;
const slicedProducts = products.slice(start, end);
return resolve(slicedProducts);
});
};
return (
<>
<TableComponent
columns={columns}
queryKey={["products"]}
queryFn={fetchProducts}
/>
<Modal
radius={"xl"}
opened={opened}
onClose={close}
withCloseButton={false}
>
<Modal.Body>
<Stack>
<Title size="h2" fz="lg" fw="500">
Are you sure you want to delete "{selectedProduct?.productname}"?
</Title>
<Flex gap=".5rem">
<Button
color="tailwind_gray"
variant="outline"
onClick={close}
radius="xl"
w="100%"
>
Discard
</Button>
<Button
w="100%"
color="red"
radius="xl"
onClick={() => {
close();
DeleteProductsMuation.mutate();
}}
>
Delete
</Button>
</Flex>
</Stack>
</Modal.Body>
</Modal>
</>
);
};
export default ProductsList;
the sorting icons " 🔼" " 🔽" visually change their appearance based on the sorting state but the columns are not being visually sorted from ascending to descending order and vice versa. I'm using "useInfiniteQuery" hook
tried other examples online, the code seems to be valid.
this bit was missing
const table = useReactTable({
columns,
data: flatData,
state: { sorting: sortingState, columnFilters, globalFilter },
onSortingChange: setSortingState,
getCoreRowModel: getCoreRowModel(),
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
getSortedRowModel: getSortedRowModel(), <=========
});