From 06de2524d90704618889b0f063e5bb4d4e42946d Mon Sep 17 00:00:00 2001 From: keisuke-umezawa Date: Thu, 16 May 2024 18:20:36 +0900 Subject: [PATCH] Copy DataGrid implementation --- tslib/react/src/components/DataGrid.tsx | 688 ++++++++++++------------ 1 file changed, 343 insertions(+), 345 deletions(-) diff --git a/tslib/react/src/components/DataGrid.tsx b/tslib/react/src/components/DataGrid.tsx index 25509683..b268ee81 100644 --- a/tslib/react/src/components/DataGrid.tsx +++ b/tslib/react/src/components/DataGrid.tsx @@ -1,9 +1,15 @@ -import { Clear } from "@mui/icons-material" -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown" -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp" +import CheckBoxIcon from "@mui/icons-material/CheckBox" +import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank" +import FilterListIcon from "@mui/icons-material/FilterList" +import FirstPageIcon from "@mui/icons-material/FirstPage" +import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft" +import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight" +import LastPageIcon from "@mui/icons-material/LastPage" import { - Collapse, + Box, IconButton, + Menu, + MenuItem, Table, TableBody, TableCell, @@ -12,372 +18,364 @@ import { TablePagination, TableRow, TableSortLabel, + TextField, useTheme, } from "@mui/material" +import ListItemIcon from "@mui/material/ListItemIcon" +import Paper from "@mui/material/Paper" +import { TablePaginationActionsProps } from "@mui/material/TablePagination/TablePaginationActions" import { styled } from "@mui/system" import React from "react" -type Order = "asc" | "desc" +import { + ColumnDef, + ColumnFiltersState, + Header, + PaginationState, + SortingState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" -// biome-ignore lint/suspicious/noExplicitAny: -type Value = any +const TableHeaderCellSpan = styled("span")({ + display: "inline-flex", +}) -const defaultRowsPerPageOption = [10, 50, 100, { label: "All", value: -1 }] +const HiddenSpan = styled("span")({ + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute", + top: 20, + width: 1, +}) -interface DataGridColumn { - field: keyof T - label: string - sortable?: boolean - less?: (a: T, b: T, ascending: boolean) => number - filterable?: boolean - toCellValue?: (rowIndex: number) => string | React.ReactNode - padding?: "normal" | "checkbox" | "none" -} - -interface RowFilter { - columnIdx: number - value: Value -} - -function DataGrid(props: { - columns: DataGridColumn[] - rows: T[] - keyField: keyof T - dense?: boolean - collapseBody?: (rowIndex: number) => React.ReactNode - initialRowsPerPage?: number - rowsPerPageOption?: Array - defaultFilter?: (row: T) => boolean +function FilterMenu({ + header, + filterChoices, +}: { + header: Header + filterChoices: string[] }): React.ReactElement { - const { columns, rows, keyField, dense, collapseBody, defaultFilter } = props - let { initialRowsPerPage, rowsPerPageOption } = props - const [order, setOrder] = React.useState("asc") - const [orderBy, setOrderBy] = React.useState(0) // index of columns - const [page, setPage] = React.useState(0) - const [filters, setFilters] = React.useState([]) - - const getRowIndex = (row: T): number => { - return rows.findIndex((row2) => row[keyField] === row2[keyField]) - } - - // Pagination - rowsPerPageOption = rowsPerPageOption || defaultRowsPerPageOption - initialRowsPerPage = initialRowsPerPage // use first element as default - ? initialRowsPerPage - : isNumber(rowsPerPageOption[0]) - ? rowsPerPageOption[0] - : rowsPerPageOption[0].value - const [rowsPerPage, setRowsPerPage] = React.useState(initialRowsPerPage) - - const handleChangePage = (_event: unknown, newPage: number) => { - setPage(newPage) - } - - const handleChangeRowsPerPage = ( - event: React.ChangeEvent - ) => { - setRowsPerPage(parseInt(event.target.value, 10)) - setPage(0) - } - - // Filtering - const fieldAlreadyFiltered = (columnIdx: number): boolean => - filters.some((f) => f.columnIdx === columnIdx) - - const handleClickFilterCell = (columnIdx: number, value: Value) => { - if (fieldAlreadyFiltered(columnIdx)) { - return - } - const newFilters = [...filters, { columnIdx: columnIdx, value: value }] - setFilters(newFilters) - } - - const clearFilter = (columnIdx: number): void => { - setFilters(filters.filter((f) => f.columnIdx !== columnIdx)) - } - - const filteredRows = rows.filter((row, rowIdx) => { - if (defaultFilter?.(row)) { - return false - } - return filters.length === 0 - ? true - : filters.some((f) => { - if (columns.length <= f.columnIdx) { - console.log( - `columnIdx=${f.columnIdx} must be smaller than columns.length=${columns.length}` - ) - return true - } - const toCellValue = columns[f.columnIdx].toCellValue - if (toCellValue !== undefined) { - return toCellValue(rowIdx) === f.value - } - const field = columns[f.columnIdx].field - return row[field] === f.value - }) - }) - - // Sorting - const createSortHandler = (columnId: number) => () => { - const isAsc = orderBy === columnId && order === "asc" - setOrder(isAsc ? "desc" : "asc") - setOrderBy(columnId) - } - const sortedRows = stableSort(filteredRows, order, orderBy, columns) - const currentPageRows = - rowsPerPage > 0 - ? sortedRows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) - : sortedRows - const emptyRows = - rowsPerPage - Math.min(rowsPerPage, sortedRows.length - page * rowsPerPage) - - const RootDiv = styled("div")({ - width: "100%", - }) - const HiddenSpan = styled("span")({ - border: 0, - clip: "rect(0 0 0 0)", - height: 1, - margin: -1, - overflow: "hidden", - padding: 0, - position: "absolute", - top: 20, - width: 1, - }) - const TableHeaderCellSpan = styled("span")({ - display: "inline-flex", - }) + const [filterMenuAnchorEl, setFilterMenuAnchorEl] = + React.useState(null) return ( - - - + <> + { + setFilterMenuAnchorEl(e.currentTarget) + }} + > + + + { + setFilterMenuAnchorEl(null) + }} + > + {filterChoices.map((choice) => ( + { + const skippedValues = header.column.getFilterValue() as string[] + const isSkipped = skippedValues.includes(choice) + const newSkippedValues = isSkipped + ? skippedValues.filter((v) => v !== choice) + : skippedValues.concat(choice) + header.column.setFilterValue(newSkippedValues) + }} + > + + {header.column.getFilterValue() !== undefined ? ( + (header.column.getFilterValue() as string[]).includes( + choice + ) ? ( + + ) : ( + + ) + ) : null} + + {choice ?? "(missing value)"} + + ))} + + + ) +} + +function DataGrid({ + data, + columns, +}: { + data: T[] + columns: ColumnDef[] +}): React.ReactElement { + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState( + [] + ) + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 50, + }) + + const table = useReactTable({ + data, + columns, + state: { + columnFilters, + sorting, + pagination, + }, + onColumnFiltersChange: setColumnFilters, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + autoResetPageIndex: false, + }) + + return ( + + +
- - {collapseBody ? : null} - {columns.map((column, columnIdx) => ( - - key={columnIdx} - padding={column.padding || "normal"} - sortDirection={orderBy === column.field ? order : false} - > - - {column.sortable ? ( - - {column.label} - {orderBy === column.field ? ( - - {order === "desc" - ? "sorted descending" - : "sorted ascending"} - - ) : null} - - ) : ( - column.label - )} - {column.filterable ? ( - { - clearFilter(columnIdx) - }} - > - - - ) : null} - - - ))} - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + if ( + header.column.getCanFilter() && + !header.column.getIsFiltered() + ) { + header.column.setFilterValue([]) + } + const order = header.column.getIsSorted() + const filterChoices = header.column.getCanFilter() + ? Array.from( + header.column.getFacetedUniqueValues().keys() + ).sort() + : null + return ( + + {header.isPlaceholder ? null : ( + + {header.column.getCanSort() ? ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {order !== null ? ( + + {order === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {filterChoices !== null ? ( + + ) : null} + + )} + + ) + })} + + ))} - {currentPageRows.map((row) => ( - - columns={columns} - rowIndex={getRowIndex(row)} - row={row} - keyField={keyField} - collapseBody={collapseBody} - key={`${row[keyField]}`} - handleClickFilterCell={handleClickFilterCell} - /> - ))} - {emptyRows > 0 && ( - - - - )} + {table.getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ) + })} + + ) + })}
- -
- ) -} - -function DataGridRow(props: { - columns: DataGridColumn[] - rowIndex: number - row: T - keyField: keyof T - collapseBody?: (rowIndex: number) => React.ReactNode - handleClickFilterCell: (columnIdx: number, value: Value) => void -}) { - const { - columns, - rowIndex, - row, - keyField, - collapseBody, - handleClickFilterCell, - } = props - const [open, setOpen] = React.useState(false) - const theme = useTheme() - - const FilterableDiv = styled("div")({ - color: theme.palette.primary.main, - textDecoration: "underline", - cursor: "pointer", - }) - return ( - - - {collapseBody ? ( - - setOpen(!open)} - > - {open ? : } - - + + { + table.setPageIndex(page) + }} + onRowsPerPageChange={(e) => { + const size = e.target.value ? Number(e.target.value) : 10 + table.setPageSize(size) + }} + ActionsComponent={TablePaginationActions} + /> + {table.getPageCount() > 2 ? ( + table.setPageIndex(page)} + maxPageNumber={table.getPageCount()} + /> ) : null} - {columns.map((column, columnIndex) => { - const cellItem = column.toCellValue - ? column.toCellValue(rowIndex) - : // TODO(c-bata): Avoid this implicit type conversion. - (row[column.field] as number | string | null | undefined) - - return column.filterable ? ( - { - const value = - column.toCellValue !== undefined - ? column.toCellValue(rowIndex) - : row[column.field] - handleClickFilterCell(columnIndex, value) - }} - > - {cellItem} - - ) : ( - - {cellItem} - - ) - })} - - {collapseBody ? ( - - - - {collapseBody(rowIndex)} - - - - ) : null} - + + ) } -function getComparator( - order: Order, - columns: DataGridColumn[], - orderBy: number -): (a: T, b: T) => number { - return order === "desc" - ? (a, b) => descendingComparator(a, b, columns, orderBy) - : (a, b) => -descendingComparator(a, b, columns, orderBy) -} - -function descendingComparator( - a: T, - b: T, - columns: DataGridColumn[], - orderBy: number -): number { - const field = columns[orderBy].field - if (b[field] < a[field]) { - return -1 +const TablePaginationActions = ({ + count, + page, + rowsPerPage, + onPageChange, +}: TablePaginationActionsProps) => { + const theme = useTheme() + const handleFirstPageButtonClick = ( + event: React.MouseEvent + ) => { + onPageChange(event, 0) } - if (b[field] > a[field]) { - return 1 + + const handleBackButtonClick = ( + event: React.MouseEvent + ) => { + onPageChange(event, page - 1) } - return 0 + + const handleNextButtonClick = ( + event: React.MouseEvent + ) => { + onPageChange(event, page + 1) + } + + const handleLastPageButtonClick = ( + event: React.MouseEvent + ) => { + onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1)) + } + + return ( + + + {theme.direction === "rtl" ? : } + + + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="next page" + > + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="last page" + > + {theme.direction === "rtl" ? : } + + + ) } -function stableSort( - array: T[], - order: Order, - orderBy: number, - columns: DataGridColumn[] -) { - // TODO(c-bata): Refactor here by implementing as the same comparator interface. - const less = columns[orderBy].less - const comparator = getComparator(order, columns, orderBy) - const stabilizedThis = array.map((el, index) => [el, index] as [T, number]) - stabilizedThis.sort((a, b) => { - if (less) { - const ascending = order === "asc" - const result = ascending - ? -less(a[0], b[0], ascending) - : less(a[0], b[0], ascending) - if (result !== 0) return result - } else { - const result = comparator(a[0], b[0]) - if (result !== 0) return result - } - return a[1] - b[1] - }) - return stabilizedThis.map((el) => el[0]) -} +const PaginationForm1: React.FC<{ + onPageNumberSubmit: (value: number) => void + maxPageNumber: number +}> = ({ onPageNumberSubmit, maxPageNumber }) => { + // This component is separated from DataGrid to prevent `DataGrid` from re-rendering the page, + // every time any letters are input. + const [specifiedPageText, setSpecifiedPageText] = React.useState("") -const isNumber = ( - rowsPerPage: number | { value: number; label: string } -): rowsPerPage is number => { - return typeof rowsPerPage === "number" + const handleSubmitPageNumber = (event: React.FormEvent) => { + event.preventDefault() + const newPageNumber = parseInt(specifiedPageText, 10) + // Page is 0-indexed in `TablePagination`. + onPageNumberSubmit(newPageNumber - 1) + setSpecifiedPageText("") // reset the input field + } + + return ( +
+ { + setSpecifiedPageText(e.target.value) + }} + /> + + ) } export { DataGrid } -export type { DataGridColumn }