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", diff --git a/package.json b/package.json index d9bdd38d2e..3e5e7451f1 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", 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 152d9c9468..0f5be0c319 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 @@ -17,7 +17,6 @@ import { MAX_COMPOSITE_NESTING_DEPTH, MODIFICATION_TYPES, ModificationType, - NameHeaderProps, NetworkModificationMetadata, NetworkModificationsTable, NotificationsUrlKeys, @@ -33,7 +32,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, Badge, Box, CircularProgress, debounce, Toolbar, Tooltip } from '@mui/material'; +import { Alert, Badge, 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'; @@ -68,7 +67,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'; @@ -1129,26 +1128,14 @@ const NetworkModificationNodeEditor = () => { [isAnyNodeBuilding, mapDataLoading, isDragging] ); - const createAllColumns = useCallback( - ( - isRowDragDisabled: boolean, - modificationsCount: number, - nameHeaderProps: NameHeaderProps, - setModifications: React.Dispatch> - ): ColumnDef[] => [ - ...createBaseColumns( - isRowDragDisabled, - modificationsCount, - nameHeaderProps, - setModifications, - handleCellEdit - ), + const columns = useMemo[]>( + () => [ + ...createBaseColumns(handleCellEdit), ...(isMonoRootStudy ? [] : createRootNetworksColumns( rootNetworks, currentRootNetworkUuid!, - modificationsCount, modificationsToExclude, setModificationsToExclude )), @@ -1169,7 +1156,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 720b9eaa6a..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 @@ -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. @@ -35,15 +33,11 @@ import { FormattedMessage } from 'react-intl'; */ export const createBaseColumns = ( - isRowDragDisabled: boolean, - modificationsCount: number, - nameHeaderProps: NameHeaderProps, - setModifications: React.Dispatch>, - handleCellNameEdit?: (modification: ComposedModificationMetadata, newName: string) => void + onEditNameCell: (modification: ComposedModificationMetadata, newName?: string) => void ): ColumnDef[] => [ { id: BASE_MODIFICATION_TABLE_COLUMNS.DRAG_HANDLE.id, - cell: () => , + cell: DragHandleRenderer, size: 24, minSize: 24, meta: { @@ -54,8 +48,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: { @@ -64,24 +58,23 @@ export const createBaseColumns = ( }, { id: BASE_MODIFICATION_TABLE_COLUMNS.NAME.id, - header: () => ( - - ), - cell: ({ row }) => , + header: NameHeaderRenderer, + cell: NameCellRenderer, meta: { cellStyle: networkModificationTableStyles.columnCell.modificationName, + onEditNameCell, }, minSize: 160, }, { 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: { @@ -95,7 +88,6 @@ export const createBaseColumns = ( export const createRootNetworksColumns = ( rootNetworks: RootNetworkMetadata[], currentRootNetworkUuid: string, - modificationsCount: number, modificationsToExclude: ExcludedNetworkModifications[], setModificationsToExclude: React.Dispatch> ): ColumnDef[] => { @@ -103,45 +95,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..abd1d3b2e8 --- /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, column }: 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/switch-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/switch-cell.tsx index 813c71b0ca..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 @@ -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) => { + 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..f3c67bd573 100644 --- a/src/module-tanstack.d.ts +++ b/src/module-tanstack.d.ts @@ -5,11 +5,34 @@ * 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 { ComposedModificationMetadata, 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; + onEditNameCell?: (modification: ComposedModificationMetadata, newName?: string) => void; } }