diff --git a/package-lock.json b/package-lock.json index bb8bcaaa589..fa6f449c82e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44324,6 +44324,7 @@ "mongodb-query-util": "^2.5.3", "polished": "^4.2.2", "react": "^17.0.2", + "react-dom": "^17.0.2", "react-hotkeys-hook": "^4.3.7", "react-intersection-observer": "^8.34.0", "react-virtualized-auto-sizer": "^1.0.6", @@ -44343,7 +44344,6 @@ "chai": "^4.3.4", "mocha": "^10.2.0", "nyc": "^15.1.0", - "react-dom": "^17.0.2", "sinon": "^9.0.0", "typescript": "^5.8.3" } diff --git a/packages/compass-components/package.json b/packages/compass-components/package.json index a03a0c56ddc..c8953364d17 100644 --- a/packages/compass-components/package.json +++ b/packages/compass-components/package.json @@ -91,6 +91,7 @@ "mongodb-query-util": "^2.5.3", "polished": "^4.2.2", "react": "^17.0.2", + "react-dom": "^17.0.2", "react-hotkeys-hook": "^4.3.7", "react-intersection-observer": "^8.34.0", "react-virtualized-auto-sizer": "^1.0.6", @@ -110,7 +111,6 @@ "chai": "^4.3.4", "mocha": "^10.2.0", "nyc": "^15.1.0", - "react-dom": "^17.0.2", "sinon": "^9.0.0", "typescript": "^5.8.3" }, diff --git a/packages/compass-components/src/components/compass-components-provider.tsx b/packages/compass-components/src/components/compass-components-provider.tsx index 88912779b56..ad648ea378c 100644 --- a/packages/compass-components/src/components/compass-components-provider.tsx +++ b/packages/compass-components/src/components/compass-components-provider.tsx @@ -7,6 +7,7 @@ import { SignalHooksProvider } from './signal-popover'; import { RequiredURLSearchParamsProvider } from './links/link'; import { StackedComponentProvider } from '../hooks/use-stacked-component'; import { ContextMenuProvider } from './context-menu'; +import { DrawerContentProvider } from './drawer-portal'; type GuideCueProviderProps = React.ComponentProps; @@ -131,33 +132,35 @@ export const CompassComponentsProvider = ({ darkMode={darkMode} popoverPortalContainer={popoverPortalContainer} > - - - + + - - - - - {typeof children === 'function' - ? children({ - darkMode, - portalContainerRef: setPortalContainer, - scrollContainerRef: setScrollContainer, - }) - : children} - - - - - - - + + + + + + {typeof children === 'function' + ? children({ + darkMode, + portalContainerRef: setPortalContainer, + scrollContainerRef: setScrollContainer, + }) + : children} + + + + + + + + ); }; diff --git a/packages/compass-components/src/components/drawer-portal.tsx b/packages/compass-components/src/components/drawer-portal.tsx new file mode 100644 index 00000000000..b0dfe27b3e8 --- /dev/null +++ b/packages/compass-components/src/components/drawer-portal.tsx @@ -0,0 +1,307 @@ +import ReactDOM from 'react-dom'; +import React, { + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { + DrawerLayout, + DisplayMode as DrawerDisplayMode, + useDrawerToolbarContext, + type DrawerLayoutProps, +} from './drawer'; +import { css, cx } from '@leafygreen-ui/emotion'; +import { isEqual } from 'lodash'; +import { rafraf } from '../utils/rafraf'; + +type SectionData = Required['toolbarData'][number]; + +type DrawerSectionProps = Omit & { + /** + * If `true` will automatically open the section when first mounted. Default: `false` + */ + autoOpen?: boolean; + /** + * Allows to control item oder in the drawer toolbar, items without the order + * provided will stay unordered at the bottom of the list + */ + order?: number; +}; + +type DrawerActionsContextValue = { + current: { + openDrawer: (id: string) => void; + closeDrawer: () => void; + updateToolbarData: (data: DrawerSectionProps) => void; + removeToolbarData: (id: string) => void; + }; +}; + +const DrawerStateContext = React.createContext([]); + +const DrawerActionsContext = React.createContext({ + current: { + openDrawer: () => undefined, + closeDrawer: () => undefined, + updateToolbarData: () => undefined, + removeToolbarData: () => undefined, + }, +}); + +/** + * Drawer component that keeps track of drawer rendering state and provides + * context to all places that require it. Separating it from DrawerAnchor and + * DrawerSection allows to freely move the actual drawer around while allowing + * the whole application access to the Drawer state, not only parts of it + * wrapped in the Drawer + * + * @example + * + * function App() { + * return ( + * + * + * + * + * + * ) + * } + * + * function Content() { + * const [showDrawerSection, setShowDrawerSection] = useState(false); + * return ( + * <> + * + * {showDrawerSection && + * + * This will be rendered inside the drawer + * + * )} + * + * ) + * } + */ +export const DrawerContentProvider: React.FunctionComponent = ({ + children, +}) => { + const [drawerState, setDrawerState] = useState([]); + const drawerActions = useRef({ + openDrawer: () => undefined, + closeDrawer: () => undefined, + updateToolbarData: (data: DrawerSectionProps) => { + setDrawerState((prevState) => { + const itemIndex = prevState.findIndex((item) => { + return item.id === data.id; + }); + if (itemIndex === -1) { + return [...prevState, data]; + } + const newState = [...prevState]; + newState[itemIndex] = data; + return newState; + }); + }, + removeToolbarData: (id: string) => { + setDrawerState((prevState) => { + return prevState.filter((data) => { + return data.id !== id; + }); + }); + }, + }); + + return ( + + + {children} + + + ); +}; + +const DrawerContextGrabber: React.FunctionComponent = ({ children }) => { + const drawerToolbarContext = useDrawerToolbarContext(); + const actions = useContext(DrawerActionsContext); + actions.current.openDrawer = drawerToolbarContext.openDrawer; + actions.current.closeDrawer = drawerToolbarContext.closeDrawer; + return <>{children}; +}; + +// Leafygreen Drawer gets right in the middle of our layout messing up most of +// the expectations for the workspace layouting. We override those to make them +// more flexible +const drawerLayoutFixesStyles = css({ + // content section + '& > div:nth-child(1)': { + display: 'flex', + alignItems: 'stretch', + overflow: 'auto', + }, + + // drawer section + '& > div:nth-child(2)': { + marginTop: -1, // hiding the top border as we already have one in the place where the Anchor is currently rendered + }, +}); + +const emptyDrawerLayoutFixesStyles = css({ + // Otherwise causes a weird content animation when the drawer becomes empty, + // the only way not to have this oterwise is to always keep the drawer toolbar + // on the screen and this eats up precious screen space + transition: 'none', + // Leafygreen removes areas when there are no drawer sections and this just + // completely breaks the grid and messes up the layout + gridTemplateAreas: '"content drawer"', + // Bug in leafygreen where if `toolbarData` becomes empty while the drawer is + // open, it never resets this value to the one that would allow drawer section + // to collapse + gridTemplateColumns: 'auto 0 !important', + + // template-columns 0 doesn't do anything if the content actually takes space, + // so we override the values to hide the drawer toolbar when there's nothing + // to show + '& > div:nth-child(2)': { + width: '0 !important', + overflow: 'hidden', + }, +}); + +const drawerSectionPortalStyles = css({ + minWidth: '100%', + minHeight: '100%', +}); + +/** + * DrawerAnchor component will render the drawer in any place it is rendered. + * This component has to wrap any content that Drawer will be shown near + */ +export const DrawerAnchor: React.FunctionComponent<{ + displayMode?: DrawerDisplayMode; +}> = ({ displayMode, children }) => { + const actions = useContext(DrawerActionsContext); + const drawerSectionItems = useContext(DrawerStateContext); + const prevDrawerSectionItems = useRef([]); + useEffect(() => { + const prevIds = new Set( + prevDrawerSectionItems.current.map((data) => { + return data.id; + }) + ); + for (const item of drawerSectionItems) { + if (!prevIds.has(item.id) && item.autoOpen) { + rafraf(() => { + actions.current.openDrawer(item.id); + }); + } + } + prevDrawerSectionItems.current = drawerSectionItems; + }, [actions, drawerSectionItems]); + const toolbarData = useMemo(() => { + return drawerSectionItems + .map((data) => { + return { + ...data, + content: ( +
+ ), + }; + }) + .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => { + return orderB < orderA ? 1 : orderB > orderA ? -1 : 0; + }); + }, [drawerSectionItems]); + return ( + + {children} + + ); +}; + +/** + * DrawerSection allows to declaratively render sections inside the drawer + * independantly from the Drawer itself + */ +export const DrawerSection: React.FunctionComponent = ({ + children, + ...props +}) => { + const [portalNode, setPortalNode] = useState(null); + const actions = useContext(DrawerActionsContext); + const prevProps = useRef(); + useEffect(() => { + if (!isEqual(prevProps.current, props)) { + actions.current.updateToolbarData({ autoOpen: false, ...props }); + prevProps.current = props; + } + }); + useLayoutEffect(() => { + const drawerEl = document.querySelector( + '.compass-drawer-anchor > div:nth-child(2)' + ); + if (!drawerEl) { + throw new Error( + 'Can not use DrawerSection without DrawerAnchor being mounted on the page' + ); + } + setPortalNode( + document.querySelector(`[data-drawer-section="${props.id}"]`) + ); + const mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of Array.from(mutation.addedNodes) as HTMLElement[]) { + if (node.dataset && node.dataset.drawerSection === props.id) { + setPortalNode(node); + } + } + } + }); + mutationObserver.observe(drawerEl, { + subtree: true, + childList: true, + }); + return () => { + mutationObserver.disconnect(); + }; + }, [actions, props.id]); + useEffect(() => { + return () => { + actions.current.removeToolbarData(props.id); + }; + }, [actions, props.id]); + if (portalNode) { + return ReactDOM.createPortal(children, portalNode); + } + return null; +}; + +export { DrawerDisplayMode }; + +export function useDrawerActions() { + const actions = useContext(DrawerActionsContext); + const stableActions = useRef({ + openDrawer(id: string) { + actions.current.openDrawer(id); + }, + closeDrawer() { + actions.current.closeDrawer(); + }, + }); + return stableActions.current; +} diff --git a/packages/compass-components/src/components/leafygreen.tsx b/packages/compass-components/src/components/leafygreen.tsx index 01fb30c4892..dd75a3543f9 100644 --- a/packages/compass-components/src/components/leafygreen.tsx +++ b/packages/compass-components/src/components/leafygreen.tsx @@ -134,16 +134,6 @@ const TextInput: typeof LeafyGreenTextInput = React.forwardRef( TextInput.displayName = 'TextInput'; -export { - Drawer, - DrawerLayout, - DisplayMode as DrawerDisplayMode, - DrawerStackProvider, - useDrawerStackContext, - useDrawerToolbarContext, - type DrawerLayoutProps, -} from './drawer'; - // 3. Export the leafygreen components. export { AtlasNavGraphic, diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 8981d156a29..10f0e819992 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -219,4 +219,5 @@ export { export { SelectList } from './components/select-list'; export { ParagraphSkeleton } from '@leafygreen-ui/skeleton-loader'; export { InsightsChip } from './components/insights-chip'; +export * from './components/drawer-portal'; export { FileSelector } from './components/file-selector'; diff --git a/packages/compass-data-modeling/src/components/diagram-editor-side-panel.spec.tsx b/packages/compass-data-modeling/src/components/diagram-editor-side-panel.spec.tsx index a4455820639..ec1f493989b 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor-side-panel.spec.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor-side-panel.spec.tsx @@ -21,6 +21,7 @@ import type { MongoDBDataModelDescription, Relationship, } from '../services/data-model-storage'; +import { DrawerAnchor } from '@mongodb-js/compass-components'; async function comboboxSelectItem( label: string, @@ -41,12 +42,21 @@ async function comboboxSelectItem( } describe('DiagramEditorSidePanel', function () { + before(function () { + // TODO(COMPASS-9618): skip in electron runtime for now, drawer has issues rendering + if ((process as any).type === 'renderer') { + this.skip(); + } + }); + function renderDrawer() { const { renderWithConnections } = createPluginTestHelpers( DataModelingWorkspaceTab.provider.withMockServices({}) ); const result = renderWithConnections( - + + + ); result.plugin.store.dispatch( openDiagram(dataModel as MongoDBDataModelDescription) @@ -59,21 +69,26 @@ describe('DiagramEditorSidePanel', function () { expect(screen.queryByTestId('data-modeling-drawer')).to.eq(null); }); - it('should render a collection context drawer when collection is clicked', function () { + it('should render a collection context drawer when collection is clicked', async function () { const result = renderDrawer(); result.plugin.store.dispatch(selectCollection('flights.airlines')); - expect(screen.getByText('flights.airlines')).to.be.visible; + + await waitFor(() => { + expect(screen.getByText('flights.airlines')).to.be.visible; + }); }); - it('should render a relationship context drawer when relations is clicked', function () { + it('should render a relationship context drawer when relations is clicked', async function () { const result = renderDrawer(); result.plugin.store.dispatch( selectRelationship('204b1fc0-601f-4d62-bba3-38fade71e049') ); - const name = screen.getByLabelText('Name'); - expect(name).to.be.visible; - expect(name).to.have.value('Airport Country'); + await waitFor(() => { + const name = screen.getByLabelText('Name'); + expect(name).to.be.visible; + expect(name).to.have.value('Airport Country'); + }); const localCollectionInput = screen.getByLabelText('Local collection'); expect(localCollectionInput).to.be.visible; @@ -108,11 +123,14 @@ describe('DiagramEditorSidePanel', function () { ).to.be.visible; }); - it('should change the content of the drawer when selecting different items', function () { + it('should change the content of the drawer when selecting different items', async function () { const result = renderDrawer(); result.plugin.store.dispatch(selectCollection('flights.airlines')); - expect(screen.getByText('flights.airlines')).to.be.visible; + + await waitFor(() => { + expect(screen.getByText('flights.airlines')).to.be.visible; + }); result.plugin.store.dispatch( selectCollection('flights.airports_coordinates_for_schema') @@ -146,6 +164,10 @@ describe('DiagramEditorSidePanel', function () { const result = renderDrawer(); result.plugin.store.dispatch(selectCollection('flights.countries')); + await waitFor(() => { + expect(screen.getByText('flights.countries')).to.be.visible; + }); + // Open relationshipt editing form const relationshipCard = document.querySelector( '[data-relationship-id="204b1fc0-601f-4d62-bba3-38fade71e049"]' diff --git a/packages/compass-data-modeling/src/components/diagram-editor-side-panel.tsx b/packages/compass-data-modeling/src/components/diagram-editor-side-panel.tsx index 956d3f3e1a8..a01b194a30c 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor-side-panel.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor-side-panel.tsx @@ -1,26 +1,12 @@ import React from 'react'; import { connect } from 'react-redux'; import type { DataModelingState } from '../store/reducer'; -import { - Button, - css, - cx, - palette, - useDarkMode, -} from '@mongodb-js/compass-components'; +import { DrawerSection } from '@mongodb-js/compass-components'; import CollectionDrawerContent from './collection-drawer-content'; import RelationshipDrawerContent from './relationship-drawer-content'; import { closeDrawer } from '../store/diagram'; -const containerStyles = css({ - width: '400px', - height: '100%', - borderLeft: `1px solid ${palette.gray.light2}`, -}); - -const darkModeContainerStyles = css({ - borderLeftColor: palette.gray.dark2, -}); +export const DATA_MODELING_DRAWER_ID = 'data-modeling-drawer'; type DiagramEditorSidePanelProps = { selectedItems: { type: 'relationship' | 'collection'; id: string } | null; @@ -29,10 +15,7 @@ type DiagramEditorSidePanelProps = { function DiagmramEditorSidePanel({ selectedItems, - onClose, }: DiagramEditorSidePanelProps) { - const isDarkMode = useDarkMode(); - if (!selectedItems) { return null; } @@ -54,15 +37,21 @@ function DiagmramEditorSidePanel({ } return ( -
{content} - -
+
); } diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx index 636b0c570a2..c81dc0b92dd 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx @@ -29,6 +29,7 @@ import { Button, useDarkMode, InlineDefinition, + useDrawerActions, } from '@mongodb-js/compass-components'; import { cancelAnalysis, retryAnalysis } from '../store/analysis-process'; import { @@ -42,6 +43,7 @@ import type { Relationship, StaticModel } from '../services/data-model-storage'; import DiagramEditorToolbar from './diagram-editor-toolbar'; import ExportDiagramModal from './export-diagram-modal'; import { useLogger } from '@mongodb-js/compass-logging/provider'; +import { DATA_MODELING_DRAWER_ID } from './diagram-editor-side-panel'; const loadingContainerStyles = css({ width: '100%', @@ -247,6 +249,7 @@ const DiagramEditor: React.FunctionComponent<{ const diagramContainerRef = useRef(null); const diagram = useDiagram(); const [areNodesReady, setAreNodesReady] = useState(false); + const { openDrawer } = useDrawerActions(); const setDiagramContainerRef = useCallback( (ref: HTMLDivElement | null) => { @@ -403,10 +406,12 @@ const DiagramEditor: React.FunctionComponent<{ return; } onCollectionSelect(node.id); + openDrawer(DATA_MODELING_DRAWER_ID); }} onPaneClick={onDiagramBackgroundClicked} onEdgeClick={(_evt, edge) => { onRelationshipSelect(edge.id); + openDrawer(DATA_MODELING_DRAWER_ID); }} fitViewOptions={{ maxZoom: 1, diff --git a/packages/compass-data-modeling/src/components/relationship-drawer-content.tsx b/packages/compass-data-modeling/src/components/relationship-drawer-content.tsx index 2651f8c7f79..845c78ec49d 100644 --- a/packages/compass-data-modeling/src/components/relationship-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/relationship-drawer-content.tsx @@ -55,10 +55,6 @@ const formFieldContainerStyles = css({ marginTop: spacing[400], }); -const containerStyles = css({ - padding: spacing[400], -}); - const accordionTitleStyles = css({ fontSize: spacing[300], color: palette.gray.dark1, @@ -188,7 +184,7 @@ const RelationshipDrawerContent: React.FunctionComponent< }, [fields, foreignCollection]); return ( -
+
= ({ >
- {activeTab && workspaceTabContent ? ( - workspaceTabContent - ) : ( - - )} + + {activeTab && workspaceTabContent ? ( + workspaceTabContent + ) : ( + + )} +
);