Docs
Data Table
Data Table
Utils and components for building client side data tables.
Loading...
Installation
Install the following dependencies:
pnpm add zustand immer
Add the following components/libraries:
Create lib/data-table/core.tsx
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { Loader2 } from "lucide-react";
import type { SortDescriptor } from "react-aria-components";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import type { ArrayItem, Slice } from "@/lib/types";
/* -------------------------------------------------------------------------- */
/* PAGINATION */
/* -------------------------------------------------------------------------- */
export type PaginationLimit = 20 | 50 | "all";
export interface PaginationState {
value: {
page: number;
limit: 20 | 50 | "all";
};
set: ({ page, limit }: { page?: number; limit?: PaginationLimit }) => void;
}
export interface PaginationSlice {
pagination: PaginationState;
}
export function createPaginationSlice(defaultValues?: {
page?: number;
limit?: PaginationLimit;
}) {
const slice: Slice<PaginationSlice> = (set) => ({
pagination: {
value: {
page: defaultValues?.page ?? 1,
limit: defaultValues?.limit ?? 20,
},
set: ({ page, limit }) => {
set((state) => {
if (page) {
state.pagination.value.page = page;
}
if (limit) {
state.pagination.value.limit = limit;
}
});
},
},
});
return slice;
}
/* -------------------------------------------------------------------------- */
/* SORTING */
/* -------------------------------------------------------------------------- */
export type SortingDirection = "asc" | "desc";
export interface SortingState<T extends readonly string[]> {
value: {
key: T[number] | undefined;
direction: SortingDirection;
};
set: (args: { key?: T[number]; direction?: SortingDirection }) => void;
}
export type SortingAlgorithms<
DataArray extends any[],
T extends readonly string[],
> = {
[key in T[number]]: (
a: ArrayItem<DataArray>,
b: ArrayItem<DataArray>,
) => {
asc: number;
desc: number;
};
};
export interface SortingSlice<
DataArray extends any[],
T extends readonly string[],
> {
sorting: SortingState<T> & {
sortingAlgorithms: SortingAlgorithms<DataArray, T>;
};
}
export const createSortingSlice = <
DataArray extends any[],
T extends readonly string[],
>(sortingAlgorithms: {
[key in T[number]]: (
a: ArrayItem<DataArray>,
b: ArrayItem<DataArray>,
) => {
asc: number;
desc: number;
};
}) => {
const slice: Slice<SortingSlice<DataArray, T>> = (set) => ({
sorting: {
value: {
direction: "asc",
key: undefined,
},
set: ({ key, direction }) => {
set((state) => {
if (key) {
// @ts-expect-error Don't know how to fix this, but should theoratically be fine 🥶
state.sorting.value.key = key;
}
if (direction) {
state.sorting.value.direction = direction;
}
});
},
sortingAlgorithms,
},
});
return slice;
};
export type InferSortingSlice<T> = T extends Slice<infer U> ? U : never;
/* -------------------------------------------------------------------------- */
/* STORE */
/* -------------------------------------------------------------------------- */
/**
* This store manages all the data filtering, sorting and pagination.
*
* You have to manually provide the generics for this function.
*
* 1. Generic type
* Declare the types of all filters you want to use.
*
* 2. Generic Type
* Declare the type of the data array you want to filter.
*
* 3. Generic Type
* Declare all keys for which you want to sort the table. You can choose
* any name you want because the mapping to the actual data is done in the
* function function parameters.
*
* @param filterSlice Define the store.
* @param sortingAlgorithms Define which sorting algorithms should be used for the declared keys.
* @returns
*/
export const createDataStore = <
TFilterSlice extends Record<string, { value: unknown; set: unknown }>,
TDataArray extends any[] = [],
const TKeys extends readonly string[] = [],
>(
filterSlice: Slice<TFilterSlice>,
sortingAlgorithms: {
[key in TKeys[number]]: (
a: ArrayItem<TDataArray>,
b: ArrayItem<TDataArray>,
) => {
asc: number;
desc: number;
};
},
) => {
const paginationSlice = createPaginationSlice();
const sortingSlice = createSortingSlice<TDataArray, TKeys>(sortingAlgorithms);
return create<
TFilterSlice & PaginationSlice & InferSortingSlice<typeof sortingSlice>
>()(
immer((...args) => ({
...filterSlice(...(args as [any, any, any])),
...paginationSlice(...(args as [any, any, any])),
...sortingSlice(...(args as [any, any, any])),
})),
);
};
/* -------------------------------------------------------------------------- */
/* SORTING ALGORITHMS */
/* -------------------------------------------------------------------------- */
export const defaultStringSort = (a: string, b: string) => ({
asc: a.localeCompare(b),
desc: b.localeCompare(a),
});
export const defaultNumberSort = (a: number, b: number) => ({
asc: a - b,
desc: b - a,
});
export const defaultDateSort = (a: Date, b: Date) => ({
asc: a.getTime() - b.getTime(),
desc: b.getTime() - a.getTime(),
});
export const defaultBooleanSort = (a: boolean, b: boolean) => ({
asc: a === b ? 0 : a ? 1 : -1,
desc: a === b ? 0 : a ? -1 : 1,
});
/* -------------------------------------------------------------------------- */
/* MAIN FUNCTIONS */
/* -------------------------------------------------------------------------- */
export function sortAndPaginate<DataType, T extends string[]>(
data: DataType[],
state?: {
pagination?: PaginationState;
sorting?: SortingState<T> & {
sortingAlgorithms: SortingAlgorithms<DataType[], T>;
};
},
): {
data: DataType[];
total: number;
} {
const total = data.length;
let dataCopy = [...data];
if (dataCopy.length === 0) {
return {
data,
total: 0,
};
}
if (state?.sorting && state.sorting.value.key) {
dataCopy = dataCopy.sort((a, b) => {
if (!state.sorting?.value.key) {
throw new Error("Undefined key.");
}
const algo = state.sorting.sortingAlgorithms[state.sorting.value.key];
return algo(a, b)[state.sorting.value.direction];
});
}
if (state?.pagination && state.pagination.value.limit !== "all") {
dataCopy = dataCopy.slice(
(state.pagination.value.page - 1) * state.pagination.value.limit,
state.pagination.value.page * state.pagination.value.limit,
);
}
return {
data: dataCopy,
total,
};
}
/**
* Obligatory dependencies:
* - data
* - pagination.value
* - sorting.value
*
* You also have to declare the filter values from the store as dependencies.
*/
export const filterData = <
TData extends any[],
TFilteredData extends {
result: TData;
},
>(config: {
data: TData | undefined;
store: {
pagination: PaginationState;
sorting: SortingState<any>;
};
filter: (data: TData) => TFilteredData;
}):
| (Omit<TFilteredData, "result"> & {
result: {
data: TData;
total: number;
};
})
| null => {
if (config.data) {
const { result, ...filtered } = config.filter(config.data);
return {
...filtered,
result: sortAndPaginate(result, {
pagination: config.store.pagination,
sorting: config.store.sorting as any,
}),
} as any;
}
return null;
};
export const getDataTableProps = <
TAllKeys extends readonly string[],
TKeys extends TAllKeys[number],
>(state: {
value: {
key: TKeys | undefined;
direction: SortingDirection;
};
set: (args: { key?: TKeys; direction?: SortingDirection }) => void;
}): {
onSortChange: (descriptor: SortDescriptor) => void;
sortDescriptor: SortDescriptor;
} => {
return {
onSortChange: (descriptor) => {
state.set({
key: descriptor.column as any,
direction: descriptor.direction === "ascending" ? "asc" : "desc",
});
},
sortDescriptor: {
column: state.value.key,
direction: state.value.direction === "asc" ? "ascending" : "descending",
},
};
};
export const renderEmptyState = (text: string, isPending?: boolean) => () => {
if (isPending) {
return <Loader2 className="h-6 w-full animate-spin" />;
}
return <>{text}</>;
};
(Optional) Create lib/data-table/table-sorting-column.tsx
"use client";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import type { TableColumnProps } from "@/components/ui/table";
import { TableColumn } from "@/components/ui/table";
import type { SortingDirection } from "@/lib/data-table/core";
export interface TableSortingColumnProps<
TAllKeys extends readonly string[],
TKeys extends TAllKeys[number],
TData extends object,
> extends Omit<TableColumnProps<TData>, "allowsSorting"> {
state: {
value: {
key: TKeys | undefined;
direction: SortingDirection;
};
set: (args: { key?: TKeys; direction?: SortingDirection }) => void;
};
sortBy: TKeys;
text: string;
}
export const TableSortingColumn = <
TAllKeys extends readonly string[],
TKeys extends TAllKeys[number],
TData extends object,
>({
state,
sortBy,
text,
...props
}: TableSortingColumnProps<TAllKeys, TKeys, TData>) => {
return (
<TableColumn id={sortBy} allowsSorting {...props}>
<div className="flex items-center">
<span className={"mr-2"}>{text}</span>
{state.value.key === sortBy ? (
state.value.direction === "asc" ? (
<ArrowUp aria-hidden="true" className="h-4 w-4" />
) : (
<ArrowDown aria-hidden="true" className="h-4 w-4" />
)
) : (
<ArrowUpDown aria-hidden="true" className="h-4 w-4" />
)}
</div>
</TableColumn>
);
};
(Optional) Create lib/data-table/paginator.tsx
"use client";
import { useState } from "react";
import { Slot } from "@radix-ui/react-slot";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import type { SelectOption, SelectOptions } from "@/components/ui/select";
import { Select } from "@/components/ui/select";
import type { PaginationLimit, PaginationState } from "@/lib/data-table/core";
import { cn } from "@/lib/utils";
const selectLimitOptions: SelectOptions<PaginationLimit> = [
{
label: "20",
value: 20,
},
{
label: "50",
value: 50,
},
{
label: "Alle",
value: "all",
},
];
const PageButton = ({
disabled,
onClick,
icon,
}: {
disabled: boolean;
onClick: () => void;
icon: React.ReactNode;
}) => {
return (
<Button
variant="ghost"
isDisabled={disabled}
className="group flex rounded-full enabled:hover:bg-muted"
onClick={onClick}
>
<Slot className="h-4 w-4 text-muted-foreground group-disabled:text-gray-300">
{icon}
</Slot>
</Button>
);
};
export const Paginator = ({
state,
total,
className,
}: {
state: PaginationState;
total: number | undefined;
className?: string;
}) => {
const pagination = state.value;
const currentLimit = selectLimitOptions.find(
(option) => option.value === state.value.limit,
);
if (!currentLimit) {
throw new Error("Invalid limit, this shouldn't happen.");
}
const [limit, setLimit] =
useState<SelectOption<PaginationLimit>>(currentLimit);
if (!total || total <= 0) {
return null;
}
const isFirstPage = pagination.page === 1;
const isLastPage =
pagination.limit === "all" ||
pagination.page === Math.ceil(total / pagination.limit);
const canGoBack = pagination.limit !== "all" && pagination.page !== 1;
const canGoForward =
pagination.limit !== "all" && pagination.limit * pagination.page <= total;
const goToFirstPage = () => state.set({ page: 1 });
const goToPreviousPage = () =>
canGoBack && state.set({ page: pagination.page - 1 });
const goToNextPage = () =>
canGoForward && state.set({ page: pagination.page + 1 });
const goToLastPage = () =>
!isLastPage &&
pagination.limit !== "all" &&
state.set({ page: Math.ceil(total / pagination.limit) });
return (
<div
className={cn(
"flex h-16 min-h-[4rem] items-center justify-between px-4",
className,
)}
>
<div className="flex items-center">
<PageButton
disabled={isFirstPage}
onClick={goToFirstPage}
icon={<ChevronsLeft />}
/>
<PageButton
disabled={isFirstPage}
onClick={goToPreviousPage}
icon={<ChevronLeft />}
/>
<div className="mx-3 space-x-2 text-sm text-muted-foreground">
<span>{pagination.page}</span>
<span>von</span>
<span>
{pagination.limit === "all"
? 1
: Math.ceil(total / pagination.limit)}
</span>
</div>
<PageButton
disabled={!canGoForward}
onClick={goToNextPage}
icon={<ChevronRight />}
/>
<PageButton
disabled={isLastPage}
onClick={goToLastPage}
icon={<ChevronsRight />}
/>
</div>
<div>
<Select
className="w-14"
options={selectLimitOptions}
value={limit}
onChange={setLimit}
onSelect={(option) => state.set({ limit: option.value })}
/>
</div>
</div>
);
};