diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 7c6a13307a..dfa4e75222 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -80,6 +80,7 @@ "global.semicolon": "Semicolon", "global.language": "Language", "global.path": "Path", + "global.weight": "Weight", "global.time.hourly": "Hourly", "global.time.daily": "Daily", "global.time.weekly": "Weekly", @@ -136,8 +137,8 @@ "maintenance.error.messageInfoError": "Unable to retrieve message info", "maintenance.error.maintenanceError": "Unable to retrieve maintenance status", "form.submit.error": "Error while submitting", - "form.submit.inProgress": "The form is being submitted. Are you sure you want to leave the page?", - "form.changeNotSaved": "The form has not been saved. Are you sure you want to leave the page?", + "form.submit.inProgress": "The form is being submitted. Are you sure you want to close it?", + "form.changeNotSaved": "The form has not been saved. Are you sure you want to close it?", "form.asyncDefaultValues.error": "Failed to get values", "form.field.required": "Field required", "form.field.duplicate": "Value already exists", @@ -361,8 +362,6 @@ "study.configuration.general.mcScenarioPlaylist.action.disableAll": "Disable all", "study.configuration.general.mcScenarioPlaylist.action.reverse": "Reverse", "study.configuration.general.mcScenarioPlaylist.action.resetWeights": "Reset weights", - "study.configuration.general.mcScenarioPlaylist.status": "Status", - "study.configuration.general.mcScenarioPlaylist.weight": "Weight", "study.configuration.general.geographicTrimming": "Geographic trimming", "study.configuration.general.thematicTrimming": "Thematic trimming", "study.configuration.general.thematicTrimming.action.enableAll": "Enable all", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 395ed91e7e..7df4675e89 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -80,6 +80,7 @@ "global.semicolon": "Point-virgule", "global.language": "Langue", "global.path": "Chemin", + "global.weight": "Poids", "global.time.hourly": "Horaire", "global.time.daily": "Journalier", "global.time.weekly": "Hebdomadaire", @@ -136,8 +137,8 @@ "maintenance.error.messageInfoError": "Impossible de récupérer le message d'info", "maintenance.error.maintenanceError": "Impossible de récupérer le status de maintenance de l'application", "form.submit.error": "Erreur lors de la soumission", - "form.submit.inProgress": "Le formulaire est en cours de soumission. Etes-vous sûr de vouloir quitter la page ?", - "form.changeNotSaved": "Le formulaire n'a pas été sauvegardé. Etes-vous sûr de vouloir quitter la page ?", + "form.submit.inProgress": "Le formulaire est en cours de soumission. Etes-vous sûr de vouloir le fermer ?", + "form.changeNotSaved": "Le formulaire n'a pas été sauvegardé. Etes-vous sûr de vouloir le fermer ?", "form.asyncDefaultValues.error": "Impossible d'obtenir les valeurs", "form.field.required": "Champ requis", "form.field.duplicate": "Cette valeur existe déjà", @@ -360,9 +361,7 @@ "study.configuration.general.mcScenarioPlaylist.action.enableAll": "Activer tous", "study.configuration.general.mcScenarioPlaylist.action.disableAll": "Désactiver tous", "study.configuration.general.mcScenarioPlaylist.action.reverse": "Inverser", - "study.configuration.general.mcScenarioPlaylist.action.resetWeights": "Reset weights", - "study.configuration.general.mcScenarioPlaylist.status": "Status", - "study.configuration.general.mcScenarioPlaylist.weight": "Weight", + "study.configuration.general.mcScenarioPlaylist.action.resetWeights": "Réinitialiser les poids", "study.configuration.general.geographicTrimming": "Geographic trimming", "study.configuration.general.thematicTrimming": "Thematic trimming", "study.configuration.general.thematicTrimming.action.enableAll": "Activer tout", diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/index.tsx index abb04d0a88..6f361f4ea3 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/index.tsx @@ -12,28 +12,28 @@ * This file is part of the Antares project. */ -import { Box, Button, Divider } from "@mui/material"; -import { useRef } from "react"; +import { Button, ButtonGroup, Divider } from "@mui/material"; +import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import * as R from "ramda"; import * as RA from "ramda-adjunct"; -import Handsontable from "handsontable"; -import { StudyMetadata } from "../../../../../../../../common/types"; +import type { StudyMetadata } from "../../../../../../../../common/types"; import usePromise from "../../../../../../../../hooks/usePromise"; import BasicDialog from "../../../../../../../common/dialogs/BasicDialog"; -import TableForm from "../../../../../../../common/TableForm"; import UsePromiseCond from "../../../../../../../common/utils/UsePromiseCond"; import { DEFAULT_WEIGHT, getPlaylist, - PlaylistData, setPlaylist, + type PlaylistData, } from "./utils"; -import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; -import { - HandsontableProps, - HotTableClass, -} from "../../../../../../../common/Handsontable"; +import type { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; +import DataGridForm, { + DataGridFormState, + type DataGridFormApi, +} from "@/components/common/DataGridForm"; +import ConfirmationDialog from "@/components/common/dialogs/ConfirmationDialog"; +import useConfirm from "@/hooks/useConfirm"; interface Props { study: StudyMetadata; @@ -44,51 +44,64 @@ interface Props { function ScenarioPlaylistDialog(props: Props) { const { study, open, onClose } = props; const { t } = useTranslation(); - const tableRef = useRef({} as HotTableClass); + const dataGridApiRef = useRef>(null!); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDirty, setIsDirty] = useState(false); + const closeAction = useConfirm(); const res = usePromise(() => getPlaylist(study.id), [study.id]); + const columns = useMemo(() => { + return [ + { + id: "status", + title: t("global.status"), + grow: 1, + }, + { + id: "weight", + title: t("global.weight"), + grow: 1, + }, + ]; + }, []); + //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// const handleUpdateStatus = (fn: RA.Pred) => () => { - const api = tableRef.current.hotInstance; - if (!api) { - return; - } - - const changes: Array<[number, string, boolean]> = api - .getDataAtProp("status") - .map((status, index) => [index, "status", fn(status)]); - - api.setDataAtRowProp(changes); + const { data, setData } = dataGridApiRef.current; + setData(R.map(R.evolve({ status: fn }), data)); }; const handleResetWeights = () => { - const api = tableRef.current.hotInstance as Handsontable; - - api.setDataAtRowProp( - api.rowIndexMapper - .getIndexesSequence() - .map((rowIndex) => [rowIndex, "weight", DEFAULT_WEIGHT]), - ); + const { data, setData } = dataGridApiRef.current; + setData(R.map(R.assoc("weight", DEFAULT_WEIGHT), data)); }; const handleSubmit = (data: SubmitHandlerPlus) => { return setPlaylist(study.id, data.values); }; - const handleCellsRender: HandsontableProps["cells"] = function cells( - this, - row, - column, - prop, - ) { - if (prop === "weight") { - const status = this.instance.getDataAtRowProp(row, "status"); - return { readOnly: !status }; + const handleClose = () => { + if (isSubmitting) { + return; } - return {}; + + if (isDirty) { + return closeAction.showConfirm().then((confirm) => { + if (confirm) { + onClose(); + } + }); + } + + onClose(); + }; + + const handleFormStateChange = (formState: DataGridFormState) => { + setIsSubmitting(formState.isSubmitting); + setIsDirty(formState.isDirty); }; //////////////////////////////////////////////////////////////// @@ -99,18 +112,25 @@ function ScenarioPlaylistDialog(props: Props) { {t("global.close")}} - // TODO: add `maxHeight` and `fullHeight` in BasicDialog` - PaperProps={{ sx: { height: 500 } }} - maxWidth="sm" + onClose={handleClose} + actions={ + + } + PaperProps={{ sx: { height: 700 } }} + maxWidth="md" fullWidth > ( <> - + - - `MC Year ${row.id}`, - tableRef, - stretchH: "all", - className: "htCenter", - cells: handleCellsRender, + + `MC Year ${index + 1}`, }} + onSubmit={handleSubmit} + onStateChange={handleFormStateChange} + apiRef={dataGridApiRef} + enableColumnResize={false} /> + + {t("form.changeNotSaved")} + )} /> diff --git a/webapp/src/components/common/DataGridForm.tsx b/webapp/src/components/common/DataGridForm.tsx index 1ae6c8064c..9069534b2d 100644 --- a/webapp/src/components/common/DataGridForm.tsx +++ b/webapp/src/components/common/DataGridForm.tsx @@ -21,17 +21,18 @@ import { type FillHandleDirection, } from "@glideapps/glide-data-grid"; import type { DeepPartial } from "react-hook-form"; -import { FormEvent, useCallback, useState } from "react"; -import DataGrid from "./DataGrid"; +import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import DataGrid, { DataGridProps } from "./DataGrid"; import { Box, Divider, IconButton, + setRef, SxProps, Theme, Tooltip, } from "@mui/material"; -import useUndo from "use-undo"; +import useUndo, { type Actions } from "use-undo"; import UndoIcon from "@mui/icons-material/Undo"; import RedoIcon from "@mui/icons-material/Redo"; import SaveIcon from "@mui/icons-material/Save"; @@ -42,53 +43,88 @@ import * as R from "ramda"; import { SubmitHandlerPlus } from "./Form/types"; import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; import useFormCloseProtection from "@/hooks/useCloseFormSecurity"; +import { useUpdateEffect } from "react-use"; +import { toError } from "@/utils/fnUtils"; -type GridFieldValuesByRow = Record< - IdType, - Record ->; +type Data = Record>; + +export interface DataGridFormState { + isDirty: boolean; + isSubmitting: boolean; +} + +export interface DataGridFormApi { + data: TData; + setData: Actions["set"]; + formState: DataGridFormState; +} export interface DataGridFormProps< - TFieldValues extends GridFieldValuesByRow = GridFieldValuesByRow, + TData extends Data = Data, SubmitReturnValue = unknown, > { - defaultData: TFieldValues; - columns: ReadonlyArray; + defaultData: TData; + columns: ReadonlyArray; + rowMarkers?: DataGridProps["rowMarkers"]; allowedFillDirections?: FillHandleDirection; - onSubmit?: ( - data: SubmitHandlerPlus, + enableColumnResize?: boolean; + onSubmit: ( + data: SubmitHandlerPlus, event?: React.BaseSyntheticEvent, ) => void | Promise; onSubmitSuccessful?: ( - data: SubmitHandlerPlus, + data: SubmitHandlerPlus, submitResult: SubmitReturnValue, ) => void; + onStateChange?: (state: DataGridFormState) => void; sx?: SxProps; extraActions?: React.ReactNode; + apiRef?: React.Ref>; } -function DataGridForm({ +function DataGridForm({ defaultData, columns, allowedFillDirections = "vertical", + enableColumnResize, + rowMarkers, onSubmit, onSubmitSuccessful, + onStateChange, sx, extraActions, -}: DataGridFormProps) { + apiRef, +}: DataGridFormProps) { const { t } = useTranslation(); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const [isSubmitting, setIsSubmitting] = useState(false); const [savedData, setSavedData] = useState(defaultData); - const [{ present: data }, { set, undo, redo, canUndo, canRedo }] = + const [{ present: data }, { set: setData, undo, redo, canUndo, canRedo }] = useUndo(defaultData); - const isSubmitAllowed = savedData !== data; + // Shallow comparison to check if the data has changed. + // So even if the content are the same, we consider it as dirty. + // Deep comparison fix the issue but we big objects it can be slow. + const isDirty = savedData !== data; + + const rowNames = Object.keys(data); + + const formState = useMemo( + () => ({ + isDirty, + isSubmitting, + }), + [isDirty, isSubmitting], + ); + + useFormCloseProtection({ isSubmitting, isDirty }); - useFormCloseProtection({ - isSubmitting, - isDirty: isSubmitAllowed, - }); + useUpdateEffect(() => onStateChange?.(formState), [formState]); + + useEffect( + () => setRef(apiRef, { data, setData, formState }), + [apiRef, data, setData, formState], + ); //////////////////////////////////////////////////////////////// // Utils @@ -96,14 +132,13 @@ function DataGridForm({ const getRowAndColumnNames = (location: Item) => { const [colIndex, rowIndex] = location; - const rowNames = Object.keys(data); const columnIds = columns.map((column) => column.id); return [rowNames[rowIndex], columnIds[colIndex]]; }; const getDirtyValues = () => { - return Object.keys(data).reduce((acc, rowName) => { + return rowNames.reduce((acc, rowName) => { const rowData = data[rowName]; const savedRowData = savedData[rowName]; @@ -125,11 +160,11 @@ function DataGridForm({ } return acc; - }, {} as DeepPartial); + }, {} as DeepPartial); }; //////////////////////////////////////////////////////////////// - // Callbacks + // Content //////////////////////////////////////////////////////////////// const getCellContent = useCallback( @@ -182,7 +217,7 @@ function DataGridForm({ //////////////////////////////////////////////////////////////// const handleCellsEdited: DataEditorProps["onCellsEdited"] = (items) => { - set( + setData( items.reduce((acc, { location, value }) => { const [rowName, columnName] = getRowAndColumnNames(location); const newValue = value.data; @@ -204,13 +239,9 @@ function DataGridForm({ return true; }; - const handleFormSubmit = (event: FormEvent) => { + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - if (!onSubmit) { - return; - } - setIsSubmitting(true); const dataArg = { @@ -218,17 +249,15 @@ function DataGridForm({ dirtyValues: getDirtyValues(), }; - Promise.resolve(onSubmit(dataArg, event)) - .then((submitRes) => { - setSavedData(data); - onSubmitSuccessful?.(dataArg, submitRes); - }) - .catch((err) => { - enqueueErrorSnackbar(t("form.submit.error"), err); - }) - .finally(() => { - setIsSubmitting(false); - }); + try { + const submitRes = await onSubmit(dataArg, event); + setSavedData(data); + onSubmitSuccessful?.(dataArg, submitRes); + } catch (err) { + enqueueErrorSnackbar(t("form.submit.error"), toError(err)); + } finally { + setIsSubmitting(false); + } }; //////////////////////////////////////////////////////////////// @@ -247,19 +276,22 @@ function DataGridForm({ sx, )} component="form" - onSubmit={handleFormSubmit} + onSubmit={handleSubmit} > Object.keys(data)[index], - }} + rowMarkers={ + rowMarkers || { + kind: "clickable-string", + getTitle: (index) => rowNames[index], + } + } fillHandle allowedFillDirections={allowedFillDirections} + enableColumnResize={enableColumnResize} getCellsForSelection /> ({ + * + * + * Are you sure? + * + * + * ); + * ``` + * * @returns An object with the following properties: * - `showConfirm`: A function that returns a `Promise` that resolves to `true` if the user confirms, * `false` if the user refuses, and `null` if the user cancel.