From d6ed9488a1b865861712d8886506992bd611e154 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Thu, 5 Oct 2023 10:18:09 +0200 Subject: [PATCH] feat(ui-thermal): minor user experience improvements --- webapp/public/locales/en/main.json | 2 +- webapp/public/locales/fr/main.json | 2 +- .../Modelization/Areas/Thermal/Form.tsx | 10 +- .../Modelization/Areas/Thermal/index.tsx | 155 ++++++++++++------ .../Modelization/Areas/Thermal/utils.ts | 44 ++--- .../Areas/common/ClusterRoot/index.tsx | 3 +- .../GroupedDataTable/CreateRowDialog.tsx | 2 +- .../common/GroupedDataTable/index.tsx | 114 ++++++++----- .../common/GroupedDataTable/utils.ts | 68 ++++++++ 9 files changed, 269 insertions(+), 131 deletions(-) create mode 100644 webapp/src/components/common/GroupedDataTable/utils.ts diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 479cda318e..0addc3bde8 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -405,7 +405,7 @@ "study.modelization.map.districts.edit": "Edit districts", "study.modelization.map.districts.delete.confirm": "Are you sure you want to delete '{{0}}' district?", "study.modelization.load": "Load", - "study.modelization.thermal": "Thermal Clus.", + "study.modelization.thermal": "Thermal", "study.modelization.hydro": "Hydro", "study.modelization.hydro.correlation.viewMatrix": "View all correlations", "study.modelization.hydro.correlation.coefficient": "Coeff. (%)", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 496e36e453..62f4657a5c 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -405,7 +405,7 @@ "study.modelization.map.districts.edit": "Modifier un district", "study.modelization.map.districts.delete.confirm": "Êtes-vous sûr de vouloir supprimer le district '{{0}}' ?", "study.modelization.load": "Conso", - "study.modelization.thermal": "Clus. Thermiques", + "study.modelization.thermal": "Thermiques", "study.modelization.hydro": "Hydro", "study.modelization.hydro.correlation.viewMatrix": "Voir les correlations", "study.modelization.hydro.correlation.coefficient": "Coeff. (%)", diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx index 36572288b5..638bb0b1e6 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Form.tsx @@ -27,11 +27,9 @@ function ThermalForm() { // Event handlers //////////////////////////////////////////////////////////////// - const handleSubmit = - (areaId: string, clusterId: string) => - ({ dirtyValues }: SubmitHandlerPlus) => { - return updateThermalCluster(study.id, areaId, clusterId, dirtyValues); - }; + const handleSubmit = ({ dirtyValues }: SubmitHandlerPlus) => { + return updateThermalCluster(study.id, areaId, clusterId, dirtyValues); + }; //////////////////////////////////////////////////////////////// // JSX @@ -55,7 +53,7 @@ function ThermalForm() { return getThermalCluster(study.id, areaId, clusterId); }, }} - onSubmit={handleSubmit(areaId, clusterId)} + onSubmit={handleSubmit} autoSubmit > diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx index bab4163c43..3aa7f30b7b 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx @@ -1,8 +1,8 @@ /* eslint-disable camelcase */ import { useMemo } from "react"; import { MRT_ColumnDef } from "material-react-table"; -import { Box, Chip, Stack } from "@mui/material"; -import { useOutletContext } from "react-router-dom"; +import { Box, Chip } from "@mui/material"; +import { useLocation, useNavigate, useOutletContext } from "react-router-dom"; import { StudyMetadata } from "../../../../../../../common/types"; import { ThermalClusterGroup, @@ -11,6 +11,7 @@ import { createThermalCluster, deleteThermalClusters, capacityAggregationFn, + CLUSTER_GROUP_OPTIONS, } from "./utils"; import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../../redux/selectors"; @@ -20,18 +21,22 @@ import SimpleLoader from "../../../../../../common/loaders/SimpleLoader"; function Thermal() { const { study } = useOutletContext<{ study: StudyMetadata }>(); - const currentArea = useAppSelector(getCurrentAreaId); + const navigate = useNavigate(); + const location = useLocation(); + const currentAreaId = useAppSelector(getCurrentAreaId); const groups = Object.values(ThermalClusterGroup); - const { data: clusters } = usePromise( - () => getThermalClusters(study.id, currentArea), - [study.id, currentArea] + const { data: clusters, isLoading } = usePromise( + () => getThermalClusters(study.id, currentAreaId), + [study.id, currentAreaId], ); /** - * Calculates the installed and enabled capacity for each thermal cluster. - * @installedCapacity = unitCount * nominalCapacity. - * @enabledCapacity = unitCount * nominalCapacity if enabled is true else = 0. + * Calculate the installed and enabled capacity for each thermal cluster. + * - `installedCapacity` is calculated as the product of `unitCount` and `nominalCapacity`. + * - `enabledCapacity` is the product of `unitCount` and `nominalCapacity` if the cluster is enabled, otherwise it's 0. + * @function + * @returns {Array} - An array of cluster objects, each augmented with `installedCapacity` and `enabledCapacity`. */ const clustersWithCapacity = useMemo( () => @@ -41,50 +46,92 @@ function Thermal() { const enabledCapacity = enabled ? installedCapacity : 0; return { ...cluster, installedCapacity, enabledCapacity }; }) || [], - [clusters] + [clusters], ); - const totalUnitCount = useMemo( - () => clusters?.reduce((acc, curr) => acc + curr.unitCount, 0), - [clusters] - ); - - const totalInstalledCapacity = useMemo( - () => - clusters?.reduce( - (acc, curr) => acc + curr.unitCount * curr.nominalCapacity, - 0 - ), - [clusters] - ); + const { totalUnitCount, totalInstalledCapacity, totalEnabledCapacity } = + useMemo(() => { + if (!clusters) { + return { + totalUnitCount: 0, + totalInstalledCapacity: 0, + totalEnabledCapacity: 0, + }; + } - const totalEnabledCapacity = useMemo( - () => - clusters?.reduce( - (acc, curr) => - acc + (curr.enabled ? curr.unitCount * curr.nominalCapacity : 0), - 0 - ), - [clusters] - ); + return clusters.reduce( + (acc, { unitCount, nominalCapacity, enabled }) => { + acc.totalUnitCount += unitCount; + acc.totalInstalledCapacity += unitCount * nominalCapacity; + acc.totalEnabledCapacity += enabled ? unitCount * nominalCapacity : 0; + return acc; + }, + { + totalUnitCount: 0, + totalInstalledCapacity: 0, + totalEnabledCapacity: 0, + }, + ); + }, [clusters]); const columns = useMemo[]>( () => [ + { + accessorKey: "name", + header: "Name", + size: 100, + Cell: ({ renderedCellValue, row }) => { + const clusterId = row.original.id; + return ( + navigate(`${location.pathname}/${clusterId}`)} + > + {renderedCellValue} + + ); + }, + }, { accessorKey: "group", header: "Group", size: 50, + filterVariant: "select", + filterSelectOptions: CLUSTER_GROUP_OPTIONS, muiTableHeadCellProps: { align: "left", }, muiTableBodyCellProps: { align: "left", }, + Footer: () => ( + Total: + ), }, { accessorKey: "enabled", header: "Enabled", size: 50, + filterVariant: "checkbox", + Cell: ({ cell }) => ( + () ? "Yes" : "No"} + color={cell.getValue() ? "success" : "error"} + size="small" + /> + ), + }, + { + accessorKey: "mustRun", + header: "Must Run", + size: 50, + filterVariant: "checkbox", Cell: ({ cell }) => ( () ? "Yes" : "No"} @@ -103,12 +150,7 @@ function Thermal() { {cell.getValue()} ), - Footer: () => ( - - Total Units: - {totalUnitCount} - - ), + Footer: () => {totalUnitCount}, }, { accessorKey: "nominalCapacity", @@ -119,7 +161,7 @@ function Thermal() { }, { accessorKey: "installedCapacity", - header: "Installed Capacity", + header: "Enabled / Installed", size: 50, aggregationFn: capacityAggregationFn, AggregatedCell: ({ cell }) => ( @@ -129,17 +171,14 @@ function Thermal() { ), Cell: ({ row }) => ( <> - {row.original.enabledCapacity ?? 0}/ + {row.original.enabledCapacity ?? 0} /{" "} {row.original.installedCapacity ?? 0} MW ), Footer: () => ( - - Total Installed: - - {totalEnabledCapacity}/{totalInstalledCapacity} MW - - + + {totalEnabledCapacity} / {totalInstalledCapacity} MW + ), }, { @@ -149,29 +188,37 @@ function Thermal() { Cell: ({ cell }) => <>{cell.getValue().toFixed(2)} €/MWh, }, ], - [totalEnabledCapacity, totalInstalledCapacity, totalUnitCount] + [ + location.pathname, + navigate, + totalEnabledCapacity, + totalInstalledCapacity, + totalUnitCount, + ], ); //////////////////////////////////////////////////////////////// // Event handlers //////////////////////////////////////////////////////////////// - const handleCreateRow = ({ name, group }: ThermalCluster) => { - return createThermalCluster(study.id, currentArea, { - name, - group, - }); + const handleCreateRow = ({ + id, + installedCapacity, + enabledCapacity, + ...cluster + }: ThermalCluster) => { + return createThermalCluster(study.id, currentAreaId, cluster); }; const handleDeleteSelection = (ids: string[]) => { - return deleteThermalClusters(study.id, currentArea, ids); + return deleteThermalClusters(study.id, currentAreaId, ids); }; //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// - if (!clusters) { + if (isLoading) { return ; } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts index 09d0232683..ef6ed3843e 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts @@ -108,21 +108,21 @@ export const POLLUTANT_NAMES: Array = [ // Functions //////////////////////////////////////////////////////////////// -const CLUSTERS_URL = ( +const getClustersUrl = ( studyId: StudyMetadata["id"], - areaId: Area["name"] + areaId: Area["name"], ): string => `/v1/studies/${studyId}/areas/${areaId}/clusters/thermal`; -const CLUSTER_URL = ( +const getClusterUrl = ( studyId: StudyMetadata["id"], areaId: Area["name"], - clusterId: Cluster["id"] -): string => `${CLUSTERS_URL(studyId, areaId)}/${clusterId}`; + clusterId: Cluster["id"], +): string => `${getClustersUrl(studyId, areaId)}/${clusterId}`; async function makeRequest( method: "get" | "post" | "patch" | "delete", url: string, - data?: Partial | { data: Array } + data?: Partial | { data: Array }, ): Promise { const res = await client[method](url, data); return res.data; @@ -130,19 +130,19 @@ async function makeRequest( export async function getThermalClusters( studyId: StudyMetadata["id"], - areaId: Area["name"] + areaId: Area["name"], ): Promise { - return makeRequest("get", CLUSTERS_URL(studyId, areaId)); + return makeRequest("get", getClustersUrl(studyId, areaId)); } export async function getThermalCluster( studyId: StudyMetadata["id"], areaId: Area["name"], - clusterId: Cluster["id"] + clusterId: Cluster["id"], ): Promise { return makeRequest( "get", - CLUSTER_URL(studyId, areaId, clusterId) + getClusterUrl(studyId, areaId, clusterId), ); } @@ -150,33 +150,33 @@ export async function updateThermalCluster( studyId: StudyMetadata["id"], areaId: Area["name"], clusterId: Cluster["id"], - data: Partial + data: Partial, ): Promise { return makeRequest( "patch", - CLUSTER_URL(studyId, areaId, clusterId), - data + getClusterUrl(studyId, areaId, clusterId), + data, ); } export async function createThermalCluster( studyId: StudyMetadata["id"], areaId: Area["name"], - data: Partial + data: Partial, ): Promise { return makeRequest( "post", - CLUSTERS_URL(studyId, areaId), - data + getClustersUrl(studyId, areaId), + data, ); } export function deleteThermalClusters( studyId: StudyMetadata["id"], areaId: Area["name"], - clusterIds: Array + clusterIds: Array, ): Promise { - return makeRequest("delete", CLUSTERS_URL(studyId, areaId), { + return makeRequest("delete", getClustersUrl(studyId, areaId), { data: clusterIds, }); } @@ -192,19 +192,19 @@ export function deleteThermalClusters( */ export const capacityAggregationFn: MRT_AggregationFn = ( colHeader, - rows + rows, ) => { const { enabledCapacitySum, installedCapacitySum } = rows.reduce( ( acc: { enabledCapacitySum: number; installedCapacitySum: number }, - row + row, ) => { acc.enabledCapacitySum += row.original.enabledCapacity ?? 0; acc.installedCapacitySum += row.original.installedCapacity ?? 0; return acc; }, - { enabledCapacitySum: 0, installedCapacitySum: 0 } + { enabledCapacitySum: 0, installedCapacitySum: 0 }, ); - return `${enabledCapacitySum}/${installedCapacitySum}`; + return `${enabledCapacitySum} / ${installedCapacitySum}`; }; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/index.tsx index 3f19ca99b4..2eeb7b9436 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/index.tsx @@ -107,8 +107,7 @@ function ClusterRoot(props: ClusterRootProps) { })) : []; - const clusterDataByGroup: Record = - byGroup(tmpData); + const clusterDataByGroup = byGroup(tmpData); const clustersObj = Object.keys(clusterDataByGroup).map( (group) => diff --git a/webapp/src/components/common/GroupedDataTable/CreateRowDialog.tsx b/webapp/src/components/common/GroupedDataTable/CreateRowDialog.tsx index ced4463db2..73b129248e 100644 --- a/webapp/src/components/common/GroupedDataTable/CreateRowDialog.tsx +++ b/webapp/src/components/common/GroupedDataTable/CreateRowDialog.tsx @@ -70,7 +70,7 @@ function CreateRowDialog({ validate: (v) => { const regex = /[^a-zA-Z0-9_\-(),& ]+$/; if (regex.test(v.trim())) { - return t("form.field.specialChars", ["&()_,-"]); + return t("form.field.specialChars", { 0: "&()_,-" }); } if (v.trim().length <= 0) { return t("form.field.required"); diff --git a/webapp/src/components/common/GroupedDataTable/index.tsx b/webapp/src/components/common/GroupedDataTable/index.tsx index 5e60fac118..5d66ab99ab 100644 --- a/webapp/src/components/common/GroupedDataTable/index.tsx +++ b/webapp/src/components/common/GroupedDataTable/index.tsx @@ -4,19 +4,18 @@ import Box from "@mui/material/Box"; import AddIcon from "@mui/icons-material/Add"; import { Button, Tooltip } from "@mui/material"; import DeleteIcon from "@mui/icons-material/Delete"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; 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 { useEffect, useMemo, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useMemo, useState } from "react"; import CreateRowDialog from "./CreateRowDialog"; import ConfirmationDialog from "../dialogs/ConfirmationDialog"; +import { generateUniqueValue } from "./utils"; export type TRow = { id: string; name: string; group: string }; @@ -36,22 +35,21 @@ function GroupedDataTable({ 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({}); - useEffect(() => { - setTableData(data); - }, [data]); - const isAnyRowSelected = useMemo( () => Object.values(rowSelection).some((value) => value), [rowSelection], ); + const isOneRowSelected = useMemo( + () => Object.values(rowSelection).filter((value) => value).length === 1, + [rowSelection], + ); + const existingNames = useMemo( () => tableData.map((row) => row.name.toLowerCase()), [tableData], @@ -86,9 +84,32 @@ function GroupedDataTable({ setConfirmDialogOpen(false); }; - const handleRowClick = (row: MRT_Row) => { - const clusterId = row.original.id; - navigate(`${location.pathname}/${clusterId}`); + const handleCopyRow = async () => { + const selectedIndex = Object.keys(rowSelection).find( + (key) => rowSelection[key], + ); + + const selectedRow = selectedIndex && tableData[+selectedIndex]; + + if (!selectedRow) { + return; + } + + const name = generateUniqueValue("name", selectedRow.name, tableData); + const id = generateUniqueValue("id", name, tableData); + + const copiedRow = { + ...selectedRow, + id, + name, + }; + + if (onCreate) { + const newRow = await onCreate(copiedRow); + + setTableData((prevTableData) => [...prevTableData, newRow]); + setRowSelection({}); + } }; //////////////////////////////////////////////////////////////// @@ -104,19 +125,38 @@ function GroupedDataTable({ grouping: ["group"], density: "compact", expanded: true, + columnPinning: { left: ["group"] }, }} + enablePinning + enableExpanding enableGrouping - enableRowSelection + muiTableBodyRowProps={({ row: { id, groupingColumnId } }) => { + const handleRowClick = () => { + // prevent group rows to be selected + if (groupingColumnId === undefined) { + setRowSelection((prev) => ({ + ...prev, + [id]: !prev[id], + })); + } + }; + + return { + onClick: handleRowClick, + selected: rowSelection[id], + sx: { + cursor: "pointer", + }, + }; + }} + state={{ rowSelection }} enableColumnDragging={false} enableColumnActions={false} positionToolbarAlertBanner="none" enableBottomToolbar={false} - enableRowActions enableStickyFooter enableStickyHeader enablePagination={false} - onRowSelectionChange={setRowSelection} - state={{ rowSelection }} renderTopToolbarCustomActions={() => ( {onCreate && ( @@ -129,13 +169,25 @@ function GroupedDataTable({ {t("button.add")} )} - {isAnyRowSelected && onDelete && ( + + + + {onDelete && ( @@ -147,34 +199,8 @@ function GroupedDataTable({ <> - )} - renderRowActions={({ row }) => ( - - handleRowClick(row)} - > - {row.original.name} - - - )} - displayColumnDefOptions={{ - "mrt-row-actions": { - header: "", // hide "Actions" column header - size: 50, - muiTableBodyCellProps: { - align: "left", - }, - }, - }} muiTableHeadCellProps={{ align: "right", }} diff --git a/webapp/src/components/common/GroupedDataTable/utils.ts b/webapp/src/components/common/GroupedDataTable/utils.ts new file mode 100644 index 0000000000..1b6bebbb6e --- /dev/null +++ b/webapp/src/components/common/GroupedDataTable/utils.ts @@ -0,0 +1,68 @@ +import * as R from "ramda"; +import { TRow } from "."; +import { nameToId } from "../../../services/utils"; + +/** + * Generates the next unique value based on a base value and a list of existing values. + * If the base value is found in the list of existing values, it appends a number + * in the format `(n)` to the base value, incrementing `n` until a unique value is found. + * + * @param {string} baseValue - The original base value to check. + * @param {string[]} existingValues - The list of existing values to check against. + * @returns {string} A unique value. + */ +export const generateNextValue = ( + baseValue: string, + existingValues: string[], +): string => { + const pattern = new RegExp(`^${baseValue}( \\(\\d+\\))?`); + const matchingValues = R.filter( + (value) => pattern.test(value), + existingValues, + ); + + if (matchingValues.length === 0) { + return baseValue; + } + + const maxCount = R.pipe( + R.map((value: string) => { + const match = value.match(/\((\d+)\)$/); + return match ? parseInt(match[1], 10) : 0; + }), + R.reduce(R.max, 0), + )(matchingValues); + + return `${baseValue} (${Number(maxCount) + 1})`; +}; + +/** + * Generates a unique value for a specified property ('name' or 'id') + * based on the given original value and the existing values in tableData. + * + * If the property is "name", the function appends " - copy" to the original value. + * If the property is "id", the function uses nameToId to get the base value. + * + * This function leverages generateNextValue to ensure the uniqueness of the value. + * + * @param {"name" | "id"} property - The property for which the unique value is generated. + * @param {string} originalValue - The original value of the specified property. + * @param {TRow[]} tableData - The existing table data to check against. + * @returns {string} A unique value for the specified property. + */ +export const generateUniqueValue = ( + property: "name" | "id", + originalValue: string, + tableData: TRow[], +): string => { + let baseValue: string; + + if (property === "name") { + baseValue = `${originalValue} - copy`; + } else { + baseValue = nameToId(originalValue); + } + + const existingValues = tableData.map((row) => row[property]); + return generateNextValue(baseValue, existingValues); +};