diff --git a/src/components/dialogs/import-modification-dialog.tsx b/src/components/dialogs/import-modification-dialog.tsx index 3b4c713038..992c4c682f 100644 --- a/src/components/dialogs/import-modification-dialog.tsx +++ b/src/components/dialogs/import-modification-dialog.tsx @@ -5,26 +5,50 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { useIntl } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { + CustomFormProvider, DirectoryItemSelector, ElementType, snackWithFallback, TreeViewFinderNodeProps, useSnackMessage, } from '@gridsuite/commons-ui'; -import { copyOrMoveModifications } from '../../services/study'; -import { FunctionComponent } from 'react'; +import { insertCompositeModifications } from '../../services/study'; +import { FunctionComponent, useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import { AppState } from 'redux/reducer.type'; -import { NetworkModificationCopyType } from 'components/graph/menus/network-modifications/network-modification-menu.type'; +import { CompositeModificationsActionType } from 'components/graph/menus/network-modifications/network-modification-menu.type'; +import { + Box, + Button, + FormControl, + FormControlLabel, + Grid, + Radio, + RadioGroup, + TextField, + Typography, +} from '@mui/material'; +import { NoteAlt as NoteAltIcon } from '@mui/icons-material'; +import { Controller, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import yup from 'components/utils/yup-config'; +import { ModificationDialog } from './commons/modificationDialog'; +import { ACTION, COMPOSITE_NAMES, SELECTED_MODIFICATIONS } from 'components/utils/field-constants'; /** - * Dialog to select some network modifications and append them in the current node - * @param {Boolean} open Is the dialog open ? - * @param {EventListener} onClose Event to close the dialog - * @param currentNode the current node - * @param studyUuid Id of the current study + * Dialog to select composite network modifications and append them to the current node. + * - SPLIT mode: extracts and inserts individual modifications contained in the composites. + * - INSERT mode: inserts the composite modifications as-is; a name per composite is required. + * + * The composite selection is presented inline (like RootNetworkCaseSelection): + * a list icon + selected item names + a button to open the DirectoryItemSelector. + * Name fields (one per selected item) appear only when INSERT mode is active, + * and are validated via react-hook-form + yup. + * + * @param open Whether the dialog is open + * @param onClose Callback to close the dialog */ interface ImportModificationDialogProps { @@ -32,39 +56,208 @@ interface ImportModificationDialogProps { onClose: () => void; } +interface SelectedModification { + id: string; + name: string; +} + +interface FormData { + [ACTION]: CompositeModificationsActionType; + [SELECTED_MODIFICATIONS]: SelectedModification[]; + [COMPOSITE_NAMES]: Record; +} + +const emptyFormData: FormData = { + [ACTION]: CompositeModificationsActionType.SPLIT, + [SELECTED_MODIFICATIONS]: [], + [COMPOSITE_NAMES]: {}, +}; + +const formSchema = yup + .object() + .shape({ + [ACTION]: yup + .mixed() + .oneOf(Object.values(CompositeModificationsActionType)) + .required(), + [SELECTED_MODIFICATIONS]: yup.array().min(1).required(), + [COMPOSITE_NAMES]: yup.mixed>().when([ACTION], { + is: CompositeModificationsActionType.INSERT, + then: (schema) => + schema.test('all-names-filled', 'FieldIsRequired', function (value) { + const selections: SelectedModification[] = this.parent[SELECTED_MODIFICATIONS] ?? []; + for (const m of selections) { + const name = value?.[m.id] ?? ''; + if (!name.trim()) { + return this.createError({ + path: `${COMPOSITE_NAMES}.${m.id}`, + message: 'FieldIsRequired', + }); + } + } + + return true; + }), + otherwise: (schema) => schema.optional(), + }), + }) + .required(); + +type FormSchemaType = yup.InferType; + const ImportModificationDialog: FunctionComponent = ({ open, onClose }) => { const intl = useIntl(); const { snackError } = useSnackMessage(); const studyUuid = useSelector((state: AppState) => state.studyUuid); const currentNode = useSelector((state: AppState) => state.currentTreeNode); - const processSelectedElements = (selectedElements: TreeViewFinderNodeProps[]) => { - const modificationUuidList = selectedElements.map((e) => e.id); - // import selected modifications - if (modificationUuidList.length > 0 && studyUuid && currentNode) { - const copyInfos = { - copyType: NetworkModificationCopyType.SPLIT_COMPOSITE, - originStudyUuid: studyUuid, - originNodeUuid: currentNode.id, - }; - copyOrMoveModifications(studyUuid, currentNode.id, modificationUuidList, copyInfos).catch((error) => { - snackWithFallback(snackError, error, { headerId: 'errDuplicateModificationMsg' }); - }); - } - // close the file selector - onClose(); + const [isSelectorOpen, setIsSelectorOpen] = useState(false); + + const formMethods = useForm({ + defaultValues: emptyFormData, + resolver: yupResolver(formSchema), + }); + + const { + control, + reset, + watch, + setValue, + formState: { isValid }, + } = formMethods; + + const action = watch(ACTION); + const selectedModifications = watch(SELECTED_MODIFICATIONS); + const compositeNames = watch(COMPOSITE_NAMES); + const isInsertMode = action === CompositeModificationsActionType.INSERT; + + const handleSelectModification = (selectedElements: TreeViewFinderNodeProps[]) => { + setIsSelectorOpen(false); + if (!selectedElements.length) return; + + const newSelections = selectedElements.map((e) => ({ id: e.id, name: e.name })); + setValue(SELECTED_MODIFICATIONS, newSelections, { shouldValidate: true, shouldDirty: true }); + + const currentNames = compositeNames ?? {}; + setValue(COMPOSITE_NAMES, Object.fromEntries(newSelections.map((e) => [e.id, currentNames[e.id] ?? e.name])), { + shouldValidate: true, + }); }; + const handleClear = useCallback(() => { + reset(emptyFormData); + }, [reset]); + + const handleSave = useCallback( + (values: FormData) => { + if (!studyUuid || !currentNode) return; + const modificationsToInsert: { first: string; second: string }[] = values[SELECTED_MODIFICATIONS].map( + (m) => ({ first: m.id, second: values[COMPOSITE_NAMES][m.id] ?? m.name }) + ); + insertCompositeModifications(studyUuid, currentNode.id, modificationsToInsert, values[ACTION]).catch( + (error) => { + snackWithFallback(snackError, error, { headerId: 'errInsertCompositeModificationMsg' }); + } + ); + onClose(); + }, + [studyUuid, currentNode, snackError, onClose] + ); + return ( - + + + + + + ( + + } + label={intl.formatMessage({ id: 'CompositeModificationsActionSplit' })} + /> + } + label={intl.formatMessage({ id: 'CompositeModificationsActionInsert' })} + /> + + )} + /> + + + + + + + + {selectedModifications.map((m) => m.name).join(', ')} + + + + + + + {isInsertMode && selectedModifications.length > 0 && ( + + + {selectedModifications.map((m) => ( + + { + return ( + + ); + }} + /> + + ))} + + + )} + + + + + ); }; diff --git a/src/components/graph/menus/network-modifications/network-modification-menu.type.ts b/src/components/graph/menus/network-modifications/network-modification-menu.type.ts index e7c1d3e216..640c619652 100644 --- a/src/components/graph/menus/network-modifications/network-modification-menu.type.ts +++ b/src/components/graph/menus/network-modifications/network-modification-menu.type.ts @@ -41,8 +41,12 @@ export interface ExcludedNetworkModifications { export enum NetworkModificationCopyType { COPY = 'COPY', MOVE = 'MOVE', - SPLIT_COMPOSITE = 'SPLIT_COMPOSITE', - INSERT_COMPOSITE = 'INSERT_COMPOSITE', +} + +// New enum for the dedicated composite-modifications endpoint +export enum CompositeModificationsActionType { + SPLIT = 'SPLIT', // Extract individual modifications from the composite + INSERT = 'INSERT', // Keep and insert the composite modification as-is } export interface NetworkModificationCopyInfos { 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 9f23ad1966..bea93cdb1b 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 @@ -78,7 +78,6 @@ import { AppState } from 'redux/reducer.type'; import { createCompositeModifications, updateCompositeModifications } from '../../../../services/explore'; import { copyOrMoveModifications } from '../../../../services/study'; import { - changeNetworkModificationOrder, fetchExcludedNetworkModifications, fetchNetworkModifications, stashModifications, @@ -123,7 +122,6 @@ import { LimitSetsModificationDialog } from '../../../dialogs/network-modificati import CreateVoltageLevelSectionDialog from '../../../dialogs/network-modifications/voltage-level/section/create-voltage-level-section-dialog'; 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 { DragStart, DropResult } from '@hello-pangea/dnd'; import { FetchStatus } from '../../../../services/utils.type'; const nonEditableModificationTypes = new Set([ @@ -163,7 +161,6 @@ const NetworkModificationNodeEditor = () => { const [selectedNetworkModifications, setSelectedNetworkModifications] = useState([]); const [isDragging, setIsDragging] = useState(false); - const [initialPosition, setInitialPosition] = useState(undefined); const [editDialogOpen, setEditDialogOpen] = useState(undefined); const [editData, setEditData] = useState(undefined); @@ -1141,47 +1138,13 @@ const NetworkModificationNodeEditor = () => { [doEditModification, isModificationClickable] ); - const onRowDragStart = (event: DragStart) => { + const onRowDragStart = useCallback(() => { setIsDragging(true); - setInitialPosition(event.source.index); - }; - - const onRowDragEnd = (event: DropResult) => { - if (!event.destination) { - setIsDragging(false); - return; - } - - let newPosition = event.destination.index; - const oldPosition = initialPosition; - - if (!currentNode?.id || newPosition === undefined || oldPosition === undefined || newPosition === oldPosition) { - setIsDragging(false); - return; - } - if (newPosition === -1) { - newPosition = modifications.length; - } - - const previousModifications = [...modifications]; - const updatedModifications = [...modifications]; - - const [movedItem] = updatedModifications.splice(oldPosition, 1); - updatedModifications.splice(newPosition, 0, movedItem); - - setModifications(updatedModifications); - - const before = updatedModifications[newPosition + 1]?.uuid || null; + }, []); - changeNetworkModificationOrder(studyUuid, currentNode?.id, movedItem.uuid, before) - .catch((error) => { - snackWithFallback(snackError, error, { headerId: 'errReorderModificationMsg' }); - setModifications(previousModifications); - }) - .finally(() => { - setIsDragging(false); - }); - }; + const onRowDragEnd = useCallback(() => { + setIsDragging(false); + }, []); const isPasteButtonDisabled = useMemo(() => { return networkModificationsToCopy.length <= 0 || isAnyNodeBuilding || mapDataLoading || !currentNode; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/columns-definition.tsx b/src/components/graph/menus/network-modifications/network-modification-table/columns-definition.tsx index eec9e7d2d1..419e044d8c 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/columns-definition.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/columns-definition.tsx @@ -7,7 +7,6 @@ import React, { SetStateAction } from 'react'; import { Badge, Box, Tooltip } from '@mui/material'; -import { NetworkModificationMetadata } from '@gridsuite/commons-ui'; import { ColumnDef } from '@tanstack/react-table'; import DragHandleCell from './renderers/drag-handle-cell'; import { @@ -23,6 +22,7 @@ import { RemoveRedEye as RemoveRedEyeIcon } from '@mui/icons-material'; import SelectCell from './renderers/select-cell'; import SelectHeaderCell from './renderers/select-header-cell'; import { createRootNetworkChipCellSx, styles } from './styles'; +import { ComposedModificationMetadata } from './utils'; import { FormattedMessage } from 'react-intl'; const CHIP_PADDING_PX = 24; @@ -74,8 +74,8 @@ export const createBaseColumns = ( isRowDragDisabled: boolean, modificationsCount: number, nameHeaderProps: NameHeaderProps, - setModifications: React.Dispatch> -): ColumnDef[] => [ + setModifications: React.Dispatch> +): ColumnDef[] => [ { id: BASE_MODIFICATION_TABLE_COLUMNS.DRAG_HANDLE.id, cell: () => , @@ -133,7 +133,7 @@ export const createRootNetworksColumns = ( modificationsCount: number, modificationsToExclude: ExcludedNetworkModifications[], setModificationsToExclude: React.Dispatch> -): ColumnDef[] => { +): 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; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/network-modifications-table.tsx b/src/components/graph/menus/network-modifications/network-modification-table/network-modifications-table.tsx index ddc64cba06..cdb97ba032 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/network-modifications-table.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/network-modifications-table.tsx @@ -5,12 +5,28 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { Dispatch, FunctionComponent, SetStateAction, useEffect, useMemo, useRef } from 'react'; -import { NetworkModificationMetadata } from '@gridsuite/commons-ui'; +import React, { + Dispatch, + FunctionComponent, + SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Box, Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material'; import { useSelector } from 'react-redux'; -import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; -import { DragDropContext, DragStart, Droppable, DroppableProvided, DropResult } from '@hello-pangea/dnd'; +import { + ColumnDef, + ExpandedState, + flexRender, + getCoreRowModel, + getExpandedRowModel, + Updater, + useReactTable, +} from '@tanstack/react-table'; +import { DragDropContext, Droppable, DroppableProvided } from '@hello-pangea/dnd'; import { useVirtualizer } from '@tanstack/react-virtual'; import { NetworkModificationEditorNameHeaderProps } from './renderers/network-modification-node-editor-name-header'; import { ExcludedNetworkModifications } from '../network-modification-menu.type'; @@ -20,14 +36,24 @@ import ModificationRow from './row/modification-row'; import { useTheme } from '@mui/material/styles'; import { useModificationsDragAndDrop } from './use-modifications-drag-and-drop'; import { AppState } from '../../../../../redux/reducer.type'; +import { + ComposedModificationMetadata, + fetchSubModificationsForExpandedRows, + findAllLoadedCompositeModifications, + formatComposedModification, + isCompositeModification, + mergeSubModificationsIntoTree, + refetchSubModificationsForExpandedRows, +} from './utils'; +import { NetworkModificationMetadata } from '@gridsuite/commons-ui'; interface NetworkModificationsTableProps extends Omit { modifications: NetworkModificationMetadata[]; setModifications: Dispatch>; handleCellClick: (modification: NetworkModificationMetadata) => void; isRowDragDisabled?: boolean; - onRowDragStart: (event: DragStart) => void; - onRowDragEnd: (event: DropResult) => void; + onRowDragStart: () => void; + onRowDragEnd: () => void; onRowSelected: (selectedRows: NetworkModificationMetadata[]) => void; modificationsToExclude: ExcludedNetworkModifications[]; setModificationsToExclude: Dispatch>; @@ -55,12 +81,54 @@ const NetworkModificationsTable: FunctionComponent(null); const lastClickedIndex = useRef(null); - const columns = useMemo[]>(() => { + const [expanded, setExpanded] = useState({}); + + const [composedModifications, setComposedModifications] = useState( + formatComposedModification(modifications) + ); + + useEffect(() => { + setComposedModifications((prevMods) => { + // Rebuild from the new modifications prop, carrying over already-fetched subModifications + // to avoid a visual flash of empty children while re-fetches are in flight. + const nextMods = mergeSubModificationsIntoTree(formatComposedModification(modifications), prevMods); + + // Re-fetch for any composite that already has loaded sub-modifications, regardless of + // whether it is currently expanded to avoid stale state + let loadedComposite: ComposedModificationMetadata[] = []; + findAllLoadedCompositeModifications(nextMods, loadedComposite); + refetchSubModificationsForExpandedRows( + loadedComposite.map((mod) => mod.uuid), + nextMods, + setComposedModifications + ); + return nextMods; + }); + }, [modifications]); + + const handleExpandRow = useCallback((updater: Updater) => { + setExpanded((prevExpanded) => { + const nextExpanded = typeof updater === 'function' ? updater(prevExpanded) : updater; + + const prevRecord = prevExpanded === true ? {} : prevExpanded; + const nextRecord = nextExpanded === true ? {} : nextExpanded; + const newlyExpandedIds = Object.keys(nextRecord).filter((id) => nextRecord[id] && !prevRecord[id]); + + setComposedModifications((prevMods) => { + fetchSubModificationsForExpandedRows(newlyExpandedIds, prevMods, setComposedModifications); + return prevMods; + }); + + return nextExpanded; + }); + }, []); + + const columns = useMemo[]>(() => { const staticColumns = createBaseColumns( isRowDragDisabled, modifications.length, nameHeaderProps, - setModifications + setComposedModifications ); const dynamicColumns = isMonoRootStudy ? [] @@ -77,7 +145,6 @@ const NetworkModificationsTable: FunctionComponent row.subModifications, getRowId: (row) => row.uuid, + getRowCanExpand: (row) => isCompositeModification(row.original), enableRowSelection: true, + enableSubRowSelection: false, + enableExpanding: true, + onExpandedChange: handleExpandRow, meta: { lastClickedIndex, onRowSelected }, }); @@ -107,11 +181,14 @@ const NetworkModificationsTable: FunctionComponent { table.resetRowSelection(); + table.resetExpanded(); lastClickedIndex.current = null; }, [currentTreeNodeId, table]); diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/depth-box.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/depth-box.tsx new file mode 100644 index 0000000000..f4ed039613 --- /dev/null +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/depth-box.tsx @@ -0,0 +1,24 @@ +/* + * 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 Box from '@mui/material/Box'; +import { styles } from '../styles'; + +interface DepthBoxProps { + showTick?: boolean; +} + +const DepthBox = ({ showTick = false }: DepthBoxProps) => { + return ( + + + {showTick && } + + ); +}; + +export default DepthBox; 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 index 702d01dd1f..b21ff21d9b 100644 --- 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 @@ -4,7 +4,7 @@ * 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 { DescriptionModificationDialog, EditNoteIcon, NetworkModificationMetadata } from '@gridsuite/commons-ui'; +import { DescriptionModificationDialog, EditNoteIcon } from '@gridsuite/commons-ui'; import { FunctionComponent, useCallback, useState } from 'react'; import { Tooltip } from '@mui/material'; import { useSelector } from 'react-redux'; @@ -13,9 +13,10 @@ import { useIsAnyNodeBuilding } from '../../../../../utils/is-any-node-building- import { createEditDescriptionStyle } from '../styles'; import { setModificationMetadata } from '../../../../../../services/study/network-modifications'; import { AppState } from '../../../../../../redux/reducer.type'; +import { ComposedModificationMetadata } from '../utils'; import { FormattedMessage } from 'react-intl'; -const DescriptionCell: FunctionComponent<{ data: NetworkModificationMetadata }> = (props) => { +const DescriptionCell: FunctionComponent<{ data: ComposedModificationMetadata }> = (props) => { const { data } = props; const studyUuid = useSelector((state: AppState) => state.studyUuid); const currentNode = useSelector((state: AppState) => state.currentTreeNode); diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/name-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/name-cell.tsx index f58f83a568..569f374754 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/name-cell.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/name-cell.tsx @@ -10,17 +10,28 @@ import { Row } from '@tanstack/react-table'; import { mergeSx, NetworkModificationMetadata, useModificationLabelComputer } from '@gridsuite/commons-ui'; import { useIntl } from 'react-intl'; import { Box, Tooltip } from '@mui/material'; -import { createModificationNameCellStyle, styles } from '../styles'; +import { createModificationNameCellStyle, createNameCellLabelBoxSx, createNameCellRootStyle, styles } from '../styles'; +import IconButton from '@mui/material/IconButton'; +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import DepthBox from './depth-box'; +import { ComposedModificationMetadata, isCompositeModification } from '../utils'; +import { useTheme } from '@mui/material/styles'; -const NameCell: FunctionComponent<{ row: Row }> = ({ row }) => { +const NameCell: FunctionComponent<{ + row: Row; +}> = ({ row }) => { const intl = useIntl(); + const theme = useTheme(); const { computeLabel } = useModificationLabelComputer(); + const depth = row.depth; + const getModificationLabel = useCallback( - (modification: NetworkModificationMetadata, formatBold: boolean = true) => { + (modification: ComposedModificationMetadata, formatBold: boolean = true) => { return intl.formatMessage( { id: `network_modifications.${modification.messageType}` }, - { ...modification, ...computeLabel(modification, formatBold) } + { ...(modification as NetworkModificationMetadata), ...computeLabel(modification, formatBold) } ); }, [computeLabel, intl] @@ -28,11 +39,49 @@ const NameCell: FunctionComponent<{ row: Row }> = ( const label = useMemo(() => getModificationLabel(row.original), [getModificationLabel, row.original]); + const renderDepthBox = () => { + const count = depth; + return Array.from({ length: count }, (_, i) => ( + + )); + }; + return ( - - - {label} - + + {renderDepthBox()} + + {isCompositeModification(row.original) && ( + + { + e.stopPropagation(); + row.getToggleExpandedHandler()(); + }} + sx={styles.nameCellToggleButton} + aria-label={row.getIsExpanded() ? 'Collapse' : 'Expand'} + > + {row.getIsExpanded() ? ( + + ) : ( + + )} + + + )} + + + + {label} + + + + ); }; 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 index 876c57d236..ec67800d6e 100644 --- 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 @@ -6,13 +6,14 @@ */ import { useState, useCallback, useMemo, SetStateAction, FunctionComponent } from 'react'; -import { ActivableChip, NetworkModificationMetadata, snackWithFallback, useSnackMessage } from '@gridsuite/commons-ui'; +import { ActivableChip, snackWithFallback, useSnackMessage } from '@gridsuite/commons-ui'; import { updateModificationStatusByRootNetwork } from 'services/study/network-modifications'; import { useSelector } from 'react-redux'; import { ExcludedNetworkModifications, 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'; +import { ComposedModificationMetadata } from '../utils'; function getUpdatedExcludedModifications( prev: ExcludedNetworkModifications[], @@ -54,7 +55,7 @@ function getUpdatedExcludedModifications( } interface RootNetworkChipCellRendererProps { - data?: NetworkModificationMetadata; + data?: ComposedModificationMetadata; modificationsToExclude: ExcludedNetworkModifications[]; setModificationsToExclude: React.Dispatch>; rootNetwork: RootNetworkMetadata; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/renderers/select-cell.tsx b/src/components/graph/menus/network-modifications/network-modification-table/renderers/select-cell.tsx index a9f92c88ff..cd43bcd389 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/renderers/select-cell.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/renderers/select-cell.tsx @@ -5,15 +5,15 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { FunctionComponent, useCallback } from 'react'; +import React, { FunctionComponent, useCallback, useMemo } from 'react'; import { Checkbox } from '@mui/material'; import { Row, Table } from '@tanstack/react-table'; -import { NetworkModificationMetadata } from '@gridsuite/commons-ui'; import { styles } from '../styles'; +import { ComposedModificationMetadata } from '../utils'; interface SelectCellRendererProps { - row: Row; - table: Table; + row: Row; + table: Table; } const SelectCell: FunctionComponent = ({ row, table }) => { @@ -21,8 +21,8 @@ const SelectCell: FunctionComponent = ({ row, table }) const handleChange = useCallback( (event: React.MouseEvent) => { - const rows = table.getRowModel().rows; - const currentIndex = row.index; + const rows = table.getRowModel().flatRows; + const currentIndex = rows.indexOf(row); const nextSelection = { ...table.getState().rowSelection }; // When shift is held and a previous click exists, select or deselect the contiguous range between @@ -66,10 +66,16 @@ const SelectCell: FunctionComponent = ({ row, table }) [table, row, meta] ); + const hasPartiallySelectedSubRows = useMemo( + () => row.subRows.some((subRow) => subRow.getIsSelected()) && !row.getIsSelected(), + [row] + ); + return ( ; + table: Table; } const SelectHeaderCell: FunctionComponent = ({ table }) => { @@ -31,7 +31,7 @@ const SelectHeaderCell: FunctionComponent = ({ table }) = ); 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 53448d46a0..ae99bb3a0a 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 @@ -7,16 +7,17 @@ import React, { FunctionComponent, SetStateAction, useCallback, useState } from 'react'; import { Switch, Tooltip } from '@mui/material'; -import { NetworkModificationMetadata, snackWithFallback, useSnackMessage } from '@gridsuite/commons-ui'; +import { 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'; +import { ComposedModificationMetadata, findModificationsInTree, updateModificationFieldInTree } from '../utils'; export interface SwitchCellRendererProps { - data: NetworkModificationMetadata; - setModifications: React.Dispatch>; + data: ComposedModificationMetadata; + setModifications: React.Dispatch>; } const SwitchCell: FunctionComponent = (props) => { @@ -54,19 +55,13 @@ const SwitchCell: FunctionComponent = (props) => { const toggleModificationActive = useCallback(() => { setIsLoading(true); setModifications((oldModifications) => { - const modificationToUpdateIndex = oldModifications.findIndex((m) => m.uuid === modificationUuid); - if (modificationToUpdateIndex === -1) { + const target = findModificationsInTree(modificationUuid, oldModifications); + if (!target) { return oldModifications; } - const newModifications = [...oldModifications]; - const newStatus = !newModifications[modificationToUpdateIndex].activated; - - newModifications[modificationToUpdateIndex] = { - ...newModifications[modificationToUpdateIndex], - }; - + const newStatus = !target.activated; updateModification(newStatus); - return newModifications; + return updateModificationFieldInTree(modificationUuid, { activated: newStatus }, oldModifications); }); }, [modificationUuid, updateModification, setModifications]); diff --git a/src/components/graph/menus/network-modifications/network-modification-table/row/drag-row-clone.tsx b/src/components/graph/menus/network-modifications/network-modification-table/row/drag-row-clone.tsx index c58dba04ec..7da21b5534 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/row/drag-row-clone.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/row/drag-row-clone.tsx @@ -6,26 +6,40 @@ */ import { Box } from '@mui/material'; -import { NetworkModificationMetadata } from '@gridsuite/commons-ui'; -import { createCellStyle, styles } from '../styles'; -import { flexRender, Row } from '@tanstack/react-table'; -import { AUTO_EXTENSIBLE_COLUMNS, BASE_MODIFICATION_TABLE_COLUMNS } from '../columns-definition'; +import { styles } from '../styles'; +import { Row } from '@tanstack/react-table'; +import { ComposedModificationMetadata } from '../utils'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import React, { useCallback } from 'react'; +import { mergeSx, NetworkModificationMetadata, useModificationLabelComputer } from '@gridsuite/commons-ui'; +import { useIntl } from 'react-intl'; -const DragCloneRow = ({ row }: { row: Row }) => ( - - {row - .getVisibleCells() - .filter((cell) => - [BASE_MODIFICATION_TABLE_COLUMNS.DRAG_HANDLE.id, BASE_MODIFICATION_TABLE_COLUMNS.NAME.id].includes( - cell.column.columnDef.id! - ) - ) - .map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} +const DragCloneRow = ({ row }: { row: Row }) => { + const intl = useIntl(); + const { computeLabel } = useModificationLabelComputer(); + + const getModificationLabel = useCallback( + (modification: ComposedModificationMetadata, formatBold: boolean = true) => { + return intl.formatMessage( + { id: `network_modifications.${modification.messageType}` }, + { ...(modification as NetworkModificationMetadata), ...computeLabel(modification, formatBold) } + ); + }, + [computeLabel, intl] + ); + + return ( + + + + - ))} - -); + + + {getModificationLabel(row.original)} + + + ); +}; export default DragCloneRow; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/row/modification-row.tsx b/src/components/graph/menus/network-modifications/network-modification-table/row/modification-row.tsx index 94c3e403c8..71855cc434 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/row/modification-row.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/row/modification-row.tsx @@ -7,18 +7,20 @@ import React, { memo, useCallback } from 'react'; import { flexRender, Row } from '@tanstack/react-table'; -import { mergeSx, NetworkModificationMetadata } from '@gridsuite/commons-ui'; -import { TableCell, TableRow, Tooltip } from '@mui/material'; -import { createCellStyle, createRowSx, styles } from '../styles'; +import { Box, TableCell, TableRow, Tooltip } from '@mui/material'; +import { BORDER_SUPPRESSED_COLUMNS, createCellContentWrapperSx, createCellStyle, createRowSx, styles } from '../styles'; import { Draggable, DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd'; import { VirtualItem } from '@tanstack/react-virtual'; import { AUTO_EXTENSIBLE_COLUMNS, BASE_MODIFICATION_TABLE_COLUMNS } from '../columns-definition'; +import { useTheme } from '@mui/material/styles'; +import { ComposedModificationMetadata } from '../utils'; import { FormattedMessage } from 'react-intl'; +import { mergeSx } from '@gridsuite/commons-ui'; interface ModificationRowProps { virtualRow: VirtualItem; - row: Row; - handleCellClick?: (modification: NetworkModificationMetadata) => void; + row: Row; + handleCellClick?: (modification: ComposedModificationMetadata) => void; isRowDragDisabled: boolean; highlightedModificationUuid: string | null; } @@ -26,6 +28,8 @@ interface ModificationRowProps { const ModificationRow = memo( ({ virtualRow, row, handleCellClick, isRowDragDisabled, highlightedModificationUuid }) => { const isHighlighted = row.original.uuid === highlightedModificationUuid; + const theme = useTheme(); + const isExpanded = row.getIsExpanded() && !!row.subRows?.length; const handleCellClickCallback = useCallback( (columnId: string) => { @@ -35,7 +39,6 @@ const ModificationRow = memo( }, [handleCellClick, row.original] ); - return ( {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => { @@ -45,9 +48,13 @@ const ModificationRow = memo( ref={provided.innerRef} {...draggablePropsWithoutStyle} data-row-id={row.original.uuid} - sx={mergeSx(styles.tableRow, createRowSx(isHighlighted, snapshot.isDragging, virtualRow))} + sx={mergeSx( + styles.tableRow, + createRowSx(theme, isHighlighted, snapshot.isDragging, virtualRow, row.depth) + )} > {row.getVisibleCells().map((cell) => { + const isNameColumn = cell.column.id === BASE_MODIFICATION_TABLE_COLUMNS.NAME.id; const isDragHandle = cell.column.id === BASE_MODIFICATION_TABLE_COLUMNS.DRAG_HANDLE.id; const isCheckboxColumn = cell.column.id === BASE_MODIFICATION_TABLE_COLUMNS.SELECT.id; const cellContent = flexRender(cell.column.columnDef.cell, cell.getContext()); @@ -59,7 +66,18 @@ const ModificationRow = memo( sx={createCellStyle(cell, AUTO_EXTENSIBLE_COLUMNS.includes(cell.column.id))} > } arrow> - {cellContent} + 0) && + BORDER_SUPPRESSED_COLUMNS.has( + cell.column.columnDef.id ?? '' + ) + )} + {...provided.dragHandleProps} + > + {cellContent} + ); @@ -85,7 +103,17 @@ const ModificationRow = memo( } arrow > - {cellContent} + 0) && + BORDER_SUPPRESSED_COLUMNS.has( + cell.column.columnDef.id ?? '' + ) + )} + > + {cellContent} + ); @@ -97,7 +125,20 @@ const ModificationRow = memo( sx={createCellStyle(cell, AUTO_EXTENSIBLE_COLUMNS.includes(cell.column.id))} onClick={() => handleCellClickCallback(cell.column.id)} > - {cellContent} + {isNameColumn ? ( + // NameCell owns its own borders entirely + flexRender(cell.column.columnDef.cell, cell.getContext()) + ) : ( + 0) && + BORDER_SUPPRESSED_COLUMNS.has(cell.column.columnDef.id ?? '') + )} + > + {cellContent} + + )} ); })} diff --git a/src/components/graph/menus/network-modifications/network-modification-table/styles.ts b/src/components/graph/menus/network-modifications/network-modification-table/styles.ts index d397c4cc63..4a8075856c 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/styles.ts +++ b/src/components/graph/menus/network-modifications/network-modification-table/styles.ts @@ -8,7 +8,7 @@ import { MuiStyles } from '@gridsuite/commons-ui'; import { VirtualItem } from '@tanstack/react-virtual'; import { SxProps, Theme } from '@mui/material'; -import { alpha } from '@mui/material/styles'; +import { alpha, darken, lighten } from '@mui/material/styles'; import { CSSProperties } from 'react'; const HIGHLIGHT_COLOR_BASE = 'rgba(144, 202, 249, 0.16)'; @@ -19,6 +19,11 @@ const DEACTIVATED_OPACITY = 0.4; export const MODIFICATION_ROW_HEIGHT = 41; +export const createCellBorderColor = (theme: Theme): string => + theme.palette.mode === 'light' + ? lighten(alpha(theme.palette.divider, 1), 0.88) + : darken(alpha(theme.palette.divider, 1), 0.68); + // Static styles export const styles = { @@ -74,7 +79,7 @@ export const styles = { border: '1px solid #f5f5f5', display: 'flex', width: 'fit-content', - paddingRight: theme.spacing(1), + padding: theme.spacing(1), }), overflow: { whiteSpace: 'pre', @@ -100,6 +105,7 @@ export const styles = { textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', + paddingLeft: '0.5vw', }, rootNetworkHeader: { width: '100%', @@ -111,6 +117,53 @@ export const styles = { modificationName: { cursor: 'pointer', minWidth: 0, overflow: 'hidden', flex: 1 }, rootNetworkChip: { textAlign: 'center' }, }, + nameCellInnerRow: { + position: 'relative', + display: 'flex', + alignItems: 'center', + gap: 0, + flex: 1, + minWidth: 0, + alignSelf: 'stretch', + }, + nameCellTogglerBox: { + width: '32px', + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + nameCellToggleButton: { + padding: '4px', + width: '32px', + height: '32px', + }, + nameCellLabelBoxPlain: { + flex: 1, + minWidth: 0, + }, + // depth-box + depthBoxOuter: { + width: '32px', + display: 'flex', + justifyContent: 'center', + alignSelf: 'stretch', + position: 'relative', + }, + depthBoxLine: { + width: '1px', + backgroundColor: 'divider', + alignSelf: 'stretch', + }, + depthBoxTick: { + position: 'absolute', + top: '50%', + left: '50%', + width: '5px', + height: '1px', + backgroundColor: 'divider', + transform: 'translateY(-50%)', + }, } as const satisfies MuiStyles; // Dynamic styles @@ -118,7 +171,16 @@ export const styles = { export const DROP_INDICATOR_TOP = 'inset 0 2px 0 #90caf9'; export const DROP_INDICATOR_BOTTOM = 'inset 0 -2px 0 #90caf9'; -export const createRowSx = (isHighlighted: boolean, isDragging: boolean, virtualRow: VirtualItem): SxProps => ({ +export const DROP_FORBIDDEN_INDICATOR_TOP = 'inset 0 2px 0 #FF3636'; +export const DROP_FORBIDDEN_INDICATOR_BOTTOM = 'inset 0 -2px 0 #FF3636'; + +export const createRowSx = ( + theme: Theme, + isHighlighted: boolean, + isDragging: boolean, + virtualRow: VirtualItem, + depth: number +): SxProps => ({ position: 'absolute', top: 0, left: 0, @@ -132,11 +194,13 @@ export const createRowSx = (isHighlighted: boolean, isDragging: boolean, virtual backgroundColor: isHighlighted ? HIGHLIGHT_COLOR_HOVER : ROW_HOVER_COLOR, }, ...(isDragging && { zIndex: 1, transform: 'none' }), + ...(depth === 0 && { + borderTop: `1px solid ${createCellBorderColor(theme)}`, + }), }); export const createModificationNameCellStyle = (activated: boolean): CSSProperties => ({ opacity: activated ? 1 : DEACTIVATED_OPACITY, - paddingLeft: '0.8vw', }); export const createRootNetworkChipCellSx = (activated: boolean): SxProps => ({ @@ -164,6 +228,8 @@ export const createCellStyle = (cell: any, isAutoExtensible: boolean) => { height: `${MODIFICATION_ROW_HEIGHT}px`, display: 'flex', alignItems: 'center', + borderTop: 'none', + borderBottom: 'none', }; }; @@ -197,3 +263,41 @@ export const createHeaderCellStyle = ( ...(isLast && { borderRight: darkBorder }), }; }; +export const BORDER_SUPPRESSED_COLUMNS = new Set(['dragHandle', 'select']); + +export const createCellContentWrapperSx = (theme: Theme, areBordersSuppressed: boolean): SxProps => ({ + display: 'flex', + alignItems: 'center', + width: '100%', + height: '100%', + borderTop: areBordersSuppressed ? 'none' : `1px solid ${createCellBorderColor(theme)}`, + borderBottom: areBordersSuppressed ? 'none' : `1px solid ${createCellBorderColor(theme)}`, +}); + +export const createNameCellRootStyle = (theme: Theme, isExpanded: boolean, depth: number) => ({ + height: '100%', + width: '100%', + display: 'flex', + alignItems: 'stretch', + gap: 0, + ...(depth === 0 && + !isExpanded && { + borderTop: `1px solid ${createCellBorderColor(theme)}`, + borderBottom: `1px solid ${createCellBorderColor(theme)}`, + }), +}); + +export const createNameCellLabelBoxSx = (isExpanded: boolean, depth: number): SxProps => { + return { + alignSelf: 'stretch', + display: 'flex', + alignItems: 'center', + flex: 1, + minWidth: 0, + ...((depth > 0 || isExpanded) && { + borderTop: (theme: Theme) => `1px solid ${createCellBorderColor(theme)}`, + borderBottom: (theme: Theme) => `1px solid ${createCellBorderColor(theme)}`, + borderLeft: (theme: Theme) => `1px solid ${createCellBorderColor(theme)}`, + }), + }; +}; diff --git a/src/components/graph/menus/network-modifications/network-modification-table/use-modifications-drag-and-drop.tsx b/src/components/graph/menus/network-modifications/network-modification-table/use-modifications-drag-and-drop.tsx index 8b35ef8e3b..eb64378682 100644 --- a/src/components/graph/menus/network-modifications/network-modification-table/use-modifications-drag-and-drop.tsx +++ b/src/components/graph/menus/network-modifications/network-modification-table/use-modifications-drag-and-drop.tsx @@ -5,17 +5,38 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { JSX, RefObject, useCallback } from 'react'; +import React, { JSX, RefObject, useCallback } from 'react'; import { Row } from '@tanstack/react-table'; -import { NetworkModificationMetadata } from '@gridsuite/commons-ui'; import { DraggableProvided, DraggableRubric, DraggableStateSnapshot, DragUpdate, DropResult } from '@hello-pangea/dnd'; import DragCloneRow from './row/drag-row-clone'; -import { DROP_INDICATOR_BOTTOM, DROP_INDICATOR_TOP } from './styles'; +import { + DROP_FORBIDDEN_INDICATOR_BOTTOM, + DROP_FORBIDDEN_INDICATOR_TOP, + DROP_INDICATOR_BOTTOM, + DROP_INDICATOR_TOP, +} from './styles'; + +import { + changeCompositeSubModificationOrder, + changeNetworkModificationOrder, +} from '../../../../../services/study/network-modifications'; +import { snackWithFallback, useSnackMessage } from '@gridsuite/commons-ui'; +import { useSelector } from 'react-redux'; +import { AppState } from '../../../../../redux/reducer.type'; +import { + ComposedModificationMetadata, + findModificationsInTree, + isCompositeModification, + moveSubModificationInTree, +} from './utils'; +import { UUID } from 'node:crypto'; interface UseModificationsDragAndDropParams { - rows: Row[]; + rows: Row[]; containerRef: RefObject; - onRowDragEnd?: (result: DropResult) => void; + composedModifications: ComposedModificationMetadata[]; + setComposedModifications: React.Dispatch>; + onDragEnd: () => void; } interface UseModificationsDragAndDropReturn { @@ -34,11 +55,38 @@ const clearRowDragIndicators = (container: HTMLDivElement | null): void => { }); }; +const isDropForbidden = ( + sourceRow: Row, + targetRow: Row +): boolean => { + const isDraggingDown = targetRow.index > sourceRow.index; + //Can't move composite inside another composite for now + if ( + isCompositeModification(sourceRow.original) && + ((isCompositeModification(targetRow.original) && targetRow.getIsExpanded() && isDraggingDown) || + isCompositeModification(targetRow.getParentRow()?.original)) + ) { + return true; + } + + //Can't drag a composite in its own subtree + return !!( + isCompositeModification(sourceRow.original) && + findModificationsInTree(targetRow.original.uuid, [sourceRow.original]) + ); +}; + export const useModificationsDragAndDrop = ({ rows, containerRef, - onRowDragEnd, + composedModifications, + setComposedModifications, + onDragEnd, }: UseModificationsDragAndDropParams): UseModificationsDragAndDropReturn => { + const { snackError } = useSnackMessage(); + const studyUuid = useSelector((state: AppState) => state.studyUuid); + const currentNodeId = useSelector((state: AppState) => state.currentTreeNode?.id); + const handleDragUpdate = useCallback( (update: DragUpdate) => { clearRowDragIndicators(containerRef.current); @@ -48,11 +96,24 @@ export const useModificationsDragAndDrop = ({ return; } - const targetUuid = rows[destination.index]?.original.uuid; - const el = containerRef.current?.querySelector(`[data-row-id="${targetUuid}"]`); - if (el) { - el.style.boxShadow = destination.index > source.index ? DROP_INDICATOR_BOTTOM : DROP_INDICATOR_TOP; + const sourceRow = rows[source.index]; + const targetRow = rows[destination.index]; + const el = containerRef.current?.querySelector(`[data-row-id="${targetRow?.original.uuid}"]`); + + if (!el) { + return; } + + const forbidden = isDropForbidden(sourceRow, targetRow); + const isMovingDown = destination.index > source.index; + + el.style.boxShadow = forbidden + ? isMovingDown + ? DROP_FORBIDDEN_INDICATOR_BOTTOM + : DROP_FORBIDDEN_INDICATOR_TOP + : isMovingDown + ? DROP_INDICATOR_BOTTOM + : DROP_INDICATOR_TOP; }, [rows, containerRef] ); @@ -60,12 +121,106 @@ export const useModificationsDragAndDrop = ({ const handleDragEnd = useCallback( (result: DropResult) => { clearRowDragIndicators(containerRef.current); + onDragEnd(); + + const { source, destination } = result; + if (!destination || source.index === destination.index) { + return; + } + + const sourceRow = rows[source.index]; + const targetRow = rows[destination.index]; + + if (isDropForbidden(sourceRow, targetRow)) { + return; + } + + const isSubRowInvolved = sourceRow.depth > 0 || targetRow.depth > 0; + + const isDraggingDown = destination.index > source.index; + const droppingIntoExpandedComposite = isDraggingDown && targetRow.getIsExpanded(); + + if (isSubRowInvolved || droppingIntoExpandedComposite) { + const movingUuid = sourceRow.original.uuid; + const sourceCompositeUuid = + sourceRow.depth > 0 ? (sourceRow.getParentRow()?.original.uuid ?? null) : null; + + // When entering an expanded composite from outside, the target composite is the + // composite row itself; otherwise derive it from the target row's parent as usual. + const targetCompositeUuid = droppingIntoExpandedComposite + ? targetRow.original.uuid + : targetRow.depth > 0 + ? (targetRow.getParentRow()?.original.uuid ?? null) + : null; + + const targetSiblings = targetCompositeUuid + ? rows.filter((r) => r.depth > 0 && r.getParentRow()?.original.uuid === targetCompositeUuid) + : rows.filter((r) => r.depth === 0); + + let beforeUuid: UUID | null; + if (droppingIntoExpandedComposite) { + // Landing on an expanded composite header: enter it at first position + beforeUuid = targetSiblings[0]?.original.uuid ?? null; + } else { + const landingIndexInSiblings = targetSiblings.findIndex( + (r) => r.original.uuid === targetRow.original.uuid + ); + const beforeSiblingIndex = isDraggingDown ? landingIndexInSiblings + 1 : landingIndexInSiblings; + beforeUuid = targetSiblings[beforeSiblingIndex]?.original.uuid ?? null; + } + + const previousComposed = composedModifications; + setComposedModifications((prev) => + moveSubModificationInTree(movingUuid, sourceCompositeUuid, targetCompositeUuid, beforeUuid, prev) + ); + + changeCompositeSubModificationOrder( + studyUuid, + currentNodeId, + movingUuid, + sourceCompositeUuid, + targetCompositeUuid, + beforeUuid + ).catch((error) => { + snackWithFallback(snackError, error, { headerId: 'errReorderModificationMsg' }); + setComposedModifications(previousComposed); + }); + } else { + const sourceDepth0Uuid = sourceRow.original.uuid; + const targetDepth0Uuid = targetRow.original.uuid; + + const oldPosition = composedModifications.findIndex((m) => m.uuid === sourceDepth0Uuid); + const newPosition = composedModifications.findIndex((m) => m.uuid === targetDepth0Uuid); + + if (oldPosition === -1 || newPosition === -1 || oldPosition === newPosition || !currentNodeId) { + return; + } + + // Optimistic update of the flat modifications list + const previousModifications = [...composedModifications]; + const updatedModifications = [...composedModifications]; + const [movedItem] = updatedModifications.splice(oldPosition, 1); + updatedModifications.splice(newPosition, 0, movedItem); + setComposedModifications(updatedModifications); + + const before = updatedModifications[newPosition + 1]?.uuid ?? null; - if (result.destination && result.source.index !== result.destination.index) { - onRowDragEnd?.(result); + changeNetworkModificationOrder(studyUuid, currentNodeId, movedItem.uuid, before).catch((error) => { + snackWithFallback(snackError, error, { headerId: 'errReorderModificationMsg' }); + setComposedModifications(previousModifications); + }); } }, - [containerRef, onRowDragEnd] + [ + containerRef, + onDragEnd, + rows, + studyUuid, + currentNodeId, + snackError, + composedModifications, + setComposedModifications, + ] ); const renderClone = useCallback( diff --git a/src/components/graph/menus/network-modifications/network-modification-table/utils.ts b/src/components/graph/menus/network-modifications/network-modification-table/utils.ts new file mode 100644 index 0000000000..53e40605a5 --- /dev/null +++ b/src/components/graph/menus/network-modifications/network-modification-table/utils.ts @@ -0,0 +1,222 @@ +/* + * 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 { MODIFICATION_TYPES, NetworkModificationMetadata } from '@gridsuite/commons-ui'; +import { getNetworkModificationsFromComposite } from '../../../../../services/study/network-modifications'; +import { Dispatch, SetStateAction } from 'react'; + +export const formatComposedModification = ( + modifications: NetworkModificationMetadata[] +): ComposedModificationMetadata[] => { + return modifications.map((modification) => ({ ...modification, subModifications: [] })); +}; + +export interface ComposedModificationMetadata extends NetworkModificationMetadata { + subModifications: ComposedModificationMetadata[]; +} + +export function isCompositeModification(modification: ComposedModificationMetadata | undefined) { + return modification?.messageType === MODIFICATION_TYPES.COMPOSITE_MODIFICATION.type; +} + +export function findAllLoadedCompositeModifications( + modifications: ComposedModificationMetadata[], + composites: ComposedModificationMetadata[] +) { + for (const modification of modifications) { + if (isCompositeModification(modification) && modification.subModifications.length > 0) { + composites.push(modification); + findAllLoadedCompositeModifications(modification.subModifications, composites); + } + } +} + +export function findModificationsInTree( + uuid: string, + mods: ComposedModificationMetadata[] +): ComposedModificationMetadata | undefined { + for (const mod of mods) { + if (mod.uuid === uuid) { + return mod; + } + const found = findModificationsInTree(uuid, mod.subModifications); + if (found) { + return found; + } + } + return undefined; +} + +export function updateModificationInTree( + uuid: string, + subModifications: ComposedModificationMetadata[], + mods: ComposedModificationMetadata[] +): ComposedModificationMetadata[] { + return mods.map((m) => { + if (m.uuid === uuid) { + return { ...m, subModifications }; + } + if (m.subModifications.length > 0) { + return { ...m, subModifications: updateModificationInTree(uuid, subModifications, m.subModifications) }; + } + return m; + }); +} + +/** + * Recursively merges already-loaded subModifications from the previous tree into a freshly + * formatted tree (where all subModifications start as []). This ensures that when `modifications` + * changes, previously fetched children are preserved and do not need to be re-fetched. + */ +export function mergeSubModificationsIntoTree( + nextMods: ComposedModificationMetadata[], + prevMods: ComposedModificationMetadata[] +): ComposedModificationMetadata[] { + return nextMods.map((nextMod) => { + const prevMod = prevMods.find((m) => m.uuid === nextMod.uuid); + if (!prevMod || prevMod.subModifications.length === 0) { + return nextMod; + } + return { + ...nextMod, + subModifications: mergeSubModificationsIntoTree( + nextMod.subModifications.length > 0 ? nextMod.subModifications : prevMod.subModifications, + prevMod.subModifications + ), + }; + }); +} + +/** + * Returns a new tree where the modification identified by {@code uuid} has the given + * partial fields merged in. All other nodes are returned as-is (referentially stable). + */ +export function updateModificationFieldInTree( + uuid: string, + fields: Partial, + mods: ComposedModificationMetadata[] +): ComposedModificationMetadata[] { + return mods.map((m) => { + if (m.uuid === uuid) { + return { ...m, ...fields }; + } + if (m.subModifications.length > 0) { + return { ...m, subModifications: updateModificationFieldInTree(uuid, fields, m.subModifications) }; + } + return m; + }); +} + +export function moveSubModificationInTree( + movingUuid: string, + sourceParentUuid: string | null, + targetParentUuid: string | null, + beforeUuid: string | null, + mods: ComposedModificationMetadata[] +): ComposedModificationMetadata[] { + let movedItem: ComposedModificationMetadata | undefined; + let next: ComposedModificationMetadata[]; + + if (sourceParentUuid) { + const sourceMod = findModificationsInTree(sourceParentUuid, mods); + if (!sourceMod) { + return mods; + } + movedItem = sourceMod.subModifications.find((m) => m.uuid === movingUuid); + if (!movedItem) { + return mods; + } + const newSourceSubs = sourceMod.subModifications.filter((m) => m.uuid !== movingUuid); + next = updateModificationInTree(sourceParentUuid, newSourceSubs, mods); + } else { + movedItem = mods.find((m) => m.uuid === movingUuid); + if (!movedItem) { + return mods; + } + next = mods.filter((m) => m.uuid !== movingUuid); + } + + if (targetParentUuid) { + const targetMod = findModificationsInTree(targetParentUuid, next); + if (!targetMod) { + return mods; + } + const newTargetSubs = [...targetMod.subModifications]; + const insertIdx = beforeUuid ? newTargetSubs.findIndex((m) => m.uuid === beforeUuid) : -1; + newTargetSubs.splice(insertIdx === -1 ? newTargetSubs.length : insertIdx, 0, movedItem); + return updateModificationInTree(targetParentUuid, newTargetSubs, next); + } else { + const insertIdx = beforeUuid ? next.findIndex((m) => m.uuid === beforeUuid) : -1; + const result = [...next]; + result.splice(insertIdx === -1 ? result.length : insertIdx, 0, movedItem); + return result; + } +} + +export function fetchSubModificationsForExpandedRows( + expandedIds: string[], + mods: ComposedModificationMetadata[], + setMods: Dispatch> +): void { + const uuidsToFetch = expandedIds.filter((id) => { + const mod = findModificationsInTree(id, mods); + return isCompositeModification(mod) && mod?.subModifications.length === 0; + }); + + if (uuidsToFetch.length === 0) { + return; + } + + getNetworkModificationsFromComposite(uuidsToFetch).then((subModsByUuid) => { + setMods((prev) => + Object.entries(subModsByUuid).reduce((tree, [uuid, subMods]) => { + const liveModifications = formatComposedModification(subMods.filter((m) => !m.stashed)); + // Preserve already-loaded children of any nested composites within the new sub-list + const existingMod = findModificationsInTree(uuid, tree); + const mergedSubs = mergeSubModificationsIntoTree( + liveModifications, + existingMod?.subModifications ?? [] + ); + return updateModificationInTree(uuid, mergedSubs, tree); + }, prev) + ); + }); +} + +/** + * Re-fetches sub-modifications for all expanded composite rows + * Used when `modifications` changes to ensure no stale sub-modification data remains. + */ +export function refetchSubModificationsForExpandedRows( + expandedIds: string[], + mods: ComposedModificationMetadata[], + setMods: Dispatch> +): void { + const uuidsToRefetch = expandedIds.filter((id) => { + const mod = findModificationsInTree(id, mods); + return isCompositeModification(mod); + }); + + if (uuidsToRefetch.length === 0) { + return; + } + + getNetworkModificationsFromComposite(uuidsToRefetch).then((subModsByUuid) => { + setMods((prev) => + Object.entries(subModsByUuid).reduce((tree, [uuid, subMods]) => { + const liveModifications = formatComposedModification(subMods.filter((m) => !m.stashed)); + // Preserve already-loaded children of any nested composites within the new sub-list + const existingMod = findModificationsInTree(uuid, tree); + const mergedSubs = mergeSubModificationsIntoTree( + liveModifications, + existingMod?.subModifications ?? [] + ); + return updateModificationInTree(uuid, mergedSubs, tree); + }, prev) + ); + }); +} diff --git a/src/components/utils/field-constants.ts b/src/components/utils/field-constants.ts index aaf2d8fd4b..c860d6ab44 100644 --- a/src/components/utils/field-constants.ts +++ b/src/components/utils/field-constants.ts @@ -490,3 +490,7 @@ export const MOVE_VOLTAGE_LEVEL_FEEDER_BAYS = 'MOVE_VOLTAGE_LEVEL_FEEDER_BAYS'; export const MOVE_VOLTAGE_LEVEL_FEEDER_BAYS_TABLE = 'moveVoltageLevelFeederBaysTable'; export const BUSBAR_SECTION_IDS = 'busbarSectionIds'; export const CONNECTION_SIDE = 'connectionSide'; + +export const ACTION = 'action'; +export const SELECTED_MODIFICATIONS = 'selectedModifications'; +export const COMPOSITE_NAMES = 'compositeNames'; diff --git a/src/services/study/index.ts b/src/services/study/index.ts index 3f56a3b271..d304195ce3 100644 --- a/src/services/study/index.ts +++ b/src/services/study/index.ts @@ -19,10 +19,16 @@ import { Parameter, safeEncodeURIComponent, } from '@gridsuite/commons-ui'; -import { NetworkModificationCopyInfos } from 'components/graph/menus/network-modifications/network-modification-menu.type'; +import { + CompositeModificationsActionType, + NetworkModificationCopyInfos, +} from 'components/graph/menus/network-modifications/network-modification-menu.type'; import type { Svg } from 'components/grid-layout/cards/diagrams/diagram.type'; export const PREFIX_STUDY_QUERIES = import.meta.env.VITE_API_GATEWAY + '/study'; +export const PREFIX_NETWORK_MODIFICATION_QUERIES = import.meta.env.VITE_API_GATEWAY + '/network-modification'; + +export const getBaseNetworkModificationUrl = () => `${PREFIX_NETWORK_MODIFICATION_QUERIES}/v1`; export const getStudyUrl = (studyUuid: UUID | null) => `${PREFIX_STUDY_QUERIES}/v1/studies/${safeEncodeURIComponent(studyUuid)}`; @@ -239,21 +245,34 @@ export function copyOrMoveModifications( originNodeUuid: copyInfos.originNodeUuid ?? '', }); - // TODO : conversion to a ModificationsToCopyInfos dto => this will be useful and improved when INSERT_COMPOSITE action will be made available from the front - const modifications = modificationToCutUuidList.map((modificationUuid) => { - return { uuid: modificationUuid }; - }); - return backendFetch(copyOrMoveModificationUrl, { method: 'PUT', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, - body: JSON.stringify(modifications), + body: JSON.stringify(modificationToCutUuidList), }); } +export const insertCompositeModifications = ( + studyUuid: string, + nodeUuid: string, + modifications: Record[], + action: CompositeModificationsActionType +): Promise => { + const urlSearchParams = new URLSearchParams({ action }); + return backendFetch(`${getStudyUrlWithNodeUuid(studyUuid, nodeUuid)}/composite-modifications?${urlSearchParams}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(modifications), + }).then((response) => { + if (!response.ok) { + return response.json().then((err) => Promise.reject(err)); + } + }); +}; + export interface ExportFormatProperties { formatName: string; parameters: Parameter[]; diff --git a/src/services/study/network-modifications.ts b/src/services/study/network-modifications.ts index ea4c847391..b0c5ffc50e 100644 --- a/src/services/study/network-modifications.ts +++ b/src/services/study/network-modifications.ts @@ -16,13 +16,17 @@ import { MODIFICATION_TYPES, ModificationType, safeEncodeURIComponent, - NetworkModificationMetadata, toModificationOperation, SubstationCreationDto, SubstationModificationDto, + NetworkModificationMetadata, VoltageLevelModificationDto, } from '@gridsuite/commons-ui'; -import { getStudyUrlWithNodeUuid, getStudyUrlWithNodeUuidAndRootNetworkUuid } from './index'; +import { + getBaseNetworkModificationUrl, + getStudyUrlWithNodeUuid, + getStudyUrlWithNodeUuidAndRootNetworkUuid, +} from './index'; import { EQUIPMENT_TYPES } from '../../components/utils/equipment-types'; import { BRANCH_SIDE, OPERATING_STATUS_ACTION } from '../../components/network/constants'; import type { UUID } from 'node:crypto'; @@ -91,6 +95,40 @@ export function changeNetworkModificationOrder( return backendFetch(url, { method: 'put' }); } +/** + * Move a composite sub-modification within or between composites, or between a composite and the group root. + * + * The four scenarios are encoded by the nullable sourceCompositeUuid / targetCompositeUuid: + * - both present → sub-to-sub (same composite = reorder, different = cross-composite move) + * - source only → extract from composite to root level + * - target only → embed root-level modification into a composite + * + * @param sourceCompositeUuid UUID of the composite that currently owns the modification; null if at root + * @param targetCompositeUuid UUID of the target composite; null to place at root level + * @param beforeUuid insert before this UUID in the target collection; null to append at end + */ +export function changeCompositeSubModificationOrder( + studyUuid: UUID | null, + nodeUuid: UUID | undefined, + modificationUuid: UUID, + sourceCompositeUuid: UUID | null, + targetCompositeUuid: UUID | null, + beforeUuid: UUID | null +) { + console.info('move composite sub-modification ' + modificationUuid + ' in node ' + nodeUuid); + const params = new URLSearchParams(); + if (sourceCompositeUuid) params.set('sourceCompositeUuid', sourceCompositeUuid); + if (targetCompositeUuid) params.set('targetCompositeUuid', targetCompositeUuid); + if (beforeUuid) params.set('beforeUuid', beforeUuid); + const url = + getStudyUrlWithNodeUuid(studyUuid, nodeUuid) + + '/composite-sub-modification/' + + modificationUuid + + (params.toString() ? '?' + params.toString() : ''); + console.debug(url); + return backendFetch(url, { method: 'put' }); +} + export function stashModifications(studyUuid: UUID | null, nodeUuid: UUID | undefined, modificationUuids: UUID[]) { const urlSearchParams = new URLSearchParams(); urlSearchParams.append('stashed', String(true)); @@ -1965,3 +2003,18 @@ export function moveVoltageLevelFeederBays({ body: JSON.stringify(moveVoltageLevelFeederBaysInfos), }); } + +export function getNetworkModificationsFromComposite( + compositeModificationUuids: string[], + onlyMetadata: boolean = true +): Promise> { + const urlSearchParams = new URLSearchParams(); + compositeModificationUuids.forEach((uuid) => urlSearchParams.append('uuids', uuid)); + urlSearchParams.append('onlyMetadata', String(onlyMetadata)); + const url = + getBaseNetworkModificationUrl() + + '/network-composite-modifications/network-modifications?' + + urlSearchParams.toString(); + console.debug(url); + return backendFetchJson(url); +} diff --git a/src/translations/en.json b/src/translations/en.json index 479a8267ff..8480cd54d6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -538,6 +538,10 @@ "SelectCompositeModificationTitle": "Composite network modification", "CreateCompositeModificationLabel": "Create a new composite modification", "UpdateCompositeModificationLabel": "Replace an existing composite modification", + "ModificationsImport": "Import modifications", + "CompositeModificationsActionSplit": "Spread composite modification", + "CompositeModificationsActionInsert": "Insert composite modification", + "ChooseModifications": "Select modifications", "descriptionProperty": "Description", "DeleteModificationText": "{numberToDelete, plural, =0 {No modification to delete.} =1 {One modification will be permanently deleted.} other {# modifications will be permanently deleted.}}", "HybridDisplay": "Hybrid display", @@ -1722,7 +1726,7 @@ "Reload": "Reload", "CopyError": "Copy Error", "Copied": "Copied", - "downloadNetworkModifications" : "Download modifications", + "downloadNetworkModifications": "Download modifications", "moveToTrash": "Move to trash", "visualizedRootNetwork": "Visualized root network : {tag}", "addDescription" : "Add description", @@ -1731,4 +1735,4 @@ "deselectModification": "Deselect modification", "selectModification": "Select modification", "moveModification": "Move modification" -} \ No newline at end of file +} diff --git a/src/translations/fr.json b/src/translations/fr.json index e0507aa25d..3f109e4af9 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -536,6 +536,10 @@ "SelectCompositeModificationTitle": "Modification composite de réseau", "CreateCompositeModificationLabel": "Créer une nouvelle modification composite", "UpdateCompositeModificationLabel": "Remplacer une modification composite existante", + "ModificationsImport": "Importer des modifications", + "CompositeModificationsActionSplit": "Déplier la modification composite", + "CompositeModificationsActionInsert": "Insérer la modification composite", + "ChooseModifications": "Sélectionner modifications", "descriptionProperty": "Description", "DeleteModificationText": "{numberToDelete, plural, =0 {Aucune modification à supprimer.} =1 {Une modification va être définitivement supprimée.} other {# modifications vont être définitivement supprimées.}}", "HybridDisplay": "Affichage hybride",