diff --git a/package-lock.json b/package-lock.json index 3e78a2c1ece..de2f94a65a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63150,6 +63150,7 @@ "@react-stately/tooltip": "^3.0.5", "@types/lodash": "^4.14.172", "bson": "^4.6.1", + "focus-trap-react": "^8.4.2", "hadron-document": "^8.0.0", "hadron-type-checker": "^7.0.0", "lodash": "^4.17.21", @@ -103496,6 +103497,7 @@ "bson": "^4.6.1", "chai": "^4.3.4", "eslint": "^7.25.0", + "focus-trap-react": "^8.4.2", "hadron-document": "^8.0.0", "hadron-type-checker": "^7.0.0", "lodash": "^4.17.21", diff --git a/packages/compass-collection/src/stores/context.tsx b/packages/compass-collection/src/stores/context.tsx index f0cb8c58fee..5166bb2f062 100644 --- a/packages/compass-collection/src/stores/context.tsx +++ b/packages/compass-collection/src/stores/context.tsx @@ -79,6 +79,11 @@ type ContextProps = { connectionString?: string; }; +type ContextWithAppRegistry = ContextProps & { + globalAppRegistry: AppRegistry; + localAppRegistry: AppRegistry; +}; + /** * Setup a scoped store to the collection. * @@ -115,7 +120,7 @@ const setupStore = ({ query, aggregation, connectionString, -}: ContextProps) => { +}: ContextWithAppRegistry) => { const store = role.configureStore({ localAppRegistry, globalAppRegistry, @@ -138,7 +143,7 @@ const setupStore = ({ aggregation, connectionString, }); - localAppRegistry?.registerStore(role.storeName, store); + localAppRegistry.registerStore(role.storeName, store); return store; }; @@ -176,7 +181,7 @@ const setupPlugin = ({ sourceName, connectionString, key, -}: ContextProps) => { +}: ContextWithAppRegistry) => { const actions = role.configureActions(); const store = setupStore({ role, @@ -232,7 +237,7 @@ const setupScopedModals = ({ isFLE, sourceName, connectionString, -}: ContextProps) => { +}: ContextWithAppRegistry) => { const roles = globalAppRegistry?.getRole('Collection.ScopedModal'); if (roles) { return roles.map((role: any, i: number) => { @@ -257,6 +262,71 @@ const setupScopedModals = ({ return []; }; +/** + * Setup the query bar plugins. Need to instantiate the store and actions + * and put them in the app registry for use by all the plugins. This way + * there is only 1 query bar store per collection tab instead of one per + * plugin that uses it. + */ +const setupQueryPlugins = ({ + globalAppRegistry, + localAppRegistry, + serverVersion, + state, + namespace, + isReadonly, + isTimeSeries, + isClustered, + isFLE, + query, + aggregation, +}: ContextWithAppRegistry): void => { + const queryBarRole = globalAppRegistry.getRole('Query.QueryBar')?.[0]; + if (queryBarRole) { + localAppRegistry.registerRole('Query.QueryBar', queryBarRole); + const queryBarActions = setupActions(queryBarRole, localAppRegistry); + setupStore({ + role: queryBarRole, + globalAppRegistry, + localAppRegistry, + dataService: state.dataService, + namespace, + serverVersion, + isReadonly, + isTimeSeries, + isClustered, + isFLE, + actions: queryBarActions, + query, + aggregation, + }); + } + + const queryHistoryRole = globalAppRegistry.getRole('Query.QueryHistory')?.[0]; + if (process?.env?.COMPASS_SHOW_NEW_TOOLBARS === 'true' && queryHistoryRole) { + localAppRegistry.registerRole('Query.QueryHistory', queryHistoryRole); + const queryHistoryActions = setupActions( + queryHistoryRole, + localAppRegistry + ); + setupStore({ + role: queryHistoryRole, + globalAppRegistry, + localAppRegistry, + dataService: state.dataService, + namespace, + serverVersion, + isReadonly, + isTimeSeries, + isClustered, + isFLE, + actions: queryHistoryActions, + query, + aggregation, + }); + } +}; + /** * Create the context in which a tab is created. * @@ -306,25 +376,16 @@ const createContext = ({ const views: JSX.Element[] = []; const queryHistoryIndexes: number[] = []; - // Setup the query bar plugin. Need to instantiate the store and actions - // and put them in the app registry for use by all the plugins. This way - // there is only 1 query bar store per collection tab instead of one per - // plugin that uses it. - const queryBarRole = globalAppRegistry.getRole('Query.QueryBar')[0]; - localAppRegistry.registerRole('Query.QueryBar', queryBarRole); - const queryBarActions = setupActions(queryBarRole, localAppRegistry); - setupStore({ - role: queryBarRole, + setupQueryPlugins({ globalAppRegistry, localAppRegistry, - dataService: state.dataService, - namespace, serverVersion, + state, + namespace, isReadonly, isTimeSeries, isClustered, isFLE, - actions: queryBarActions, query, aggregation, }); diff --git a/packages/compass-components/package.json b/packages/compass-components/package.json index 42d16f788ba..aaedb9b2d84 100644 --- a/packages/compass-components/package.json +++ b/packages/compass-components/package.json @@ -73,6 +73,7 @@ "@react-stately/tooltip": "^3.0.5", "@types/lodash": "^4.14.172", "bson": "^4.6.1", + "focus-trap-react": "^8.4.2", "hadron-document": "^8.0.0", "hadron-type-checker": "^7.0.0", "lodash": "^4.17.21", diff --git a/packages/compass-components/src/components/interactive-popover.spec.tsx b/packages/compass-components/src/components/interactive-popover.spec.tsx new file mode 100644 index 00000000000..ce16703127b --- /dev/null +++ b/packages/compass-components/src/components/interactive-popover.spec.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { render, screen, cleanup } from '@testing-library/react'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { InteractivePopover } from './interactive-popover'; + +const innerContentTestId = 'testing-inner-content'; + +function renderPopover( + props?: Partial> +) { + const openSpy = sinon.spy(); + + const popoverContent = ({ onClose }) => ( + <> + +
inner content
+ + + ); + + return render( + ( + <> + + {children} + + )} + {...props} + > + {popoverContent} + + ); +} + +describe('InteractivePopover Component', function () { + afterEach(function () { + cleanup(); + }); + + it('when open it should show the popover content', function () { + renderPopover({ + open: true, + }); + expect(screen.getByTestId(innerContentTestId)).to.be.visible; + }); + + it('when closed it should not show the popover content', function () { + renderPopover({ + open: false, + }); + expect(screen.queryByTestId(innerContentTestId)).to.not.exist; + }); + + it('should render the trigger', function () { + renderPopover({ + open: false, + }); + const button = screen.getByRole('button'); + expect(button).to.be.visible; + expect(screen.getByText('Trigger Button Text')).to.be.visible; + }); + + it('when closed and the trigger is clicked it should call to open', function () { + const openSpy = sinon.fake(); + + renderPopover({ + open: false, + setOpen: openSpy, + }); + expect(openSpy.calledOnce).to.be.false; + + const button = screen.getByText('Trigger Button Text'); + button.click(); + expect(openSpy.calledOnce).to.be.true; + expect(openSpy.firstCall.firstArg).to.equal(true); + }); + + it('when open and the trigger is clicked it should call to close', function () { + const openSpy = sinon.fake(); + + renderPopover({ + open: true, + setOpen: openSpy, + }); + expect(openSpy.calledOnce).to.be.false; + + const button = screen.getByText('Trigger Button Text'); + button.click(); + expect(openSpy.calledOnce).to.be.true; + expect(openSpy.firstCall.firstArg).to.equal(false); + }); +}); diff --git a/packages/compass-components/src/components/interactive-popover.tsx b/packages/compass-components/src/components/interactive-popover.tsx new file mode 100644 index 00000000000..d7e455b2d55 --- /dev/null +++ b/packages/compass-components/src/components/interactive-popover.tsx @@ -0,0 +1,141 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { css } from '@leafygreen-ui/emotion'; +import FocusTrap from 'focus-trap-react'; + +import { Popover } from './leafygreen'; + +const contentContainerStyles = css({ + display: 'flex', + height: '100%', +}); + +type InteractivePopoverProps = { + className: string; + children: (childrenProps: { onClose: () => void }) => React.ReactElement; + trigger: (triggerProps: { + onClick: (event: React.MouseEvent | React.TouchEvent) => void; + ref: React.RefObject; + children: React.ReactNode; + }) => React.ReactElement; + open: boolean; + setOpen: (open: boolean) => void; +}; + +function InteractivePopover({ + className, + children, + trigger, + open, + setOpen, +}: InteractivePopoverProps): React.ReactElement { + const triggerRef = useRef(null); + const popoverContentContainerRef = useRef(null); + + const onClose = useCallback(() => { + setOpen(false); + + // Return focus to the trigger when the popover is hidden. + setTimeout(() => { + triggerRef.current?.focus(); + }); + }, [setOpen]); + + const onClickTrigger = useCallback(() => { + if (open) { + onClose(); + return; + } + + setOpen(!open); + }, [open, setOpen, onClose]); + + // When the popover is open, close it when an item that isn't the popover + // is clicked. + useEffect(() => { + if (!open) { + return; + } + + const clickEventListener = (event: MouseEvent | TouchEvent) => { + // Ignore clicks on the popover. + if ( + !popoverContentContainerRef.current || + popoverContentContainerRef.current.contains(event.target as Node) + ) { + return; + } + // Ignore clicks on the trigger as it has its own handler. + if ( + !triggerRef.current || + triggerRef.current.contains(event.target as Node) + ) { + return; + } + + onClose(); + }; + window.addEventListener('mousedown', clickEventListener); + window.addEventListener('touchstart', clickEventListener); + return () => { + window.removeEventListener('mousedown', clickEventListener); + window.removeEventListener('touchstart', clickEventListener); + }; + }, [open, onClose]); + + const onPopoverKeyDown = useCallback( + (evt: KeyboardEvent) => { + if (evt.key === 'Escape') { + onClose(); + return; + } + }, + [onClose] + ); + + useEffect(() => { + if (!open) { + return; + } + document.addEventListener('keydown', onPopoverKeyDown); + + return () => { + document.removeEventListener('keydown', onPopoverKeyDown); + }; + }, [onPopoverKeyDown, open]); + + return trigger({ + onClick: onClickTrigger, + ref: triggerRef, + children: ( + + {open && ( + +
+ {children({ + onClose: onClose, + })} +
+
+ )} +
+ ), + }); +} + +export { InteractivePopover }; diff --git a/packages/compass-components/src/components/leafygreen.tsx b/packages/compass-components/src/components/leafygreen.tsx index 2bb25517b0b..d1a21a5543a 100644 --- a/packages/compass-components/src/components/leafygreen.tsx +++ b/packages/compass-components/src/components/leafygreen.tsx @@ -25,6 +25,7 @@ import { default as LeafyGreenModal, Footer as LeafyGreenModalFooter, } from '@leafygreen-ui/modal'; +import Popover from '@leafygreen-ui/popover'; import { RadioBox, RadioBoxGroup } from '@leafygreen-ui/radio-box-group'; import { Radio, @@ -160,6 +161,7 @@ export { ModalFooter, MongoDBLogoMark, MongoDBLogo, + Popover, RadioBox, RadioBoxGroup, Radio, diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index fbd2412925c..d9797f70f67 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -119,6 +119,7 @@ export { ErrorBoundary } from './components/error-boundary'; export { TabNavBar } from './components/tab-nav-bar'; export { WorkspaceContainer } from './components/workspace-container'; export { InlineInfoLink } from './components/inline-info-link'; +export { InteractivePopover } from './components/interactive-popover'; export { Placeholder } from './components/placeholder'; export { useDOMRect } from './hooks/use-dom-rect'; export { VirtualGrid } from './components/virtual-grid'; diff --git a/packages/compass-query-bar/src/components/option-editor.tsx b/packages/compass-query-bar/src/components/option-editor.tsx index b5537c87a4e..1e69ce899ca 100644 --- a/packages/compass-query-bar/src/components/option-editor.tsx +++ b/packages/compass-query-bar/src/components/option-editor.tsx @@ -35,7 +35,7 @@ const editorStyles = cx( border: `1px solid ${uiColors.gray.light2}`, borderRadius: '4px', overflow: 'visible', - background: 'transparent', + background: uiColors.white, '&:hover': { '&::after': { boxShadow: `0 0 0 3px ${uiColors.gray.light2}`, diff --git a/packages/compass-query-bar/src/components/query-bar.spec.tsx b/packages/compass-query-bar/src/components/query-bar.spec.tsx index 54d79894a28..8536a608983 100644 --- a/packages/compass-query-bar/src/components/query-bar.spec.tsx +++ b/packages/compass-query-bar/src/components/query-bar.spec.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import { expect } from 'chai'; import sinon from 'sinon'; import type { SinonSpy } from 'sinon'; +import AppRegistry from 'hadron-app-registry'; import { QueryBar } from './query-bar'; @@ -37,14 +38,43 @@ const queryOptionProps = { const exportToLanguageButtonId = 'query-bar-open-export-to-language-button'; const queryHistoryButtonId = 'query-history-button'; +const queryHistoryComponentTestId = 'query-history-component-test-id'; + +const mockQueryHistoryRole = { + name: 'Query History', + // eslint-disable-next-line react/display-name + component: () => ( +
+
Query history
+ +
+ ), + configureStore: () => ({}), + configureActions: () => {}, + storeName: 'Query.History', + actionName: 'Query.History.Actions', +}; const renderQueryBar = ( props: Partial> = {} ) => { + const globalAppRegistry = new AppRegistry(); + globalAppRegistry.registerRole('Query.QueryHistory', mockQueryHistoryRole); + + const localAppRegistry = new AppRegistry(); + localAppRegistry.registerStore('Query.History', { + onActivated: noop, + }); + localAppRegistry.registerAction('Query.History.Actions', { + actions: true, + }); + render( void; + onChangeQueryOption: (queryOption: QueryOption, value: string) => void; + onOpenExportToLanguage: () => void; + onReset: () => void; queryOptions?: ( | 'project' | 'sort' @@ -66,37 +55,35 @@ type QueryBarProps = { | 'limit' | 'maxTimeMS' )[]; - onApply: () => void; - onChangeQueryOption: (queryOption: QueryOption, value: string) => void; - onOpenExportToLanguage: () => void; - onReset: () => void; queryState: 'apply' | 'reset'; refreshEditorAction: Listenable; + resultId: string | number; schemaFields: string[]; serverVersion: string; showExportToLanguageButton?: boolean; showQueryHistoryButton?: boolean; toggleExpandQueryOptions: () => void; - toggleQueryHistory: () => void; valid: boolean; } & QueryBarOptionProps; export const QueryBar: React.FunctionComponent = ({ buttonLabel = 'Apply', expanded: isQueryOptionsExpanded = false, - queryOptions = ['project', 'sort', 'collation', 'skip', 'limit', 'maxTimeMS'], + globalAppRegistry, + localAppRegistry, onApply: _onApply, onChangeQueryOption, onOpenExportToLanguage, onReset, + queryOptions = ['project', 'sort', 'collation', 'skip', 'limit', 'maxTimeMS'], queryState, refreshEditorAction, + resultId, schemaFields, serverVersion, showExportToLanguageButton = true, showQueryHistoryButton = true, toggleExpandQueryOptions, - toggleQueryHistory, valid: isQueryValid, ...queryOptionProps }) => { @@ -116,20 +103,19 @@ export const QueryBar: React.FunctionComponent = ({ ); return ( -
+
{showQueryHistoryButton && ( - + )}
= ({ variant="primary" size="small" type="submit" + onClick={onFormSubmit} > {buttonLabel} diff --git a/packages/compass-query-bar/src/components/query-history-button-popover.tsx b/packages/compass-query-bar/src/components/query-history-button-popover.tsx new file mode 100644 index 00000000000..e6c5c504318 --- /dev/null +++ b/packages/compass-query-bar/src/components/query-history-button-popover.tsx @@ -0,0 +1,110 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { + Icon, + InteractivePopover, + css, + cx, + focusRingStyles, + focusRingVisibleStyles, + spacing, +} from '@mongodb-js/compass-components'; +import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; +import type AppRegistry from 'hadron-app-registry'; + +const { track } = createLoggerAndTelemetry('COMPASS-QUERY-BAR-UI'); + +const openQueryHistoryButtonStyles = cx( + css({ + border: 'none', + backgroundColor: 'transparent', + display: 'inline-flex', + alignItems: 'center', + padding: spacing[2] - 2, // -2px for border. + '&:hover': { + cursor: 'pointer', + }, + '&:focus': focusRingVisibleStyles, + }), + focusRingStyles +); + +const queryHistoryPopoverStyles = css({ + // We want the popover to open almost to the shell at the bottom of Compass. + maxHeight: 'calc(100vh - 270px)', + display: 'flex', + marginLeft: -spacing[2] - 1, // Align to the left of the query bar. +}); + +type QueryHistoryProps = { + globalAppRegistry: AppRegistry; + localAppRegistry: AppRegistry; +}; + +export const QueryHistoryButtonPopover: React.FunctionComponent< + QueryHistoryProps +> = ({ globalAppRegistry, localAppRegistry }) => { + const queryHistoryRef = useRef<{ + component?: React.ComponentType; + store: any; // Query history store is not currently typed. + actions: any; // Query history actions are not typed. + }>({ + component: globalAppRegistry.getRole('Query.QueryHistory')?.[0].component, + store: localAppRegistry.getStore('Query.History'), + actions: localAppRegistry.getAction('Query.History.Actions'), + }); + const [showQueryHistory, setShowQueryHistory] = useState(false); + + const onSetShowQueryHistory = useCallback( + (newShowQueryHistory: boolean) => { + if (newShowQueryHistory) { + track('Query History Opened'); + } + + setShowQueryHistory(newShowQueryHistory); + }, + [setShowQueryHistory] + ); + + const QueryHistoryComponent = queryHistoryRef.current.component; + + if (!QueryHistoryComponent) { + return null; + } + + const popoverContent = ({ onClose }: { onClose: () => void }) => ( + + ); + + return ( + ( + <> + + {children} + + )} + open={showQueryHistory} + setOpen={onSetShowQueryHistory} + > + {popoverContent} + + ); +}; diff --git a/packages/compass-query-bar/src/plugin.jsx b/packages/compass-query-bar/src/plugin.jsx index 06c595f9531..8e7957fa507 100644 --- a/packages/compass-query-bar/src/plugin.jsx +++ b/packages/compass-query-bar/src/plugin.jsx @@ -41,7 +41,8 @@ function Plugin({ onOpenExportToLanguage={actions.exportToLanguage} refreshEditorAction={actions.refreshEditor} toggleExpandQueryOptions={actions.toggleQueryOptions} - toggleQueryHistory={actions.toggleQueryHistory} + globalAppRegistry={store.globalAppRegistry} + localAppRegistry={store.localAppRegistry} {...props} /> ) : ( @@ -61,6 +62,8 @@ Plugin.propTypes = { actions: PropTypes.object.isRequired, onApply: PropTypes.func, onReset: PropTypes.func, + queryOptions: PropTypes.arrayOf(PropTypes.string), + resultId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), showExportToLanguageButton: PropTypes.bool, showQueryHistoryButton: PropTypes.bool, store: PropTypes.object.isRequired, diff --git a/packages/compass-query-history/src/components/query-history/query-history.jsx b/packages/compass-query-history/src/components/query-history/query-history.jsx index 5b048aafbc7..1d572a6ff99 100644 --- a/packages/compass-query-history/src/components/query-history/query-history.jsx +++ b/packages/compass-query-history/src/components/query-history/query-history.jsx @@ -18,6 +18,9 @@ class QueryHistory extends PureComponent { actions: PropTypes.object.isRequired, store: PropTypes.object.isRequired, showing: PropTypes.oneOf(['recent', 'favorites']), + // TODO(COMPASS-5679): After we enable the toolbars feature flag, + // we can remove the collapsed boolean and make `onClose` required. + onClose: PropTypes.func, collapsed: PropTypes.bool, ns: PropTypes.object, }; @@ -59,16 +62,22 @@ class QueryHistory extends PureComponent { ); render() { - const { collapsed, showing, actions } = this.props; + const { collapsed, showing, onClose, actions } = this.props; - if (collapsed) { + if (!onClose && collapsed) { + // TODO(COMPASS-5679): After we enable the toolbars feature flag, + // we can remove the collapsed boolean and make `onClose` required. + // And remove this condition. return null; } return ( -
+
- + {showing === 'favorites' ? this.renderFavorites() : null} {showing === 'recent' ? this.renderRecents() : null} diff --git a/packages/compass-query-history/src/components/query-history/query-history.module.less b/packages/compass-query-history/src/components/query-history/query-history.module.less index d0f9c37bdf0..d4e71f6298d 100644 --- a/packages/compass-query-history/src/components/query-history/query-history.module.less +++ b/packages/compass-query-history/src/components/query-history/query-history.module.less @@ -7,7 +7,7 @@ } } -.component { +.component-legacy { display: flex; flex-direction: column; flex: 1; @@ -20,6 +20,17 @@ order: 3; } +.component { + display: flex; + flex-direction: column; + flex: 1; + background-color: #f5f6f7; + border: 1px solid #dddddd; + width: 325px; + height: 100%; + box-shadow: rgba(0, 30, 43, 0.3) 0px 4px 10px -4px; +} + .inner { display: flex; flex-direction: column; diff --git a/packages/compass-query-history/src/components/toolbar/toolbar.tsx b/packages/compass-query-history/src/components/toolbar/toolbar.tsx index ea3ad3755c7..8257fe79ce9 100644 --- a/packages/compass-query-history/src/components/toolbar/toolbar.tsx +++ b/packages/compass-query-history/src/components/toolbar/toolbar.tsx @@ -10,6 +10,9 @@ import { spacing, useId, } from '@mongodb-js/compass-components'; +import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; + +const { track } = createLoggerAndTelemetry('COMPASS-QUERY-HISTORY-UI'); const toolbarStyles = css({ display: 'flex', @@ -38,12 +41,14 @@ type ToolbarProps = { showFavorites: () => void; collapse: () => void; }; // Query history actions are not currently typed. + onClose?: () => void; showing: 'recent' | 'favorites'; }; const Toolbar: React.FunctionComponent = ({ actions, showing, + onClose, }) => { const onViewSwitch = useCallback( (label: 'recent' | 'favorites') => { @@ -60,6 +65,13 @@ const Toolbar: React.FunctionComponent = ({ actions.collapse(); }, [actions]); + // TODO(COMPASS-5679): After we enable the feature flag, + // we can remove the collapsed handler and make `onClose` default required. + const onClickClose = useCallback(() => { + track('Query History Closed'); + onClose?.(); + }, [onClose]); + const labelId = useId(); const controlId = useId(); @@ -95,7 +107,7 @@ const Toolbar: React.FunctionComponent = ({ diff --git a/packages/compass-query-history/src/index.ts b/packages/compass-query-history/src/index.ts index 32ce6045b4b..92bc03e6961 100644 --- a/packages/compass-query-history/src/index.ts +++ b/packages/compass-query-history/src/index.ts @@ -14,6 +14,7 @@ const ROLE = { configureStore: configureStore, configureActions: configureActions, storeName: 'Query.History', + actionName: 'Query.History.Actions', }; /** @@ -21,7 +22,11 @@ const ROLE = { * @param {Object} appRegistry - The Hadron appRegisrty to activate this plugin with. **/ function activate(appRegistry: AppRegistry): void { + // TODO(COMPASS-5679): After we enable the toolbars feature flag, + // we can remove the ScopedModal role for this plugin as it's no longer used. appRegistry.registerRole('Collection.ScopedModal', ROLE); + + appRegistry.registerRole('Query.QueryHistory', ROLE); } /** @@ -30,6 +35,8 @@ function activate(appRegistry: AppRegistry): void { **/ function deactivate(appRegistry: AppRegistry): void { appRegistry.deregisterRole('Collection.ScopedModal', ROLE); + + appRegistry.deregisterRole('Query.QueryHistory', ROLE); } export default QueryHistoryPlugin;