diff --git a/plugins/ui/src/js/src/layout/ReactPanel.test.tsx b/plugins/ui/src/js/src/layout/ReactPanel.test.tsx index 7fd8f0fcf..573c11277 100644 --- a/plugins/ui/src/js/src/layout/ReactPanel.test.tsx +++ b/plugins/ui/src/js/src/layout/ReactPanel.test.tsx @@ -146,6 +146,56 @@ it('finds and closes existing panels from the layout root, but opens in the pare }); }); +it('re-attaches a detached parent to the root before opening the panel', () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + // A parent that is detached from the root (parent.parent === null) + const parent = TestUtils.createMockProxy({ parent: null }); + + render( + + {makeTestComponent({ onOpen, onClose })} + + ); + + const { root } = (useLayoutManager as jest.Mock).mock.results[0].value; + + // parent is the topmost detached ancestor; root has no children so addChild is called on root + expect(root.addChild).toHaveBeenCalledWith(parent); + // Panel should still open in the parent stack after re-attachment + expect(LayoutUtils.openComponent).toHaveBeenCalledTimes(1); + expect(LayoutUtils.openComponent).toHaveBeenCalledWith( + expect.objectContaining({ root: parent }) + ); +}); + +it('re-attaches the topmost detached ancestor to the root before opening the panel', () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + // parent is a stack inside a detached row (grandparent.parent === null) + const grandparent = TestUtils.createMockProxy({ parent: null }); + const parent = TestUtils.createMockProxy({ + parent: grandparent, + }); + + render( + + {makeTestComponent({ onOpen, onClose })} + + ); + + const { root } = (useLayoutManager as jest.Mock).mock.results[0].value; + + // The topmost detached ancestor (grandparent) should be re-added, not just parent + expect(root.addChild).toHaveBeenCalledWith(grandparent); + expect(root.addChild).not.toHaveBeenCalledWith(parent); + // Panel should still open in the original parent (not grandparent) + expect(LayoutUtils.openComponent).toHaveBeenCalledTimes(1); + expect(LayoutUtils.openComponent).toHaveBeenCalledWith( + expect.objectContaining({ root: parent }) + ); +}); + it('only calls open once if the panel has not closed and only children change', () => { const onOpen = jest.fn(); const onClose = jest.fn(); diff --git a/plugins/ui/src/js/src/layout/ReactPanel.tsx b/plugins/ui/src/js/src/layout/ReactPanel.tsx index c81e94589..d9442e54f 100644 --- a/plugins/ui/src/js/src/layout/ReactPanel.tsx +++ b/plugins/ui/src/js/src/layout/ReactPanel.tsx @@ -192,7 +192,23 @@ function ReactPanel({ isClosable, }; - // If we didn't find it, we still want to open it in the same place in the layout as before (parent stack) instead of opening at the root + // If we didn't find it, we still want to open it in the same place in the layout as before (parent stack) instead of opening at the root. + // Walk up from parent, tracking the topmost item seen, to determine in one pass whether the parent + // is detached and, if so, which ancestor to re-add. Re-adding the topmost detached ancestor (e.g. a + // row containing multiple stacks) better preserves the layout and ensures stack headers are rendered. + let topDetached: typeof parent = parent; + let currentParent: typeof parent | null = parent.parent; + while (currentParent != null && currentParent !== root) { + topDetached = currentParent; + currentParent = currentParent.parent; + } + if (currentParent === null) { + // currentParent reached null without hitting root, so the parent is detached. + // Root can only have one direct child (a row/column container), so add to that instead. + const rootChild = + root.contentItems.length > 0 ? root.contentItems[0] : root; + rootChild.addChild(topDetached); + } LayoutUtils.openComponent({ root: parent, config }); log.debug('Opened panel', panelId, config); } else if (openedMetadataRef.current !== metadata) {