diff --git a/src/components/ui/dialogs/descriptionModificationDialog/DescriptionModificationDialog.tsx b/src/components/ui/dialogs/descriptionModificationDialog/DescriptionModificationDialog.tsx index 8595ae830..e0fbb7216 100644 --- a/src/components/ui/dialogs/descriptionModificationDialog/DescriptionModificationDialog.tsx +++ b/src/components/ui/dialogs/descriptionModificationDialog/DescriptionModificationDialog.tsx @@ -24,6 +24,7 @@ export interface DescriptionModificationDialogProps { onClose: () => void; updateElement?: (data: Record) => Promise; updateForm?: (data: Record) => void; + disabledSave?: boolean; } const schema = yup.object().shape({ @@ -37,6 +38,7 @@ export function DescriptionModificationDialog({ onClose, updateElement, updateForm, + disabledSave, }: Readonly) { const { snackError } = useSnackMessage(); @@ -79,6 +81,7 @@ export function DescriptionModificationDialog({ onSave={onSubmit} formContext={{ ...methods, validationSchema: schema, removeOptional: true }} titleId="description" + disabledSave={disabledSave} > { modifications: NetworkModificationMetadata[]; @@ -54,7 +57,11 @@ interface NetworkModificationsTableProps extends Omit void; onRowDragEnd: () => void; - onSelectedRowsChange: (selectedRows: ComposedModificationMetadata[], isAssemblyDepthExceeded: boolean) => void; + onSelectedRowsChange: ( + selectedRows: ComposedModificationMetadata[], + isAssemblyDepthExceeded: boolean, + containsLockedModification: boolean + ) => void; columns: ColumnDef[]; highlightedModificationUuid: UUID | null; modificationUuidsToReset?: UUID[]; // those modifications are unselected and unexpanded @@ -117,6 +124,13 @@ export function NetworkModificationsTable({ composedModificationsRef.current = composedModifications; }, [composedModifications]); + // Resolved from the whole loaded tree rather than from the node's modifications, so we get all nested references + const referenceModifications = useMemo( + () => collectReferenceModifications(composedModifications), + [composedModifications] + ); + const { readOnlySharedModificationUuids } = useSharedModificationsPermissions(referenceModifications); + // refs are kept for the "event" props to prevent retriggering the associated useEffects const modificationToEditLabelRef = useRef(modificationToEditLabel); useEffect(() => { @@ -137,9 +151,12 @@ export function NetworkModificationsTable({ const handleRowSelected = useCallback( (selectedRows: ComposedModificationMetadata[]) => { - onSelectedRowsChange(selectedRows, isAssemblyDepthExceeded(selectedRows)); + const containsLockedModification = selectedRows.some((row) => + isInLockedSharedModification(row, readOnlySharedModificationUuids) + ); + onSelectedRowsChange(selectedRows, isAssemblyDepthExceeded(selectedRows), containsLockedModification); }, - [onSelectedRowsChange, isAssemblyDepthExceeded] + [onSelectedRowsChange, isAssemblyDepthExceeded, readOnlySharedModificationUuids] ); const { rowSelection, onRowSelectionChange, lastClickedRowId, emitSelection } = useModificationsSelection({ @@ -220,6 +237,9 @@ export function NetworkModificationsTable({ isRowDragDisabled, modificationToEditLabel: modificationToEditLabelRef, }, + permissions: { + readOnlySharedModificationUuids, + }, status: { isImpactedByNotification, notificationMessageId, @@ -240,6 +260,7 @@ export function NetworkModificationsTable({ handleRowSelected, modificationToEditLabelRef, isRowDragDisabled, + readOnlySharedModificationUuids, isImpactedByNotification, notificationMessageId, isFetchingModifications, @@ -287,6 +308,7 @@ export function NetworkModificationsTable({ onDragEnd: onRowDragEnd, studyUuid, currentNodeUuid: currentNodeId, + readOnlySharedModificationUuids, }); // unselect and unexpand all network modifications from modificationUuidsToReset and their sub-modifications @@ -385,6 +407,10 @@ export function NetworkModificationsTable({ handleCellClick={handleCellClick} isRowDragDisabled={isRowDragDisabled} highlightedModificationUuid={highlightedModificationUuid} + isFormOpeningLocked={isInLockedSharedModification( + row.original, + readOnlySharedModificationUuids + )} /> ); })} diff --git a/src/features/network-modification-table/renderers/cell-renderers.tsx b/src/features/network-modification-table/renderers/cell-renderers.tsx index 11a679315..57522835e 100644 --- a/src/features/network-modification-table/renderers/cell-renderers.tsx +++ b/src/features/network-modification-table/renderers/cell-renderers.tsx @@ -18,8 +18,8 @@ import { DescriptionCell } from './description-cell'; import { SwitchCell } from './switch-cell'; import { RootNetworkChipCell } from './root-network-chip-cell'; import { createRootNetworkChipCellSx, networkModificationTableStyles } from '../network-modification-table-styles'; +import { isModificationEditLocked, isReferenceModification } from '../utils'; import { ComposedModificationMetadata } from '../../../utils'; -import { isReferenceModification } from '../utils'; import { ReferenceLinkCell } from './reference-link-cell'; /** @@ -35,6 +35,11 @@ import { ReferenceLinkCell } from './reference-link-cell'; type CCtx = CellContext; type HCtx = HeaderContext; +/** True when the row can't be edited because of the permissions on the shared modification behind it. */ +function isRowEditLocked({ row, table }: CCtx): boolean { + return isModificationEditLocked(row.original, table.options.meta?.permissions.readOnlySharedModificationUuids); +} + export function DragHandleRenderer({ table }: CCtx) { return ; } @@ -60,11 +65,20 @@ export function NameHeaderRenderer({ table }: HCtx) { ); } -export function NameCellRenderer({ row, table, column }: CCtx) { - return ; +export function NameCellRenderer(context: CCtx) { + const { row, table, column } = context; + return ( + + ); } -export function DescriptionCellRenderer({ row, table }: CCtx) { +export function DescriptionCellRenderer(context: CCtx) { + const { row, table } = context; const { meta } = table.options; return ( ); } @@ -84,14 +99,15 @@ export function ReferenceCellRenderer({ row, table }: CCtx) { } return null; } -export function SwitchCellRenderer({ row, table }: CCtx) { +export function SwitchCellRenderer(context: CCtx) { + const { row, table } = context; const { meta } = table.options; return ( ); } @@ -118,7 +134,8 @@ export function RootNetworkHeaderRenderer({ column, table }: HCtx) { ); } -export function RootNetworkCellRenderer({ row, column, table }: CCtx) { +export function RootNetworkCellRenderer(context: CCtx) { + const { row, column, table } = context; const { meta } = table.options; // `column.id` is the rootNetworkUuid (set in createRootNetworksColumns). const rootNetwork = meta?.context.rootNetworks?.find((r) => r.rootNetworkUuid === column.id); @@ -134,7 +151,7 @@ export function RootNetworkCellRenderer({ row, column, table }: CCtx) { rootNetwork={rootNetwork} applicabilities={meta.modifications.applicabilities} setApplicabilities={meta.modifications.setApplicabilities} - isDisabled={meta?.status.isDisabled} + isDisabled={meta?.status.isDisabled || isRowEditLocked(context)} /> ); diff --git a/src/features/network-modification-table/renderers/description-cell.tsx b/src/features/network-modification-table/renderers/description-cell.tsx index 2ed278a0f..c8c0005d6 100644 --- a/src/features/network-modification-table/renderers/description-cell.tsx +++ b/src/features/network-modification-table/renderers/description-cell.tsx @@ -20,10 +20,12 @@ export interface DescriptionCellProps { studyUuid: UUID | null; currentNodeId?: UUID; isDisabled?: boolean; + // the dialog stays reachable to read an existing description, only its validation is denied + isSaveDisabled?: boolean; } export function DescriptionCell(props: DescriptionCellProps) { - const { data, studyUuid, currentNodeId, isDisabled = false } = props; + const { data, studyUuid, currentNodeId, isDisabled = false, isSaveDisabled = false } = props; const [isLoading, setIsLoading] = useState(false); const [openDescModificationDialog, setOpenDescModificationDialog] = useState(false); @@ -52,6 +54,11 @@ export function DescriptionCell(props: DescriptionCellProps) { setOpenDescModificationDialog(true); }, []); + // As the description is empty and we can't update it, we don't want to render the cell and its button + if (empty && isSaveDisabled) { + return null; + } + return ( <> {openDescModificationDialog && modificationUuid && ( @@ -60,6 +67,7 @@ export function DescriptionCell(props: DescriptionCellProps) { description={description ?? ''} onClose={handleDescDialogClose} updateElement={updateModification} + disabledSave={isSaveDisabled} /> )} } arrow enterDelay={250}> diff --git a/src/features/network-modification-table/renderers/name-cell.tsx b/src/features/network-modification-table/renderers/name-cell.tsx index 8e270bb0e..7f79c90e7 100644 --- a/src/features/network-modification-table/renderers/name-cell.tsx +++ b/src/features/network-modification-table/renderers/name-cell.tsx @@ -37,9 +37,10 @@ interface NameCellProps { row: Row; table: Table; onChange?: (modification: ComposedModificationMetadata, newValue: string) => Promise; + isRenameDisabled?: boolean; } -export function NameCell({ row, table, onChange }: Readonly) { +export function NameCell({ row, table, onChange, isRenameDisabled = false }: Readonly) { const { meta } = table.options; const intl = useIntl(); const theme = useTheme(); @@ -49,6 +50,7 @@ export function NameCell({ row, table, onChange }: Readonly) { const { depth } = row; const isComposite = isCompositeModification(row.original); + const isCompositeAndRenamable = isComposite && !isRenameDisabled; const getModificationLabel = useCallback( (modification: ComposedModificationMetadata, formatBold: boolean = true) => { @@ -157,6 +159,7 @@ export function NameCell({ row, table, onChange }: Readonly) { const defaultCompositeName: string = useMemo(() => intl.formatMessage({ id: 'CompositeModification' }), [intl]); // triggers composite name editing from outside the component + // i.e., when a composite is being created useEffect(() => { const modificationToEditLabel = meta?.interaction.modificationToEditLabel.current; if (isComposite && !isEditingRef.current && modificationToEditLabel === row.original.uuid) { @@ -198,7 +201,7 @@ export function NameCell({ row, table, onChange }: Readonly) { )); }; - const compositeReadModeProps = isComposite + const renamableCompositeModeProps = isCompositeAndRenamable ? { ref: labelRef, onClick: handleLabelClick, @@ -274,11 +277,11 @@ export function NameCell({ row, table, onChange }: Readonly) { /* Read mode */ {label} 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 9bc37c07e..17efaeb7e 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,11 +12,11 @@ import { updateModificationStatusByRootNetwork } from '../../../services'; import { useSnackMessage } from '../../../hooks'; import { ComposedModificationMetadata, - ModificationType, NetworkModificationApplicabilities, RootNetworkRowInfo, snackWithFallback, } from '../../../utils'; +import { isReferenceModificationOrInsideOne } from '../utils'; /** * A modification is applicable on a root network unless its applicability for it is explicitly false: @@ -69,8 +69,7 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { const { snackError } = useSnackMessage(); const modificationUuid = data.uuid; - const isReferenceModificationOrInsideOne = - data.type === ModificationType.MODIFICATION_REFERENCE || data.childFromShared; + const isSharedContent = isReferenceModificationOrInsideOne(data); const isModificationApplicable = useMemo(() => { return isApplicableOn(applicabilities, modificationUuid, rootNetwork.rootNetworkUuid); @@ -124,7 +123,7 @@ export function RootNetworkChipCell(props: RootNetworkChipCellProps) { label={rootNetwork.tag} tooltipMessage={rootNetwork.name} isActivated={isModificationApplicable} - isDisabled={isLoading || isDisabled || isReferenceModificationOrInsideOne || rootNetwork.isCreating} + isDisabled={isLoading || isDisabled || isSharedContent || rootNetwork.isCreating} onClick={handleModificationActivationByRootNetwork} /> ); diff --git a/src/features/network-modification-table/row/modification-row.tsx b/src/features/network-modification-table/row/modification-row.tsx index f7d91a623..80ed12ab2 100644 --- a/src/features/network-modification-table/row/modification-row.tsx +++ b/src/features/network-modification-table/row/modification-row.tsx @@ -28,6 +28,8 @@ interface ModificationRowProps { handleCellClick?: (modification: ComposedModificationMetadata) => void; isRowDragDisabled: boolean; highlightedModificationUuid: string | null; + // TODO temporary before GRD-5139 + isFormOpeningLocked?: boolean; } export function ModificationRow({ @@ -36,6 +38,7 @@ export function ModificationRow({ handleCellClick, isRowDragDisabled, highlightedModificationUuid, + isFormOpeningLocked = false, }: Readonly) { const isHighlighted = row.original.uuid === highlightedModificationUuid; const theme = useTheme(); @@ -44,11 +47,11 @@ export function ModificationRow({ const handleCellClickCallback = useCallback( (columnId: string) => { - if (columnId === BASE_MODIFICATION_TABLE_COLUMNS.NAME.id) { + if (columnId === BASE_MODIFICATION_TABLE_COLUMNS.NAME.id && !isFormOpeningLocked) { handleCellClick?.(row.original); } }, - [handleCellClick, row.original] + [handleCellClick, row.original, isFormOpeningLocked] ); return ( diff --git a/src/features/network-modification-table/use-modifications-drag-and-drop.tsx b/src/features/network-modification-table/use-modifications-drag-and-drop.tsx index f41acdf47..397ee10c4 100644 --- a/src/features/network-modification-table/use-modifications-drag-and-drop.tsx +++ b/src/features/network-modification-table/use-modifications-drag-and-drop.tsx @@ -20,7 +20,10 @@ import { containsReferenceModification, findModificationInTree, isCompositeModification, + isInLockedSharedModification, + isModificationEditLocked, isReferenceModification, + isReferenceModificationOrInsideOne, MAX_COMPOSITE_NESTING_DEPTH, moveSubModificationInTree, } from './utils'; @@ -37,6 +40,8 @@ interface UseModificationsDragAndDropParams { onDragEnd: () => void; studyUuid: UUID | null; currentNodeUuid?: UUID; + // uuids of the shared modifications the user can't write into + readOnlySharedModificationUuids?: Set; } interface UseModificationsDragAndDropReturn { @@ -103,6 +108,7 @@ export const useModificationsDragAndDrop = ({ onDragEnd, studyUuid = null, currentNodeUuid = undefined, + readOnlySharedModificationUuids, }: UseModificationsDragAndDropParams): UseModificationsDragAndDropReturn => { const { snackError } = useSnackMessage(); const { rows } = table.getRowModel(); @@ -133,6 +139,23 @@ export const useModificationsDragAndDrop = ({ const isDropForbidden = useCallback( (sourceRow: Row, targetRow: Row): boolean => { + const isDraggingDown = computeIsDraggingDown(sourceRow, targetRow); + const entersTargetRowItself = + (isCompositeModification(targetRow.original) || isReferenceModification(targetRow.original)) && + targetRow.getIsExpanded() && + isDraggingDown; + const enteringParent = entersTargetRowItself ? targetRow.original : targetRow.getParentRow()?.original; + + // Without write rights on a shared modification, its content is frozen: nothing can be taken + // out of it, moved around inside it, nor dropped into it. The shared modification taken as a + // whole stays movable, hence a source tested on its ancestors only. + const movesLockedContent = + isInLockedSharedModification(sourceRow.original, readOnlySharedModificationUuids) || + (!!enteringParent && isModificationEditLocked(enteringParent, readOnlySharedModificationUuids)); + if (movesLockedContent) { + return true; + } + const sourceIsCompositeOrReference = isCompositeModification(sourceRow.original) || isReferenceModification(sourceRow.original); @@ -145,28 +168,21 @@ export const useModificationsDragAndDrop = ({ // GRD-4772 (temporary): a shared modification (reference) cannot be drag-and-dropped // into another shared modification, nor into one of its descendants (expanded children // of the referenced composite). - const isDraggingDown = computeIsDraggingDown(sourceRow, targetRow); - const entersTargetRowItself = - (isCompositeModification(targetRow.original) || isReferenceModification(targetRow.original)) && - targetRow.getIsExpanded() && - isDraggingDown; - const enteringParent = entersTargetRowItself ? targetRow.original : targetRow.getParentRow()?.original; // A reference, or a composite carrying a reference among its (loaded) descendants, // would end up nested under another reference — same forbidden shape either way. const sourceCarriesReference = isReferenceModification(sourceRow.original) || containsReferenceModification(sourceRow.original); - // A composite nested under a reference (directly or transitively) is flagged - // childFromShared, same as the reference's own children — so this also forbids - // dropping into such a composite, not just into the reference itself. - const enteringSharedSubtree = - isReferenceModification(enteringParent) || enteringParent?.childFromShared === true; - const isReferenceIntoReference = sourceCarriesReference && enteringSharedSubtree; + // A composite nested under a reference (directly or transitively) carries that + // reference in its ancestors, same as the reference's own children — so this also + // forbids dropping into such a composite, not just into the reference itself. + const isReferenceIntoReference = + sourceCarriesReference && isReferenceModificationOrInsideOne(enteringParent); return exceedsNestingLimit || isSelfDrop || isReferenceIntoReference; } return false; }, - [computeTargetDepth, computeIsDraggingDown] + [computeTargetDepth, computeIsDraggingDown, readOnlySharedModificationUuids] ); const handleDragUpdate = useCallback( (update: DragUpdate) => { diff --git a/src/features/network-modification-table/use-shared-modifications-permissions.ts b/src/features/network-modification-table/use-shared-modifications-permissions.ts new file mode 100644 index 000000000..76b1fda63 --- /dev/null +++ b/src/features/network-modification-table/use-shared-modifications-permissions.ts @@ -0,0 +1,117 @@ +/* + * 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 { useCallback, useEffect, useState } from 'react'; +import type { UUID } from 'node:crypto'; +import { getAccessibleElements, PermissionType } from '../../services'; +import { equalsArrayAnyOrder, NetworkModificationMetadata } from '../../utils'; +import { DirectoriesNotificationType, NotificationsUrlKeys } from '../../utils/constants/notificationsProvider'; +import { useNotificationsListener } from '../notifications/hooks/useNotificationsListener'; +import { isReferenceModification } from './utils'; + +const EMPTY_UUID_SET: Set = new Set(); + +/** The distinct shared modifications pointed at by the given reference modifications. */ +function getReferenceIds(referenceModifications: NetworkModificationMetadata[]): UUID[] { + return [ + ...new Set( + referenceModifications.map((modification) => modification.referenceId).filter((id) => id !== undefined) + ), + ]; +} + +/** A shared modification is read-only if it does not explicitly have the write permission. */ +function buildReadOnlySharedModificationUuids(referenceIds: UUID[], permissions: Map): Set { + return new Set(referenceIds.filter((id) => !permissions.get(id))); +} + +/** State updater keeping the previous Set when the new one has the same content. */ +function replaceIfChanged(nextUuids: Set) { + return (previousUuids: Set) => + equalsArrayAnyOrder([...previousUuids], [...nextUuids]) ? previousUuids : nextUuids; +} + +/** + * Resolves the write permission the current user has on the shared modifications a node points at. + * + * A reference modification carries the uuid of the shared modification it points at (its `referenceId`), which + * is also the uuid of the corresponding element in the directory - so its permission is the one of the + * directory holding it. + * + * @param modifications a list of modifications we want to check the rights + * @return the uuids of the **shared modifications** (not its references) the user is not allowed to write into + */ +// TODO a permission granted through a group stays cached when the user is added to / removed from that group: +// user-admin-server emits no notification on group membership changes, unlike directory-server on permissions. +// Also consider deleting this hook and using Redux instead if we start using this hook at several places. +export function useSharedModificationsPermissions(modifications: NetworkModificationMetadata[]): { + readOnlySharedModificationUuids: Set; +} { + const [readOnlySharedModificationUuids, setReadOnlySharedModificationUuids] = useState>(EMPTY_UUID_SET); + // referenceId -> has the write permission + const [permissionsCache, setPermissionsCache] = useState>(() => new Map()); + + // The directory server has no notification dedicated to permissions: any change on a directory - including + // the ones on its permissions - is emitted under this single type. See useStudyPath. + // Such a notification may therefore carry a permission change, which would make the cached answers wrong. It + // doesn't tell which elements are affected - only which directory - and an element's directory is unknown + // here, so the whole cache is dropped. + const handleDirectoryNotification = useCallback((event: MessageEvent) => { + const eventData = JSON.parse(event.data); + if (eventData.headers?.notificationType === DirectoriesNotificationType.UPDATE_DIRECTORY) { + setPermissionsCache(new Map()); + } + }, []); + + useNotificationsListener(NotificationsUrlKeys.DIRECTORY, { + listenerCallbackMessage: handleDirectoryNotification, + }); + + useEffect(() => { + let aborted = false; + + const referenceIds = getReferenceIds(modifications.filter(isReferenceModification)); + + // Published before the fetch is even started, and refreshed by the cache update it triggers. An + // unresolved permission counts as read-only, since buildReadOnlySharedModificationUuids keeps the + // ids the cache doesn't answer for: locking a modification that turns out to be writable only lasts + // the time of the call, whereas leaving it open breaches the very rule this hook enforces. + setReadOnlySharedModificationUuids( + replaceIfChanged( + referenceIds.length === 0 + ? EMPTY_UUID_SET + : buildReadOnlySharedModificationUuids(referenceIds, permissionsCache) + ) + ); + + const missingIds = referenceIds.filter((id) => !permissionsCache.has(id)); + if (missingIds.length === 0) { + // no fetch needed + return undefined; + } + + getAccessibleElements(missingIds, PermissionType.WRITE) + .then((accessibleIds) => { + if (aborted) { + return; + } + const accessible = new Set(accessibleIds); + setPermissionsCache((previousCache) => { + const nextCache = new Map(previousCache); + missingIds.forEach((id) => nextCache.set(id, accessible.has(id))); + return nextCache; + }); + }) + .catch((error) => console.error('Failed to resolve the permissions on the shared modifications', error)); + + return () => { + aborted = true; + }; + }, [modifications, permissionsCache]); + + return { readOnlySharedModificationUuids }; +} diff --git a/src/features/network-modification-table/utils.ts b/src/features/network-modification-table/utils.ts index 0a352693f..7e1b29e96 100644 --- a/src/features/network-modification-table/utils.ts +++ b/src/features/network-modification-table/utils.ts @@ -9,6 +9,7 @@ import { Dispatch, SetStateAction } from 'react'; import type { UUID } from 'node:crypto'; import { fetchNetworkModification, getNetworkModificationsFromComposite } from '../../services'; import { + BasicComposedModificationMetadata, ComposedModificationMetadata, MODIFICATION_TYPES, ModificationReferenceInfos, @@ -78,15 +79,22 @@ export function toMessageValues(modification: NetworkModificationMetadata) { return messageValues; } -export function isCompositeModification(modification: ComposedModificationMetadata | undefined) { +export function isCompositeModification(modification: NetworkModificationMetadata | undefined) { return modification?.type === MODIFICATION_TYPES.COMPOSITE_MODIFICATION.type; } // TODO GRD-5250 : Adjust isReferenceModification condition after reference modification types update -export function isReferenceModification(modification: ComposedModificationMetadata | undefined) { +export function isReferenceModification(modification: NetworkModificationMetadata | undefined) { return modification?.type === MODIFICATION_TYPES.MODIFICATION_REFERENCE.type; } +export function collectReferenceModifications(mods: ComposedModificationMetadata[]): ComposedModificationMetadata[] { + return mods.flatMap((mod) => [ + ...(isReferenceModification(mod) ? [mod] : []), + ...collectReferenceModifications(mod.subModifications), + ]); +} + // Only inspects already-loaded subModifications (children fetched on row expansion), so a // reference nested under a not-yet-expanded composite won't be detected. Same limitation as the // rest of the drag-and-drop forbidden-drop checks, which all reason over the currently loaded tree. @@ -99,6 +107,43 @@ export function containsReferenceModification(modification: ComposedModification ); } +export function isReferenceModificationOrInsideOne( + modification: BasicComposedModificationMetadata | undefined +): boolean { + return isReferenceModification(modification) || !!modification?.ancestorSharedModificationUuids?.length; +} + +/** + * Tells whether a modification sits inside a shared modification the user can't write into - whatever it is. + * + * @param modification the row to check + * @param readOnlySharedModificationUuids uuids of the shared modifications the user can't write into + * (a reference modification's referenceId, not the row's own uuid) + */ +export function isInLockedSharedModification( + modification: BasicComposedModificationMetadata, + readOnlySharedModificationUuids: Set | undefined +) { + return !!modification.ancestorSharedModificationUuids?.some((uuid) => readOnlySharedModificationUuids?.has(uuid)); +} + +/** + * Same as isInLockedSharedModification, plus the reference modifications pointing at a shared modification the + * user can't write into. Only for what targets the shared modification itself - renaming a reference or saving + * its description does, moving or deleting it doesn't. + */ +export function isModificationEditLocked( + modification: BasicComposedModificationMetadata, + readOnlySharedModificationUuids: Set | undefined +) { + return ( + isInLockedSharedModification(modification, readOnlySharedModificationUuids) || + (isReferenceModification(modification) && + !!modification.referenceId && + !!readOnlySharedModificationUuids?.has(modification.referenceId)) + ); +} + function normalizeReferenceChild(child: NetworkModificationMetadata): NetworkModificationMetadata { return { ...child, @@ -368,11 +413,13 @@ export async function fetchSubModificationsForExpandedRows( return tree; } const existingMod = findModificationInTree(node.rowKey, tree); - // A composite nested inside a reference is itself flagged childFromShared; - // propagate the flag to its children so they stay non-clickable as well. - const inheritsReference = existingMod?.childFromShared === true; + // A composite nested inside a reference carries the chain of its ancestor references; + // propagate it as-is to its children so they stay locked as well. + const inheritedAncestorUuids = existingMod?.ancestorSharedModificationUuids; const liveModifications = formatToComposedModification(subMods.filter((m) => !m.stashed)).map((m) => - inheritsReference ? { ...m, childFromShared: true } : m + inheritedAncestorUuids?.length + ? { ...m, ancestorSharedModificationUuids: inheritedAncestorUuids } + : m ); // Preserve already-loaded children of any nested composites within the new sub-list. @@ -396,9 +443,13 @@ export async function fetchSubModificationsForExpandedRows( const detail: ModificationReferenceInfos = await res.json(); const children = extractReferenceChildren(detail).filter((m) => !m.stashed); + const ancestorSharedModificationUuids = [ + ...(node.ancestorSharedModificationUuids ?? []), + ...(node.referenceId ? [node.referenceId] : []), + ]; const liveModifications = formatToComposedModification(children).map((m) => ({ ...m, - childFromShared: true, + ancestorSharedModificationUuids, })); setMods((prev) => updateSubModificationsOfACompositeInTree(node.rowKey, liveModifications, prev)); diff --git a/src/module-tanstack.d.ts b/src/module-tanstack.d.ts index f8e5fafe4..eaaa3dc5b 100644 --- a/src/module-tanstack.d.ts +++ b/src/module-tanstack.d.ts @@ -31,6 +31,10 @@ declare module '@tanstack/react-table' { isRowDragDisabled?: boolean; modificationToEditLabel: RefObject; }; + permissions: { + // uuids of the shared modifications the user can't write into + readOnlySharedModificationUuids?: Set; + }; status: { isImpactedByNotification?: () => boolean; notificationMessageId?: string; diff --git a/src/services/directory.ts b/src/services/directory.ts index 8fb615224..7dbe06dc3 100644 --- a/src/services/directory.ts +++ b/src/services/directory.ts @@ -74,11 +74,22 @@ export enum PermissionType { MANAGE = 'MANAGE', } -export function hasElementPermission(elementUuid: UUID, permission: PermissionType) { - const url = `${PREFIX_EXPLORE_SERVER_QUERIES}/v1/explore/elements/${elementUuid}?permission=${permission}`; +/** + * Asks which of the given elements the user has the permission on, in a single call. A directory is checked + * on itself, any other element on its parent directory. + * + * @return the uuids the user has the permission on, the forbidden and the unknown ones being left out + */ +export function getAccessibleElements(elementUuids: UUID[], permission: PermissionType): Promise { + const params = new URLSearchParams({ ids: elementUuids.join(','), accessType: permission }); + const url = `${PREFIX_EXPLORE_SERVER_QUERIES}/v1/explore/elements/accessible?${params.toString()}`; console.debug(url); - return backendFetch(url, { method: 'get' }) - .then((response) => response.status === 200) + return backendFetchJson(url); +} + +export function hasElementPermission(elementUuid: UUID, permission: PermissionType): Promise { + return getAccessibleElements([elementUuid], permission) + .then((accessibleUuids) => accessibleUuids.includes(elementUuid)) .catch(() => { console.info(`${permission} permission denied for element or directory ${elementUuid}`); return false; diff --git a/src/utils/constants/notificationsProvider.ts b/src/utils/constants/notificationsProvider.ts index 4abaad7be..25c956ea2 100644 --- a/src/utils/constants/notificationsProvider.ts +++ b/src/utils/constants/notificationsProvider.ts @@ -14,6 +14,12 @@ export enum NotificationsUrlKeys { DIRECTORY_DELETE_STUDY = 'DIRECTORY_DELETE_STUDY', MONITOR = 'MONITOR', } +export enum DirectoriesNotificationType { + DELETE_DIRECTORY = 'DELETE_DIRECTORY', + ADD_DIRECTORY = 'ADD_DIRECTORY', + UPDATE_DIRECTORY = 'UPDATE_DIRECTORY', +} + export const PREFIX_CONFIG_NOTIFICATION_WS = `${import.meta.env.VITE_WS_GATEWAY}/config-notification`; export const PREFIX_STUDY_NOTIFICATION_WS = `${import.meta.env.VITE_WS_GATEWAY}/study-notification`; export const PREFIX_DIRECTORY_NOTIFICATION_WS = `${import.meta.env.VITE_WS_GATEWAY}/directory-notification`; diff --git a/src/utils/types/network-modification-metadata.ts b/src/utils/types/network-modification-metadata.ts index cc2bd195b..9d5730f07 100644 --- a/src/utils/types/network-modification-metadata.ts +++ b/src/utils/types/network-modification-metadata.ts @@ -17,6 +17,8 @@ export interface NetworkModificationMetadata { description: string; messageType: string; messageValues: string; + // MODIFICATION_REFERENCE only: uuid of the referenced composite modification + referenceId?: UUID; applicabilityByRootNetworkTag?: Record; } @@ -24,7 +26,9 @@ export interface BasicComposedModificationMetadata extends NetworkModificationMe subModifications: ComposedModificationMetadata[]; maxDepth?: number; name?: string; - childFromShared?: boolean; + // The uuids of the shared modifications it sits inside (if it does), outermost first. + // A modification nested under several reference modifications carries them all. + ancestorSharedModificationUuids?: UUID[]; } export interface ComposedModificationMetadata extends BasicComposedModificationMetadata {