From 7dc74d26488ecaafe52ee576ea36953e8ba6abd3 Mon Sep 17 00:00:00 2001 From: Florent MILLOT Date: Mon, 4 May 2026 18:00:19 +0200 Subject: [PATCH 1/9] Improve network modifications table rendering performance Stop unmounting cells (and resetting their local state like SwitchCell's modificationActivated) on every columns rebuild by hoisting all cell/header renderers to module scope and routing dynamic values through react-table meta instead of closure capture. - Hoist renderers to a new cell-renderers.tsx (DragHandle, Select, Name, Description, Switch, RootNetwork) - Replace createBaseColumns factory with a static BASE_COLUMNS constant - Build the columns array directly in the editor and pass it as a `columns` prop instead of a createAllColumns callback - Augment react-table TableMeta/ColumnMeta to type the meta channel - Drop the optimistic global update from SwitchCell; the row state is refreshed by the server notification, and a local useState handles the immediate checkbox feedback with rollback on error - Replace useState/useEffect in useIsAnyNodeBuilding with a direct selector - Remove the now-broken inline debounce around handleCellClick Signed-off-by: Florent MILLOT --- .../network-modifications/PERF-ANALYSIS.md | 92 +++++++++++++++ .../network-modification-node-editor.tsx | 23 ++-- .../createColumns.tsx | 104 ++++++----------- .../renderers/cell-renderers.tsx | 105 ++++++++++++++++++ .../renderers/switch-cell.tsx | 50 ++++----- .../utils/is-any-node-building-hook.ts | 14 +-- src/module-tanstack.d.ts | 24 +++- 7 files changed, 284 insertions(+), 128 deletions(-) create mode 100644 src/components/graph/menus/network-modifications/PERF-ANALYSIS.md create mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx diff --git a/src/components/graph/menus/network-modifications/PERF-ANALYSIS.md b/src/components/graph/menus/network-modifications/PERF-ANALYSIS.md new file mode 100644 index 0000000000..6a92d69e35 --- /dev/null +++ b/src/components/graph/menus/network-modifications/PERF-ANALYSIS.md @@ -0,0 +1,92 @@ +# Analyse perf — table des modifications réseau (toggle Switch) + +## Résumé + +Après les premiers correctifs (étapes 1, 2 et stabilisation de `handleCellClick`), le `Switch` répond visuellement de manière fluide. Reste un effet de "rechargement complet" de la table à chaque clic, qui dure 100-500 ms selon la taille de la liste. + +## Cause racine + +À chaque toggle, deux mises à jour de la liste s'enchaînent : + +1. **Optimistic update local** dans `SwitchCell.toggleModificationActive` : + ```ts + setModifications((oldModifications) => + updateModificationFieldInTree(modificationUuid, { activated: checked }, oldModifications) + ); + ``` + `updateModificationFieldInTree` (`commons-ui/.../utils.ts:117`) ne crée des objets neufs que sur le chemin du nœud touché → ciblé, peu coûteux. + +2. **Refetch global déclenché par notification serveur** : + - `setModificationMetadata` côté client envoie un `PUT` au serveur. + - Le serveur émet une notification WS `MODIFICATIONS_UPDATE_FINISHED`. + - `handleEvent` (`network-modification-node-editor.tsx:836-846`) appelle `dofetchNetworkModifications()` ET `dofetchExcludedNetworkModifications()`. + - `dofetchNetworkModifications` (`network-modification-node-editor.tsx:722`) re-fetche **toute la liste** et fait `setModifications(liveModifications)`. + - L'effet `useEffect([modifications])` dans `NetworkModificationsTable` (`commons-ui/.../network-modifications-table.tsx:98-116`) appelle : + ```ts + formatToComposedModification(modifications) // commons-ui/.../utils.ts:15 + // -> modifications.map(m => ({ ...m, subModifications: [] })) + ``` + Cette fonction recrée **un nouvel objet pour chaque modification de la liste**. + - `mergeSubModificationsIntoTree` réemballe ensuite tous les nœuds qui ont des sous-modifications déjà chargées. + - Résultat : tous les `row.original` ont des références neuves → react-table régénère son `coreRowModel` → **toutes les lignes virtualisées re-render**. + +## Impact mesuré (qualitatif) + +- Optimistic update : 1 re-render ciblé sur la ligne touchée. +- Refetch + reformat : 1 re-render de toutes les lignes visibles + reconstruction des modèles react-table. +- Total : la table se "fige" visuellement pendant le reformat (proportionnel au nombre total de modifications, pas seulement aux lignes visibles, car `formatToComposedModification` itère sur tout). + +## Pistes de correction (par impact décroissant) + +### A. Supprimer l'optimistic update du `SwitchCell` (retenu) + +L'idée : ne garder qu'**une seule source de vérité**, la notification serveur. La mise à jour optimistic locale est supprimée ; le `Switch` reste en `disabled` pendant l'aller-retour HTTP + la notif, puis se met à jour quand le refetch arrive. + +**Avantages** : +- Élimine la double mise à jour (1 re-render au lieu de 2). +- Élimine aussi la branche de rollback en cas d'erreur (le serveur n'aura simplement pas changé l'état). +- Simplifie le code. + +**Inconvénient** : +- Délai visible entre le clic et le changement d'état du `Switch` (durée du PUT + propagation WS, typiquement 100-500 ms). Acceptable selon le contexte. + +**Modifications nécessaires** dans `gridstudy-app/.../renderers/switch-cell.tsx` : +- Supprimer le bloc `setModifications((oldModifications) => updateModificationFieldInTree(...))` (mise à jour optimistic). +- Supprimer le `.catch` qui faisait le rollback. +- Garder `setIsLoading(true/false)` pour griser le `Switch` pendant l'attente serveur. +- Garder le `snackWithFallback` en cas d'erreur. +- À terme, on peut aussi retirer la prop `setModifications` du `SwitchCell` si plus aucun cell-renderer ne l'utilise. + +### B. Refetch ciblé au lieu du refetch total (à creuser plus tard) + +Si l'API permet de re-fetcher une seule modification par uuid, remplacer `dofetchNetworkModifications` (sur ce chemin) par un fetch ciblé du ou des uuids présents dans `eventData.headers.modifications`. Le merge se ferait via `updateModificationFieldInTree`. Évite la reconstruction de toute la liste. + +### C. Préserver les références dans `formatToComposedModification` + +Modifier la fonction pour réutiliser l'objet précédent quand les champs n'ont pas changé. Couplé à un `React.memo(ModificationRow)`, seules les lignes effectivement modifiées re-rendraient — y compris après un refetch venant d'un autre utilisateur/onglet. + +```ts +export const formatToComposedModification = ( + modifications: NetworkModificationMetadata[], + prev?: ComposedModificationMetadata[] +): ComposedModificationMetadata[] => { + const prevByUuid = prev ? new Map(prev.map((m) => [m.uuid, m])) : null; + return modifications.map((m) => { + const prevMod = prevByUuid?.get(m.uuid); + if (prevMod && shallowEqualBaseFields(prevMod, m)) return prevMod; + return { ...m, subModifications: prevMod?.subModifications ?? [] }; + }); +}; +``` + +### D. Debouncer/grouper les refetches + +Si plusieurs toggles s'enchaînent rapidement, plusieurs notifs arrivent → plusieurs refetches en cascade. Un `debounce` propre (via `useMemo`) sur `dofetchNetworkModifications` (~200 ms) évite de re-fetcher plusieurs fois. + +### E. Conditionner `dofetchExcludedNetworkModifications` + +Ligne 844 : à chaque notif `MODIFICATIONS_UPDATE_FINISHED`, on re-fetche aussi les exclusions. À voir si pertinent dans tous les cas — sinon ne le faire que pour les events qui modifient les exclusions. + +## Décision + +On part sur **A** : supprimer l'optimistic update du `SwitchCell`. La source de vérité unique est la notification serveur. Les pistes C et D restent disponibles si la perception du délai après refetch reste gênante. diff --git a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx index 391cc41eb9..1d4b9d7bca 100644 --- a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx @@ -16,7 +16,6 @@ import { IElementUpdateDialog, MODIFICATION_TYPES, ModificationType, - NameHeaderProps, NetworkModificationMetadata, NetworkModificationsTable, NotificationsUrlKeys, @@ -32,7 +31,7 @@ import ContentCutIcon from '@mui/icons-material/ContentCut'; import ContentPasteIcon from '@mui/icons-material/ContentPaste'; import DeleteIcon from '@mui/icons-material/Delete'; import SaveIcon from '@mui/icons-material/Save'; -import { Alert, Box, CircularProgress, debounce, Toolbar, Tooltip } from '@mui/material'; +import { Alert, Box, CircularProgress, Toolbar, Tooltip } from '@mui/material'; import IconButton from '@mui/material/IconButton'; import BatteryCreationDialog from 'components/dialogs/network-modifications/battery/creation/battery-creation-dialog'; @@ -67,7 +66,7 @@ import VoltageLevelModificationDialog from 'components/dialogs/network-modificat import VscCreationDialog from 'components/dialogs/network-modifications/hvdc-line/vsc/creation/vsc-creation-dialog'; import VscModificationDialog from 'components/dialogs/network-modifications/hvdc-line/vsc/modification/vsc-modification-dialog'; import NetworkModificationsMenu from 'components/graph/menus/network-modifications/network-modifications-menu'; -import { SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { useDispatch, useSelector } from 'react-redux'; import { addNotification, removeNotificationByNode, setModificationsInProgress } from '../../../../redux/actions'; @@ -125,7 +124,7 @@ import CreateVoltageLevelSectionDialog from '../../../dialogs/network-modificati import MoveVoltageLevelFeederBaysDialog from '../../../dialogs/network-modifications/voltage-level/move-feeder-bays/move-voltage-level-feeder-bays-dialog'; import { useCopiedNetworkModifications } from 'hooks/copy-paste/use-copied-network-modifications'; import { FetchStatus } from '../../../../services/utils.type'; -import { createBaseColumns, createRootNetworksColumns } from './network-modification-table/createColumns'; +import { BASE_COLUMNS, createRootNetworksColumns } from './network-modification-table/createColumns'; import { ColumnDef } from '@tanstack/react-table'; const nonEditableModificationTypes = new Set([ @@ -1080,20 +1079,14 @@ const NetworkModificationNodeEditor = () => { [isAnyNodeBuilding, mapDataLoading, isDragging] ); - const createAllColumns = useCallback( - ( - isRowDragDisabled: boolean, - modificationsCount: number, - nameHeaderProps: NameHeaderProps, - setModifications: React.Dispatch> - ): ColumnDef[] => [ - ...createBaseColumns(isRowDragDisabled, modificationsCount, nameHeaderProps, setModifications), + const columns = useMemo[]>( + () => [ + ...BASE_COLUMNS, ...(isMonoRootStudy ? [] : createRootNetworksColumns( rootNetworks, currentRootNetworkUuid!, - modificationsCount, modificationsToExclude, setModificationsToExclude )), @@ -1114,7 +1107,7 @@ const NetworkModificationNodeEditor = () => { return ( { notificationMessageId={notificationMessageId} isFetchingModifications={isFetchingModifications} pendingState={pendingState} - createAllColumns={createAllColumns} + columns={columns} highlightedModificationUuid={highlightedModificationUuid} studyUuid={studyUuid} currentNodeId={currentNode?.id} diff --git a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx index f98a421c0b..fcfe8f8936 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx @@ -8,25 +8,23 @@ import { BASE_MODIFICATION_TABLE_COLUMNS, ComposedModificationMetadata, computeTagMinSize, - createRootNetworkChipCellSx, - DragHandleCell, ExcludedNetworkModifications, - NameCell, - NameHeaderProps, - NetworkModificationEditorNameHeader, networkModificationTableStyles, - SelectCell, - SelectHeaderCell, } from '@gridsuite/commons-ui'; import React, { SetStateAction } from 'react'; import { ColumnDef } from '@tanstack/react-table'; -import DescriptionCell from './renderers/description-cell'; -import SwitchCell from './renderers/switch-cell'; import { RootNetworkMetadata } from '../network-modification-menu.type'; -import { Badge, Box, Tooltip } from '@mui/material'; -import { RemoveRedEye as RemoveRedEyeIcon } from '@mui/icons-material'; -import RootNetworkChipCell from './renderers/root-network-chip-cell'; -import { FormattedMessage } from 'react-intl'; +import { + DescriptionCellRenderer, + DragHandleRenderer, + NameCellRenderer, + NameHeaderRenderer, + RootNetworkCellRenderer, + RootNetworkHeaderRenderer, + SelectCellRenderer, + SelectHeaderRenderer, + SwitchCellRenderer, +} from './renderers/cell-renderers'; /** * Column definition is broken up in 2 parts : base columns which are always on display and root networks columns. @@ -34,15 +32,10 @@ import { FormattedMessage } from 'react-intl'; * for each individual root network hence they all have a dedicated column generated on the fly */ -export const createBaseColumns = ( - isRowDragDisabled: boolean, - modificationsCount: number, - nameHeaderProps: NameHeaderProps, - setModifications: React.Dispatch> -): ColumnDef[] => [ +export const BASE_COLUMNS: ColumnDef[] = [ { id: BASE_MODIFICATION_TABLE_COLUMNS.DRAG_HANDLE.id, - cell: () => , + cell: DragHandleRenderer, size: 24, minSize: 24, meta: { @@ -53,8 +46,8 @@ export const createBaseColumns = ( }, { id: BASE_MODIFICATION_TABLE_COLUMNS.SELECT.id, - header: ({ table }) => , - cell: ({ row, table }) => , + header: SelectHeaderRenderer, + cell: SelectCellRenderer, size: 32, minSize: 32, meta: { @@ -63,10 +56,8 @@ export const createBaseColumns = ( }, { id: BASE_MODIFICATION_TABLE_COLUMNS.NAME.id, - header: () => ( - - ), - cell: ({ row }) => , + header: NameHeaderRenderer, + cell: NameCellRenderer, meta: { cellStyle: networkModificationTableStyles.columnCell.modificationName, }, @@ -74,13 +65,13 @@ export const createBaseColumns = ( }, { id: BASE_MODIFICATION_TABLE_COLUMNS.DESCRIPTION.id, - cell: ({ row }) => , + cell: DescriptionCellRenderer, size: 40, minSize: 32, }, { id: BASE_MODIFICATION_TABLE_COLUMNS.SWITCH.id, - cell: ({ row }) => , + cell: SwitchCellRenderer, size: 48, minSize: 48, meta: { @@ -94,7 +85,6 @@ export const createBaseColumns = ( export const createRootNetworksColumns = ( rootNetworks: RootNetworkMetadata[], currentRootNetworkUuid: string, - modificationsCount: number, modificationsToExclude: ExcludedNetworkModifications[], setModificationsToExclude: React.Dispatch> ): ColumnDef[] => { @@ -102,45 +92,19 @@ export const createRootNetworksColumns = ( const sharedSize = Math.max(Math.min(...tagMinSizes), 56); const currentRootNetworkTag = rootNetworks.find((item) => item.rootNetworkUuid === currentRootNetworkUuid)?.tag; - return rootNetworks.map((rootNetwork, index) => { - const rootNetworkUuid = rootNetwork.rootNetworkUuid; - const isCurrentRootNetwork = rootNetworkUuid === currentRootNetworkUuid; - const tagMinSize = tagMinSizes[index]; - - return { - id: rootNetworkUuid, - header: () => - isCurrentRootNetwork && modificationsCount >= 1 ? ( - - - } - > - - - - - - ) : null, - cell: ({ row }) => ( - - - - ), - size: sharedSize, - minSize: tagMinSize, - meta: { - cellStyle: networkModificationTableStyles.columnCell.rootNetworkChip, - }, - }; - }); + return rootNetworks.map((rootNetwork, index) => ({ + id: rootNetwork.rootNetworkUuid, + header: RootNetworkHeaderRenderer, + cell: RootNetworkCellRenderer, + size: sharedSize, + minSize: tagMinSizes[index], + meta: { + cellStyle: networkModificationTableStyles.columnCell.rootNetworkChip, + rootNetwork, + modificationsToExclude, + setModificationsToExclude, + isCurrentRootNetwork: rootNetwork.rootNetworkUuid === currentRootNetworkUuid, + currentRootNetworkTag, + }, + })); }; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx new file mode 100644 index 0000000000..4d423e48fd --- /dev/null +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { + ComposedModificationMetadata, + createRootNetworkChipCellSx, + DragHandleCell, + NameCell, + NetworkModificationEditorNameHeader, + networkModificationTableStyles, + SelectCell, + SelectHeaderCell, +} from '@gridsuite/commons-ui'; +import { CellContext, HeaderContext } from '@tanstack/react-table'; +import { Badge, Box, Tooltip } from '@mui/material'; +import { RemoveRedEye as RemoveRedEyeIcon } from '@mui/icons-material'; +import { FormattedMessage } from 'react-intl'; +import DescriptionCell from './description-cell'; +import SwitchCell from './switch-cell'; +import RootNetworkChipCell from './root-network-chip-cell'; + +/** + * Renderers are defined as named module-scope components so their reference is stable across renders. + * Without this, every rebuild of the `columns` memo produces new inline functions which React treats as + * different component types, unmounting/remounting cells and resetting their local state. Dynamic values + * are routed via react-table's `meta` (table-wide via `table.options.meta`, per-column via `column.columnDef.meta`). + */ + +type Ctx = CellContext; +type HCtx = HeaderContext; + +export function DragHandleRenderer({ table }: Ctx) { + return ; +} + +export function SelectHeaderRenderer({ table }: HCtx) { + return ; +} + +export function SelectCellRenderer({ row, table }: Ctx) { + return ; +} + +export function NameHeaderRenderer({ table }: HCtx) { + const meta = table.options.meta; + const nameHeaderProps = meta?.nameHeaderProps; + if (!nameHeaderProps) { + return null; + } + return ( + + ); +} + +export function NameCellRenderer({ row }: Ctx) { + return ; +} + +export function DescriptionCellRenderer({ row }: Ctx) { + return ; +} + +export function SwitchCellRenderer({ row }: Ctx) { + return ; +} + +export function RootNetworkHeaderRenderer({ column, table }: HCtx) { + const colMeta = column.columnDef.meta; + const tableMeta = table.options.meta; + if (!colMeta?.isCurrentRootNetwork || (tableMeta?.modificationsCount ?? 0) < 1) { + return null; + } + return ( + + } + > + + + + + + ); +} + +export function RootNetworkCellRenderer({ row, column }: Ctx) { + const meta = column.columnDef.meta; + if (!meta?.rootNetwork || !meta.modificationsToExclude || !meta.setModificationsToExclude) { + return null; + } + return ( + + + + ); +} diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx index 813c71b0ca..5dfd4378fd 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx @@ -5,15 +5,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { FunctionComponent, SetStateAction, useCallback, useState } from 'react'; +import React, { FunctionComponent, useCallback, useEffect, useState } from 'react'; import { Switch, Tooltip } from '@mui/material'; -import { - ComposedModificationMetadata, - findModificationInTree, - snackWithFallback, - updateModificationFieldInTree, - useSnackMessage, -} from '@gridsuite/commons-ui'; +import { ComposedModificationMetadata, snackWithFallback, useSnackMessage } from '@gridsuite/commons-ui'; import { setModificationMetadata } from 'services/study/network-modifications'; import { useSelector } from 'react-redux'; import { FormattedMessage } from 'react-intl'; @@ -22,11 +16,10 @@ import { AppState } from '../../../../../../redux/reducer.type'; export interface SwitchCellRendererProps { data: ComposedModificationMetadata; - setModifications: React.Dispatch>; } const SwitchCell: FunctionComponent = (props) => { - const { data, setModifications } = props; + const { data } = props; const studyUuid = useSelector((state: AppState) => state.studyUuid); const currentNode = useSelector((state: AppState) => state.currentTreeNode); const [isLoading, setIsLoading] = useState(false); @@ -36,40 +29,37 @@ const SwitchCell: FunctionComponent = (props) => { const { snackError } = useSnackMessage(); const modificationUuid = data?.uuid; - const modificationActivated = data?.activated; + const [modificationActivated, setModificationActivated] = useState(data?.activated); - const updateModification = useCallback( - (activated: boolean) => { + // Re-sync the local checked state when the row data is refreshed (e.g. after a server notification). + useEffect(() => { + setModificationActivated(data?.activated); + }, [data?.activated]); + + const toggleModificationActive = useCallback( + (_event: React.ChangeEvent, checked: boolean) => { if (!modificationUuid) { return; } + + setIsLoading(true); + setModificationActivated(checked); + setModificationMetadata(studyUuid, currentNode?.id, modificationUuid, { - activated: activated, + activated: checked, type: data?.type, }) - .catch((error) => { + ?.catch((error) => { + setModificationActivated(data?.activated); // rollback snackWithFallback(snackError, error, { headerId: 'networkModificationActivationError' }); }) .finally(() => { setIsLoading(false); }); }, - [modificationUuid, studyUuid, currentNode?.id, data?.type, snackError] + [modificationUuid, studyUuid, currentNode?.id, data?.type, data?.activated, snackError] ); - const toggleModificationActive = useCallback(() => { - setIsLoading(true); - setModifications((oldModifications) => { - const target = findModificationInTree(modificationUuid, oldModifications); - if (!target) { - return oldModifications; - } - const newStatus = !target.activated; - updateModification(newStatus); - return updateModificationFieldInTree(modificationUuid, { activated: newStatus }, oldModifications); - }); - }, [modificationUuid, updateModification, setModifications]); - return ( } @@ -81,7 +71,7 @@ const SwitchCell: FunctionComponent = (props) => { size="small" disabled={isLoading || isAnyNodeBuilding || mapDataLoading} checked={modificationActivated} - onClick={toggleModificationActive} + onChange={toggleModificationActive} /> diff --git a/src/components/utils/is-any-node-building-hook.ts b/src/components/utils/is-any-node-building-hook.ts index 3179cb4da2..c4c6cba34f 100644 --- a/src/components/utils/is-any-node-building-hook.ts +++ b/src/components/utils/is-any-node-building-hook.ts @@ -5,18 +5,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { AppState } from 'redux/reducer.type'; -export const useIsAnyNodeBuilding = () => { - const [iAnyNodeBuild, setAnyNodeBuilding] = useState(false); - - const treeModel = useSelector((state: AppState) => state.networkModificationTreeModel); - - useEffect(() => { - setAnyNodeBuilding(treeModel?.isAnyNodeBuilding || false); - }, [treeModel]); - - return iAnyNodeBuild; -}; +export const useIsAnyNodeBuilding = () => + useSelector((state: AppState) => state.networkModificationTreeModel?.isAnyNodeBuilding ?? false); diff --git a/src/module-tanstack.d.ts b/src/module-tanstack.d.ts index fcfaed2d9a..88d834bb61 100644 --- a/src/module-tanstack.d.ts +++ b/src/module-tanstack.d.ts @@ -5,11 +5,33 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { RefObject } from 'react'; +import { Dispatch, RefObject, SetStateAction } from 'react'; +import { SxProps, Theme } from '@mui/material'; +import { ExcludedNetworkModifications, NameHeaderProps } from '@gridsuite/commons-ui'; +import { RootNetworkMetadata } from 'components/graph/menus/network-modifications/network-modification-menu.type'; declare module '@tanstack/react-table' { + // TableMeta = values shared by the whole table (same value across every cell). + // Read at runtime via `table.options.meta` from any cell/header renderer. interface TableMeta { lastClickedRowId: RefObject; onRowSelected?: (selectedRows: TData[]) => void; + isRowDragDisabled?: boolean; + modificationsCount?: number; + nameHeaderProps?: NameHeaderProps; + } + + // ColumnMeta = values that differ from one column to another. + // Read at runtime via `column.columnDef.meta` (per-column). + // TData / TValue must match the original generic signature of ColumnMeta for the + // module-augmentation merge to apply. They aren't referenced in this body, hence the disable. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + cellStyle?: SxProps; + rootNetwork?: RootNetworkMetadata; + modificationsToExclude?: ExcludedNetworkModifications[]; + setModificationsToExclude?: Dispatch>; + isCurrentRootNetwork?: boolean; + currentRootNetworkTag?: string; } } From a7713057cde60b15e4a6b26307612f6c5f688e37 Mon Sep 17 00:00:00 2001 From: Florent MILLOT Date: Tue, 5 May 2026 10:31:33 +0200 Subject: [PATCH 2/9] cleaning Signed-off-by: Florent MILLOT --- .../network-modifications/PERF-ANALYSIS.md | 92 ------------------- .../renderers/cell-renderers.tsx | 14 +-- .../renderers/switch-cell.tsx | 2 +- 3 files changed, 8 insertions(+), 100 deletions(-) delete mode 100644 src/components/graph/menus/network-modifications/PERF-ANALYSIS.md diff --git a/src/components/graph/menus/network-modifications/PERF-ANALYSIS.md b/src/components/graph/menus/network-modifications/PERF-ANALYSIS.md deleted file mode 100644 index 6a92d69e35..0000000000 --- a/src/components/graph/menus/network-modifications/PERF-ANALYSIS.md +++ /dev/null @@ -1,92 +0,0 @@ -# Analyse perf — table des modifications réseau (toggle Switch) - -## Résumé - -Après les premiers correctifs (étapes 1, 2 et stabilisation de `handleCellClick`), le `Switch` répond visuellement de manière fluide. Reste un effet de "rechargement complet" de la table à chaque clic, qui dure 100-500 ms selon la taille de la liste. - -## Cause racine - -À chaque toggle, deux mises à jour de la liste s'enchaînent : - -1. **Optimistic update local** dans `SwitchCell.toggleModificationActive` : - ```ts - setModifications((oldModifications) => - updateModificationFieldInTree(modificationUuid, { activated: checked }, oldModifications) - ); - ``` - `updateModificationFieldInTree` (`commons-ui/.../utils.ts:117`) ne crée des objets neufs que sur le chemin du nœud touché → ciblé, peu coûteux. - -2. **Refetch global déclenché par notification serveur** : - - `setModificationMetadata` côté client envoie un `PUT` au serveur. - - Le serveur émet une notification WS `MODIFICATIONS_UPDATE_FINISHED`. - - `handleEvent` (`network-modification-node-editor.tsx:836-846`) appelle `dofetchNetworkModifications()` ET `dofetchExcludedNetworkModifications()`. - - `dofetchNetworkModifications` (`network-modification-node-editor.tsx:722`) re-fetche **toute la liste** et fait `setModifications(liveModifications)`. - - L'effet `useEffect([modifications])` dans `NetworkModificationsTable` (`commons-ui/.../network-modifications-table.tsx:98-116`) appelle : - ```ts - formatToComposedModification(modifications) // commons-ui/.../utils.ts:15 - // -> modifications.map(m => ({ ...m, subModifications: [] })) - ``` - Cette fonction recrée **un nouvel objet pour chaque modification de la liste**. - - `mergeSubModificationsIntoTree` réemballe ensuite tous les nœuds qui ont des sous-modifications déjà chargées. - - Résultat : tous les `row.original` ont des références neuves → react-table régénère son `coreRowModel` → **toutes les lignes virtualisées re-render**. - -## Impact mesuré (qualitatif) - -- Optimistic update : 1 re-render ciblé sur la ligne touchée. -- Refetch + reformat : 1 re-render de toutes les lignes visibles + reconstruction des modèles react-table. -- Total : la table se "fige" visuellement pendant le reformat (proportionnel au nombre total de modifications, pas seulement aux lignes visibles, car `formatToComposedModification` itère sur tout). - -## Pistes de correction (par impact décroissant) - -### A. Supprimer l'optimistic update du `SwitchCell` (retenu) - -L'idée : ne garder qu'**une seule source de vérité**, la notification serveur. La mise à jour optimistic locale est supprimée ; le `Switch` reste en `disabled` pendant l'aller-retour HTTP + la notif, puis se met à jour quand le refetch arrive. - -**Avantages** : -- Élimine la double mise à jour (1 re-render au lieu de 2). -- Élimine aussi la branche de rollback en cas d'erreur (le serveur n'aura simplement pas changé l'état). -- Simplifie le code. - -**Inconvénient** : -- Délai visible entre le clic et le changement d'état du `Switch` (durée du PUT + propagation WS, typiquement 100-500 ms). Acceptable selon le contexte. - -**Modifications nécessaires** dans `gridstudy-app/.../renderers/switch-cell.tsx` : -- Supprimer le bloc `setModifications((oldModifications) => updateModificationFieldInTree(...))` (mise à jour optimistic). -- Supprimer le `.catch` qui faisait le rollback. -- Garder `setIsLoading(true/false)` pour griser le `Switch` pendant l'attente serveur. -- Garder le `snackWithFallback` en cas d'erreur. -- À terme, on peut aussi retirer la prop `setModifications` du `SwitchCell` si plus aucun cell-renderer ne l'utilise. - -### B. Refetch ciblé au lieu du refetch total (à creuser plus tard) - -Si l'API permet de re-fetcher une seule modification par uuid, remplacer `dofetchNetworkModifications` (sur ce chemin) par un fetch ciblé du ou des uuids présents dans `eventData.headers.modifications`. Le merge se ferait via `updateModificationFieldInTree`. Évite la reconstruction de toute la liste. - -### C. Préserver les références dans `formatToComposedModification` - -Modifier la fonction pour réutiliser l'objet précédent quand les champs n'ont pas changé. Couplé à un `React.memo(ModificationRow)`, seules les lignes effectivement modifiées re-rendraient — y compris après un refetch venant d'un autre utilisateur/onglet. - -```ts -export const formatToComposedModification = ( - modifications: NetworkModificationMetadata[], - prev?: ComposedModificationMetadata[] -): ComposedModificationMetadata[] => { - const prevByUuid = prev ? new Map(prev.map((m) => [m.uuid, m])) : null; - return modifications.map((m) => { - const prevMod = prevByUuid?.get(m.uuid); - if (prevMod && shallowEqualBaseFields(prevMod, m)) return prevMod; - return { ...m, subModifications: prevMod?.subModifications ?? [] }; - }); -}; -``` - -### D. Debouncer/grouper les refetches - -Si plusieurs toggles s'enchaînent rapidement, plusieurs notifs arrivent → plusieurs refetches en cascade. Un `debounce` propre (via `useMemo`) sur `dofetchNetworkModifications` (~200 ms) évite de re-fetcher plusieurs fois. - -### E. Conditionner `dofetchExcludedNetworkModifications` - -Ligne 844 : à chaque notif `MODIFICATIONS_UPDATE_FINISHED`, on re-fetche aussi les exclusions. À voir si pertinent dans tous les cas — sinon ne le faire que pour les events qui modifient les exclusions. - -## Décision - -On part sur **A** : supprimer l'optimistic update du `SwitchCell`. La source de vérité unique est la notification serveur. Les pistes C et D restent disponibles si la perception du délai après refetch reste gênante. diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx index 4d423e48fd..a6ee8920e1 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx @@ -30,10 +30,10 @@ import RootNetworkChipCell from './root-network-chip-cell'; * are routed via react-table's `meta` (table-wide via `table.options.meta`, per-column via `column.columnDef.meta`). */ -type Ctx = CellContext; +type CCtx = CellContext; type HCtx = HeaderContext; -export function DragHandleRenderer({ table }: Ctx) { +export function DragHandleRenderer({ table }: CCtx) { return ; } @@ -41,7 +41,7 @@ export function SelectHeaderRenderer({ table }: HCtx) { return ; } -export function SelectCellRenderer({ row, table }: Ctx) { +export function SelectCellRenderer({ row, table }: CCtx) { return ; } @@ -56,15 +56,15 @@ export function NameHeaderRenderer({ table }: HCtx) { ); } -export function NameCellRenderer({ row }: Ctx) { +export function NameCellRenderer({ row }: CCtx) { return ; } -export function DescriptionCellRenderer({ row }: Ctx) { +export function DescriptionCellRenderer({ row }: CCtx) { return ; } -export function SwitchCellRenderer({ row }: Ctx) { +export function SwitchCellRenderer({ row }: CCtx) { return ; } @@ -87,7 +87,7 @@ export function RootNetworkHeaderRenderer({ column, table }: HCtx) { ); } -export function RootNetworkCellRenderer({ row, column }: Ctx) { +export function RootNetworkCellRenderer({ row, column }: CCtx) { const meta = column.columnDef.meta; if (!meta?.rootNetwork || !meta.modificationsToExclude || !meta.setModificationsToExclude) { return null; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx index 5dfd4378fd..f5cc3abdcf 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx @@ -49,7 +49,7 @@ const SwitchCell: FunctionComponent = (props) => { activated: checked, type: data?.type, }) - ?.catch((error) => { + .catch((error) => { setModificationActivated(data?.activated); // rollback snackWithFallback(snackError, error, { headerId: 'networkModificationActivationError' }); }) From a5e6c0070888ff36db19cefd923c4371a8b4df60 Mon Sep 17 00:00:00 2001 From: Florent MILLOT Date: Tue, 5 May 2026 16:01:53 +0200 Subject: [PATCH 3/9] Refine renderer documentation for network modification table - Clarify the importance of stable references for cell/header renderers. - Update explanatory comments about renderers' scope and usage in react-table. Signed-off-by: Florent MILLOT --- .../renderers/cell-renderers.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx index a6ee8920e1..a9979811a8 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx @@ -24,10 +24,19 @@ import SwitchCell from './switch-cell'; import RootNetworkChipCell from './root-network-chip-cell'; /** - * Renderers are defined as named module-scope components so their reference is stable across renders. - * Without this, every rebuild of the `columns` memo produces new inline functions which React treats as - * different component types, unmounting/remounting cells and resetting their local state. Dynamic values - * are routed via react-table's `meta` (table-wide via `table.options.meta`, per-column via `column.columnDef.meta`). + * Cell/header renderers must keep a stable reference across renders: react-table calls + * `flexRender(columnDef.cell, ctx)`, and when a renderer function is used as a component, + * a new function reference is treated as a different component type — which can + * unmount/remount the cell and reset its local state. + * + * But what matters is the *scope* the renderer is defined in, not whether it is inline or named. + * An inline arrow inside a module-scope constant (e.g. `BASE_COLUMNS`) is created once and is + * just as stable as a named component. The renderers below are hoisted because they are reused + * by `createRootNetworksColumns`, which is a factory called inside a hook — defining them inline + * there would produce a fresh reference on every call. + * + * Dynamic values are routed via react-table's `meta`: table-wide via `table.options.meta`, + * per-column via `column.columnDef.meta`. */ type CCtx = CellContext; From 6c47b857bb55c9b2d563e8f4acd64e1ee1b2cca4 Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Wed, 13 May 2026 17:10:55 +0200 Subject: [PATCH 4/9] Consume network-modification-table renderers from commons-ui The per-row cells, the cell-renderers adapter, the two services (setModificationMetadata, updateModificationStatusByRootNetwork) and the @tanstack/react-table module augmentation that they relied on have moved to commons-ui. Local files are deleted and createColumns now imports the renderers from commons-ui. createRootNetworksColumns is simplified: it only needs the root-networks list since the rest of the per-column meta (rootNetwork, modificationsToExclude, isCurrentRootNetwork, currentRootNetworkTag) is now derived from TableMeta. The node editor passes studyUuid, currentNodeId, currentRootNetworkUuid, rootNetworks, modificationsToExclude, setModificationsToExclude and a combined isDisabled (isAnyNodeBuilding || mapDataLoading) to NetworkModificationsTable. Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- .../network-modification-node-editor.tsx | 19 +-- .../createColumns.tsx | 23 +-- .../renderers/cell-renderers.tsx | 114 -------------- .../renderers/description-cell.tsx | 82 ---------- .../renderers/root-network-chip-cell.tsx | 145 ------------------ .../renderers/switch-cell.tsx | 81 ---------- src/module-tanstack.d.ts | 37 ----- src/services/study/network-modifications.ts | 46 +----- 8 files changed, 13 insertions(+), 534 deletions(-) delete mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx delete mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx delete mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx delete mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx delete mode 100644 src/module-tanstack.d.ts diff --git a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx index 1d4b9d7bca..dcf2d34411 100644 --- a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx @@ -1080,18 +1080,8 @@ const NetworkModificationNodeEditor = () => { ); const columns = useMemo[]>( - () => [ - ...BASE_COLUMNS, - ...(isMonoRootStudy - ? [] - : createRootNetworksColumns( - rootNetworks, - currentRootNetworkUuid!, - modificationsToExclude, - setModificationsToExclude - )), - ], - [isMonoRootStudy, rootNetworks, currentRootNetworkUuid, modificationsToExclude] + () => [...BASE_COLUMNS, ...(isMonoRootStudy ? [] : createRootNetworksColumns(rootNetworks))], + [isMonoRootStudy, rootNetworks] ); const renderNetworkModificationsTable = () => { @@ -1121,6 +1111,11 @@ const NetworkModificationNodeEditor = () => { highlightedModificationUuid={highlightedModificationUuid} studyUuid={studyUuid} currentNodeId={currentNode?.id} + currentRootNetworkUuid={currentRootNetworkUuid ?? undefined} + rootNetworks={isMonoRootStudy ? undefined : rootNetworks} + modificationsToExclude={modificationsToExclude} + setModificationsToExclude={setModificationsToExclude} + isDisabled={isAnyNodeBuilding || mapDataLoading} /> ); }; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx index fcfe8f8936..8279a8b22d 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx @@ -8,23 +8,19 @@ import { BASE_MODIFICATION_TABLE_COLUMNS, ComposedModificationMetadata, computeTagMinSize, - ExcludedNetworkModifications, - networkModificationTableStyles, -} from '@gridsuite/commons-ui'; -import React, { SetStateAction } from 'react'; -import { ColumnDef } from '@tanstack/react-table'; -import { RootNetworkMetadata } from '../network-modification-menu.type'; -import { DescriptionCellRenderer, DragHandleRenderer, NameCellRenderer, NameHeaderRenderer, + networkModificationTableStyles, RootNetworkCellRenderer, RootNetworkHeaderRenderer, SelectCellRenderer, SelectHeaderRenderer, SwitchCellRenderer, -} from './renderers/cell-renderers'; +} from '@gridsuite/commons-ui'; +import { ColumnDef } from '@tanstack/react-table'; +import { RootNetworkMetadata } from '../network-modification-menu.type'; /** * Column definition is broken up in 2 parts : base columns which are always on display and root networks columns. @@ -83,14 +79,10 @@ export const BASE_COLUMNS: ColumnDef[] = [ ]; export const createRootNetworksColumns = ( - rootNetworks: RootNetworkMetadata[], - currentRootNetworkUuid: string, - modificationsToExclude: ExcludedNetworkModifications[], - setModificationsToExclude: React.Dispatch> + rootNetworks: RootNetworkMetadata[] ): ColumnDef[] => { const tagMinSizes = rootNetworks.map((rootNetwork) => computeTagMinSize(rootNetwork.tag ?? '')); const sharedSize = Math.max(Math.min(...tagMinSizes), 56); - const currentRootNetworkTag = rootNetworks.find((item) => item.rootNetworkUuid === currentRootNetworkUuid)?.tag; return rootNetworks.map((rootNetwork, index) => ({ id: rootNetwork.rootNetworkUuid, @@ -100,11 +92,6 @@ export const createRootNetworksColumns = ( minSize: tagMinSizes[index], meta: { cellStyle: networkModificationTableStyles.columnCell.rootNetworkChip, - rootNetwork, - modificationsToExclude, - setModificationsToExclude, - isCurrentRootNetwork: rootNetwork.rootNetworkUuid === currentRootNetworkUuid, - currentRootNetworkTag, }, })); }; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx deleted file mode 100644 index a9979811a8..0000000000 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Copyright (c) 2026, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { - ComposedModificationMetadata, - createRootNetworkChipCellSx, - DragHandleCell, - NameCell, - NetworkModificationEditorNameHeader, - networkModificationTableStyles, - SelectCell, - SelectHeaderCell, -} from '@gridsuite/commons-ui'; -import { CellContext, HeaderContext } from '@tanstack/react-table'; -import { Badge, Box, Tooltip } from '@mui/material'; -import { RemoveRedEye as RemoveRedEyeIcon } from '@mui/icons-material'; -import { FormattedMessage } from 'react-intl'; -import DescriptionCell from './description-cell'; -import SwitchCell from './switch-cell'; -import RootNetworkChipCell from './root-network-chip-cell'; - -/** - * Cell/header renderers must keep a stable reference across renders: react-table calls - * `flexRender(columnDef.cell, ctx)`, and when a renderer function is used as a component, - * a new function reference is treated as a different component type — which can - * unmount/remount the cell and reset its local state. - * - * But what matters is the *scope* the renderer is defined in, not whether it is inline or named. - * An inline arrow inside a module-scope constant (e.g. `BASE_COLUMNS`) is created once and is - * just as stable as a named component. The renderers below are hoisted because they are reused - * by `createRootNetworksColumns`, which is a factory called inside a hook — defining them inline - * there would produce a fresh reference on every call. - * - * Dynamic values are routed via react-table's `meta`: table-wide via `table.options.meta`, - * per-column via `column.columnDef.meta`. - */ - -type CCtx = CellContext; -type HCtx = HeaderContext; - -export function DragHandleRenderer({ table }: CCtx) { - return ; -} - -export function SelectHeaderRenderer({ table }: HCtx) { - return ; -} - -export function SelectCellRenderer({ row, table }: CCtx) { - return ; -} - -export function NameHeaderRenderer({ table }: HCtx) { - const meta = table.options.meta; - const nameHeaderProps = meta?.nameHeaderProps; - if (!nameHeaderProps) { - return null; - } - return ( - - ); -} - -export function NameCellRenderer({ row }: CCtx) { - return ; -} - -export function DescriptionCellRenderer({ row }: CCtx) { - return ; -} - -export function SwitchCellRenderer({ row }: CCtx) { - return ; -} - -export function RootNetworkHeaderRenderer({ column, table }: HCtx) { - const colMeta = column.columnDef.meta; - const tableMeta = table.options.meta; - if (!colMeta?.isCurrentRootNetwork || (tableMeta?.modificationsCount ?? 0) < 1) { - return null; - } - return ( - - } - > - - - - - - ); -} - -export function RootNetworkCellRenderer({ row, column }: CCtx) { - const meta = column.columnDef.meta; - if (!meta?.rootNetwork || !meta.modificationsToExclude || !meta.setModificationsToExclude) { - return null; - } - return ( - - - - ); -} diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx deleted file mode 100644 index d0d06294f2..0000000000 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Copyright (c) 2025, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ -import { - ComposedModificationMetadata, - createEditDescriptionStyle, - DescriptionModificationDialog, - EditNoteIcon, -} from '@gridsuite/commons-ui'; -import { FunctionComponent, useCallback, useState } from 'react'; -import { Tooltip } from '@mui/material'; -import { useSelector } from 'react-redux'; -import IconButton from '@mui/material/IconButton'; -import { useIsAnyNodeBuilding } from '../../../../../utils/is-any-node-building-hook'; -import { setModificationMetadata } from '../../../../../../services/study/network-modifications'; -import { AppState } from '../../../../../../redux/reducer.type'; -import { FormattedMessage } from 'react-intl'; - -const DescriptionCell: FunctionComponent<{ data: ComposedModificationMetadata }> = (props) => { - const { data } = props; - const studyUuid = useSelector((state: AppState) => state.studyUuid); - const currentNode = useSelector((state: AppState) => state.currentTreeNode); - const [isLoading, setIsLoading] = useState(false); - const isAnyNodeBuilding = useIsAnyNodeBuilding(); - const mapDataLoading = useSelector((state: AppState) => state.mapDataLoading); - const [openDescModificationDialog, setOpenDescModificationDialog] = useState(false); - - const modificationUuid = data?.uuid; - const description = data?.description; - const empty = !description; - - const updateModification = useCallback( - async (descriptionRecord: Record) => { - setIsLoading(true); - - return setModificationMetadata(studyUuid, currentNode?.id, modificationUuid, { - description: descriptionRecord.description, - type: data?.type, - }).finally(() => { - setIsLoading(false); - }); - }, - [studyUuid, currentNode?.id, modificationUuid, data?.type] - ); - - const handleDescDialogClose = useCallback(() => { - setOpenDescModificationDialog(false); - }, []); - - const handleModifyDescription = useCallback(() => { - setOpenDescModificationDialog(true); - }, []); - - return ( - <> - {openDescModificationDialog && modificationUuid && ( - - )} - } arrow enterDelay={250}> - - - - - - - - ); -}; - -export default DescriptionCell; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx deleted file mode 100644 index 17dab4d05a..0000000000 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Copyright (c) 2025, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import React, { useState, useCallback, useMemo, SetStateAction, FunctionComponent } from 'react'; -import { - ActivableChip, - ComposedModificationMetadata, - ExcludedNetworkModifications, - snackWithFallback, - useSnackMessage, -} from '@gridsuite/commons-ui'; -import { updateModificationStatusByRootNetwork } from 'services/study/network-modifications'; -import { useSelector } from 'react-redux'; -import { RootNetworkMetadata } from '../../network-modification-menu.type'; -import { useIsAnyNodeBuilding } from 'components/utils/is-any-node-building-hook'; -import type { UUID } from 'node:crypto'; -import { AppState } from '../../../../../../redux/reducer.type'; - -function getUpdatedExcludedModifications( - prev: ExcludedNetworkModifications[], - rootNetworkUuid: UUID, - modificationUuid: UUID, - updateStatus: (isExcluded: boolean) => void -): ExcludedNetworkModifications[] { - const exists = prev.some((item) => item.rootNetworkUuid === rootNetworkUuid); - - if (exists) { - return prev.map((modif) => { - if (modif.rootNetworkUuid !== rootNetworkUuid) { - return modif; - } - - const isExcluded = modif.modificationUuidsToExclude.includes(modificationUuid); - const newModificationUuidsToExclude = isExcluded - ? modif.modificationUuidsToExclude.filter((id) => id !== modificationUuid) - : [...modif.modificationUuidsToExclude, modificationUuid]; - - // If previously excluded, now it is activated (true), else deactivated (false) - updateStatus(isExcluded); - - return { - ...modif, - modificationUuidsToExclude: newModificationUuidsToExclude, - }; - }); - } else { - updateStatus(false); - return [ - ...prev, - { - rootNetworkUuid: rootNetworkUuid, - modificationUuidsToExclude: [modificationUuid], - }, - ]; - } -} - -interface RootNetworkChipCellRendererProps { - data?: ComposedModificationMetadata; - modificationsToExclude: ExcludedNetworkModifications[]; - setModificationsToExclude: React.Dispatch>; - rootNetwork: RootNetworkMetadata; -} - -const RootNetworkChipCell: FunctionComponent = ({ - data, - rootNetwork, - modificationsToExclude, - setModificationsToExclude, -}) => { - const studyUuid = useSelector((state: AppState) => state.studyUuid); - const currentNode = useSelector((state: AppState) => state.currentTreeNode); - const [isLoading, setIsLoading] = useState(false); - const isAnyNodeBuilding = useIsAnyNodeBuilding(); - const mapDataLoading = useSelector((state: AppState) => state.mapDataLoading); - - const { snackError } = useSnackMessage(); - const modificationUuid = data?.uuid; - const isModificationActivated = useMemo(() => { - if (!modificationUuid) { - return false; - } - if (rootNetwork.isCreating) { - return true; - } - - const excludedSet = new Set( - modificationsToExclude.find((item) => item.rootNetworkUuid === rootNetwork.rootNetworkUuid) - ?.modificationUuidsToExclude || [] - ); - - return !excludedSet.has(modificationUuid); - }, [modificationUuid, modificationsToExclude, rootNetwork.rootNetworkUuid, rootNetwork.isCreating]); - - const updateStatus = useCallback( - (newStatus: boolean) => { - if (!studyUuid || !modificationUuid || !currentNode) { - setIsLoading(false); - return; - } - - updateModificationStatusByRootNetwork( - studyUuid, - currentNode?.id, - rootNetwork.rootNetworkUuid, - modificationUuid, - newStatus - ) - .catch((error) => { - snackWithFallback(snackError, error, { headerId: 'modificationActivationByRootNetworkError' }); - }) - .finally(() => { - setIsLoading(false); - }); - }, - [studyUuid, modificationUuid, currentNode, rootNetwork, snackError] - ); - const handleModificationActivationByRootNetwork = useCallback(() => { - if (!modificationUuid) { - return; - } - - setIsLoading(true); - - setModificationsToExclude((prev) => - getUpdatedExcludedModifications(prev, rootNetwork.rootNetworkUuid, modificationUuid, updateStatus) - ); - }, [modificationUuid, rootNetwork.rootNetworkUuid, setModificationsToExclude, updateStatus]); - - return ( - - ); -}; - -export default RootNetworkChipCell; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx deleted file mode 100644 index f5cc3abdcf..0000000000 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Copyright (c) 2025, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import React, { FunctionComponent, useCallback, useEffect, useState } from 'react'; -import { Switch, Tooltip } from '@mui/material'; -import { ComposedModificationMetadata, snackWithFallback, useSnackMessage } from '@gridsuite/commons-ui'; -import { setModificationMetadata } from 'services/study/network-modifications'; -import { useSelector } from 'react-redux'; -import { FormattedMessage } from 'react-intl'; -import { useIsAnyNodeBuilding } from 'components/utils/is-any-node-building-hook'; -import { AppState } from '../../../../../../redux/reducer.type'; - -export interface SwitchCellRendererProps { - data: ComposedModificationMetadata; -} - -const SwitchCell: FunctionComponent = (props) => { - const { data } = props; - const studyUuid = useSelector((state: AppState) => state.studyUuid); - const currentNode = useSelector((state: AppState) => state.currentTreeNode); - const [isLoading, setIsLoading] = useState(false); - const isAnyNodeBuilding = useIsAnyNodeBuilding(); - const mapDataLoading = useSelector((state: AppState) => state.mapDataLoading); - - const { snackError } = useSnackMessage(); - - const modificationUuid = data?.uuid; - const [modificationActivated, setModificationActivated] = useState(data?.activated); - - // Re-sync the local checked state when the row data is refreshed (e.g. after a server notification). - useEffect(() => { - setModificationActivated(data?.activated); - }, [data?.activated]); - - const toggleModificationActive = useCallback( - (_event: React.ChangeEvent, checked: boolean) => { - if (!modificationUuid) { - return; - } - - setIsLoading(true); - setModificationActivated(checked); - - setModificationMetadata(studyUuid, currentNode?.id, modificationUuid, { - activated: checked, - type: data?.type, - }) - .catch((error) => { - setModificationActivated(data?.activated); // rollback - snackWithFallback(snackError, error, { headerId: 'networkModificationActivationError' }); - }) - .finally(() => { - setIsLoading(false); - }); - }, - [modificationUuid, studyUuid, currentNode?.id, data?.type, data?.activated, snackError] - ); - - return ( - } - arrow - enterDelay={250} - > - - - - - ); -}; - -export default SwitchCell; diff --git a/src/module-tanstack.d.ts b/src/module-tanstack.d.ts deleted file mode 100644 index 88d834bb61..0000000000 --- a/src/module-tanstack.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2026, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Dispatch, RefObject, SetStateAction } from 'react'; -import { SxProps, Theme } from '@mui/material'; -import { ExcludedNetworkModifications, NameHeaderProps } from '@gridsuite/commons-ui'; -import { RootNetworkMetadata } from 'components/graph/menus/network-modifications/network-modification-menu.type'; - -declare module '@tanstack/react-table' { - // TableMeta = values shared by the whole table (same value across every cell). - // Read at runtime via `table.options.meta` from any cell/header renderer. - interface TableMeta { - lastClickedRowId: RefObject; - onRowSelected?: (selectedRows: TData[]) => void; - isRowDragDisabled?: boolean; - modificationsCount?: number; - nameHeaderProps?: NameHeaderProps; - } - - // ColumnMeta = values that differ from one column to another. - // Read at runtime via `column.columnDef.meta` (per-column). - // TData / TValue must match the original generic signature of ColumnMeta for the - // module-augmentation merge to apply. They aren't referenced in this body, hence the disable. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface ColumnMeta { - cellStyle?: SxProps; - rootNetwork?: RootNetworkMetadata; - modificationsToExclude?: ExcludedNetworkModifications[]; - setModificationsToExclude?: Dispatch>; - isCurrentRootNetwork?: boolean; - currentRootNetworkTag?: string; - } -} diff --git a/src/services/study/network-modifications.ts b/src/services/study/network-modifications.ts index c665269828..4ea7e31978 100644 --- a/src/services/study/network-modifications.ts +++ b/src/services/study/network-modifications.ts @@ -28,11 +28,7 @@ import { GeneratorCreationDto, GeneratorModificationDto, } from '@gridsuite/commons-ui'; -import { - getBaseNetworkModificationUrl, - getStudyUrlWithNodeUuid, - getStudyUrlWithNodeUuidAndRootNetworkUuid, -} from './index'; +import { getBaseNetworkModificationUrl, getStudyUrlWithNodeUuid } from './index'; import { BRANCH_SIDE, OPERATING_STATUS_ACTION } from '../../components/network/constants'; import type { UUID } from 'node:crypto'; import { @@ -143,46 +139,6 @@ export function stashModifications(studyUuid: UUID | null, nodeUuid: UUID | unde }); } -export function setModificationMetadata( - studyUuid: UUID | null, - nodeUuid: UUID | undefined, - modificationUuid: UUID | undefined, - metadata: Partial -): Promise { - const urlSearchParams = new URLSearchParams(); - urlSearchParams.append('uuids', String([modificationUuid])); - const modificationUpdateUrl = getNetworkModificationUrl(studyUuid, nodeUuid) + '?' + urlSearchParams.toString(); - return backendFetch(modificationUpdateUrl, { - method: 'PUT', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(metadata), - }); -} - -export function updateModificationStatusByRootNetwork( - studyUuid: UUID, - nodeUuid: UUID, - rootNetworkUuid: UUID, - modificationUuid: UUID, - activated: boolean -) { - const urlSearchParams = new URLSearchParams(); - urlSearchParams.append('activated', String(activated)); - urlSearchParams.append('uuids', String([modificationUuid])); - const modificationUpdateActiveUrl = - getStudyUrlWithNodeUuidAndRootNetworkUuid(studyUuid, nodeUuid, rootNetworkUuid) + - '/network-modifications' + - '?' + - urlSearchParams.toString(); - console.debug(modificationUpdateActiveUrl); - return backendFetch(modificationUpdateActiveUrl, { - method: 'PUT', - }); -} - export function restoreModifications(studyUuid: UUID | null, nodeUuid: UUID | undefined, modificationUuids: UUID[]) { const urlSearchParams = new URLSearchParams(); urlSearchParams.append('stashed', String(false)); From 2ead34d346bac4ab6520b0aae643fe27619146f5 Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Wed, 13 May 2026 17:19:46 +0200 Subject: [PATCH 5/9] Revert "Consume network-modification-table renderers from commons-ui" This reverts commit 6c47b857bb55c9b2d563e8f4acd64e1ee1b2cca4. --- .../network-modification-node-editor.tsx | 19 ++- .../createColumns.tsx | 23 ++- .../renderers/cell-renderers.tsx | 114 ++++++++++++++ .../renderers/description-cell.tsx | 82 ++++++++++ .../renderers/root-network-chip-cell.tsx | 145 ++++++++++++++++++ .../renderers/switch-cell.tsx | 81 ++++++++++ src/module-tanstack.d.ts | 37 +++++ src/services/study/network-modifications.ts | 46 +++++- 8 files changed, 534 insertions(+), 13 deletions(-) create mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx create mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx create mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx create mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx create mode 100644 src/module-tanstack.d.ts diff --git a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx index dcf2d34411..1d4b9d7bca 100644 --- a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx @@ -1080,8 +1080,18 @@ const NetworkModificationNodeEditor = () => { ); const columns = useMemo[]>( - () => [...BASE_COLUMNS, ...(isMonoRootStudy ? [] : createRootNetworksColumns(rootNetworks))], - [isMonoRootStudy, rootNetworks] + () => [ + ...BASE_COLUMNS, + ...(isMonoRootStudy + ? [] + : createRootNetworksColumns( + rootNetworks, + currentRootNetworkUuid!, + modificationsToExclude, + setModificationsToExclude + )), + ], + [isMonoRootStudy, rootNetworks, currentRootNetworkUuid, modificationsToExclude] ); const renderNetworkModificationsTable = () => { @@ -1111,11 +1121,6 @@ const NetworkModificationNodeEditor = () => { highlightedModificationUuid={highlightedModificationUuid} studyUuid={studyUuid} currentNodeId={currentNode?.id} - currentRootNetworkUuid={currentRootNetworkUuid ?? undefined} - rootNetworks={isMonoRootStudy ? undefined : rootNetworks} - modificationsToExclude={modificationsToExclude} - setModificationsToExclude={setModificationsToExclude} - isDisabled={isAnyNodeBuilding || mapDataLoading} /> ); }; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx index 8279a8b22d..fcfe8f8936 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx @@ -8,19 +8,23 @@ import { BASE_MODIFICATION_TABLE_COLUMNS, ComposedModificationMetadata, computeTagMinSize, + ExcludedNetworkModifications, + networkModificationTableStyles, +} from '@gridsuite/commons-ui'; +import React, { SetStateAction } from 'react'; +import { ColumnDef } from '@tanstack/react-table'; +import { RootNetworkMetadata } from '../network-modification-menu.type'; +import { DescriptionCellRenderer, DragHandleRenderer, NameCellRenderer, NameHeaderRenderer, - networkModificationTableStyles, RootNetworkCellRenderer, RootNetworkHeaderRenderer, SelectCellRenderer, SelectHeaderRenderer, SwitchCellRenderer, -} from '@gridsuite/commons-ui'; -import { ColumnDef } from '@tanstack/react-table'; -import { RootNetworkMetadata } from '../network-modification-menu.type'; +} from './renderers/cell-renderers'; /** * Column definition is broken up in 2 parts : base columns which are always on display and root networks columns. @@ -79,10 +83,14 @@ export const BASE_COLUMNS: ColumnDef[] = [ ]; export const createRootNetworksColumns = ( - rootNetworks: RootNetworkMetadata[] + rootNetworks: RootNetworkMetadata[], + currentRootNetworkUuid: string, + modificationsToExclude: ExcludedNetworkModifications[], + setModificationsToExclude: React.Dispatch> ): ColumnDef[] => { const tagMinSizes = rootNetworks.map((rootNetwork) => computeTagMinSize(rootNetwork.tag ?? '')); const sharedSize = Math.max(Math.min(...tagMinSizes), 56); + const currentRootNetworkTag = rootNetworks.find((item) => item.rootNetworkUuid === currentRootNetworkUuid)?.tag; return rootNetworks.map((rootNetwork, index) => ({ id: rootNetwork.rootNetworkUuid, @@ -92,6 +100,11 @@ export const createRootNetworksColumns = ( minSize: tagMinSizes[index], meta: { cellStyle: networkModificationTableStyles.columnCell.rootNetworkChip, + rootNetwork, + modificationsToExclude, + setModificationsToExclude, + isCurrentRootNetwork: rootNetwork.rootNetworkUuid === currentRootNetworkUuid, + currentRootNetworkTag, }, })); }; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx new file mode 100644 index 0000000000..a9979811a8 --- /dev/null +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { + ComposedModificationMetadata, + createRootNetworkChipCellSx, + DragHandleCell, + NameCell, + NetworkModificationEditorNameHeader, + networkModificationTableStyles, + SelectCell, + SelectHeaderCell, +} from '@gridsuite/commons-ui'; +import { CellContext, HeaderContext } from '@tanstack/react-table'; +import { Badge, Box, Tooltip } from '@mui/material'; +import { RemoveRedEye as RemoveRedEyeIcon } from '@mui/icons-material'; +import { FormattedMessage } from 'react-intl'; +import DescriptionCell from './description-cell'; +import SwitchCell from './switch-cell'; +import RootNetworkChipCell from './root-network-chip-cell'; + +/** + * Cell/header renderers must keep a stable reference across renders: react-table calls + * `flexRender(columnDef.cell, ctx)`, and when a renderer function is used as a component, + * a new function reference is treated as a different component type — which can + * unmount/remount the cell and reset its local state. + * + * But what matters is the *scope* the renderer is defined in, not whether it is inline or named. + * An inline arrow inside a module-scope constant (e.g. `BASE_COLUMNS`) is created once and is + * just as stable as a named component. The renderers below are hoisted because they are reused + * by `createRootNetworksColumns`, which is a factory called inside a hook — defining them inline + * there would produce a fresh reference on every call. + * + * Dynamic values are routed via react-table's `meta`: table-wide via `table.options.meta`, + * per-column via `column.columnDef.meta`. + */ + +type CCtx = CellContext; +type HCtx = HeaderContext; + +export function DragHandleRenderer({ table }: CCtx) { + return ; +} + +export function SelectHeaderRenderer({ table }: HCtx) { + return ; +} + +export function SelectCellRenderer({ row, table }: CCtx) { + return ; +} + +export function NameHeaderRenderer({ table }: HCtx) { + const meta = table.options.meta; + const nameHeaderProps = meta?.nameHeaderProps; + if (!nameHeaderProps) { + return null; + } + return ( + + ); +} + +export function NameCellRenderer({ row }: CCtx) { + return ; +} + +export function DescriptionCellRenderer({ row }: CCtx) { + return ; +} + +export function SwitchCellRenderer({ row }: CCtx) { + return ; +} + +export function RootNetworkHeaderRenderer({ column, table }: HCtx) { + const colMeta = column.columnDef.meta; + const tableMeta = table.options.meta; + if (!colMeta?.isCurrentRootNetwork || (tableMeta?.modificationsCount ?? 0) < 1) { + return null; + } + return ( + + } + > + + + + + + ); +} + +export function RootNetworkCellRenderer({ row, column }: CCtx) { + const meta = column.columnDef.meta; + if (!meta?.rootNetwork || !meta.modificationsToExclude || !meta.setModificationsToExclude) { + return null; + } + return ( + + + + ); +} diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx new file mode 100644 index 0000000000..d0d06294f2 --- /dev/null +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +import { + ComposedModificationMetadata, + createEditDescriptionStyle, + DescriptionModificationDialog, + EditNoteIcon, +} from '@gridsuite/commons-ui'; +import { FunctionComponent, useCallback, useState } from 'react'; +import { Tooltip } from '@mui/material'; +import { useSelector } from 'react-redux'; +import IconButton from '@mui/material/IconButton'; +import { useIsAnyNodeBuilding } from '../../../../../utils/is-any-node-building-hook'; +import { setModificationMetadata } from '../../../../../../services/study/network-modifications'; +import { AppState } from '../../../../../../redux/reducer.type'; +import { FormattedMessage } from 'react-intl'; + +const DescriptionCell: FunctionComponent<{ data: ComposedModificationMetadata }> = (props) => { + const { data } = props; + const studyUuid = useSelector((state: AppState) => state.studyUuid); + const currentNode = useSelector((state: AppState) => state.currentTreeNode); + const [isLoading, setIsLoading] = useState(false); + const isAnyNodeBuilding = useIsAnyNodeBuilding(); + const mapDataLoading = useSelector((state: AppState) => state.mapDataLoading); + const [openDescModificationDialog, setOpenDescModificationDialog] = useState(false); + + const modificationUuid = data?.uuid; + const description = data?.description; + const empty = !description; + + const updateModification = useCallback( + async (descriptionRecord: Record) => { + setIsLoading(true); + + return setModificationMetadata(studyUuid, currentNode?.id, modificationUuid, { + description: descriptionRecord.description, + type: data?.type, + }).finally(() => { + setIsLoading(false); + }); + }, + [studyUuid, currentNode?.id, modificationUuid, data?.type] + ); + + const handleDescDialogClose = useCallback(() => { + setOpenDescModificationDialog(false); + }, []); + + const handleModifyDescription = useCallback(() => { + setOpenDescModificationDialog(true); + }, []); + + return ( + <> + {openDescModificationDialog && modificationUuid && ( + + )} + } arrow enterDelay={250}> + + + + + + + + ); +}; + +export default DescriptionCell; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx new file mode 100644 index 0000000000..17dab4d05a --- /dev/null +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx @@ -0,0 +1,145 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import React, { useState, useCallback, useMemo, SetStateAction, FunctionComponent } from 'react'; +import { + ActivableChip, + ComposedModificationMetadata, + ExcludedNetworkModifications, + snackWithFallback, + useSnackMessage, +} from '@gridsuite/commons-ui'; +import { updateModificationStatusByRootNetwork } from 'services/study/network-modifications'; +import { useSelector } from 'react-redux'; +import { RootNetworkMetadata } from '../../network-modification-menu.type'; +import { useIsAnyNodeBuilding } from 'components/utils/is-any-node-building-hook'; +import type { UUID } from 'node:crypto'; +import { AppState } from '../../../../../../redux/reducer.type'; + +function getUpdatedExcludedModifications( + prev: ExcludedNetworkModifications[], + rootNetworkUuid: UUID, + modificationUuid: UUID, + updateStatus: (isExcluded: boolean) => void +): ExcludedNetworkModifications[] { + const exists = prev.some((item) => item.rootNetworkUuid === rootNetworkUuid); + + if (exists) { + return prev.map((modif) => { + if (modif.rootNetworkUuid !== rootNetworkUuid) { + return modif; + } + + const isExcluded = modif.modificationUuidsToExclude.includes(modificationUuid); + const newModificationUuidsToExclude = isExcluded + ? modif.modificationUuidsToExclude.filter((id) => id !== modificationUuid) + : [...modif.modificationUuidsToExclude, modificationUuid]; + + // If previously excluded, now it is activated (true), else deactivated (false) + updateStatus(isExcluded); + + return { + ...modif, + modificationUuidsToExclude: newModificationUuidsToExclude, + }; + }); + } else { + updateStatus(false); + return [ + ...prev, + { + rootNetworkUuid: rootNetworkUuid, + modificationUuidsToExclude: [modificationUuid], + }, + ]; + } +} + +interface RootNetworkChipCellRendererProps { + data?: ComposedModificationMetadata; + modificationsToExclude: ExcludedNetworkModifications[]; + setModificationsToExclude: React.Dispatch>; + rootNetwork: RootNetworkMetadata; +} + +const RootNetworkChipCell: FunctionComponent = ({ + data, + rootNetwork, + modificationsToExclude, + setModificationsToExclude, +}) => { + const studyUuid = useSelector((state: AppState) => state.studyUuid); + const currentNode = useSelector((state: AppState) => state.currentTreeNode); + const [isLoading, setIsLoading] = useState(false); + const isAnyNodeBuilding = useIsAnyNodeBuilding(); + const mapDataLoading = useSelector((state: AppState) => state.mapDataLoading); + + const { snackError } = useSnackMessage(); + const modificationUuid = data?.uuid; + const isModificationActivated = useMemo(() => { + if (!modificationUuid) { + return false; + } + if (rootNetwork.isCreating) { + return true; + } + + const excludedSet = new Set( + modificationsToExclude.find((item) => item.rootNetworkUuid === rootNetwork.rootNetworkUuid) + ?.modificationUuidsToExclude || [] + ); + + return !excludedSet.has(modificationUuid); + }, [modificationUuid, modificationsToExclude, rootNetwork.rootNetworkUuid, rootNetwork.isCreating]); + + const updateStatus = useCallback( + (newStatus: boolean) => { + if (!studyUuid || !modificationUuid || !currentNode) { + setIsLoading(false); + return; + } + + updateModificationStatusByRootNetwork( + studyUuid, + currentNode?.id, + rootNetwork.rootNetworkUuid, + modificationUuid, + newStatus + ) + .catch((error) => { + snackWithFallback(snackError, error, { headerId: 'modificationActivationByRootNetworkError' }); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [studyUuid, modificationUuid, currentNode, rootNetwork, snackError] + ); + const handleModificationActivationByRootNetwork = useCallback(() => { + if (!modificationUuid) { + return; + } + + setIsLoading(true); + + setModificationsToExclude((prev) => + getUpdatedExcludedModifications(prev, rootNetwork.rootNetworkUuid, modificationUuid, updateStatus) + ); + }, [modificationUuid, rootNetwork.rootNetworkUuid, setModificationsToExclude, updateStatus]); + + return ( + + ); +}; + +export default RootNetworkChipCell; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx new file mode 100644 index 0000000000..f5cc3abdcf --- /dev/null +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import React, { FunctionComponent, useCallback, useEffect, useState } from 'react'; +import { Switch, Tooltip } from '@mui/material'; +import { ComposedModificationMetadata, snackWithFallback, useSnackMessage } from '@gridsuite/commons-ui'; +import { setModificationMetadata } from 'services/study/network-modifications'; +import { useSelector } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import { useIsAnyNodeBuilding } from 'components/utils/is-any-node-building-hook'; +import { AppState } from '../../../../../../redux/reducer.type'; + +export interface SwitchCellRendererProps { + data: ComposedModificationMetadata; +} + +const SwitchCell: FunctionComponent = (props) => { + const { data } = props; + const studyUuid = useSelector((state: AppState) => state.studyUuid); + const currentNode = useSelector((state: AppState) => state.currentTreeNode); + const [isLoading, setIsLoading] = useState(false); + const isAnyNodeBuilding = useIsAnyNodeBuilding(); + const mapDataLoading = useSelector((state: AppState) => state.mapDataLoading); + + const { snackError } = useSnackMessage(); + + const modificationUuid = data?.uuid; + const [modificationActivated, setModificationActivated] = useState(data?.activated); + + // Re-sync the local checked state when the row data is refreshed (e.g. after a server notification). + useEffect(() => { + setModificationActivated(data?.activated); + }, [data?.activated]); + + const toggleModificationActive = useCallback( + (_event: React.ChangeEvent, checked: boolean) => { + if (!modificationUuid) { + return; + } + + setIsLoading(true); + setModificationActivated(checked); + + setModificationMetadata(studyUuid, currentNode?.id, modificationUuid, { + activated: checked, + type: data?.type, + }) + .catch((error) => { + setModificationActivated(data?.activated); // rollback + snackWithFallback(snackError, error, { headerId: 'networkModificationActivationError' }); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [modificationUuid, studyUuid, currentNode?.id, data?.type, data?.activated, snackError] + ); + + return ( + } + arrow + enterDelay={250} + > + + + + + ); +}; + +export default SwitchCell; diff --git a/src/module-tanstack.d.ts b/src/module-tanstack.d.ts new file mode 100644 index 0000000000..88d834bb61 --- /dev/null +++ b/src/module-tanstack.d.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Dispatch, RefObject, SetStateAction } from 'react'; +import { SxProps, Theme } from '@mui/material'; +import { ExcludedNetworkModifications, NameHeaderProps } from '@gridsuite/commons-ui'; +import { RootNetworkMetadata } from 'components/graph/menus/network-modifications/network-modification-menu.type'; + +declare module '@tanstack/react-table' { + // TableMeta = values shared by the whole table (same value across every cell). + // Read at runtime via `table.options.meta` from any cell/header renderer. + interface TableMeta { + lastClickedRowId: RefObject; + onRowSelected?: (selectedRows: TData[]) => void; + isRowDragDisabled?: boolean; + modificationsCount?: number; + nameHeaderProps?: NameHeaderProps; + } + + // ColumnMeta = values that differ from one column to another. + // Read at runtime via `column.columnDef.meta` (per-column). + // TData / TValue must match the original generic signature of ColumnMeta for the + // module-augmentation merge to apply. They aren't referenced in this body, hence the disable. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + cellStyle?: SxProps; + rootNetwork?: RootNetworkMetadata; + modificationsToExclude?: ExcludedNetworkModifications[]; + setModificationsToExclude?: Dispatch>; + isCurrentRootNetwork?: boolean; + currentRootNetworkTag?: string; + } +} diff --git a/src/services/study/network-modifications.ts b/src/services/study/network-modifications.ts index 4ea7e31978..c665269828 100644 --- a/src/services/study/network-modifications.ts +++ b/src/services/study/network-modifications.ts @@ -28,7 +28,11 @@ import { GeneratorCreationDto, GeneratorModificationDto, } from '@gridsuite/commons-ui'; -import { getBaseNetworkModificationUrl, getStudyUrlWithNodeUuid } from './index'; +import { + getBaseNetworkModificationUrl, + getStudyUrlWithNodeUuid, + getStudyUrlWithNodeUuidAndRootNetworkUuid, +} from './index'; import { BRANCH_SIDE, OPERATING_STATUS_ACTION } from '../../components/network/constants'; import type { UUID } from 'node:crypto'; import { @@ -139,6 +143,46 @@ export function stashModifications(studyUuid: UUID | null, nodeUuid: UUID | unde }); } +export function setModificationMetadata( + studyUuid: UUID | null, + nodeUuid: UUID | undefined, + modificationUuid: UUID | undefined, + metadata: Partial +): Promise { + const urlSearchParams = new URLSearchParams(); + urlSearchParams.append('uuids', String([modificationUuid])); + const modificationUpdateUrl = getNetworkModificationUrl(studyUuid, nodeUuid) + '?' + urlSearchParams.toString(); + return backendFetch(modificationUpdateUrl, { + method: 'PUT', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(metadata), + }); +} + +export function updateModificationStatusByRootNetwork( + studyUuid: UUID, + nodeUuid: UUID, + rootNetworkUuid: UUID, + modificationUuid: UUID, + activated: boolean +) { + const urlSearchParams = new URLSearchParams(); + urlSearchParams.append('activated', String(activated)); + urlSearchParams.append('uuids', String([modificationUuid])); + const modificationUpdateActiveUrl = + getStudyUrlWithNodeUuidAndRootNetworkUuid(studyUuid, nodeUuid, rootNetworkUuid) + + '/network-modifications' + + '?' + + urlSearchParams.toString(); + console.debug(modificationUpdateActiveUrl); + return backendFetch(modificationUpdateActiveUrl, { + method: 'PUT', + }); +} + export function restoreModifications(studyUuid: UUID | null, nodeUuid: UUID | undefined, modificationUuids: UUID[]) { const urlSearchParams = new URLSearchParams(); urlSearchParams.append('stashed', String(false)); From e84e0c2885530d86b6234b0a84897cb42ab2a000 Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Wed, 13 May 2026 17:21:43 +0200 Subject: [PATCH 6/9] Consume network-modification-table renderers from commons-ui The per-row cells, the cell-renderers adapter, the two services (setModificationMetadata, updateModificationStatusByRootNetwork) and the @tanstack/react-table module augmentation that they relied on have moved to commons-ui. Local files are deleted and createColumns now imports the renderers from commons-ui. createRootNetworksColumns is simplified: it only needs the root-networks list since the rest of the per-column meta (rootNetwork, modificationsToExclude, isCurrentRootNetwork, currentRootNetworkTag) is now derived from TableMeta. The node editor passes studyUuid, currentNodeId, currentRootNetworkUuid, rootNetworks, modificationsToExclude, setModificationsToExclude and a combined isDisabled (isAnyNodeBuilding || mapDataLoading) to NetworkModificationsTable. Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- .../network-modification-node-editor.tsx | 19 +-- .../createColumns.tsx | 23 +-- .../renderers/cell-renderers.tsx | 114 -------------- .../renderers/description-cell.tsx | 82 ---------- .../renderers/root-network-chip-cell.tsx | 145 ------------------ .../renderers/switch-cell.tsx | 81 ---------- src/module-tanstack.d.ts | 37 ----- src/services/study/network-modifications.ts | 46 +----- 8 files changed, 13 insertions(+), 534 deletions(-) delete mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx delete mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx delete mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx delete mode 100644 src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx delete mode 100644 src/module-tanstack.d.ts diff --git a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx index 1d4b9d7bca..dcf2d34411 100644 --- a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx @@ -1080,18 +1080,8 @@ const NetworkModificationNodeEditor = () => { ); const columns = useMemo[]>( - () => [ - ...BASE_COLUMNS, - ...(isMonoRootStudy - ? [] - : createRootNetworksColumns( - rootNetworks, - currentRootNetworkUuid!, - modificationsToExclude, - setModificationsToExclude - )), - ], - [isMonoRootStudy, rootNetworks, currentRootNetworkUuid, modificationsToExclude] + () => [...BASE_COLUMNS, ...(isMonoRootStudy ? [] : createRootNetworksColumns(rootNetworks))], + [isMonoRootStudy, rootNetworks] ); const renderNetworkModificationsTable = () => { @@ -1121,6 +1111,11 @@ const NetworkModificationNodeEditor = () => { highlightedModificationUuid={highlightedModificationUuid} studyUuid={studyUuid} currentNodeId={currentNode?.id} + currentRootNetworkUuid={currentRootNetworkUuid ?? undefined} + rootNetworks={isMonoRootStudy ? undefined : rootNetworks} + modificationsToExclude={modificationsToExclude} + setModificationsToExclude={setModificationsToExclude} + isDisabled={isAnyNodeBuilding || mapDataLoading} /> ); }; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx index fcfe8f8936..8279a8b22d 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx @@ -8,23 +8,19 @@ import { BASE_MODIFICATION_TABLE_COLUMNS, ComposedModificationMetadata, computeTagMinSize, - ExcludedNetworkModifications, - networkModificationTableStyles, -} from '@gridsuite/commons-ui'; -import React, { SetStateAction } from 'react'; -import { ColumnDef } from '@tanstack/react-table'; -import { RootNetworkMetadata } from '../network-modification-menu.type'; -import { DescriptionCellRenderer, DragHandleRenderer, NameCellRenderer, NameHeaderRenderer, + networkModificationTableStyles, RootNetworkCellRenderer, RootNetworkHeaderRenderer, SelectCellRenderer, SelectHeaderRenderer, SwitchCellRenderer, -} from './renderers/cell-renderers'; +} from '@gridsuite/commons-ui'; +import { ColumnDef } from '@tanstack/react-table'; +import { RootNetworkMetadata } from '../network-modification-menu.type'; /** * Column definition is broken up in 2 parts : base columns which are always on display and root networks columns. @@ -83,14 +79,10 @@ export const BASE_COLUMNS: ColumnDef[] = [ ]; export const createRootNetworksColumns = ( - rootNetworks: RootNetworkMetadata[], - currentRootNetworkUuid: string, - modificationsToExclude: ExcludedNetworkModifications[], - setModificationsToExclude: React.Dispatch> + rootNetworks: RootNetworkMetadata[] ): ColumnDef[] => { const tagMinSizes = rootNetworks.map((rootNetwork) => computeTagMinSize(rootNetwork.tag ?? '')); const sharedSize = Math.max(Math.min(...tagMinSizes), 56); - const currentRootNetworkTag = rootNetworks.find((item) => item.rootNetworkUuid === currentRootNetworkUuid)?.tag; return rootNetworks.map((rootNetwork, index) => ({ id: rootNetwork.rootNetworkUuid, @@ -100,11 +92,6 @@ export const createRootNetworksColumns = ( minSize: tagMinSizes[index], meta: { cellStyle: networkModificationTableStyles.columnCell.rootNetworkChip, - rootNetwork, - modificationsToExclude, - setModificationsToExclude, - isCurrentRootNetwork: rootNetwork.rootNetworkUuid === currentRootNetworkUuid, - currentRootNetworkTag, }, })); }; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx deleted file mode 100644 index a9979811a8..0000000000 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/cell-renderers.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Copyright (c) 2026, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { - ComposedModificationMetadata, - createRootNetworkChipCellSx, - DragHandleCell, - NameCell, - NetworkModificationEditorNameHeader, - networkModificationTableStyles, - SelectCell, - SelectHeaderCell, -} from '@gridsuite/commons-ui'; -import { CellContext, HeaderContext } from '@tanstack/react-table'; -import { Badge, Box, Tooltip } from '@mui/material'; -import { RemoveRedEye as RemoveRedEyeIcon } from '@mui/icons-material'; -import { FormattedMessage } from 'react-intl'; -import DescriptionCell from './description-cell'; -import SwitchCell from './switch-cell'; -import RootNetworkChipCell from './root-network-chip-cell'; - -/** - * Cell/header renderers must keep a stable reference across renders: react-table calls - * `flexRender(columnDef.cell, ctx)`, and when a renderer function is used as a component, - * a new function reference is treated as a different component type — which can - * unmount/remount the cell and reset its local state. - * - * But what matters is the *scope* the renderer is defined in, not whether it is inline or named. - * An inline arrow inside a module-scope constant (e.g. `BASE_COLUMNS`) is created once and is - * just as stable as a named component. The renderers below are hoisted because they are reused - * by `createRootNetworksColumns`, which is a factory called inside a hook — defining them inline - * there would produce a fresh reference on every call. - * - * Dynamic values are routed via react-table's `meta`: table-wide via `table.options.meta`, - * per-column via `column.columnDef.meta`. - */ - -type CCtx = CellContext; -type HCtx = HeaderContext; - -export function DragHandleRenderer({ table }: CCtx) { - return ; -} - -export function SelectHeaderRenderer({ table }: HCtx) { - return ; -} - -export function SelectCellRenderer({ row, table }: CCtx) { - return ; -} - -export function NameHeaderRenderer({ table }: HCtx) { - const meta = table.options.meta; - const nameHeaderProps = meta?.nameHeaderProps; - if (!nameHeaderProps) { - return null; - } - return ( - - ); -} - -export function NameCellRenderer({ row }: CCtx) { - return ; -} - -export function DescriptionCellRenderer({ row }: CCtx) { - return ; -} - -export function SwitchCellRenderer({ row }: CCtx) { - return ; -} - -export function RootNetworkHeaderRenderer({ column, table }: HCtx) { - const colMeta = column.columnDef.meta; - const tableMeta = table.options.meta; - if (!colMeta?.isCurrentRootNetwork || (tableMeta?.modificationsCount ?? 0) < 1) { - return null; - } - return ( - - } - > - - - - - - ); -} - -export function RootNetworkCellRenderer({ row, column }: CCtx) { - const meta = column.columnDef.meta; - if (!meta?.rootNetwork || !meta.modificationsToExclude || !meta.setModificationsToExclude) { - return null; - } - return ( - - - - ); -} diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx deleted file mode 100644 index d0d06294f2..0000000000 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/description-cell.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Copyright (c) 2025, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ -import { - ComposedModificationMetadata, - createEditDescriptionStyle, - DescriptionModificationDialog, - EditNoteIcon, -} from '@gridsuite/commons-ui'; -import { FunctionComponent, useCallback, useState } from 'react'; -import { Tooltip } from '@mui/material'; -import { useSelector } from 'react-redux'; -import IconButton from '@mui/material/IconButton'; -import { useIsAnyNodeBuilding } from '../../../../../utils/is-any-node-building-hook'; -import { setModificationMetadata } from '../../../../../../services/study/network-modifications'; -import { AppState } from '../../../../../../redux/reducer.type'; -import { FormattedMessage } from 'react-intl'; - -const DescriptionCell: FunctionComponent<{ data: ComposedModificationMetadata }> = (props) => { - const { data } = props; - const studyUuid = useSelector((state: AppState) => state.studyUuid); - const currentNode = useSelector((state: AppState) => state.currentTreeNode); - const [isLoading, setIsLoading] = useState(false); - const isAnyNodeBuilding = useIsAnyNodeBuilding(); - const mapDataLoading = useSelector((state: AppState) => state.mapDataLoading); - const [openDescModificationDialog, setOpenDescModificationDialog] = useState(false); - - const modificationUuid = data?.uuid; - const description = data?.description; - const empty = !description; - - const updateModification = useCallback( - async (descriptionRecord: Record) => { - setIsLoading(true); - - return setModificationMetadata(studyUuid, currentNode?.id, modificationUuid, { - description: descriptionRecord.description, - type: data?.type, - }).finally(() => { - setIsLoading(false); - }); - }, - [studyUuid, currentNode?.id, modificationUuid, data?.type] - ); - - const handleDescDialogClose = useCallback(() => { - setOpenDescModificationDialog(false); - }, []); - - const handleModifyDescription = useCallback(() => { - setOpenDescModificationDialog(true); - }, []); - - return ( - <> - {openDescModificationDialog && modificationUuid && ( - - )} - } arrow enterDelay={250}> - - - - - - - - ); -}; - -export default DescriptionCell; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx deleted file mode 100644 index 17dab4d05a..0000000000 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/root-network-chip-cell.tsx +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Copyright (c) 2025, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import React, { useState, useCallback, useMemo, SetStateAction, FunctionComponent } from 'react'; -import { - ActivableChip, - ComposedModificationMetadata, - ExcludedNetworkModifications, - snackWithFallback, - useSnackMessage, -} from '@gridsuite/commons-ui'; -import { updateModificationStatusByRootNetwork } from 'services/study/network-modifications'; -import { useSelector } from 'react-redux'; -import { RootNetworkMetadata } from '../../network-modification-menu.type'; -import { useIsAnyNodeBuilding } from 'components/utils/is-any-node-building-hook'; -import type { UUID } from 'node:crypto'; -import { AppState } from '../../../../../../redux/reducer.type'; - -function getUpdatedExcludedModifications( - prev: ExcludedNetworkModifications[], - rootNetworkUuid: UUID, - modificationUuid: UUID, - updateStatus: (isExcluded: boolean) => void -): ExcludedNetworkModifications[] { - const exists = prev.some((item) => item.rootNetworkUuid === rootNetworkUuid); - - if (exists) { - return prev.map((modif) => { - if (modif.rootNetworkUuid !== rootNetworkUuid) { - return modif; - } - - const isExcluded = modif.modificationUuidsToExclude.includes(modificationUuid); - const newModificationUuidsToExclude = isExcluded - ? modif.modificationUuidsToExclude.filter((id) => id !== modificationUuid) - : [...modif.modificationUuidsToExclude, modificationUuid]; - - // If previously excluded, now it is activated (true), else deactivated (false) - updateStatus(isExcluded); - - return { - ...modif, - modificationUuidsToExclude: newModificationUuidsToExclude, - }; - }); - } else { - updateStatus(false); - return [ - ...prev, - { - rootNetworkUuid: rootNetworkUuid, - modificationUuidsToExclude: [modificationUuid], - }, - ]; - } -} - -interface RootNetworkChipCellRendererProps { - data?: ComposedModificationMetadata; - modificationsToExclude: ExcludedNetworkModifications[]; - setModificationsToExclude: React.Dispatch>; - rootNetwork: RootNetworkMetadata; -} - -const RootNetworkChipCell: FunctionComponent = ({ - data, - rootNetwork, - modificationsToExclude, - setModificationsToExclude, -}) => { - const studyUuid = useSelector((state: AppState) => state.studyUuid); - const currentNode = useSelector((state: AppState) => state.currentTreeNode); - const [isLoading, setIsLoading] = useState(false); - const isAnyNodeBuilding = useIsAnyNodeBuilding(); - const mapDataLoading = useSelector((state: AppState) => state.mapDataLoading); - - const { snackError } = useSnackMessage(); - const modificationUuid = data?.uuid; - const isModificationActivated = useMemo(() => { - if (!modificationUuid) { - return false; - } - if (rootNetwork.isCreating) { - return true; - } - - const excludedSet = new Set( - modificationsToExclude.find((item) => item.rootNetworkUuid === rootNetwork.rootNetworkUuid) - ?.modificationUuidsToExclude || [] - ); - - return !excludedSet.has(modificationUuid); - }, [modificationUuid, modificationsToExclude, rootNetwork.rootNetworkUuid, rootNetwork.isCreating]); - - const updateStatus = useCallback( - (newStatus: boolean) => { - if (!studyUuid || !modificationUuid || !currentNode) { - setIsLoading(false); - return; - } - - updateModificationStatusByRootNetwork( - studyUuid, - currentNode?.id, - rootNetwork.rootNetworkUuid, - modificationUuid, - newStatus - ) - .catch((error) => { - snackWithFallback(snackError, error, { headerId: 'modificationActivationByRootNetworkError' }); - }) - .finally(() => { - setIsLoading(false); - }); - }, - [studyUuid, modificationUuid, currentNode, rootNetwork, snackError] - ); - const handleModificationActivationByRootNetwork = useCallback(() => { - if (!modificationUuid) { - return; - } - - setIsLoading(true); - - setModificationsToExclude((prev) => - getUpdatedExcludedModifications(prev, rootNetwork.rootNetworkUuid, modificationUuid, updateStatus) - ); - }, [modificationUuid, rootNetwork.rootNetworkUuid, setModificationsToExclude, updateStatus]); - - return ( - - ); -}; - -export default RootNetworkChipCell; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx deleted file mode 100644 index f5cc3abdcf..0000000000 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Copyright (c) 2025, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import React, { FunctionComponent, useCallback, useEffect, useState } from 'react'; -import { Switch, Tooltip } from '@mui/material'; -import { ComposedModificationMetadata, snackWithFallback, useSnackMessage } from '@gridsuite/commons-ui'; -import { setModificationMetadata } from 'services/study/network-modifications'; -import { useSelector } from 'react-redux'; -import { FormattedMessage } from 'react-intl'; -import { useIsAnyNodeBuilding } from 'components/utils/is-any-node-building-hook'; -import { AppState } from '../../../../../../redux/reducer.type'; - -export interface SwitchCellRendererProps { - data: ComposedModificationMetadata; -} - -const SwitchCell: FunctionComponent = (props) => { - const { data } = props; - const studyUuid = useSelector((state: AppState) => state.studyUuid); - const currentNode = useSelector((state: AppState) => state.currentTreeNode); - const [isLoading, setIsLoading] = useState(false); - const isAnyNodeBuilding = useIsAnyNodeBuilding(); - const mapDataLoading = useSelector((state: AppState) => state.mapDataLoading); - - const { snackError } = useSnackMessage(); - - const modificationUuid = data?.uuid; - const [modificationActivated, setModificationActivated] = useState(data?.activated); - - // Re-sync the local checked state when the row data is refreshed (e.g. after a server notification). - useEffect(() => { - setModificationActivated(data?.activated); - }, [data?.activated]); - - const toggleModificationActive = useCallback( - (_event: React.ChangeEvent, checked: boolean) => { - if (!modificationUuid) { - return; - } - - setIsLoading(true); - setModificationActivated(checked); - - setModificationMetadata(studyUuid, currentNode?.id, modificationUuid, { - activated: checked, - type: data?.type, - }) - .catch((error) => { - setModificationActivated(data?.activated); // rollback - snackWithFallback(snackError, error, { headerId: 'networkModificationActivationError' }); - }) - .finally(() => { - setIsLoading(false); - }); - }, - [modificationUuid, studyUuid, currentNode?.id, data?.type, data?.activated, snackError] - ); - - return ( - } - arrow - enterDelay={250} - > - - - - - ); -}; - -export default SwitchCell; diff --git a/src/module-tanstack.d.ts b/src/module-tanstack.d.ts deleted file mode 100644 index 88d834bb61..0000000000 --- a/src/module-tanstack.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2026, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Dispatch, RefObject, SetStateAction } from 'react'; -import { SxProps, Theme } from '@mui/material'; -import { ExcludedNetworkModifications, NameHeaderProps } from '@gridsuite/commons-ui'; -import { RootNetworkMetadata } from 'components/graph/menus/network-modifications/network-modification-menu.type'; - -declare module '@tanstack/react-table' { - // TableMeta = values shared by the whole table (same value across every cell). - // Read at runtime via `table.options.meta` from any cell/header renderer. - interface TableMeta { - lastClickedRowId: RefObject; - onRowSelected?: (selectedRows: TData[]) => void; - isRowDragDisabled?: boolean; - modificationsCount?: number; - nameHeaderProps?: NameHeaderProps; - } - - // ColumnMeta = values that differ from one column to another. - // Read at runtime via `column.columnDef.meta` (per-column). - // TData / TValue must match the original generic signature of ColumnMeta for the - // module-augmentation merge to apply. They aren't referenced in this body, hence the disable. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface ColumnMeta { - cellStyle?: SxProps; - rootNetwork?: RootNetworkMetadata; - modificationsToExclude?: ExcludedNetworkModifications[]; - setModificationsToExclude?: Dispatch>; - isCurrentRootNetwork?: boolean; - currentRootNetworkTag?: string; - } -} diff --git a/src/services/study/network-modifications.ts b/src/services/study/network-modifications.ts index c665269828..4ea7e31978 100644 --- a/src/services/study/network-modifications.ts +++ b/src/services/study/network-modifications.ts @@ -28,11 +28,7 @@ import { GeneratorCreationDto, GeneratorModificationDto, } from '@gridsuite/commons-ui'; -import { - getBaseNetworkModificationUrl, - getStudyUrlWithNodeUuid, - getStudyUrlWithNodeUuidAndRootNetworkUuid, -} from './index'; +import { getBaseNetworkModificationUrl, getStudyUrlWithNodeUuid } from './index'; import { BRANCH_SIDE, OPERATING_STATUS_ACTION } from '../../components/network/constants'; import type { UUID } from 'node:crypto'; import { @@ -143,46 +139,6 @@ export function stashModifications(studyUuid: UUID | null, nodeUuid: UUID | unde }); } -export function setModificationMetadata( - studyUuid: UUID | null, - nodeUuid: UUID | undefined, - modificationUuid: UUID | undefined, - metadata: Partial -): Promise { - const urlSearchParams = new URLSearchParams(); - urlSearchParams.append('uuids', String([modificationUuid])); - const modificationUpdateUrl = getNetworkModificationUrl(studyUuid, nodeUuid) + '?' + urlSearchParams.toString(); - return backendFetch(modificationUpdateUrl, { - method: 'PUT', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(metadata), - }); -} - -export function updateModificationStatusByRootNetwork( - studyUuid: UUID, - nodeUuid: UUID, - rootNetworkUuid: UUID, - modificationUuid: UUID, - activated: boolean -) { - const urlSearchParams = new URLSearchParams(); - urlSearchParams.append('activated', String(activated)); - urlSearchParams.append('uuids', String([modificationUuid])); - const modificationUpdateActiveUrl = - getStudyUrlWithNodeUuidAndRootNetworkUuid(studyUuid, nodeUuid, rootNetworkUuid) + - '/network-modifications' + - '?' + - urlSearchParams.toString(); - console.debug(modificationUpdateActiveUrl); - return backendFetch(modificationUpdateActiveUrl, { - method: 'PUT', - }); -} - export function restoreModifications(studyUuid: UUID | null, nodeUuid: UUID | undefined, modificationUuids: UUID[]) { const urlSearchParams = new URLSearchParams(); urlSearchParams.append('stashed', String(false)); From 1639d8cc3f2809be97e3407afa6d8faee6aac47c Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Wed, 20 May 2026 11:25:57 +0200 Subject: [PATCH 7/9] Remove network-modification rename handler Composite name editing is now self-contained in commons-ui's NameCell, so the updateModification/handleCellEdit handlers and the onEditNameCell prop are no longer needed. Adds the networkModificationRenamingError message displayed by the cell on failure. Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- .../network-modification-node-editor.tsx | 43 ------------------- src/translations/en.json | 1 + src/translations/fr.json | 1 + 3 files changed, 2 insertions(+), 43 deletions(-) diff --git a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx index 6451ff1d6c..ee2132241d 100644 --- a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx @@ -21,7 +21,6 @@ import { NetworkModificationsTable, NotificationsUrlKeys, removeNullFields, - setModificationMetadata, snackWithFallback, useNotificationsListener, usePrevious, @@ -809,47 +808,6 @@ const NetworkModificationNodeEditor = () => { modificationsToExclude, ]); - const updateModification = useCallback( - async (modif: ComposedModificationMetadata, newName: string) => { - return setModificationMetadata(studyUuid, currentNode?.id, modif.uuid, { - name: newName, - type: modif?.type, - }); - }, - [studyUuid, currentNode?.id] - ); - const handleCellEdit = useCallback( - async (modification: ComposedModificationMetadata, newName?: string) => { - if (!newName || newName.trim() === '') { - return; - } - const trimmed = newName.trim(); - - // Optimistic immediate update - setModifications((prev) => - prev.map((m) => { - if (m.uuid !== modification.uuid) return m; - try { - const parsed = JSON.parse(m.messageValues); - return { - ...m, - messageValues: JSON.stringify({ ...parsed, name: trimmed }), - }; - } catch { - return m; - } - }) - ); - - try { - await updateModification(modification, trimmed); - } catch { - // Rollback in case of an error - setModifications((prev) => prev.map((m) => (m.uuid !== modification.uuid ? m : modification))); - } - }, - [updateModification] - ); const handleEvent = useCallback( (event: MessageEvent) => { const eventData = parseEventData(event); @@ -1165,7 +1123,6 @@ const NetworkModificationNodeEditor = () => { modificationsToExclude={modificationsToExclude} setModificationsToExclude={setModificationsToExclude} isDisabled={isAnyNodeBuilding || mapDataLoading} - onEditNameCell={handleCellEdit} /> ); }; diff --git a/src/translations/en.json b/src/translations/en.json index 99619bfd91..a5c82bfc0d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1233,6 +1233,7 @@ "guidancePopUp.action": "Close editor", "generateNad": "Generate", "networkModificationActivationError": "An error occurred while enabling / disabling modification", + "networkModificationRenamingError": "An error occurred while renaming the modification", "AddAutomaton": "Add an automaton", "BusBarCountMustBeGreaterThanOrEqualToOne": "Number of busbars must be greater than or equal to 1", "SectionCountMustBeGreaterThanOrEqualToOne": "Number of sections must be greater than or equal to 1", diff --git a/src/translations/fr.json b/src/translations/fr.json index a07fdd1d5b..45933dfbdd 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -1231,6 +1231,7 @@ "guidancePopUp.action": "Fermer l'éditeur", "generateNad": "Générer", "networkModificationActivationError": "Une erreur est survenue lors de l'activation / désactivation de la modification", + "networkModificationRenamingError": "Une erreur est survenue lors du renommage de la modification", "AddAutomaton": "Ajouter un automate", "BusBarCountMustBeGreaterThanOrEqualToOne": "Un nombre de barres doit être supérieur ou égal à 1", "SectionCountMustBeGreaterThanOrEqualToOne": "Un nombre de sections doit être supérieur ou égal à 1", From 7e89dcb2f49273a65f199fce8e1918daa5a4b6ff Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Wed, 20 May 2026 17:43:18 +0200 Subject: [PATCH 8/9] Provide the modifications table rename callback createBaseColumns takes the name column's onChange callback; the node editor supplies handleNameChange, which persists the rename via setModificationMetadata. Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- .../network-modification-node-editor.tsx | 19 ++++++++++++++++--- .../createColumns.tsx | 5 ++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx index ee2132241d..bbd2709536 100644 --- a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx @@ -21,6 +21,7 @@ import { NetworkModificationsTable, NotificationsUrlKeys, removeNullFields, + setModificationMetadata, snackWithFallback, useNotificationsListener, usePrevious, @@ -125,7 +126,7 @@ import CreateVoltageLevelSectionDialog from '../../../dialogs/network-modificati import MoveVoltageLevelFeederBaysDialog from '../../../dialogs/network-modifications/voltage-level/move-feeder-bays/move-voltage-level-feeder-bays-dialog'; import { useCopiedNetworkModifications } from 'hooks/copy-paste/use-copied-network-modifications'; import { FetchStatus } from '../../../../services/utils.type'; -import { BASE_COLUMNS, createRootNetworksColumns } from './network-modification-table/createColumns'; +import { createBaseColumns, createRootNetworksColumns } from './network-modification-table/createColumns'; import { ColumnDef } from '@tanstack/react-table'; const nonEditableModificationTypes = new Set([ @@ -1086,9 +1087,21 @@ const NetworkModificationNodeEditor = () => { [isAnyNodeBuilding, mapDataLoading, isDragging] ); + const handleNameChange = useCallback( + (modification: ComposedModificationMetadata, newName: string) => + setModificationMetadata(studyUuid, currentNode?.id, modification.uuid, { + name: newName, + type: modification.type, + }), + [studyUuid, currentNode?.id] + ); + const columns = useMemo[]>( - () => [...BASE_COLUMNS, ...(isMonoRootStudy ? [] : createRootNetworksColumns(rootNetworks))], - [isMonoRootStudy, rootNetworks] + () => [ + ...createBaseColumns(handleNameChange), + ...(isMonoRootStudy ? [] : createRootNetworksColumns(rootNetworks)), + ], + [handleNameChange, isMonoRootStudy, rootNetworks] ); const renderNetworkModificationsTable = () => { diff --git a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx index 8279a8b22d..26c8483693 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx @@ -28,7 +28,9 @@ import { RootNetworkMetadata } from '../network-modification-menu.type'; * for each individual root network hence they all have a dedicated column generated on the fly */ -export const BASE_COLUMNS: ColumnDef[] = [ +export const createBaseColumns = ( + onNameCellEdit: (modification: ComposedModificationMetadata, newName: string) => Promise +): ColumnDef[] => [ { id: BASE_MODIFICATION_TABLE_COLUMNS.DRAG_HANDLE.id, cell: DragHandleRenderer, @@ -56,6 +58,7 @@ export const BASE_COLUMNS: ColumnDef[] = [ cell: NameCellRenderer, meta: { cellStyle: networkModificationTableStyles.columnCell.modificationName, + onChange: onNameCellEdit, }, minSize: 160, }, From 305329315b0f170b60f483c81d9e6222a289ea60 Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Wed, 20 May 2026 22:51:25 +0200 Subject: [PATCH 9/9] Rename rename callback in createBaseColumns to onNameChange and refactor handleNameChange definition in node editor Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- .../network-modification-node-editor.tsx | 20 +++++++++---------- .../createColumns.tsx | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx index bbd2709536..ce5a9b6d49 100644 --- a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx @@ -102,6 +102,7 @@ import ByFilterDeletionDialog from '../../../dialogs/network-modifications/by-fi import { LccCreationDialog } from '../../../dialogs/network-modifications/hvdc-line/lcc/creation/lcc-creation-dialog'; import { styles } from './network-modification-node-editor-utils'; import { + CommonStudyEventData, isModificationsDeleteFinishedNotification, isModificationsUpdateFinishedNotification, isNodeDeletedNotification, @@ -113,7 +114,6 @@ import { ModificationsUpdatingInProgressEventData, NotificationType, parseEventData, - CommonStudyEventData, } from 'types/notification-types'; import { LccModificationDialog } from '../../../dialogs/network-modifications/hvdc-line/lcc/modification/lcc-modification-dialog'; import VoltageLevelTopologyModificationDialog from '../../../dialogs/network-modifications/voltage-level/topology-modification/voltage-level-topology-modification-dialog'; @@ -809,6 +809,15 @@ const NetworkModificationNodeEditor = () => { modificationsToExclude, ]); + const handleNameChange = useCallback( + (modification: ComposedModificationMetadata, newName: string) => + setModificationMetadata(studyUuid, currentNode?.id, modification.uuid, { + name: newName, + type: modification.type, + }), + [studyUuid, currentNode?.id] + ); + const handleEvent = useCallback( (event: MessageEvent) => { const eventData = parseEventData(event); @@ -1087,15 +1096,6 @@ const NetworkModificationNodeEditor = () => { [isAnyNodeBuilding, mapDataLoading, isDragging] ); - const handleNameChange = useCallback( - (modification: ComposedModificationMetadata, newName: string) => - setModificationMetadata(studyUuid, currentNode?.id, modification.uuid, { - name: newName, - type: modification.type, - }), - [studyUuid, currentNode?.id] - ); - const columns = useMemo[]>( () => [ ...createBaseColumns(handleNameChange), diff --git a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx index 26c8483693..bbc5481057 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/createColumns.tsx @@ -29,7 +29,7 @@ import { RootNetworkMetadata } from '../network-modification-menu.type'; */ export const createBaseColumns = ( - onNameCellEdit: (modification: ComposedModificationMetadata, newName: string) => Promise + onNameChange: (modification: ComposedModificationMetadata, newName: string) => Promise ): ColumnDef[] => [ { id: BASE_MODIFICATION_TABLE_COLUMNS.DRAG_HANDLE.id, @@ -58,7 +58,7 @@ export const createBaseColumns = ( cell: NameCellRenderer, meta: { cellStyle: networkModificationTableStyles.columnCell.modificationName, - onChange: onNameCellEdit, + onChange: onNameChange, }, minSize: 160, },