Files
bknd/app/src/ui/components/table/DataTable.tsx

315 lines
12 KiB
TypeScript

import { Menu } from "@mantine/core";
import { useToggle } from "@mantine/hooks";
import { ucFirst } from "core/utils";
import {
TbArrowDown,
TbArrowUp,
TbChevronLeft,
TbChevronRight,
TbChevronsLeft,
TbChevronsRight,
TbDotsVertical,
TbSelector,
TbSquare,
TbSquareCheckFilled,
} from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "../buttons/IconButton";
import { Dropdown, type DropdownItem } from "../overlay/Dropdown";
export const Check = () => {
const [checked, toggle] = useToggle([false, true]);
const Icon = checked ? TbSquareCheckFilled : TbSquare;
return (
<button role="checkbox" type="button" className="flex px-3 py-3" onClick={() => toggle()}>
<Icon size={18} />
</button>
);
};
export type DataTableProps<Data> = {
data: Data[] | null; // "null" implies loading
columns?: string[];
checkable?: boolean;
onClickRow?: (row: Data) => void;
onClickPage?: (page: number) => void;
total?: number;
page?: number;
perPage?: number;
rowActions?: (Omit<DropdownItem, "onClick"> & {
onClick: (row: Data, key: number) => void;
})[];
perPageOptions?: number[];
sort?: { by?: string; dir?: "asc" | "desc" };
onClickSort?: (name: string) => void;
onClickPerPage?: (perPage: number) => void;
renderHeader?: (column: string) => React.ReactNode;
renderValue?: ({ value, property }: { value: any; property: string }) => React.ReactNode;
classNames?: {
value?: string;
};
onClickNew?: () => void;
};
export function DataTable<Data extends Record<string, any> = Record<string, any>>({
data = [],
columns,
checkable,
onClickRow,
onClickPage,
onClickSort,
total,
sort,
page = 1,
perPage = 10,
perPageOptions,
onClickPerPage,
classNames,
renderHeader,
rowActions,
renderValue,
onClickNew,
}: DataTableProps<Data>) {
total = total || data?.length || 0;
page = page || 1;
const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {});
const pages = Math.max(Math.ceil(total / perPage), 1);
const CellRender = renderValue || CellValue;
return (
<div className="flex flex-col gap-3">
{onClickNew && (
<div className="flex flex-row space-between">
{onClickNew && <Button onClick={onClickNew}>Create new</Button>}
</div>
)}
<div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-x-scroll overflow-y-hidden">
<table className="w-full text-md">
{select.length > 0 ? (
<thead className="sticky top-0 bg-muted/10">
<tr>
{checkable && (
<th align="center" className="w-[40px]">
<Check />
</th>
)}
{select.map((property, key) => {
const label = renderHeader?.(property) ?? ucFirst(property);
return (
<th key={key}>
<div className="flex flex-row py-1 px-1 font-normal text-primary/55">
<button
type="button"
className={twMerge(
"link hover:bg-primary/5 py-1.5 rounded-md inline-flex flex-row justify-start items-center gap-1",
onClickSort ? "pl-2.5 pr-1" : "px-2.5",
)}
onClick={() => onClickSort?.(property)}
>
<span className="text-left text-nowrap whitespace-nowrap">
{label}
</span>
{onClickSort && (
<SortIndicator sort={sort} field={property} />
)}
</button>
</div>
</th>
);
})}
{rowActions && rowActions.length > 0 && <th className="w-10" />}
</tr>
</thead>
) : null}
<tbody>
{!data || !Array.isArray(data) || data.length === 0 ? (
<tr>
<td colSpan={select.length + (checkable ? 1 : 0)}>
<div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted">
<i className="opacity-50">
{Array.isArray(data) ? (
"No data to show"
) : !data ? (
"Loading..."
) : (
<pre>{JSON.stringify(data, null, 2)}</pre>
)}
</i>
</div>
</td>
</tr>
) : (
data.map((row, key) => {
const rowClick = () => onClickRow?.(row);
return (
<tr
key={key}
data-border={key > 0}
className="hover:bg-primary/5 active:bg-muted border-muted data-[border]:border-t cursor-pointer transition-colors"
>
{checkable && (
<td align="center">
<Check />
</td>
)}
{Object.entries(row).map(([key, value], index) => (
<td key={index} onClick={rowClick}>
<div className="flex flex-row items-start py-3 px-3.5 font-normal ">
<CellRender property={key} value={value} />
</div>
</td>
))}
{rowActions && rowActions.length > 0 && (
<td>
{/* @todo: create new dropdown using popover */}
<div className="flex flex-row justify-end pr-2">
<Menu position="bottom-end">
<Menu.Target>
<IconButton Icon={TbDotsVertical} />
</Menu.Target>
<Menu.Dropdown>
{rowActions.map((a: any) => (
<Menu.Item
key={a.label}
onClick={() => a.onClick(row, key)}
leftSection={a.icon && <a.icon />}
>
{a.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</div>
</td>
)}
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="flex flex-row items-center justify-between">
<div className="hidden md:flex text-primary/40">
<TableDisplay
perPage={perPage}
page={page}
items={data?.length || 0}
total={total}
/>
</div>
<div className="flex flex-row gap-2 md:gap-10 items-center">
{perPageOptions && (
<div className="hidden md:flex flex-row items-center gap-2 text-primary/40">
Per Page{" "}
<Dropdown
items={perPageOptions.map((perPage) => ({
label: String(perPage),
perPage,
}))}
position="top-end"
onClickItem={(item: any) => onClickPerPage?.(item.perPage)}
>
<Button>{perPage}</Button>
</Dropdown>
</div>
)}
<div className="text-primary/40">
Page {page} of {pages}
</div>
{onClickPage && (
<div className="flex flex-row gap-1.5">
<TableNav current={page} total={pages} onClick={onClickPage} />
</div>
)}
</div>
</div>
</div>
);
}
export const CellValue = ({ value, property }) => {
let value_mono = false;
//console.log("value", property, value);
if (value !== null && typeof value === "object") {
value = JSON.stringify(value);
value_mono = true;
}
if (value !== null && typeof value !== "undefined") {
return (
<span className={twMerge("line-clamp-2", value_mono && "font-mono break-all")}>
{value}
</span>
);
}
return <span className="opacity-10 font-mono">null</span>;
};
const SortIndicator = ({
sort,
field,
}: {
sort: Pick<DataTableProps<any>, "sort">["sort"];
field: string;
}) => {
if (!sort || sort.by !== field) return <TbSelector size={18} className="mt-[1px]" />;
if (sort.dir === "asc") return <TbArrowUp size={18} className="mt-[1px]" />;
return <TbArrowDown size={18} className="mt-[1px]" />;
};
const TableDisplay = ({ perPage, page, items, total }) => {
if (total === 0) {
return <>No rows to show</>;
}
if (total === 1) {
return <>Showing 1 row</>;
}
return (
<>
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
</>
);
};
type TableNavProps = {
current: number;
total: number;
onClick?: (page: number) => void;
};
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
const navMap = [
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
{ value: total, Icon: TbChevronsRight, disabled: current === total },
] as const;
return navMap.map((nav, key) => (
<button
role="button"
type="button"
key={key}
disabled={nav.disabled}
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
const page = nav.value;
const safePage = page < 1 ? 1 : page > total ? total : page;
onClick?.(safePage);
}}
>
<nav.Icon />
</button>
));
};