From be61085d11a8848ebf7e7ea41318058f21d1f72a Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Thu, 13 Jul 2023 16:38:53 +0200 Subject: [PATCH] refactor(ui-common): implement MaterialReactTable --- webapp/package.json | 3 +- webapp/public/locales/en/main.json | 1 + webapp/public/locales/fr/main.json | 1 + .../common/DynamicDataTable/TableRowGroup.tsx | 86 -------- .../common/DynamicDataTable/TableRowItem.tsx | 66 ------ .../common/DynamicDataTable/TableToolbar.tsx | 54 ----- .../common/DynamicDataTable/index.tsx | 192 ------------------ .../common/DynamicDataTable/utils.ts | 85 -------- .../GroupedDataTable/CreateRowDialog.tsx | 92 +++++++++ .../common/GroupedDataTable/index.tsx | 179 ++++++++++++++++ webapp/src/theme.ts | 12 ++ 11 files changed, 287 insertions(+), 484 deletions(-) delete mode 100644 webapp/src/components/common/DynamicDataTable/TableRowGroup.tsx delete mode 100644 webapp/src/components/common/DynamicDataTable/TableRowItem.tsx delete mode 100644 webapp/src/components/common/DynamicDataTable/TableToolbar.tsx delete mode 100644 webapp/src/components/common/DynamicDataTable/index.tsx delete mode 100644 webapp/src/components/common/DynamicDataTable/utils.ts create mode 100644 webapp/src/components/common/GroupedDataTable/CreateRowDialog.tsx create mode 100644 webapp/src/components/common/GroupedDataTable/index.tsx diff --git a/webapp/package.json b/webapp/package.json index 86d4d04cf8..313f841d3b 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -3,7 +3,7 @@ "version": "2.14.4", "private": true, "engines": { - "node": "^14" + "node": "18.12.0" }, "dependencies": { "@emotion/react": "11.10.6", @@ -42,6 +42,7 @@ "js-cookie": "3.0.1", "jwt-decode": "3.1.2", "lodash": "4.17.21", + "material-react-table": "^1.14.0", "moment": "2.29.4", "notistack": "2.0.8", "os": "0.1.2", diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 44dd881f26..ff2eb9c7f9 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -112,6 +112,7 @@ "form.submit.inProgress": "The form is being submitted. Are you sure you want to leave the page?", "form.asyncDefaultValues.error": "Failed to get values", "form.field.required": "Field required", + "form.field.duplicate": "{{0}} already exists", "form.field.minLength": "{{0}} character(s) minimum", "form.field.minValue": "The minimum value is {{0}}", "form.field.maxValue": "The maximum value is {{0}}", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index bed46ad55d..0827d9a7c2 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -112,6 +112,7 @@ "form.submit.inProgress": "Le formulaire est en cours de soumission. Etes-vous sûr de vouloir quitter la page ?", "form.asyncDefaultValues.error": "Impossible d'obtenir les valeurs", "form.field.required": "Champ requis", + "form.field.duplicate": "{{0}} cette valeur existe déjà", "form.field.minLength": "{{0}} caractère(s) minimum", "form.field.minValue": "La valeur minimum est {{0}}", "form.field.maxValue": "La valeur maximum est {{0}}", diff --git a/webapp/src/components/common/DynamicDataTable/TableRowGroup.tsx b/webapp/src/components/common/DynamicDataTable/TableRowGroup.tsx deleted file mode 100644 index ad4ade26e4..0000000000 --- a/webapp/src/components/common/DynamicDataTable/TableRowGroup.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { - TableRow, - TableCell, - IconButton, - Box, - Typography, -} from "@mui/material"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; -import { ChangeEvent, useMemo, useState } from "react"; -import TableRowItem from "./TableRowItem"; -import { Item, Column, calculateColumnResults } from "./utils"; - -interface Props { - itemsByGroup: { group?: string; items: Item[] }; - columns: Column[]; - selected: string[]; - onClick: (e: ChangeEvent, id: string) => void; -} - -function TableRowGroup({ - itemsByGroup: { group, items }, - columns, - selected, - onClick, -}: Props) { - const [openRow, setOpenRow] = useState(false); - const columnResults = useMemo( - () => calculateColumnResults(columns, items), - [columns, items] - ); - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - <> - {group && ( - - {/* Merge the first two columns into one. The first column, which is always "name", - * does not contain an operation so no value will be displayed on the TableRowGroup header. */} - - - setOpenRow(!openRow)}> - {openRow ? : } - - {group} - - - {/* Skip the first column since it's already included in the merged TableCell above. */} - {columns.slice(1).map((column) => ( - - {column.operation && ( - - {columnResults[column.name]} - - )} - - ))} - - )} - {openRow && - items.map((item) => ( - - ))} - - ); -} - -export default TableRowGroup; diff --git a/webapp/src/components/common/DynamicDataTable/TableRowItem.tsx b/webapp/src/components/common/DynamicDataTable/TableRowItem.tsx deleted file mode 100644 index 15386dc1d2..0000000000 --- a/webapp/src/components/common/DynamicDataTable/TableRowItem.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { TableCell, Checkbox, Chip, TableRow } from "@mui/material"; -import { ChangeEvent, memo, useCallback } from "react"; -import { Item, Column } from "./utils"; - -interface Props { - item: Item; - columns: Column[]; - selected: string[]; - onClick: (e: ChangeEvent, name: string) => void; -} - -function TableRowItem({ item, columns, selected, onClick }: Props) { - const isSelected = selected.includes(item.id); - - //////////////////////////////////////////////////////////////// - // Event handlers - //////////////////////////////////////////////////////////////// - - const handleChange = useCallback( - (e: ChangeEvent) => { - onClick(e, item.id); - }, - [item.id, onClick] - ); - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - *": { borderBottom: "none !important" } }} - selected={isSelected} - > - - - - {columns.map((column) => { - const cellValue = item.columns[column.name]; - return ( - - {column.chipColorMap && typeof cellValue === "string" ? ( - - ) : ( - cellValue - )} - - ); - })} - - ); -} - -export default memo(TableRowItem); diff --git a/webapp/src/components/common/DynamicDataTable/TableToolbar.tsx b/webapp/src/components/common/DynamicDataTable/TableToolbar.tsx deleted file mode 100644 index 568d8c86e3..0000000000 --- a/webapp/src/components/common/DynamicDataTable/TableToolbar.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { - Toolbar, - alpha, - Typography, - Tooltip, - IconButton, - Fade, -} from "@mui/material"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { useTranslation } from "react-i18next"; - -interface Props { - numSelected: number; - handleDelete: () => void; -} - -function TableToolbar({ numSelected, handleDelete }: Props) { - const { t } = useTranslation(); - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - 0} timeout={300}> - 0 && { - bgcolor: (theme) => - alpha( - theme.palette.primary.main, - theme.palette.action.activatedOpacity - ), - }), - }} - > - {numSelected > 0 && ( - <> - - {numSelected} {t("global.selected")} - - - - - - - - )} - - - ); -} - -export default TableToolbar; diff --git a/webapp/src/components/common/DynamicDataTable/index.tsx b/webapp/src/components/common/DynamicDataTable/index.tsx deleted file mode 100644 index 28a0a165d3..0000000000 --- a/webapp/src/components/common/DynamicDataTable/index.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import Box from "@mui/material/Box"; -import Table from "@mui/material/Table"; -import TableBody from "@mui/material/TableBody"; -import TableCell from "@mui/material/TableCell"; -import TableContainer from "@mui/material/TableContainer"; -import TableHead from "@mui/material/TableHead"; -import TableRow from "@mui/material/TableRow"; -import CompareArrowsIcon from "@mui/icons-material/CompareArrows"; -import AddIcon from "@mui/icons-material/Add"; -import { - ChangeEvent, - FunctionComponent, - useCallback, - useMemo, - useState, -} from "react"; -import { Button, Checkbox } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import TableRowGroup from "./TableRowGroup"; -import TableToolbar from "./TableToolbar"; -import TableRowItem from "./TableRowItem"; -import { Item, Column, AddItemDialogProps } from "./utils"; - -export interface DynamicDataTableProps { - items: Item[]; - columns: Column[]; - onDeleteItems: (ids: string[]) => void; - onAddItem: (item: Item) => void; - AddItemDialog: FunctionComponent; -} - -function DynamicDataTable({ - items, - columns, - onDeleteItems, - onAddItem, - AddItemDialog, -}: DynamicDataTableProps) { - const [selected, setSelected] = useState([]); - const [openAddItemDialog, setOpenAddItemDialog] = useState(false); - const { t } = useTranslation(); - - const itemsByGroup = useMemo(() => { - return items.reduce((acc, item) => { - if (item.group) { - if (acc[item.group]) { - acc[item.group].items.push(item); - } else { - acc[item.group] = { group: item.group, items: [item] }; - } - } - return acc; - }, {} as { [key: string]: { group: string; items: Item[] } }); - }, [items]); - - //////////////////////////////////////////////////////////////// - // Event Handlers - //////////////////////////////////////////////////////////////// - - const handleSelectAll = useCallback( - (e: ChangeEvent) => { - setSelected(e.target.checked ? items.map((item) => item.id) : []); - }, - [items] - ); - - const handleSelect = useCallback( - (_e: ChangeEvent, name: string) => { - setSelected((prevSelected) => { - if (prevSelected.includes(name)) { - return prevSelected.filter((item) => item !== name); - } - return [...prevSelected, name]; - }); - }, - [] - ); - - const handleDelete = () => { - onDeleteItems(selected); - }; - - //////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////// - - const rows = useMemo(() => { - const rowsArr = Object.values(itemsByGroup).map((items) => ( - - )); - - // Add items without group to rows - items - .filter((item) => !item.group) - .forEach((item) => - rowsArr.push( - - ) - ); - - return rowsArr; - }, [items, itemsByGroup, columns, selected, handleSelect]); - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - <> - - - - - - {selected.length > 0 && ( - - )} - - - *": { - backgroundColor: "rgba(34, 35, 51, 1)", - position: "sticky", - top: 0, - zIndex: 1, - }, - }} - > - - 0 && selected.length === items.length} - /> - - {columns.map((column) => ( - - {column.label} - - ))} - - - {rows} -
-
- {openAddItemDialog && ( - setOpenAddItemDialog(false)} - onAddItem={onAddItem} - /> - )} - - ); -} - -export default DynamicDataTable; diff --git a/webapp/src/components/common/DynamicDataTable/utils.ts b/webapp/src/components/common/DynamicDataTable/utils.ts deleted file mode 100644 index f0a8a19db4..0000000000 --- a/webapp/src/components/common/DynamicDataTable/utils.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { ChipProps } from "@mui/material"; - -//////////////////////////////////////////////////////////////// -// Types -//////////////////////////////////////////////////////////////// - -export enum ColumnOperation { - SUM = "SUM", - MAX = "MAX", - MIN = "MIN", -} - -type ColumnResult = { - [key: string]: number; -}; - -export interface ChipColorMap { - [key: string]: ChipProps["color"]; -} - -export interface Column { - name: string; - label: string; - chipColorMap?: ChipColorMap; - operation?: ColumnOperation; -} - -export interface ColumnValues { - [key: string]: string | number | boolean; -} - -export interface Item { - id: string; - name: string; - group?: string; - columns: ColumnValues; -} - -export interface AddItemDialogProps { - open: boolean; - onClose: () => void; - onAddItem: (item: Item) => void; -} - -//////////////////////////////////////////////////////////////// -// Functions -//////////////////////////////////////////////////////////////// - -export function performColumnOperation( - operation: ColumnOperation, - values: number[] -): number { - switch (operation) { - case ColumnOperation.SUM: - return values.reduce((acc, value) => acc + value, 0); - case ColumnOperation.MAX: - return Math.max(...values); - case ColumnOperation.MIN: - return Math.min(...values); - default: - return 0; - } -} - -export function calculateColumnResults( - columns: Column[], - items: Item[] -): ColumnResult { - const columnResults: ColumnResult = {}; - - columns.forEach((column) => { - if (column.operation) { - const values = items - .filter((item) => item.columns[column.name] !== undefined) - .map((item) => item.columns[column.name]); - - columnResults[column.name] = performColumnOperation( - column.operation, - values as number[] - ); - } - }); - - return columnResults; -} diff --git a/webapp/src/components/common/GroupedDataTable/CreateRowDialog.tsx b/webapp/src/components/common/GroupedDataTable/CreateRowDialog.tsx new file mode 100644 index 0000000000..724ca8af07 --- /dev/null +++ b/webapp/src/components/common/GroupedDataTable/CreateRowDialog.tsx @@ -0,0 +1,92 @@ +import { t } from "i18next"; +import AddCircleIcon from "@mui/icons-material/AddCircle"; +import FormDialog from "../dialogs/FormDialog"; +import StringFE from "../fieldEditors/StringFE"; +import Fieldset from "../Fieldset"; +import { SubmitHandlerPlus } from "../Form/types"; +import SelectFE from "../fieldEditors/SelectFE"; +import { TData } from "."; + +interface Props { + open: boolean; + onClose: () => void; + onSubmit: (values: TData) => void; + groups: string[]; + existingNames: TData["name"][]; +} + +const defaultValues = { + name: "", + group: "", +}; + +function CreateRowDialog({ + open, + onClose, + onSubmit, + groups, + existingNames, +}: Props) { + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleSubmit = ({ + values, + }: SubmitHandlerPlus) => { + onSubmit({ ...values, name: values.name.trim() }); + onClose(); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + + {({ control }) => ( +
+ { + if (v.trim().length <= 0) { + return t("form.field.required"); + } + if (existingNames.includes(v.trim())) { + return t("form.field.duplicate", [v]); + } + }, + }} + sx={{ m: 0 }} + /> + +
+ )} +
+ ); +} + +export default CreateRowDialog; diff --git a/webapp/src/components/common/GroupedDataTable/index.tsx b/webapp/src/components/common/GroupedDataTable/index.tsx new file mode 100644 index 0000000000..6b964abef0 --- /dev/null +++ b/webapp/src/components/common/GroupedDataTable/index.tsx @@ -0,0 +1,179 @@ +/* eslint-disable react/jsx-pascal-case */ +/* eslint-disable camelcase */ +import Box from "@mui/material/Box"; +import AddIcon from "@mui/icons-material/Add"; +import { Button, IconButton, Tooltip } from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import MaterialReactTable, { + MaterialReactTableProps, + MRT_Row, + MRT_RowSelectionState, + MRT_ToggleDensePaddingButton, + MRT_ToggleFiltersButton, + MRT_ToggleGlobalFilterButton, + type MRT_ColumnDef, +} from "material-react-table"; +import { useTranslation } from "react-i18next"; +import { useCallback, useMemo, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import CreateRowDialog from "./CreateRowDialog"; +import ConfirmationDialog from "../dialogs/ConfirmationDialog"; + +export type TData = Record; + +export interface GroupedDataTableProps { + materialReactTableProps?: Partial; + data: T[]; + columns: MRT_ColumnDef[]; + groups: string[]; +} + +function GroupedDataTable({ + data, + columns, + groups, + materialReactTableProps, +}: GroupedDataTableProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [tableData, setTableData] = useState(data); + const [rowSelection, setRowSelection] = useState({}); + + const isAnyRowSelected = useMemo(() => { + return Object.values(rowSelection).some((value) => value); + }, [rowSelection]); + + const existingNames = useMemo( + () => tableData.map((row) => String(row.name).toLowerCase()), + [tableData] + ); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleCreateRow = (values: TData) => { + // TODO: Send POST request to the API + setTableData((prevTableData) => [...prevTableData, values]); + }; + + const handleDeleteSelection = () => { + /** + * `rowSelection` maps row indexes to selection status (boolean) + * convert it to an array of selected row indexes ignoring groups (non-integer) + */ + const rowIndexes = Object.keys(rowSelection) + .map(Number) + .filter(Number.isInteger); + const rowsToDelete = rowIndexes.map((index) => tableData[index]); + // TODO: Send DELETE request to the API + setTableData((prevTableData) => + prevTableData.filter((row) => !rowsToDelete.includes(row)) + ); + setRowSelection({}); + setConfirmDialogOpen(false); + }; + + const handleRowClick = useCallback( + (row: MRT_Row) => { + const clusterId = row.original.id; + navigate(`${location.pathname}/${clusterId}`); + }, + [navigate, location.pathname] + ); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + <> + columns, [columns])} + initialState={{ + grouping: ["state"], + density: "comfortable", + expanded: true, + }} + enableGrouping + enableRowSelection + positionToolbarAlertBanner="none" + enableBottomToolbar={false} + enableRowActions + onRowSelectionChange={setRowSelection} + state={{ rowSelection }} + renderTopToolbarCustomActions={() => ( + + + {isAnyRowSelected && ( + + + + )} + + )} + renderToolbarInternalActions={({ table }) => ( + <> + + + + + )} + renderRowActions={({ row }) => ( + + handleRowClick(row)}> + + + + )} + displayColumnDefOptions={{ + "mrt-row-actions": { + header: "", + }, + }} + /> + {createDialogOpen && ( + setCreateDialogOpen(false)} + groups={groups} + existingNames={existingNames} + onSubmit={handleCreateRow} + /> + )} + {confirmDialogOpen && ( + setConfirmDialogOpen(false)} + onConfirm={handleDeleteSelection} + alert="warning" + open + > + {t("studies.modelization.clusters.question.delete")} + + )} + + ); +} + +export default GroupedDataTable; diff --git a/webapp/src/theme.ts b/webapp/src/theme.ts index 04da51e72c..52e4ad3877 100644 --- a/webapp/src/theme.ts +++ b/webapp/src/theme.ts @@ -69,6 +69,14 @@ const theme = createTheme({ }, }, }, + MuiAlert: { + styleOverrides: { + root: { + backgroundColor: "#222333", + color: "#FFFFFF", + }, + }, + }, MuiPopover: { styleOverrides: { paper: { @@ -152,6 +160,10 @@ const theme = createTheme({ fontFamily: "'Inter', sans-serif", }, palette: { + background: { + default: "#222333", + paper: "#222333", + }, primary: { dark: "#C9940B", main: "#FFB800",