From 3a1a789482c524f15563826e726579058c3c938d Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Thu, 10 Aug 2023 15:14:40 +0200 Subject: [PATCH] feat(ui-common): add GroupedDataTable --- 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 | 182 +++++++++++++++++ webapp/src/theme.ts | 11 + 10 files changed, 770 insertions(+) create mode 100644 webapp/src/components/common/DynamicDataTable/TableRowGroup.tsx create mode 100644 webapp/src/components/common/DynamicDataTable/TableRowItem.tsx create mode 100644 webapp/src/components/common/DynamicDataTable/TableToolbar.tsx create mode 100644 webapp/src/components/common/DynamicDataTable/index.tsx create 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/public/locales/en/main.json b/webapp/public/locales/en/main.json index 8ea07b6405..5e5d65edbd 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -110,6 +110,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": "Value already exists: {{0}}", "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 afecd951bb..314d3c9b03 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -110,6 +110,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": "Cette valeur existe déjà: {{0}}", "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 new file mode 100644 index 0000000000..ad4ade26e4 --- /dev/null +++ b/webapp/src/components/common/DynamicDataTable/TableRowGroup.tsx @@ -0,0 +1,86 @@ +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 new file mode 100644 index 0000000000..15386dc1d2 --- /dev/null +++ b/webapp/src/components/common/DynamicDataTable/TableRowItem.tsx @@ -0,0 +1,66 @@ +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 new file mode 100644 index 0000000000..568d8c86e3 --- /dev/null +++ b/webapp/src/components/common/DynamicDataTable/TableToolbar.tsx @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000000..28a0a165d3 --- /dev/null +++ b/webapp/src/components/common/DynamicDataTable/index.tsx @@ -0,0 +1,192 @@ +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 new file mode 100644 index 0000000000..f0a8a19db4 --- /dev/null +++ b/webapp/src/components/common/DynamicDataTable/utils.ts @@ -0,0 +1,85 @@ +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..dba6c80b18 --- /dev/null +++ b/webapp/src/components/common/GroupedDataTable/CreateRowDialog.tsx @@ -0,0 +1,92 @@ +import { t } from "i18next"; +import { v4 as uuidv4 } from "uuid"; +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 { TRow } from "."; + +interface Props { + open: boolean; + onClose: VoidFunction; + onSubmit: (values: TData) => void; + groups: string[]; + existingNames: Array; +} + +const defaultValues = { + name: "", + group: "", +}; + +function CreateRowDialog({ + open, + onClose, + onSubmit, + groups, + existingNames, +}: Props) { + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleSubmit = ({ + values, + }: SubmitHandlerPlus) => { + onSubmit({ ...values, id: uuidv4(), name: values.name.trim() } as TData); + 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..7e1ba77955 --- /dev/null +++ b/webapp/src/components/common/GroupedDataTable/index.tsx @@ -0,0 +1,182 @@ +/* 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, { + MRT_Row, + MRT_RowSelectionState, + MRT_ToggleDensePaddingButton, + MRT_ToggleFiltersButton, + MRT_ToggleGlobalFilterButton, + type MRT_ColumnDef, +} from "material-react-table"; +import { useTranslation } from "react-i18next"; +import { useMemo, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import CreateRowDialog from "./CreateRowDialog"; +import ConfirmationDialog from "../dialogs/ConfirmationDialog"; + +export type TRow = { id: string; name: string; group: string }; + +export interface GroupedDataTableProps { + data: TData[]; + columns: MRT_ColumnDef[]; + groups: string[]; + onCreate?: (values: TData) => void; + onDelete?: (ids: string[]) => void; +} + +function GroupedDataTable({ + data, + columns, + groups, + onCreate, + onDelete, +}: 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( + () => Object.values(rowSelection).some((value) => value), + [rowSelection] + ); + + const existingNames = useMemo( + () => tableData.map((row) => row.name.toLowerCase()), + [tableData] + ); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleCreateRow = (values: TData) => { + if (onCreate) { + onCreate(values); + setTableData((prevTableData) => [...prevTableData, values]); + } + }; + + const handleDeleteSelection = () => { + if (!onDelete) { + return; + } + + const rowIndexes = Object.keys(rowSelection) + .map(Number) + // ignore groups names + .filter(Number.isInteger); + const rowIdsToDelete = rowIndexes.map((index) => tableData[index].id); + onDelete(rowIdsToDelete); + setTableData((prevTableData) => + prevTableData.filter((row) => !rowIdsToDelete.includes(row.id)) + ); + setRowSelection({}); + setConfirmDialogOpen(false); + }; + + const handleRowClick = (row: MRT_Row) => { + const clusterId = row.original.id; + navigate(`${location.pathname}/${clusterId}`); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + <> + ( + + {onCreate && ( + + )} + {isAnyRowSelected && onDelete && ( + + + + )} + + )} + renderToolbarInternalActions={({ table }) => ( + <> + + + + + )} + renderRowActions={({ row }) => ( + + handleRowClick(row)}> + + + + )} + displayColumnDefOptions={{ + "mrt-row-actions": { + header: "", // hide 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 155f60c88f..8a930afba4 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,9 @@ const theme = createTheme({ fontFamily: "'Inter', sans-serif", }, palette: { + background: { + default: "#222333", + }, primary: { dark: "#C9940B", main: "#FFB800",