Files
Open-Assistant/website/src/components/DataTable.tsx
T
2023-02-07 19:56:59 +09:00

206 lines
5.6 KiB
TypeScript

import {
Box,
Button,
Flex,
FormControl,
FormLabel,
Input,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverTrigger,
Spacer,
Table,
TableCaption,
TableContainer,
TableRowProps,
Tbody,
Td,
Th,
Thead,
Tr,
useDisclosure,
} from "@chakra-ui/react";
import { Cell, ColumnDef, flexRender, getCoreRowModel, Row, useReactTable } from "@tanstack/react-table";
import { Filter } from "lucide-react";
import { useTranslation } from "next-i18next";
import { ChangeEvent, ReactNode } from "react";
import { useDebouncedCallback } from "use-debounce";
export type DataTableColumnDef<T> = ColumnDef<T> & {
filterable?: boolean;
span?: number | ((cell: Cell<T, unknown>) => number | undefined);
};
// TODO: stricter type
export type FilterItem = {
id: string;
value: string;
};
export type DataTableRowPropsCallback<T> = (row: Row<T>) => TableRowProps;
export type DataTableProps<T> = {
data: T[];
columns: DataTableColumnDef<T>[];
caption?: string;
filterValues?: FilterItem[];
onNextClick?: () => void;
onPreviousClick?: () => void;
onFilterChange?: (items: FilterItem[]) => void;
disableNext?: boolean;
disablePrevious?: boolean;
disablePagination?: boolean;
rowProps?: TableRowProps | DataTableRowPropsCallback<T>;
};
export const DataTable = <T,>({
data,
columns,
caption,
filterValues = [],
onNextClick,
onPreviousClick,
onFilterChange,
disableNext,
disablePrevious,
disablePagination,
rowProps,
}: DataTableProps<T>) => {
const { t } = useTranslation("leaderboard");
const { getHeaderGroups, getRowModel } = useReactTable<T>({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const handleFilterChange = (value: FilterItem) => {
const idx = filterValues.findIndex((oldValue) => oldValue.id === value.id);
let newValues: FilterItem[] = [];
if (idx === -1) {
newValues = [...filterValues, value];
} else {
newValues = filterValues.map((oldValue) => (oldValue.id === value.id ? value : oldValue));
}
onFilterChange && onFilterChange(newValues);
};
return (
<>
{!disablePagination && (
<Flex mb="2">
<Button onClick={onPreviousClick} disabled={disablePrevious}>
{t("previous")}
</Button>
<Spacer />
<Button onClick={onNextClick} disabled={disableNext}>
{t("next")}
</Button>
</Flex>
)}
<TableContainer>
<Table variant="simple">
<TableCaption pb={0}>{caption}</TableCaption>
<Thead>
{getHeaderGroups().map((headerGroup) => (
<Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Th key={header.id}>
<Box display="flex" alignItems="center">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{(header.column.columnDef as DataTableColumnDef<T>).filterable && (
<FilterModal
value={filterValues.find((value) => value.id === header.id)?.value ?? ""}
onChange={(value) => handleFilterChange({ id: header.id, value })}
label={flexRender(header.column.columnDef.header, header.getContext())}
></FilterModal>
)}
</Box>
</Th>
))}
</Tr>
))}
</Thead>
<Tbody>
{getRowModel().rows.map((row) => {
const props = typeof rowProps === "function" ? rowProps(row) : rowProps;
return (
<Tr key={row.id} {...props}>
<DataTableRow row={row}></DataTableRow>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
</>
);
};
type WithSpanCell<T> = Cell<T, unknown> & { span?: number };
const DataTableRow = <T,>({ row }: { row: Row<T> }) => {
const cells: WithSpanCell<T>[] = row.getVisibleCells();
const renderCells: WithSpanCell<T>[] = [];
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
const span = (cell.column.columnDef as DataTableColumnDef<T>).span;
const spanValue = typeof span === "function" ? span(cell) : span;
if (spanValue && spanValue > 1) {
i += spanValue - 1; // skip next `spanValue - 1` cell
}
cell.span = spanValue;
renderCells.push(cell);
}
return (
<>
{renderCells.map((cell) => {
return (
<Td key={cell.id} colSpan={cell.span}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Td>
);
})}
</>
);
};
const FilterModal = ({
label,
onChange,
value,
}: {
label: ReactNode;
onChange: (val: string) => void;
value: string;
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const handleInputChange = useDebouncedCallback((e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
}, 500);
return (
<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<PopoverTrigger>
<Button variant={"unstyled"} ml="2">
<Filter size="1em"></Filter>
</Button>
</PopoverTrigger>
<PopoverContent w="fit-content">
<PopoverArrow />
<PopoverCloseButton />
<PopoverBody mt="4">
<FormControl>
<FormLabel>{label}</FormLabel>
<Input onChange={handleInputChange} defaultValue={value}></Input>
</FormControl>
</PopoverBody>
</PopoverContent>
</Popover>
);
};