From dc55ad7405e60e8648234ef3f3ca5f3e524a0dcf Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Sun, 24 Mar 2024 12:49:31 +0100 Subject: [PATCH] feat(common-ui,clusters-ui): implement optimistic row creation * fix TS mistakes * update i18n --- webapp/public/locales/en/main.json | 3 +- webapp/public/locales/fr/main.json | 3 +- .../Modelization/Areas/Renewables/index.tsx | 21 ++- .../Modelization/Areas/Renewables/utils.ts | 12 +- .../Modelization/Areas/Storages/index.tsx | 10 +- .../Modelization/Areas/Storages/utils.ts | 7 +- .../Modelization/Areas/Thermal/index.tsx | 21 ++- .../Modelization/Areas/Thermal/utils.ts | 12 +- .../Modelization/Areas/common/utils.ts | 15 +- .../common/GroupedDataTable/CreateDialog.tsx | 44 ++--- .../common/GroupedDataTable/index.tsx | 164 +++++++++++++----- .../common/GroupedDataTable/types.ts | 4 + .../common/GroupedDataTable/utils.ts | 26 +-- webapp/src/utils/validationUtils.ts | 2 +- 14 files changed, 199 insertions(+), 145 deletions(-) create mode 100644 webapp/src/components/common/GroupedDataTable/types.ts diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 6433dc8947..953a3d9b95 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -77,6 +77,7 @@ "global.error.failedtoretrievejobs": "Failed to retrieve job information", "global.error.failedtoretrievelogs": "Failed to retrieve job logs", "global.error.failedtoretrievedownloads": "Failed to retrieve downloads list", + "global.error.createFailed": "Unable to create", "global.error.deleteFailed": "Unable to delete", "global.area.add": "Add an area", "login.error": "Failed to authenticate", @@ -115,7 +116,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.duplicate": "Value 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 fa9dd73fae..56a4bf6582 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -77,6 +77,7 @@ "global.error.failedtoretrievejobs": "Échec de la récupération des tâches", "global.error.failedtoretrievelogs": "Échec de la récupération des logs", "global.error.failedtoretrievedownloads": "Échec de la récupération des exports", + "global.error.createFailed": "Impossible d'effectuer la création", "global.error.deleteFailed": "Impossible d'effectuer la suppression", "global.area.add": "Ajouter une zone", "login.error": "Échec de l'authentification", @@ -115,7 +116,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.duplicate": "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/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx index cfc2c726f5..971984be02 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx @@ -8,6 +8,7 @@ import { RENEWABLE_GROUPS, RenewableCluster, RenewableClusterWithCapacity, + RenewableGroup, createRenewableCluster, deleteRenewableClusters, getRenewableClusters, @@ -16,9 +17,11 @@ import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../../redux/selectors"; import GroupedDataTable from "../../../../../../common/GroupedDataTable"; import { + addCapacity, capacityAggregationFn, useClusterDataWithCapacity, } from "../common/utils"; +import { TRow } from "../../../../../../common/GroupedDataTable/types"; function Renewables() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -86,8 +89,8 @@ function Renewables() { ), Cell: ({ row }) => ( <> - {Math.floor(row.original.enabledCapacity ?? 0)} /{" "} - {Math.floor(row.original.installedCapacity ?? 0)} + {Math.floor(row.original.enabledCapacity)} /{" "} + {Math.floor(row.original.installedCapacity)} ), Footer: () => ( @@ -105,13 +108,9 @@ function Renewables() { // Event handlers //////////////////////////////////////////////////////////////// - const handleCreateRow = ({ - id, - installedCapacity, - enabledCapacity, - ...cluster - }: RenewableClusterWithCapacity) => { - return createRenewableCluster(study.id, areaId, cluster); + const handleCreate = async (values: TRow) => { + const cluster = await createRenewableCluster(study.id, areaId, values); + return addCapacity(cluster); }; const handleDelete = (rows: RenewableClusterWithCapacity[]) => { @@ -132,8 +131,8 @@ function Renewables() { isLoading={isLoading} data={clustersWithCapacity} columns={columns} - groups={RENEWABLE_GROUPS} - onCreate={handleCreateRow} + groups={[...RENEWABLE_GROUPS]} + onCreate={handleCreate} onDelete={handleDelete} onNameClick={handleNameClick} deleteConfirmationMessage={(count) => diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts index 074a19c84f..9ac4a6eb02 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts @@ -4,6 +4,7 @@ import { StudyMetadata, } from "../../../../../../../common/types"; import client from "../../../../../../../services/api/client"; +import type { PartialExceptFor } from "../../../../../../../utils/tsUtils"; //////////////////////////////////////////////////////////////// // Constants @@ -30,8 +31,9 @@ export const TS_INTERPRETATION_OPTIONS = [ // Types //////////////////////////////////////////////////////////////// +export type RenewableGroup = (typeof RENEWABLE_GROUPS)[number]; + type TimeSeriesInterpretation = (typeof TS_INTERPRETATION_OPTIONS)[number]; -type RenewableGroup = (typeof RENEWABLE_GROUPS)[number]; export interface RenewableFormFields { name: string; @@ -115,12 +117,12 @@ export async function updateRenewableCluster( ); } -export async function createRenewableCluster( +export function createRenewableCluster( studyId: StudyMetadata["id"], areaId: Area["name"], - data: Partial, -): Promise { - return makeRequest( + data: PartialExceptFor, +) { + return makeRequest( "post", getClustersUrl(studyId, areaId), data, diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx index b88156e214..51acad4243 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx @@ -13,8 +13,10 @@ import { deleteStorages, createStorage, STORAGE_GROUPS, + StorageGroup, } from "./utils"; import usePromiseWithSnackbarError from "../../../../../../../hooks/usePromiseWithSnackbarError"; +import type { TRow } from "../../../../../../common/GroupedDataTable/types"; function Storages() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -155,8 +157,8 @@ function Storages() { // Event handlers //////////////////////////////////////////////////////////////// - const handleCreateRow = ({ id, ...storage }: Storage) => { - return createStorage(study.id, areaId, storage); + const handleCreate = (values: TRow) => { + return createStorage(study.id, areaId, values); }; const handleDelete = (rows: Storage[]) => { @@ -177,8 +179,8 @@ function Storages() { isLoading={isLoading} data={storages || []} columns={columns} - groups={STORAGE_GROUPS} - onCreate={handleCreateRow} + groups={[...STORAGE_GROUPS]} + onCreate={handleCreate} onDelete={handleDelete} onNameClick={handleNameClick} deleteConfirmationMessage={(count) => diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts index 1226bcac66..6855ff566c 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts @@ -1,5 +1,6 @@ import { StudyMetadata, Area } from "../../../../../../../common/types"; import client from "../../../../../../../services/api/client"; +import type { PartialExceptFor } from "../../../../../../../utils/tsUtils"; //////////////////////////////////////////////////////////////// // Constants @@ -87,11 +88,11 @@ export async function updateStorage( ); } -export async function createStorage( +export function createStorage( studyId: StudyMetadata["id"], areaId: Area["name"], - data: Partial, -): Promise { + data: PartialExceptFor, +) { return makeRequest("post", getStoragesUrl(studyId, areaId), data); } 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 e410897db7..19e1e0ec5a 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 @@ -11,14 +11,17 @@ import { ThermalClusterWithCapacity, THERMAL_GROUPS, ThermalCluster, + ThermalGroup, } from "./utils"; import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../../redux/selectors"; import GroupedDataTable from "../../../../../../common/GroupedDataTable"; import { + addCapacity, capacityAggregationFn, useClusterDataWithCapacity, } from "../common/utils"; +import { TRow } from "../../../../../../common/GroupedDataTable/types"; function Thermal() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -95,8 +98,8 @@ function Thermal() { ), Cell: ({ row }) => ( <> - {Math.floor(row.original.enabledCapacity ?? 0)} /{" "} - {Math.floor(row.original.installedCapacity ?? 0)} + {Math.floor(row.original.enabledCapacity)} /{" "} + {Math.floor(row.original.installedCapacity)} ), Footer: () => ( @@ -119,13 +122,9 @@ function Thermal() { // Event handlers //////////////////////////////////////////////////////////////// - const handleCreateRow = ({ - id, - installedCapacity, - enabledCapacity, - ...cluster - }: ThermalClusterWithCapacity) => { - return createThermalCluster(study.id, areaId, cluster); + const handleCreate = async (values: TRow) => { + const cluster = await createThermalCluster(study.id, areaId, values); + return addCapacity(cluster); }; const handleDelete = (rows: ThermalClusterWithCapacity[]) => { @@ -146,8 +145,8 @@ function Thermal() { isLoading={isLoading} data={clustersWithCapacity} columns={columns} - groups={THERMAL_GROUPS} - onCreate={handleCreateRow} + groups={[...THERMAL_GROUPS]} + onCreate={handleCreate} onDelete={handleDelete} onNameClick={handleNameClick} deleteConfirmationMessage={(count) => 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 d113e06c4f..730ce37db2 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 @@ -4,6 +4,7 @@ import { StudyMetadata, } from "../../../../../../../common/types"; import client from "../../../../../../../services/api/client"; +import type { PartialExceptFor } from "../../../../../../../utils/tsUtils"; //////////////////////////////////////////////////////////////// // Constants @@ -51,7 +52,8 @@ export const TS_LAW_OPTIONS = ["geometric", "uniform"] as const; // Types //////////////////////////////////////////////////////////////// -type ThermalGroup = (typeof THERMAL_GROUPS)[number]; +export type ThermalGroup = (typeof THERMAL_GROUPS)[number]; + type LocalTSGenerationBehavior = (typeof TS_GENERATION_OPTIONS)[number]; type TimeSeriesLawOption = (typeof TS_LAW_OPTIONS)[number]; @@ -143,12 +145,12 @@ export async function updateThermalCluster( ); } -export async function createThermalCluster( +export function createThermalCluster( studyId: StudyMetadata["id"], areaId: Area["name"], - data: Partial, -): Promise { - return makeRequest( + data: PartialExceptFor, +) { + return makeRequest( "post", getClustersUrl(studyId, areaId), data, diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts index 1128a0fc60..d1604667c3 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts @@ -81,13 +81,7 @@ export const useClusterDataWithCapacity = ( }); const clustersWithCapacity: Array> = useMemo( - () => - clusters?.map((cluster) => { - const { unitCount, nominalCapacity, enabled } = cluster; - const installedCapacity = unitCount * nominalCapacity; - const enabledCapacity = enabled ? installedCapacity : 0; - return { ...cluster, installedCapacity, enabledCapacity }; - }) || [], + () => clusters?.map(addCapacity) || [], [clusters], ); @@ -116,3 +110,10 @@ export const useClusterDataWithCapacity = ( isLoading, }; }; + +export function addCapacity(cluster: T) { + const { unitCount, nominalCapacity, enabled } = cluster; + const installedCapacity = unitCount * nominalCapacity; + const enabledCapacity = enabled ? installedCapacity : 0; + return { ...cluster, installedCapacity, enabledCapacity }; +} diff --git a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx index c920dc6efc..9df4f4e2c5 100644 --- a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx +++ b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx @@ -1,48 +1,38 @@ -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 { nameToId } from "../../../services/utils"; -import { TRow } from "./utils"; import { validateString } from "../../../utils/validationUtils"; +import type { TRow } from "./types"; +import { useTranslation } from "react-i18next"; -interface Props { +interface Props { open: boolean; onClose: VoidFunction; - onSubmit: (values: TData) => Promise; - groups: string[] | readonly string[]; - existingNames: Array; + onSubmit: (values: TRow) => Promise; + groups: string[]; + existingNames: Array; } -const defaultValues = { - name: "", - group: "", -}; - -function CreateDialog({ +function CreateDialog({ open, onClose, onSubmit, groups, existingNames, -}: Props) { +}: Props) { + const { t } = useTranslation(); + //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// - const handleSubmit = async ({ - values, - }: SubmitHandlerPlus) => { - await onSubmit({ - ...values, - id: nameToId(values.name), - name: values.name.trim(), - } as TData); - - onClose(); + const handleSubmit = ({ + values: { name, group }, + }: SubmitHandlerPlus) => { + return onSubmit({ name: name.trim(), group }); }; //////////////////////////////////////////////////////////////// @@ -56,7 +46,6 @@ function CreateDialog({ open={open} onCancel={onClose} onSubmit={handleSubmit} - config={{ defaultValues }} > {({ control }) => (
@@ -76,10 +65,7 @@ function CreateDialog({ name="group" control={control} options={groups} - required - sx={{ - alignSelf: "center", - }} + rules={{ required: t("form.field.required") }} />
)} diff --git a/webapp/src/components/common/GroupedDataTable/index.tsx b/webapp/src/components/common/GroupedDataTable/index.tsx index 34569f32b5..da32867271 100644 --- a/webapp/src/components/common/GroupedDataTable/index.tsx +++ b/webapp/src/components/common/GroupedDataTable/index.tsx @@ -3,7 +3,7 @@ import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DeleteIcon from "@mui/icons-material/Delete"; -import { Button } from "@mui/material"; +import { Button, Skeleton } from "@mui/material"; import { MaterialReactTable, MRT_ToggleFiltersButton, @@ -14,10 +14,10 @@ import { type MRT_Row, } from "material-react-table"; import { useTranslation } from "react-i18next"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import CreateDialog from "./CreateDialog"; import ConfirmationDialog from "../dialogs/ConfirmationDialog"; -import { TRow, generateUniqueValue, getTableOptionsForAlign } from "./utils"; +import { generateUniqueValue, getTableOptionsForAlign } from "./utils"; import DuplicateDialog from "./DuplicateDialog"; import { translateWithColon } from "../../../utils/i18nUtils"; import useAutoUpdateRef from "../../../hooks/useAutoUpdateRef"; @@ -29,13 +29,17 @@ import { PromiseAny } from "../../../utils/tsUtils"; import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; import { toError } from "../../../utils/fnUtils"; import useOperationInProgressCount from "../../../hooks/useOperationInProgressCount"; +import type { TRow } from "./types"; -export interface GroupedDataTableProps { +export interface GroupedDataTableProps< + TGroups extends string[], + TData extends TRow, +> { data: TData[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any columns: Array>; - groups: string[] | readonly string[]; - onCreate?: (values: TData) => Promise; + groups: TGroups; + onCreate?: (values: TRow) => Promise; onDelete?: (rows: TData[]) => PromiseAny | void; onNameClick?: (row: MRT_Row) => void; isLoading?: boolean; @@ -48,7 +52,10 @@ export interface GroupedDataTableProps { const GROUP_COLUMN_ID = "_group"; const NAME_COLUMN_ID = "_name"; -function GroupedDataTable({ +function GroupedDataTable< + TGroups extends string[], + TData extends TRow, +>({ data, columns, groups, @@ -57,7 +64,7 @@ function GroupedDataTable({ onNameClick, isLoading, deleteConfirmationMessage, -}: GroupedDataTableProps) { +}: GroupedDataTableProps) { const { t } = useTranslation(); const [openDialog, setOpenDialog] = useState< "add" | "duplicate" | "delete" | "" @@ -67,7 +74,8 @@ function GroupedDataTable({ const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const callbacksRef = useAutoUpdateRef({ onNameClick }); const prevData = usePrevious(data); - const { deleteOps, totalOps } = useOperationInProgressCount(); + const pendingRows = useRef>>([]); + const { createOps, deleteOps, totalOps } = useOperationInProgressCount(); // Update once `data` only if previous value was empty. // It allows to handle loading data. @@ -103,23 +111,49 @@ function GroupedDataTable({ filterSelectOptions: existingNames, Cell: callbacksRef.current.onNameClick && - (({ renderedCellValue, row }) => ( - callbacksRef.current.onNameClick?.(row)} - > - {renderedCellValue} - - )), + (({ renderedCellValue, row }) => { + if (isPendingRow(row.original)) { + return renderedCellValue; + } + + return ( + callbacksRef.current.onNameClick?.(row)} + > + {renderedCellValue} + + ); + }), ...getTableOptionsForAlign("left"), }, - ...columns, + ...columns.map( + (column) => + ({ + ...column, + Cell: (props) => { + const { row, renderedCellValue } = props; + + if (isPendingRow(row.original)) { + return ( + + ); + } + + return column.Cell?.(props) || renderedCellValue; + }, + }) as MRT_ColumnDef, + ), ], // eslint-disable-next-line react-hooks/exhaustive-deps [columns, t, ...groups], @@ -144,27 +178,35 @@ function GroupedDataTable({ enablePagination: false, positionToolbarAlertBanner: "none", // Rows - muiTableBodyRowProps: ({ row }) => ({ - onClick: () => { - const isGrouped = row.getIsGrouped(); - const rowIds = isGrouped - ? row.getLeafRows().map((r) => r.id) - : [row.id]; + muiTableBodyRowProps: ({ row }) => { + const isPending = isPendingRow(row.original); + + return { + onClick: () => { + if (isPending) { + return; + } + + const isGrouped = row.getIsGrouped(); + const rowIds = isGrouped + ? row.getLeafRows().map((r) => r.id) + : [row.id]; setRowSelection((prev) => { const newValue = isGrouped ? !rowIds.some((id) => prev[id]) // Select/Deselect all : !prev[row.id]; - return { - ...prev, - ...rowIds.reduce((acc, id) => ({ ...acc, [id]: newValue }), {}), - }; - }); - }, - selected: rowSelection[row.id], - sx: { cursor: "pointer" }, - }), + return { + ...prev, + ...rowIds.reduce((acc, id) => ({ ...acc, [id]: newValue }), {}), + }; + }); + }, + selected: rowSelection[row.id], + sx: { cursor: isPending ? "wait" : "pointer" }, + }; + }, // Toolbars renderTopToolbarCustomActions: ({ table }) => ( @@ -210,6 +252,7 @@ function GroupedDataTable({ ), onRowSelectionChange: setRowSelection, // Styles + muiTablePaperProps: { sx: { display: "flex", flexDirection: "column" } }, // Allow to have scroll ...R.mergeDeepRight(getTableOptionsForAlign("right"), { muiTableBodyCellProps: { sx: { borderBottom: "1px solid rgba(224, 224, 224, 0.3)" }, @@ -222,6 +265,25 @@ function GroupedDataTable({ .rows.map((row) => row.original); const selectedRow = selectedRows.length === 1 ? selectedRows[0] : null; + //////////////////////////////////////////////////////////////// + // Optimistic + //////////////////////////////////////////////////////////////// + + const addPendingRow = (row: TRow) => { + pendingRows.current.push(row); + // Type can be asserted as `TData` because the row will be checked in cell renders + setTableData((prev) => [...prev, row as TData]); + }; + + const removePendingRow = (row: TRow) => { + pendingRows.current = pendingRows.current.filter((r) => r !== row); + setTableData((prev) => prev.filter((r) => r !== row)); + }; + + function isPendingRow(row: TData) { + return pendingRows.current.includes(row); + } + //////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////// @@ -232,11 +294,25 @@ function GroupedDataTable({ // Event Handlers //////////////////////////////////////////////////////////////// - const handleCreate = async (values: TData) => { - if (onCreate) { + const handleCreate = async (values: TRow) => { + closeDialog(); + + if (!onCreate) { + return; + } + + createOps.increment(); + addPendingRow(values); + + try { const newRow = await onCreate(values); - setTableData((prevTableData) => [...prevTableData, newRow]); + setTableData((prev) => [...prev, newRow]); + } catch (error) { + enqueueErrorSnackbar(t("global.error.createFailed"), toError(error)); } + + removePendingRow(values); + createOps.decrement(); }; const handleDelete = async () => { @@ -271,7 +347,7 @@ function GroupedDataTable({ return; } - const id = generateUniqueValue("id", name, tableData); + const id = generateUniqueValue(name, tableData); const duplicatedRow = { ...selectedRow, @@ -308,7 +384,7 @@ function GroupedDataTable({ onClose={closeDialog} onSubmit={handleDuplicate} existingNames={existingNames} - defaultName={generateUniqueValue("name", selectedRow.name, tableData)} + defaultName={generateUniqueValue(selectedRow.name, tableData)} /> )} {openDialog === "delete" && ( diff --git a/webapp/src/components/common/GroupedDataTable/types.ts b/webapp/src/components/common/GroupedDataTable/types.ts new file mode 100644 index 0000000000..6f91852cb4 --- /dev/null +++ b/webapp/src/components/common/GroupedDataTable/types.ts @@ -0,0 +1,4 @@ +export interface TRow { + name: string; + group: T; +} diff --git a/webapp/src/components/common/GroupedDataTable/utils.ts b/webapp/src/components/common/GroupedDataTable/utils.ts index b861cfb83d..f209d83bae 100644 --- a/webapp/src/components/common/GroupedDataTable/utils.ts +++ b/webapp/src/components/common/GroupedDataTable/utils.ts @@ -1,16 +1,6 @@ import * as R from "ramda"; -import { nameToId } from "../../../services/utils"; import { TableCellProps } from "@mui/material"; - -//////////////////////////////////////////////////////////////// -// Types -//////////////////////////////////////////////////////////////// - -export interface TRow { - id: string; - name: string; - group: string; -} +import type { TRow } from "./types"; //////////////////////////////////////////////////////////////// // Functions @@ -59,26 +49,16 @@ export const generateNextValue = ( * * This function leverages `generateNextValue` to ensure the uniqueness of the value. * - * @param property - The property for which the unique value is generated, either "name" or "id". * @param originalValue - The original value of the specified property. * @param tableData - The existing table data to check against for ensuring uniqueness. * @returns 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); + const existingValues = tableData.map((row) => row.name); + return generateNextValue(`${originalValue} - copy`, existingValues); }; export function getTableOptionsForAlign(align: TableCellProps["align"]) { diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts index 94f1f95c30..9af316cbba 100644 --- a/webapp/src/utils/validationUtils.ts +++ b/webapp/src/utils/validationUtils.ts @@ -99,7 +99,7 @@ export function validateString( // Check for duplication against existing values. if (existingValues.map(normalize).includes(comparisonValue)) { - return t("form.field.duplicate", { 0: value }); + return t("form.field.duplicate"); } // Check for inclusion in the list of excluded values.