From 7dc74d26488ecaafe52ee576ea36953e8ba6abd3 Mon Sep 17 00:00:00 2001 From: Florent MILLOT Date: Mon, 4 May 2026 18:00:19 +0200 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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 9b1d485a5f3ba889f3abeca3ad39b72679a3a902 Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Tue, 19 May 2026 18:34:33 +0200 Subject: [PATCH 6/8] Restore handleCellEdit on modification name column after merge Thread the optimistic name edit handler through the NAME column's ColumnMeta (same pattern as createRootNetworksColumns) instead of the previously removed createBaseColumns parameter. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- .../network-modification-node-editor.tsx | 4 ++-- .../network-modification-table/createColumns.tsx | 5 ++++- .../network-modification-table/renderers/cell-renderers.tsx | 4 ++-- src/module-tanstack.d.ts | 3 ++- 4 files changed, 10 insertions(+), 6 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 d1ed27f793..1b607862eb 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 @@ -126,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([ @@ -1126,7 +1126,7 @@ const NetworkModificationNodeEditor = () => { const columns = useMemo[]>( () => [ - ...BASE_COLUMNS, + ...createBaseColumns(handleCellEdit), ...(isMonoRootStudy ? [] : createRootNetworksColumns( 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..3fdc0578fc 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 @@ -32,7 +32,9 @@ import { * for each individual root network hence they all have a dedicated column generated on the fly */ -export const BASE_COLUMNS: ColumnDef[] = [ +export const createBaseColumns = ( + onEditNameCell: (modification: ComposedModificationMetadata, newName?: string) => void +): ColumnDef[] => [ { id: BASE_MODIFICATION_TABLE_COLUMNS.DRAG_HANDLE.id, cell: DragHandleRenderer, @@ -60,6 +62,7 @@ export const BASE_COLUMNS: ColumnDef[] = [ cell: NameCellRenderer, meta: { cellStyle: networkModificationTableStyles.columnCell.modificationName, + onEditNameCell, }, minSize: 160, }, 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 a9979811a8..abd1d3b2e8 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 @@ -65,8 +65,8 @@ export function NameHeaderRenderer({ table }: HCtx) { ); } -export function NameCellRenderer({ row }: CCtx) { - return ; +export function NameCellRenderer({ row, column }: CCtx) { + return ; } export function DescriptionCellRenderer({ row }: CCtx) { diff --git a/src/module-tanstack.d.ts b/src/module-tanstack.d.ts index 88d834bb61..f3c67bd573 100644 --- a/src/module-tanstack.d.ts +++ b/src/module-tanstack.d.ts @@ -7,7 +7,7 @@ import { Dispatch, RefObject, SetStateAction } from 'react'; import { SxProps, Theme } from '@mui/material'; -import { ExcludedNetworkModifications, NameHeaderProps } from '@gridsuite/commons-ui'; +import { ComposedModificationMetadata, ExcludedNetworkModifications, NameHeaderProps } from '@gridsuite/commons-ui'; import { RootNetworkMetadata } from 'components/graph/menus/network-modifications/network-modification-menu.type'; declare module '@tanstack/react-table' { @@ -33,5 +33,6 @@ declare module '@tanstack/react-table' { setModificationsToExclude?: Dispatch>; isCurrentRootNetwork?: boolean; currentRootNetworkTag?: string; + onEditNameCell?: (modification: ComposedModificationMetadata, newName?: string) => void; } } From 940476b10212440a2033677a51b3fc2728fdd6f1 Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Tue, 19 May 2026 18:34:52 +0200 Subject: [PATCH 7/8] Update @gridsuite/commons-ui to v0.212.0 in dependencies Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d0f3ac4fe..89822af866 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@gridsuite/commons-ui": "0.211.0", + "@gridsuite/commons-ui": "0.212.0", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^4.1.3", "@mui/icons-material": "^6.5.0", From 8de1f2384e015e3edf768ebebacd5760350f7564 Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Tue, 19 May 2026 18:40:17 +0200 Subject: [PATCH 8/8] Update @gridsuite/commons-ui to v0.212.0 in package-lock.json Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8268db2507..ec1b5e7687 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@gridsuite/commons-ui": "0.211.0", + "@gridsuite/commons-ui": "0.212.0", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^4.1.3", "@mui/icons-material": "^6.5.0", @@ -3288,9 +3288,9 @@ } }, "node_modules/@gridsuite/commons-ui": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@gridsuite/commons-ui/-/commons-ui-0.211.0.tgz", - "integrity": "sha512-6v2ZSeiqh8DQPvzXHj/fz80zHZwE+oMrO8K1YEson4coqCGA05HuDU8eVeYlaljjY22pc+vf9wtHqOAnPbOMWg==", + "version": "0.212.0", + "resolved": "https://registry.npmjs.org/@gridsuite/commons-ui/-/commons-ui-0.212.0.tgz", + "integrity": "sha512-yN+h2W1S6exoRZFfvJHoLsKAc8u4WAvqcttG4LqPQ/6MymdD3+RCg76i5nbzLtF/ML1C0ZpNjJvBUNIRKsNbMQ==", "license": "MPL-2.0", "dependencies": { "@ag-grid-community/locale": "^33.3.2",