From 79738ea635baf402307dbec9f6f49b486e142d0a Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:08:31 +0200 Subject: [PATCH 1/7] Handle the modification applicability per root network tag Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- .../network-modifications-table.tsx | 18 ++-- .../renderers/cell-renderers.tsx | 6 +- .../renderers/root-network-chip-cell.tsx | 94 ++++++------------- src/module-tanstack.d.ts | 6 +- src/utils/types/network-modification-types.ts | 9 +- 5 files changed, 50 insertions(+), 83 deletions(-) diff --git a/src/features/network-modification-table/network-modifications-table.tsx b/src/features/network-modification-table/network-modifications-table.tsx index ebb88f091..08038d289 100644 --- a/src/features/network-modification-table/network-modifications-table.tsx +++ b/src/features/network-modification-table/network-modifications-table.tsx @@ -21,7 +21,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { UUID } from 'node:crypto'; import { NetworkModificationEditorNameHeaderProps } from './renderers'; import { - ExcludedNetworkModifications, + NetworkModificationApplicabilities, RootNetworkRowInfo, ComposedModificationMetadata, NetworkModificationMetadata, @@ -61,8 +61,8 @@ interface NetworkModificationsTableProps extends Omit>; + applicabilities?: NetworkModificationApplicabilities; + setApplicabilities?: Dispatch>; isDisabled?: boolean; } @@ -81,8 +81,8 @@ export function NetworkModificationsTable({ currentNodeId = undefined, currentRootNetworkUuid, rootNetworks, - modificationsToExclude, - setModificationsToExclude, + applicabilities, + setApplicabilities, isDisabled = false, isImpactedByNotification, notificationMessageId, @@ -194,8 +194,8 @@ export function NetworkModificationsTable({ }, modifications: { count: modifications.length, - toExclude: modificationsToExclude, - setToExclude: setModificationsToExclude, + applicabilities, + setApplicabilities, }, interaction: { lastClickedRowId, @@ -217,8 +217,8 @@ export function NetworkModificationsTable({ currentRootNetworkUuid, rootNetworks, modifications.length, - modificationsToExclude, - setModificationsToExclude, + applicabilities, + setApplicabilities, lastClickedRowId, handleRowSelected, modificationToEditLabelRef, diff --git a/src/features/network-modification-table/renderers/cell-renderers.tsx b/src/features/network-modification-table/renderers/cell-renderers.tsx index 97f581e5b..88b4a2d28 100644 --- a/src/features/network-modification-table/renderers/cell-renderers.tsx +++ b/src/features/network-modification-table/renderers/cell-renderers.tsx @@ -112,7 +112,7 @@ export function RootNetworkCellRenderer({ row, column, table }: CCtx) { const { meta } = table.options; // `column.id` is the rootNetworkUuid (set in createRootNetworksColumns). const rootNetwork = meta?.context.rootNetworks?.find((r) => r.rootNetworkUuid === column.id); - if (!rootNetwork || !meta?.modifications.toExclude || !meta.modifications.setToExclude) { + if (!rootNetwork || !meta?.modifications.applicabilities || !meta.modifications.setApplicabilities) { return null; } return ( @@ -122,8 +122,8 @@ export function RootNetworkCellRenderer({ row, column, table }: CCtx) { studyUuid={meta?.context.studyUuid ?? null} currentNodeId={meta?.context.currentNodeId} rootNetwork={rootNetwork} - modificationsToExclude={meta.modifications.toExclude} - setModificationsToExclude={meta.modifications.setToExclude} + applicabilities={meta.modifications.applicabilities} + setApplicabilities={meta.modifications.setApplicabilities} isDisabled={meta?.status.isDisabled} /> diff --git a/src/features/network-modification-table/renderers/root-network-chip-cell.tsx b/src/features/network-modification-table/renderers/root-network-chip-cell.tsx index b17e5b1fd..eac456388 100644 --- a/src/features/network-modification-table/renderers/root-network-chip-cell.tsx +++ b/src/features/network-modification-table/renderers/root-network-chip-cell.tsx @@ -12,50 +12,30 @@ import { updateModificationStatusByRootNetwork } from '../../../services'; import { useSnackMessage } from '../../../hooks'; import { ComposedModificationMetadata, - ExcludedNetworkModifications, + NetworkModificationApplicabilities, RootNetworkRowInfo, snackWithFallback, } from '../../../utils'; -function getUpdatedExcludedModifications( - prev: ExcludedNetworkModifications[], - rootNetworkUuid: UUID, - modificationUuid: UUID -): { nextExcluded: ExcludedNetworkModifications[]; newStatus: boolean } { - const exists = prev.some((item) => item.rootNetworkUuid === rootNetworkUuid); - - if (exists) { - let newStatus = false; - const nextExcluded = 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) - newStatus = isExcluded; - - return { - ...modif, - modificationUuidsToExclude: newModificationUuidsToExclude, - }; - }); +/** + * A modification is applicable on a root network unless its applicability for that tag is explicitly false: + * a tag without an entry is applicable. + */ +function isApplicableOn(applicabilities: NetworkModificationApplicabilities, modificationUuid: UUID, tag: string) { + return applicabilities[modificationUuid]?.[tag] ?? true; +} - return { nextExcluded, newStatus }; - } +function getToggledApplicabilities( + prev: NetworkModificationApplicabilities, + modificationUuid: UUID, + tag: string +): NetworkModificationApplicabilities { return { - nextExcluded: [ - ...prev, - { - rootNetworkUuid, - modificationUuidsToExclude: [modificationUuid], - }, - ], - newStatus: false, + ...prev, + [modificationUuid]: { + ...prev[modificationUuid], + [tag]: !isApplicableOn(prev, modificationUuid, tag), + }, }; } @@ -64,8 +44,8 @@ export interface RootNetworkChipCellProps { studyUuid: UUID | null; currentNodeId?: UUID; rootNetwork: RootNetworkRowInfo; - modificationsToExclude: ExcludedNetworkModifications[]; - setModificationsToExclude: React.Dispatch>; + applicabilities: NetworkModificationApplicabilities; + setApplicabilities: React.Dispatch>; isDisabled?: boolean; } @@ -75,8 +55,8 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { studyUuid, currentNodeId, rootNetwork, - modificationsToExclude, - setModificationsToExclude, + applicabilities, + setApplicabilities, isDisabled = false, } = props; const [isLoading, setIsLoading] = useState(false); @@ -87,14 +67,8 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { 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]); + return isApplicableOn(applicabilities, modificationUuid, rootNetwork.tag); + }, [modificationUuid, applicabilities, rootNetwork.tag, rootNetwork.isCreating]); const handleModificationActivationByRootNetwork = useCallback(() => { if (!studyUuid || !currentNodeId) { @@ -103,15 +77,10 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { setIsLoading(true); - // Compute next state (pure, no side effects) - const { nextExcluded, newStatus } = getUpdatedExcludedModifications( - modificationsToExclude, - rootNetwork.rootNetworkUuid, - modificationUuid - ); + const newStatus = !isApplicableOn(applicabilities, modificationUuid, rootNetwork.tag); // Apply optimistic update - setModificationsToExclude(nextExcluded); + setApplicabilities(getToggledApplicabilities(applicabilities, modificationUuid, rootNetwork.tag)); // Perform backend call updateModificationStatusByRootNetwork( @@ -123,11 +92,7 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { ) .catch((error) => { // Rollback on failure by toggling back - setModificationsToExclude( - (prev) => - getUpdatedExcludedModifications(prev, rootNetwork.rootNetworkUuid, modificationUuid) - .nextExcluded - ); + setApplicabilities((prev) => getToggledApplicabilities(prev, modificationUuid, rootNetwork.tag)); snackWithFallback(snackError, error, { headerId: 'modificationActivationByRootNetworkError' }); }) .finally(() => { @@ -137,9 +102,10 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { modificationUuid, studyUuid, currentNodeId, - modificationsToExclude, + applicabilities, rootNetwork.rootNetworkUuid, - setModificationsToExclude, + rootNetwork.tag, + setApplicabilities, snackError, ]); diff --git a/src/module-tanstack.d.ts b/src/module-tanstack.d.ts index f53622171..f8e5fafe4 100644 --- a/src/module-tanstack.d.ts +++ b/src/module-tanstack.d.ts @@ -8,7 +8,7 @@ import { Dispatch, RefObject, SetStateAction } from 'react'; import { SxProps, Theme } from '@mui/material'; import type { UUID } from 'node:crypto'; -import { ComposedModificationMetadata, ExcludedNetworkModifications, RootNetworkRowInfo } from './utils'; +import { ComposedModificationMetadata, NetworkModificationApplicabilities, RootNetworkRowInfo } from './utils'; declare module '@tanstack/react-table' { // TableMeta = values shared by the whole table (same value across every cell). @@ -22,8 +22,8 @@ declare module '@tanstack/react-table' { }; modifications: { count?: number; - toExclude?: ExcludedNetworkModifications[]; - setToExclude?: Dispatch>; + applicabilities?: NetworkModificationApplicabilities; + setApplicabilities?: Dispatch>; }; interaction: { lastClickedRowId: RefObject; diff --git a/src/utils/types/network-modification-types.ts b/src/utils/types/network-modification-types.ts index 44727a79f..d4f7e8002 100644 --- a/src/utils/types/network-modification-types.ts +++ b/src/utils/types/network-modification-types.ts @@ -16,10 +16,11 @@ export type AttributeModification = { op: OperationType; }; -export interface ExcludedNetworkModifications { - rootNetworkUuid: UUID; - modificationUuidsToExclude: UUID[]; -} +/** + * Applicability of each network modification per root network tag, as held by the modifications themselves: + * a tag without an entry is applicable. + */ +export type NetworkModificationApplicabilities = Record>; export interface RootNetworkRowInfo { rootNetworkUuid: UUID; From b89e72db800187d9839be962a46b11801f528b74 Mon Sep 17 00:00:00 2001 From: Florent MILLOT Date: Tue, 11 Aug 2026 18:53:58 +0200 Subject: [PATCH 2/7] Rename the root network applicability query parameter to applicable Follows the study server rename: the endpoint sets the applicability of a modification on one root network, not its global activation flag. Signed-off-by: Florent MILLOT --- src/services/networkModification.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/networkModification.ts b/src/services/networkModification.ts index 766d68fbd..36093f763 100644 --- a/src/services/networkModification.ts +++ b/src/services/networkModification.ts @@ -162,10 +162,10 @@ export function updateModificationStatusByRootNetwork( nodeUuid: UUID, rootNetworkUuid: UUID, modificationUuid: UUID, - activated: boolean + applicable: boolean ) { const urlSearchParams = new URLSearchParams(); - urlSearchParams.append('activated', String(activated)); + urlSearchParams.append('applicable', String(applicable)); urlSearchParams.append('uuids', String([modificationUuid])); const url = `${getStudyUrlWithNodeUuidAndRootNetworkUuid(studyUuid, nodeUuid, rootNetworkUuid)}/network-modifications?${urlSearchParams.toString()}`; console.debug(url); From 29f58d8d020e42f31ad33acda9c872131021d934 Mon Sep 17 00:00:00 2001 From: Florent MILLOT Date: Wed, 12 Aug 2026 10:52:24 +0200 Subject: [PATCH 3/7] Rename `newStatus` to `newApplicability` in `root-network-chip-cell.tsx` for clarity Signed-off-by: Florent MILLOT --- .../renderers/root-network-chip-cell.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/network-modification-table/renderers/root-network-chip-cell.tsx b/src/features/network-modification-table/renderers/root-network-chip-cell.tsx index eac456388..5a370f8fc 100644 --- a/src/features/network-modification-table/renderers/root-network-chip-cell.tsx +++ b/src/features/network-modification-table/renderers/root-network-chip-cell.tsx @@ -77,7 +77,8 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { setIsLoading(true); - const newStatus = !isApplicableOn(applicabilities, modificationUuid, rootNetwork.tag); + // toggle the current applicability + const newApplicability = !isApplicableOn(applicabilities, modificationUuid, rootNetwork.tag); // Apply optimistic update setApplicabilities(getToggledApplicabilities(applicabilities, modificationUuid, rootNetwork.tag)); @@ -88,7 +89,7 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { currentNodeId, rootNetwork.rootNetworkUuid, modificationUuid, - newStatus + newApplicability ) .catch((error) => { // Rollback on failure by toggling back From 3449f874cac328f5e7727249065d9dce317b2700 Mon Sep 17 00:00:00 2001 From: Florent MILLOT Date: Thu, 13 Aug 2026 11:13:22 +0200 Subject: [PATCH 4/7] Disable root network chip while the root network is being created Also make the optimistic update and its rollback use the value captured at click time instead of blindly toggling the current state. Signed-off-by: Florent MILLOT --- .../renderers/root-network-chip-cell.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/features/network-modification-table/renderers/root-network-chip-cell.tsx b/src/features/network-modification-table/renderers/root-network-chip-cell.tsx index ba58ef2b3..0ca136c34 100644 --- a/src/features/network-modification-table/renderers/root-network-chip-cell.tsx +++ b/src/features/network-modification-table/renderers/root-network-chip-cell.tsx @@ -26,16 +26,17 @@ function isApplicableOn(applicabilities: NetworkModificationApplicabilities, mod return applicabilities[modificationUuid]?.[tag] ?? true; } -function getToggledApplicabilities( - prev: NetworkModificationApplicabilities, +function withApplicability( + applicabilities: NetworkModificationApplicabilities, modificationUuid: UUID, - tag: string + tag: string, + applicable: boolean ): NetworkModificationApplicabilities { return { - ...prev, + ...applicabilities, [modificationUuid]: { - ...prev[modificationUuid], - [tag]: !isApplicableOn(prev, modificationUuid, tag), + ...applicabilities[modificationUuid], + [tag]: applicable, }, }; } @@ -68,11 +69,8 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { data.type === ModificationType.MODIFICATION_REFERENCE || data.childFromShared; const isModificationActivated = useMemo(() => { - if (rootNetwork.isCreating) { - return true; - } return isApplicableOn(applicabilities, modificationUuid, rootNetwork.tag); - }, [modificationUuid, applicabilities, rootNetwork.tag, rootNetwork.isCreating]); + }, [modificationUuid, applicabilities, rootNetwork.tag]); const handleModificationActivationByRootNetwork = useCallback(() => { if (!studyUuid || !currentNodeId) { @@ -82,10 +80,10 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { setIsLoading(true); // toggle the current applicability - const newApplicability = !isApplicableOn(applicabilities, modificationUuid, rootNetwork.tag); + const newApplicability = !isModificationActivated; // Apply optimistic update - setApplicabilities(getToggledApplicabilities(applicabilities, modificationUuid, rootNetwork.tag)); + setApplicabilities((prev) => withApplicability(prev, modificationUuid, rootNetwork.tag, newApplicability)); // Perform backend call updateModificationStatusByRootNetwork( @@ -96,8 +94,10 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { newApplicability ) .catch((error) => { - // Rollback on failure by toggling back - setApplicabilities((prev) => getToggledApplicabilities(prev, modificationUuid, rootNetwork.tag)); + // Rollback on failure to the value shown when the user clicked + setApplicabilities((prev) => + withApplicability(prev, modificationUuid, rootNetwork.tag, isModificationActivated) + ); snackWithFallback(snackError, error, { headerId: 'modificationActivationByRootNetworkError' }); }) .finally(() => { @@ -107,7 +107,7 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { modificationUuid, studyUuid, currentNodeId, - applicabilities, + isModificationActivated, rootNetwork.rootNetworkUuid, rootNetwork.tag, setApplicabilities, @@ -119,7 +119,7 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { label={rootNetwork.tag} tooltipMessage={rootNetwork.name} isActivated={isModificationActivated} - isDisabled={isLoading || isDisabled || isReferenceModificationOrInsideOne} + isDisabled={isLoading || isDisabled || isReferenceModificationOrInsideOne || rootNetwork.isCreating} onClick={handleModificationActivationByRootNetwork} /> ); From a0cf91cb2db963f4754fdf517276244ea53a82e5 Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:28:16 +0200 Subject: [PATCH 5/7] Derive the applicabilities from the modifications Every modification carries its own applicability per root network tag, so the table indexes them itself instead of receiving them from its parent, and the two props leave the public API. Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- .../network-modifications-table.tsx | 13 +++++++----- .../renderers/name-cell.tsx | 7 +++++-- .../row/drag-row-clone.tsx | 6 +++++- .../network-modification-table/utils.ts | 21 +++++++++++++++++++ .../types/network-modification-metadata.ts | 1 + 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/features/network-modification-table/network-modifications-table.tsx b/src/features/network-modification-table/network-modifications-table.tsx index 04e6f7f41..caeda9973 100644 --- a/src/features/network-modification-table/network-modifications-table.tsx +++ b/src/features/network-modification-table/network-modifications-table.tsx @@ -5,7 +5,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box, Table, TableBody, TableCell, TableHead, TableRow, useTheme } from '@mui/material'; import { ColumnDef, @@ -35,6 +35,7 @@ import { AUTO_EXTENSIBLE_COLUMNS } from './columns-definition'; import { useModificationsDragAndDrop } from './use-modifications-drag-and-drop'; import { useModificationsSelection } from './use-modifications-selection'; import { + collectApplicabilities, fetchSubModificationsForExpandedRows, findAllLoadedCompositeModifications, findDepth, @@ -62,8 +63,6 @@ interface NetworkModificationsTableProps extends Omit>; isDisabled?: boolean; } @@ -82,8 +81,6 @@ export function NetworkModificationsTable({ currentNodeId = undefined, currentRootNetworkUuid, rootNetworks, - applicabilities, - setApplicabilities, isDisabled = false, isImpactedByNotification, notificationMessageId, @@ -99,6 +96,12 @@ export function NetworkModificationsTable({ const [composedModifications, setComposedModifications] = useState( formatToComposedModification(modifications) ); + + const [applicabilities, setApplicabilities] = useState({}); + useEffect(() => { + setApplicabilities(collectApplicabilities(composedModifications)); + }, [composedModifications]); + // composedModificationsRef is used to access composedModifications data from other useEffects // without having to add composedModifications to their dependencies (so it doesn't trigger them) const composedModificationsRef = useRef(composedModifications); diff --git a/src/features/network-modification-table/renderers/name-cell.tsx b/src/features/network-modification-table/renderers/name-cell.tsx index ae9fd5b0f..8e270bb0e 100644 --- a/src/features/network-modification-table/renderers/name-cell.tsx +++ b/src/features/network-modification-table/renderers/name-cell.tsx @@ -17,7 +17,7 @@ import { networkModificationTableStyles, } from '../network-modification-table-styles'; import { DepthBox } from './depth-box'; -import { isCompositeModification } from '../utils'; +import { isCompositeModification, toMessageValues } from '../utils'; import { useModificationLabelComputer, useSnackMessage } from '../../../hooks'; import { ComposedModificationMetadata, mergeSx, NetworkModificationMetadata, snackWithFallback } from '../../../utils'; @@ -54,7 +54,10 @@ export function NameCell({ row, table, onChange }: Readonly) { (modification: ComposedModificationMetadata, formatBold: boolean = true) => { return intl.formatMessage( { id: `network_modifications.${modification.messageType}` }, - { ...(modification as NetworkModificationMetadata), ...computeLabel(modification, formatBold) } + { + ...toMessageValues(modification as NetworkModificationMetadata), + ...computeLabel(modification, formatBold), + } ); }, [computeLabel, intl] diff --git a/src/features/network-modification-table/row/drag-row-clone.tsx b/src/features/network-modification-table/row/drag-row-clone.tsx index f2fac5d40..cd19fc14f 100644 --- a/src/features/network-modification-table/row/drag-row-clone.tsx +++ b/src/features/network-modification-table/row/drag-row-clone.tsx @@ -13,6 +13,7 @@ import { useIntl } from 'react-intl'; import { networkModificationTableStyles } from '../network-modification-table-styles'; import { useModificationLabelComputer } from '../../../hooks'; import { ComposedModificationMetadata, mergeSx, NetworkModificationMetadata } from '../../../utils'; +import { toMessageValues } from '../utils'; interface DragCloneRowProps { row: Row; @@ -26,7 +27,10 @@ export function DragCloneRow({ row }: Readonly) { (modification: ComposedModificationMetadata, formatBold: boolean = true) => { return intl.formatMessage( { id: `network_modifications.${modification.messageType}` }, - { ...(modification as NetworkModificationMetadata), ...computeLabel(modification, formatBold) } + { + ...toMessageValues(modification as NetworkModificationMetadata), + ...computeLabel(modification, formatBold), + } ); }, [computeLabel, intl] diff --git a/src/features/network-modification-table/utils.ts b/src/features/network-modification-table/utils.ts index f88e6f44e..52821cba5 100644 --- a/src/features/network-modification-table/utils.ts +++ b/src/features/network-modification-table/utils.ts @@ -11,6 +11,7 @@ import { fetchNetworkModification, getNetworkModificationsFromComposite } from ' import { ComposedModificationMetadata, MODIFICATION_TYPES, + NetworkModificationApplicabilities, NetworkModificationMetadata, ReferencedCompositeModifications, ReferenceModificationInfos, @@ -18,6 +19,20 @@ import { export const MAX_COMPOSITE_NESTING_DEPTH = 5; +// Indexes by uuid the applicability every modification of the tree carries, sub modifications included. +export function collectApplicabilities( + modifications: ComposedModificationMetadata[] +): NetworkModificationApplicabilities { + return modifications.reduce( + (applicabilities, modification) => ({ + ...applicabilities, + [modification.uuid]: modification.applicabilityByRootNetworkTag ?? {}, + ...collectApplicabilities(modification.subModifications), + }), + {} + ); +} + // Every ComposedModificationMetadata carries a `rowKey`: a random id generated once when the node // is created, decorrelated from the business `uuid`. It is the ONLY identity used to locate a // specific node's *position* in the tree @@ -31,6 +46,12 @@ export const formatToComposedModification = ( })); }; +// Remove the applicabilities +export function toMessageValues(modification: NetworkModificationMetadata) { + const { applicabilityByRootNetworkTag, ...messageValues } = modification; + return messageValues; +} + export function isCompositeModification(modification: ComposedModificationMetadata | undefined) { return modification?.type === MODIFICATION_TYPES.COMPOSITE_MODIFICATION.type; } diff --git a/src/utils/types/network-modification-metadata.ts b/src/utils/types/network-modification-metadata.ts index 9c452f1d8..b8f121da7 100644 --- a/src/utils/types/network-modification-metadata.ts +++ b/src/utils/types/network-modification-metadata.ts @@ -17,6 +17,7 @@ export interface NetworkModificationMetadata { description: string; messageType: string; messageValues: string; + applicabilityByRootNetworkTag?: Record; } export interface BasicComposedModificationMetadata extends NetworkModificationMetadata { From a3794c70208d53fcbe77db8494830530d9e4bbfd Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:53:10 +0200 Subject: [PATCH 6/7] Key the applicabilities by root network uuid The modifications carry their applicability keyed by the root network tag, which renaming a root network changes under the table: the entries in hand then match no tag and every root network reads back as applicable. The tag is resolved to the root network uuid on collect, so what the table holds survives a rename; the tag stays a label. Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- .../network-modifications-table.tsx | 10 ++++- .../renderers/root-network-chip-cell.tsx | 27 +++++++------ .../network-modification-table/utils.ts | 38 ++++++++++++++++--- src/utils/types/network-modification-types.ts | 6 +-- 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/features/network-modification-table/network-modifications-table.tsx b/src/features/network-modification-table/network-modifications-table.tsx index caeda9973..361db415a 100644 --- a/src/features/network-modification-table/network-modifications-table.tsx +++ b/src/features/network-modification-table/network-modifications-table.tsx @@ -97,9 +97,17 @@ export function NetworkModificationsTable({ formatToComposedModification(modifications) ); + // The tags are read from a ref on purpose: renaming a root network must not retrigger the collect, or the + // modifications in hand, still carrying the previous tag, would resolve to no root network at all and read + // back as applicable. The next fetch of the modifications brings both sides in step again. + const rootNetworksRef = useRef(rootNetworks); + useEffect(() => { + rootNetworksRef.current = rootNetworks; + }, [rootNetworks]); + const [applicabilities, setApplicabilities] = useState({}); useEffect(() => { - setApplicabilities(collectApplicabilities(composedModifications)); + setApplicabilities(collectApplicabilities(composedModifications, rootNetworksRef.current)); }, [composedModifications]); // composedModificationsRef is used to access composedModifications data from other useEffects diff --git a/src/features/network-modification-table/renderers/root-network-chip-cell.tsx b/src/features/network-modification-table/renderers/root-network-chip-cell.tsx index 0ca136c34..d94fc50ba 100644 --- a/src/features/network-modification-table/renderers/root-network-chip-cell.tsx +++ b/src/features/network-modification-table/renderers/root-network-chip-cell.tsx @@ -19,24 +19,28 @@ import { } from '../../../utils'; /** - * A modification is applicable on a root network unless its applicability for that tag is explicitly false: - * a tag without an entry is applicable. + * A modification is applicable on a root network unless its applicability for it is explicitly false: + * a root network without an entry is applicable. */ -function isApplicableOn(applicabilities: NetworkModificationApplicabilities, modificationUuid: UUID, tag: string) { - return applicabilities[modificationUuid]?.[tag] ?? true; +function isApplicableOn( + applicabilities: NetworkModificationApplicabilities, + modificationUuid: UUID, + rootNetworkUuid: UUID +) { + return applicabilities[modificationUuid]?.[rootNetworkUuid] ?? true; } function withApplicability( applicabilities: NetworkModificationApplicabilities, modificationUuid: UUID, - tag: string, + rootNetworkUuid: UUID, applicable: boolean ): NetworkModificationApplicabilities { return { ...applicabilities, [modificationUuid]: { ...applicabilities[modificationUuid], - [tag]: applicable, + [rootNetworkUuid]: applicable, }, }; } @@ -69,8 +73,8 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { data.type === ModificationType.MODIFICATION_REFERENCE || data.childFromShared; const isModificationActivated = useMemo(() => { - return isApplicableOn(applicabilities, modificationUuid, rootNetwork.tag); - }, [modificationUuid, applicabilities, rootNetwork.tag]); + return isApplicableOn(applicabilities, modificationUuid, rootNetwork.rootNetworkUuid); + }, [modificationUuid, applicabilities, rootNetwork.rootNetworkUuid]); const handleModificationActivationByRootNetwork = useCallback(() => { if (!studyUuid || !currentNodeId) { @@ -83,7 +87,9 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { const newApplicability = !isModificationActivated; // Apply optimistic update - setApplicabilities((prev) => withApplicability(prev, modificationUuid, rootNetwork.tag, newApplicability)); + setApplicabilities((prev) => + withApplicability(prev, modificationUuid, rootNetwork.rootNetworkUuid, newApplicability) + ); // Perform backend call updateModificationStatusByRootNetwork( @@ -96,7 +102,7 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { .catch((error) => { // Rollback on failure to the value shown when the user clicked setApplicabilities((prev) => - withApplicability(prev, modificationUuid, rootNetwork.tag, isModificationActivated) + withApplicability(prev, modificationUuid, rootNetwork.rootNetworkUuid, isModificationActivated) ); snackWithFallback(snackError, error, { headerId: 'modificationActivationByRootNetworkError' }); }) @@ -109,7 +115,6 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { currentNodeId, isModificationActivated, rootNetwork.rootNetworkUuid, - rootNetwork.tag, setApplicabilities, snackError, ]); diff --git a/src/features/network-modification-table/utils.ts b/src/features/network-modification-table/utils.ts index 52821cba5..c67aefb9f 100644 --- a/src/features/network-modification-table/utils.ts +++ b/src/features/network-modification-table/utils.ts @@ -15,24 +15,50 @@ import { NetworkModificationMetadata, ReferencedCompositeModifications, ReferenceModificationInfos, + RootNetworkRowInfo, } from '../../utils'; export const MAX_COMPOSITE_NESTING_DEPTH = 5; -// Indexes by uuid the applicability every modification of the tree carries, sub modifications included. -export function collectApplicabilities( - modifications: ComposedModificationMetadata[] +// Convert applicability by tag to applicability by root network id. +function toApplicabilityByRootNetworkUuid( + applicabilityByRootNetworkTag: Record | undefined, + uuidByTag: Map +): Record { + return Object.entries(applicabilityByRootNetworkTag ?? {}).reduce((applicability, [tag, applicable]) => { + const rootNetworkUuid = uuidByTag.get(tag); + return rootNetworkUuid ? { ...applicability, [rootNetworkUuid]: applicable } : applicability; + }, {}); +} + +function collectApplicabilitiesByUuid( + modifications: ComposedModificationMetadata[], + uuidByTag: Map ): NetworkModificationApplicabilities { - return modifications.reduce( + return modifications.reduce( (applicabilities, modification) => ({ ...applicabilities, - [modification.uuid]: modification.applicabilityByRootNetworkTag ?? {}, - ...collectApplicabilities(modification.subModifications), + [modification.uuid]: toApplicabilityByRootNetworkUuid( + modification.applicabilityByRootNetworkTag, + uuidByTag + ), + ...collectApplicabilitiesByUuid(modification.subModifications, uuidByTag), }), {} ); } +// Indexes by uuid the applicability every modification of the tree carries, sub modifications included. +// The modifications key it by root network tag; it is resolved here to the root network uuid. +// A tag no root network claims is dropped. +export function collectApplicabilities( + modifications: ComposedModificationMetadata[], + rootNetworks: RootNetworkRowInfo[] = [] +): NetworkModificationApplicabilities { + const uuidByTag = new Map(rootNetworks.map((rootNetwork) => [rootNetwork.tag, rootNetwork.rootNetworkUuid])); + return collectApplicabilitiesByUuid(modifications, uuidByTag); +} + // Every ComposedModificationMetadata carries a `rowKey`: a random id generated once when the node // is created, decorrelated from the business `uuid`. It is the ONLY identity used to locate a // specific node's *position* in the tree diff --git a/src/utils/types/network-modification-types.ts b/src/utils/types/network-modification-types.ts index d4f7e8002..386ee4ed0 100644 --- a/src/utils/types/network-modification-types.ts +++ b/src/utils/types/network-modification-types.ts @@ -17,10 +17,10 @@ export type AttributeModification = { }; /** - * Applicability of each network modification per root network tag, as held by the modifications themselves: - * a tag without an entry is applicable. + * Applicability of each network modification per root network, keyed by root network uuid: a root network + * without an entry is applicable. The modifications carry it keyed by tag, which is resolved to the root network uuid. */ -export type NetworkModificationApplicabilities = Record>; +export type NetworkModificationApplicabilities = Record>; export interface RootNetworkRowInfo { rootNetworkUuid: UUID; From bb89cf1048ab47e5518b9ca14d4de99e0c5c5af6 Mon Sep 17 00:00:00 2001 From: Florent MILLOT <75525996+flomillot@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:32:21 +0200 Subject: [PATCH 7/7] Refactor modification applicability logic for clarity and consistency Signed-off-by: Florent MILLOT <75525996+flomillot@users.noreply.github.com> --- .../renderers/root-network-chip-cell.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/features/network-modification-table/renderers/root-network-chip-cell.tsx b/src/features/network-modification-table/renderers/root-network-chip-cell.tsx index d94fc50ba..9bc37c07e 100644 --- a/src/features/network-modification-table/renderers/root-network-chip-cell.tsx +++ b/src/features/network-modification-table/renderers/root-network-chip-cell.tsx @@ -30,16 +30,16 @@ function isApplicableOn( return applicabilities[modificationUuid]?.[rootNetworkUuid] ?? true; } -function withApplicability( - applicabilities: NetworkModificationApplicabilities, +function setApplicability( + prevApplicabilities: NetworkModificationApplicabilities, modificationUuid: UUID, rootNetworkUuid: UUID, applicable: boolean ): NetworkModificationApplicabilities { return { - ...applicabilities, + ...prevApplicabilities, [modificationUuid]: { - ...applicabilities[modificationUuid], + ...prevApplicabilities[modificationUuid], [rootNetworkUuid]: applicable, }, }; @@ -72,7 +72,7 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { const isReferenceModificationOrInsideOne = data.type === ModificationType.MODIFICATION_REFERENCE || data.childFromShared; - const isModificationActivated = useMemo(() => { + const isModificationApplicable = useMemo(() => { return isApplicableOn(applicabilities, modificationUuid, rootNetwork.rootNetworkUuid); }, [modificationUuid, applicabilities, rootNetwork.rootNetworkUuid]); @@ -84,11 +84,11 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { setIsLoading(true); // toggle the current applicability - const newApplicability = !isModificationActivated; + const newApplicability = !isModificationApplicable; // Apply optimistic update setApplicabilities((prev) => - withApplicability(prev, modificationUuid, rootNetwork.rootNetworkUuid, newApplicability) + setApplicability(prev, modificationUuid, rootNetwork.rootNetworkUuid, newApplicability) ); // Perform backend call @@ -102,7 +102,7 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { .catch((error) => { // Rollback on failure to the value shown when the user clicked setApplicabilities((prev) => - withApplicability(prev, modificationUuid, rootNetwork.rootNetworkUuid, isModificationActivated) + setApplicability(prev, modificationUuid, rootNetwork.rootNetworkUuid, isModificationApplicable) ); snackWithFallback(snackError, error, { headerId: 'modificationActivationByRootNetworkError' }); }) @@ -113,7 +113,7 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { modificationUuid, studyUuid, currentNodeId, - isModificationActivated, + isModificationApplicable, rootNetwork.rootNetworkUuid, setApplicabilities, snackError, @@ -123,7 +123,7 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) {