From 5139e25a731570ada1211d40554720abafa999d2 Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 5 Aug 2025 12:31:01 +0200 Subject: [PATCH 1/6] chore(compass-telemetry): add telemetry events for Compass --- configs/eslint-config-compass/index.js | 3 +- .../compass-components-provider.tsx | 25 +++- .../src/components/context-menu.spec.tsx | 14 +- .../src/components/context-menu.tsx | 134 +++++++++++------- .../src/components/document-list/element.tsx | 65 +++++---- .../src/components/workspace-tabs/tab.tsx | 13 +- packages/compass-components/src/index.ts | 1 - .../src/connections-navigation-tree.tsx | 3 + .../src/context-menus.ts | 10 +- .../src/navigation-item.tsx | 4 +- .../src/virtual-list/virtual-list.tsx | 13 +- .../src/context-menu-provider.tsx | 15 +- packages/compass-context-menu/src/types.ts | 17 ++- .../src/use-context-menu.spec.tsx | 50 ++++++- .../src/use-context-menu.tsx | 10 +- .../src/components/crud-toolbar.tsx | 127 +++++++++-------- .../use-document-item-context-menu.tsx | 87 +++++++----- .../connections-navigation.tsx | 18 ++- .../compass-telemetry/src/telemetry-events.ts | 29 +++- packages/compass/src/app/components/home.tsx | 13 ++ 20 files changed, 421 insertions(+), 230 deletions(-) diff --git a/configs/eslint-config-compass/index.js b/configs/eslint-config-compass/index.js index 752f4daaaa1..628e29c1ea2 100644 --- a/configs/eslint-config-compass/index.js +++ b/configs/eslint-config-compass/index.js @@ -44,8 +44,7 @@ const tsxRules = { 'react-hooks/exhaustive-deps': [ 'warn', { - additionalHooks: - '(useTrackOnChange|useContextMenuItems|useContextMenuGroups)', + additionalHooks: '(useTrackOnChange|useContextMenuGroups)', }, ], }; diff --git a/packages/compass-components/src/components/compass-components-provider.tsx b/packages/compass-components/src/components/compass-components-provider.tsx index ad648ea378c..b46c32cc606 100644 --- a/packages/compass-components/src/components/compass-components-provider.tsx +++ b/packages/compass-components/src/components/compass-components-provider.tsx @@ -6,7 +6,11 @@ import { GuideCueProvider } from './guide-cue/guide-cue'; import { SignalHooksProvider } from './signal-popover'; import { RequiredURLSearchParamsProvider } from './links/link'; import { StackedComponentProvider } from '../hooks/use-stacked-component'; -import { ContextMenuProvider } from './context-menu'; +import { + type ContextMenuItem, + type ContextMenuItemGroup, + ContextMenuProvider, +} from './context-menu'; import { DrawerContentProvider } from './drawer-portal'; type GuideCueProviderProps = React.ComponentProps; @@ -46,6 +50,17 @@ type CompassComponentsProviderProps = { } & { utmSource?: string; utmMedium?: string; + /** + * Callback for when the context menu is opened. + */ + onContextMenuOpen?: (itemGroups: ContextMenuItemGroup[]) => void; + /** + * Callback for when a context menu item is clicked. + */ + onContextMenuItemClick?: ( + itemGroup: ContextMenuItemGroup, + item: ContextMenuItem + ) => void; } & React.ComponentProps; const darkModeMediaQuery = (() => { @@ -102,6 +117,8 @@ export const CompassComponentsProvider = ({ children, onNextGuideGue, onNextGuideCueGroup, + onContextMenuOpen, + onContextMenuItemClick, utmSource, utmMedium, stackedElementsZIndex, @@ -144,7 +161,11 @@ export const CompassComponentsProvider = ({ > - + {typeof children === 'function' ? children({ diff --git a/packages/compass-components/src/components/context-menu.spec.tsx b/packages/compass-components/src/components/context-menu.spec.tsx index ac6a2b23882..823f1010d3b 100644 --- a/packages/compass-components/src/components/context-menu.spec.tsx +++ b/packages/compass-components/src/components/context-menu.spec.tsx @@ -4,12 +4,12 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { ContextMenuProvider } from '@mongodb-js/compass-context-menu'; import { - useContextMenuItems, ContextMenu, type ContextMenuItem, + useContextMenuGroups, } from './context-menu'; -describe('useContextMenuItems', function () { +describe('useContextMenuGroups', function () { const menuTestTriggerId = 'test-trigger'; const TestComponent = ({ @@ -21,7 +21,15 @@ describe('useContextMenuItems', function () { children?: React.ReactNode; 'data-testid'?: string; }) => { - const ref = useContextMenuItems(() => items, [items]); + const ref = useContextMenuGroups( + () => [ + { + telemetryLabel: 'Test Item Group', + items, + }, + ], + [items] + ); return (
diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index 65f67a64f5b..8168d32dc83 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -33,18 +33,42 @@ const itemStyles = css({ export function ContextMenuProvider({ children, disabled, + onContextMenuItemClick, + onContextMenuOpen, }: { children: React.ReactNode; disabled?: boolean; + onContextMenuOpen?: (itemGroups: ContextMenuItemGroup[]) => void; + onContextMenuItemClick?: ( + itemGroup: ContextMenuItemGroup, + item: ContextMenuItem + ) => void; }) { return ( - + ( + + )} + onContextMenuOpen={onContextMenuOpen} + > {children} ); } -export function ContextMenu({ menu }: ContextMenuWrapperProps) { +export function ContextMenu({ + menu, + onContextMenuItemClick, +}: ContextMenuWrapperProps & { + onContextMenuItemClick?: ( + itemGroup: ContextMenuItemGroup, + item: ContextMenuItem + ) => void; +}) { const menuRef = useRef(null); const anchorRef = useRef(null); @@ -80,79 +104,79 @@ export function ContextMenu({ menu }: ContextMenuWrapperProps) { className={cx(menuStyles, contextMenuClassName)} maxHeight={Number.MAX_SAFE_INTEGER} > - {itemGroups.map((items: ContextMenuItemGroup, groupIndex: number) => { - return ( -
- {items.map((item: ContextMenuItem, itemIndex: number) => { - return ( - { - item.onAction?.(evt); - menu.close(); - }} + {itemGroups.map( + (itemGroup: ContextMenuItemGroup, groupIndex: number) => { + return ( +
+ {itemGroup.items.map( + (item: ContextMenuItem, itemIndex: number) => { + return ( + { + item.onAction?.(evt); + onContextMenuItemClick?.(itemGroup, item); + menu.close(); + }} + > + {item.label} + + ); + } + )} + {groupIndex < itemGroups.length - 1 && ( +
- {item.label} - - ); - })} - {groupIndex < itemGroups.length - 1 && ( -
- -
- )} -
- ); - })} + +
+ )} +
+ ); + } + )}
); } -/** Registers context menu items - items that are `undefined` will get filtered. */ -export function useContextMenuItems( - getItems: () => (ContextMenuItem | undefined)[], - dependencies: React.DependencyList | undefined -): React.RefCallback { - const memoizedItems = useMemo( - () => - getItems().filter((item): item is ContextMenuItem => item !== undefined), - // eslint-disable-next-line react-hooks/exhaustive-deps - dependencies - ); - const contextMenu = useContextMenu(); - return contextMenu.registerItems(memoizedItems); -} - /** Registers context menu groups - groups and items that are `undefined` will get filtered. */ export function useContextMenuGroups( - getGroups: () => ((ContextMenuItem | undefined)[] | undefined)[], + getGroups: () => ( + | (Omit & { + items: undefined | (ContextMenuItem | undefined)[]; + }) + | undefined + )[], dependencies: React.DependencyList | undefined ): React.RefCallback { - const memoizedGroups: ContextMenuItem[][] = useMemo( + const memoizedGroups: ContextMenuItemGroup[] = useMemo( () => { const groups = getGroups(); // Cleanup all undefined fields across items and groups which is used // for conditional displaying of groups and items. return groups .filter( - (groupItems): groupItems is ContextMenuItem[] => - groupItems !== undefined && groupItems.length > 0 + (group): group is ContextMenuItemGroup => + group !== undefined && + group.items !== undefined && + group.items.length > 0 ) - .map((groupItems) => groupItems.filter((item) => item !== undefined)); + .map(({ items, telemetryLabel }) => ({ + items: items?.filter((item) => item !== undefined), + telemetryLabel, + })); }, // eslint-disable-next-line react-hooks/exhaustive-deps dependencies ); const contextMenu = useContextMenu(); - return contextMenu.registerItems(...memoizedGroups); + return contextMenu.registerItemGroups(memoizedGroups); } diff --git a/packages/compass-components/src/components/document-list/element.tsx b/packages/compass-components/src/components/document-list/element.tsx index 83973ce71d5..dc6e7a63a7a 100644 --- a/packages/compass-components/src/components/document-list/element.tsx +++ b/packages/compass-components/src/components/document-list/element.tsx @@ -28,8 +28,8 @@ import { palette } from '@leafygreen-ui/palette'; import { Icon } from '../leafygreen'; import { useDarkMode } from '../../hooks/use-theme'; import VisibleFieldsToggle from './visible-field-toggle'; -import { useContextMenuItems } from '../context-menu'; import { hasDistinctValue } from 'mongodb-query-util'; +import { useContextMenuGroups } from '../context-menu'; function getEditorByType(type: HadronElementType['type']) { switch (type) { @@ -499,40 +499,45 @@ export const HadronElement: React.FunctionComponent<{ ); // Add context menu hook for the field - const fieldContextMenuRef = useContextMenuItems( + const fieldContextMenuRef = useContextMenuGroups( () => [ - onUpdateQuery - ? { - label: isFieldInQuery( - getNestedKeyPathForElement(element), - element.generateObject() - ) - ? 'Remove from query' - : 'Add to query', + { + telemetryLabel: 'Element Field', + items: [ + onUpdateQuery + ? { + label: isFieldInQuery( + getNestedKeyPathForElement(element), + element.generateObject() + ) + ? 'Remove from query' + : 'Add to query', + onAction: () => { + onUpdateQuery( + getNestedKeyPathForElement(element), + element.generateObject() + ); + }, + } + : undefined, + { + label: 'Copy field & value', onAction: () => { - onUpdateQuery( - getNestedKeyPathForElement(element), - element.generateObject() + void navigator.clipboard.writeText( + `${key.value}: ${element.toEJSON()}` ); }, - } - : undefined, - { - label: 'Copy field & value', - onAction: () => { - void navigator.clipboard.writeText( - `${key.value}: ${element.toEJSON()}` - ); - }, + }, + type.value === 'String' && isValidUrl(value.value) + ? { + label: 'Open URL in browser', + onAction: () => { + window.open(value.value, '_blank', 'noopener'); + }, + } + : undefined, + ], }, - type.value === 'String' && isValidUrl(value.value) - ? { - label: 'Open URL in browser', - onAction: () => { - window.open(value.value, '_blank', 'noopener'); - }, - } - : undefined, ], [element, key.value, value.value, type.value, onUpdateQuery, isFieldInQuery] ); diff --git a/packages/compass-components/src/components/workspace-tabs/tab.tsx b/packages/compass-components/src/components/workspace-tabs/tab.tsx index a24ed4c6f7b..09208422056 100644 --- a/packages/compass-components/src/components/workspace-tabs/tab.tsx +++ b/packages/compass-components/src/components/workspace-tabs/tab.tsx @@ -14,7 +14,7 @@ import { LogoIcon } from '../icons/logo-icon'; import { Tooltip } from '../leafygreen'; import { ServerIcon } from '../icons/server-icon'; import { useTabTheme } from './use-tab-theme'; -import { useContextMenuItems } from '../context-menu'; +import { useContextMenuGroups } from '../context-menu'; function focusedChild(className: string) { return `&:hover ${className}, &:focus-visible ${className}, &:focus-within:not(:focus) ${className}`; @@ -239,10 +239,15 @@ function Tab({ return css(tabTheme); }, [tabTheme, darkMode]); - const contextMenuRef = useContextMenuItems( + const contextMenuRef = useContextMenuGroups( () => [ - { label: 'Close all other tabs', onAction: onCloseAllOthers }, - { label: 'Duplicate', onAction: onDuplicate }, + { + telemetryLabel: 'Workspace Tab', + items: [ + { label: 'Close all other tabs', onAction: onCloseAllOthers }, + { label: 'Duplicate', onAction: onDuplicate }, + ], + }, ], [onCloseAllOthers, onDuplicate] ); diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index da7dce70fb2..d43122604d6 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -103,7 +103,6 @@ export { FormModal } from './components/modals/form-modal'; export { InfoModal } from './components/modals/info-modal'; export { - useContextMenuItems, useContextMenuGroups, type ContextMenuItem, type ContextMenuItemGroup, diff --git a/packages/compass-connections-navigation/src/connections-navigation-tree.tsx b/packages/compass-connections-navigation/src/connections-navigation-tree.tsx index 7958cda5b8f..0e6a375b430 100644 --- a/packages/compass-connections-navigation/src/connections-navigation-tree.tsx +++ b/packages/compass-connections-navigation/src/connections-navigation-tree.tsx @@ -233,6 +233,7 @@ const ConnectionsNavigationTree: React.FunctionComponent< return []; case 'connection': return itemActionsToContextMenuGroups( + 'Connection Tree Item', item, onItemAction, item.connectionStatus === 'connected' @@ -258,6 +259,7 @@ const ConnectionsNavigationTree: React.FunctionComponent< connectionInfo, } = item.connectionItem; return itemActionsToContextMenuGroups( + 'Database Tree Item', item, onItemAction, databaseContextMenuActions({ @@ -278,6 +280,7 @@ const ConnectionsNavigationTree: React.FunctionComponent< connectionInfo, } = item.databaseItem.connectionItem; return itemActionsToContextMenuGroups( + 'Collection Tree Item', item, onItemAction, collectionContextMenuActions({ diff --git a/packages/compass-connections-navigation/src/context-menus.ts b/packages/compass-connections-navigation/src/context-menus.ts index 49d5e5436e1..f9475c5037d 100644 --- a/packages/compass-connections-navigation/src/context-menus.ts +++ b/packages/compass-connections-navigation/src/context-menus.ts @@ -8,16 +8,18 @@ import type { Actions } from './constants'; import type { SidebarActionableItem } from './tree-data'; export function itemActionsToContextMenuGroups( + telemetryLabel: string, item: SidebarActionableItem, onItemAction: (item: SidebarActionableItem, action: Actions) => void, itemActions: NavigationItemActions ): ContextMenuItemGroup[] { - return splitBySeparator(itemActions).map((actions) => - actions.map(({ label, action }) => ({ + return splitBySeparator(itemActions).map((actions) => ({ + telemetryLabel, + items: actions.map(({ label, action }) => ({ label, onAction() { onItemAction({ ...item, entrypoint: 'context-menu' }, action); }, - })) - ); + })), + })); } diff --git a/packages/compass-connections-navigation/src/navigation-item.tsx b/packages/compass-connections-navigation/src/navigation-item.tsx index b840de2561f..f634dcf8964 100644 --- a/packages/compass-connections-navigation/src/navigation-item.tsx +++ b/packages/compass-connections-navigation/src/navigation-item.tsx @@ -7,7 +7,7 @@ import { useDarkMode, useContextMenuGroups, type ItemAction, - type ContextMenuItem, + type ContextMenuItemGroup, } from '@mongodb-js/compass-components'; import { PlaceholderItem } from './placeholder'; import StyledNavigationItem from './styled-navigation-item'; @@ -103,7 +103,7 @@ type NavigationItemProps = { }; onItemAction: (item: SidebarActionableItem, action: Actions) => void; onItemExpand(item: SidebarActionableItem, isExpanded: boolean): void; - getContextMenuGroups(item: SidebarTreeItem): ContextMenuItem[][]; + getContextMenuGroups(item: SidebarTreeItem): ContextMenuItemGroup[]; }; export function NavigationItem({ diff --git a/packages/compass-connections-navigation/src/virtual-list/virtual-list.tsx b/packages/compass-connections-navigation/src/virtual-list/virtual-list.tsx index 1bc2605e0bd..ad342101796 100644 --- a/packages/compass-connections-navigation/src/virtual-list/virtual-list.tsx +++ b/packages/compass-connections-navigation/src/virtual-list/virtual-list.tsx @@ -10,6 +10,7 @@ import { type ListChildComponentProps, } from 'react-window'; import { + type ContextMenuItemGroup, css, mergeProps, useFocusRing, @@ -77,7 +78,7 @@ type RenderItem = (props: { getContextMenuGroups: ( this: void, item: SidebarTreeItem - ) => ContextMenuItem[][]; + ) => ContextMenuItemGroup[]; }) => React.ReactNode; export type OnDefaultAction = ( item: T, @@ -109,7 +110,10 @@ type VirtualTreeProps = { collapseAfter: number; }; }; - getContextMenuGroups(this: void, item: SidebarTreeItem): ContextMenuItem[][]; + getContextMenuGroups( + this: void, + item: SidebarTreeItem + ): ContextMenuItemGroup[]; __TEST_OVER_SCAN_COUNT?: number; }; @@ -250,7 +254,10 @@ type VirtualItemData = { collapseAfter: number; }; }; - getContextMenuGroups(this: void, item: SidebarTreeItem): ContextMenuItem[][]; + getContextMenuGroups( + this: void, + item: SidebarTreeItem + ): ContextMenuItemGroup[]; }; function TreeItem({ index, diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index b0c82d8ba2f..a8ee6486e40 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -7,7 +7,11 @@ import React, { useContext, } from 'react'; -import type { ContextMenuContextType, ContextMenuState } from './types'; +import type { + ContextMenuContextType, + ContextMenuItemGroup, + ContextMenuState, +} from './types'; import { getContextMenuContent, type EnhancedMouseEvent, @@ -22,12 +26,14 @@ export function ContextMenuProvider({ disabled = false, children, menuWrapper: Wrapper, + onContextMenuOpen, }: { disabled?: boolean; children: React.ReactNode; menuWrapper: React.ComponentType<{ menu: ContextMenuState & { close: () => void }; }>; + onContextMenuOpen?: (itemGroups: ContextMenuItemGroup[]) => void; }) { // Check if there's already a parent context menu provider const parentContext = useContext(ContextMenuContext); @@ -64,11 +70,14 @@ export function ContextMenuProvider({ return; } + if (onContextMenuOpen) { + onContextMenuOpen(itemGroups); + } + setMenu({ isOpen: true, itemGroups, position: { - // TODO: Fix handling offset while scrolling x: event.clientX, y: event.clientY, }, @@ -95,7 +104,7 @@ export function ContextMenuProvider({ capture: true, }); }; - }, [disabled, handleClosingEvent, parentContext]); + }, [disabled, handleClosingEvent, onContextMenuOpen, parentContext]); const value = useMemo( () => ({ diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index 1b342f9a3da..54a65bece74 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -1,16 +1,23 @@ -export type ContextMenuItemGroup = ContextMenuItem[]; +export type ContextMenuItemGroup = + { + /** Label for the group used for telemetry. */ + telemetryLabel: string; + items: T[]; + }; -export type ContextMenuState = { +export type ContextMenuState = { isOpen: boolean; - itemGroups: ContextMenuItemGroup[]; + itemGroups: ContextMenuItemGroup[]; position: { x: number; y: number; }; }; -export type ContextMenuWrapperProps = { - menu: ContextMenuState & { close: () => void }; +export type ContextMenuWrapperProps< + T extends ContextMenuItem = ContextMenuItem +> = { + menu: ContextMenuState & { close: () => void }; }; export type ContextMenuContextType = { diff --git a/packages/compass-context-menu/src/use-context-menu.spec.tsx b/packages/compass-context-menu/src/use-context-menu.spec.tsx index 37a6cb46c20..261c3ea80d1 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -16,7 +16,7 @@ const { render } = testingLibrary; describe('useContextMenu', function () { const TestMenu: React.FC = ({ menu }) => (
- {menu.itemGroups.flatMap((items, groupIdx) => + {menu.itemGroups.flatMap(({ items }, groupIdx) => items.map((item, idx) => (
onAction?.(1), }, ]; - const ref = contextMenu.registerItems(items); + const ref = contextMenu.registerItemGroups([ + { + telemetryLabel: 'Test Item Group', + items, + }, + ]); React.useEffect(() => { onRegister?.(ref); @@ -82,7 +87,12 @@ describe('useContextMenu', function () { onAction: () => onAction?.(2), }, ]; - const ref = contextMenu.registerItems(parentItems); + const ref = contextMenu.registerItemGroups([ + { + telemetryLabel: 'Parent Item Group', + items: parentItems, + }, + ]); return (
@@ -108,7 +118,12 @@ describe('useContextMenu', function () { onAction: () => onAction?.(2), }, ]; - const ref = contextMenu.registerItems(childItems); + const ref = contextMenu.registerItemGroups([ + { + telemetryLabel: 'Child Item Group', + items: childItems, + }, + ]); return (
@@ -264,6 +279,33 @@ describe('useContextMenu', function () { }); }); + it('calls onContextMenuOpen when context menu is opened', function () { + const onContextMenuOpen = sinon.spy(); + + render( + + + + ); + + const trigger = screen.getByTestId('test-trigger'); + userEvent.click(trigger, { button: 2 }); + + // Verify the callback was called + expect(onContextMenuOpen).to.have.been.calledOnce; + + // Verify the callback was called with the correct item groups + const [itemGroup] = onContextMenuOpen.firstCall.args[0]; + expect(itemGroup).to.include({ + telemetryLabel: 'Test Item Group', + }); + expect(itemGroup.items).to.have.lengthOf(1); + expect(itemGroup.items[0]).to.include({ label: 'Test Item' }); + }); + describe('menu closing behavior', function () { for (const event of ['scroll', 'resize', 'click']) { it(`closes menu on window ${event} event`, function () { diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index 56754933c33..e8ba9c3eae9 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -2,7 +2,7 @@ import type { RefCallback } from 'react'; import { useContext, useMemo, useRef } from 'react'; import { ContextMenuContext } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; -import type { ContextMenuItem } from './types'; +import type { ContextMenuItem, ContextMenuItemGroup } from './types'; export type ContextMenuMethods = { /** @@ -10,10 +10,12 @@ export type ContextMenuMethods = { */ close(): void; /** - * Register the menu items for the context menu. + * Register the menu item group for the context menu. * @returns a callback ref to be passed onto the element responsible for triggering the menu. */ - registerItems(...groups: T[][]): RefCallback; + registerItemGroups( + groups: ContextMenuItemGroup[] + ): RefCallback; }; export function useContextMenu< @@ -34,7 +36,7 @@ export function useContextMenu< /** * @returns a callback ref, passed onto the element responsible for triggering the menu. */ - registerItems(...groups: ContextMenuItem[][]) { + registerItemGroups(groups: ContextMenuItemGroup[]) { function listener(event: MouseEvent): void { appendContextMenuContent(event, ...groups); } diff --git a/packages/compass-crud/src/components/crud-toolbar.tsx b/packages/compass-crud/src/components/crud-toolbar.tsx index e1d56e389d8..a5b6d03a203 100644 --- a/packages/compass-crud/src/components/crud-toolbar.tsx +++ b/packages/compass-crud/src/components/crud-toolbar.tsx @@ -13,7 +13,7 @@ import { Select, Option, SignalPopover, - useContextMenuItems, + useContextMenuGroups, } from '@mongodb-js/compass-components'; import type { MenuAction, Signal } from '@mongodb-js/compass-components'; import { ViewSwitcher } from './view-switcher'; @@ -203,73 +203,78 @@ const CrudToolbar: React.FunctionComponent = ({ [querySkip, queryLimit] ); - const contextMenuRef = useContextMenuItems( + const contextMenuRef = useContextMenuGroups( () => [ { - label: 'Expand all documents', - onAction: () => { - onExpandAllClicked(); - }, - }, - { - label: 'Collapse all documents', - onAction: () => { - onCollapseAllClicked(); - }, - }, - isImportExportEnabled - ? { - label: 'Import JSON or CSV file', + telemetryLabel: 'Expand all documents', + items: [ + { + label: 'Expand all documents', onAction: () => { - insertDataHandler('import-file'); + onExpandAllClicked(); }, - } - : undefined, - !readonly - ? { - label: 'Insert document...', + }, + { + label: 'Collapse all documents', onAction: () => { - insertDataHandler('insert-document'); - }, - } - : undefined, - ...(isImportExportEnabled - ? [ - { - label: 'Export query results...', - onAction: () => { - openExportFileDialog(false); - }, + onCollapseAllClicked(); }, - { - label: 'Export full collection...', - onAction: () => { - openExportFileDialog(true); - }, - }, - ] - : []), - ...(!readonly && isWritable && !shouldDisableBulkOp - ? [ - { - label: 'Bulk update', - onAction: () => { - onUpdateButtonClicked(); - }, - }, - { - label: 'Bulk delete', - onAction: () => { - onDeleteButtonClicked(); - }, + }, + isImportExportEnabled + ? { + label: 'Import JSON or CSV file', + onAction: () => { + insertDataHandler('import-file'); + }, + } + : undefined, + !readonly + ? { + label: 'Insert document...', + onAction: () => { + insertDataHandler('insert-document'); + }, + } + : undefined, + ...(isImportExportEnabled + ? [ + { + label: 'Export query results...', + onAction: () => { + openExportFileDialog(false); + }, + }, + { + label: 'Export full collection...', + onAction: () => { + openExportFileDialog(true); + }, + }, + ] + : []), + ...(!readonly && isWritable && !shouldDisableBulkOp + ? [ + { + label: 'Bulk update', + onAction: () => { + onUpdateButtonClicked(); + }, + }, + { + label: 'Bulk delete', + onAction: () => { + onDeleteButtonClicked(); + }, + }, + ] + : []), + { + label: 'Refresh', + onAction: () => { + onClickRefreshDocuments(); }, - ] - : []), - { - label: 'Refresh', - onAction: () => { - onClickRefreshDocuments(); - }, + }, + ], }, ], [ diff --git a/packages/compass-crud/src/components/use-document-item-context-menu.tsx b/packages/compass-crud/src/components/use-document-item-context-menu.tsx index db3c9c547d0..e21245c0b09 100644 --- a/packages/compass-crud/src/components/use-document-item-context-menu.tsx +++ b/packages/compass-crud/src/components/use-document-item-context-menu.tsx @@ -19,39 +19,43 @@ export function useDocumentItemContextMenu({ return useContextMenuGroups( () => [ isEditable - ? [ - { - label: isEditing ? 'Cancel editing' : 'Edit document', - onAction: () => { - if (isEditing) { - doc.finishEditing(); - } else { - doc.startEditing(); - } + ? { + telemetryLabel: 'Document Item Edit', + items: [ + { + label: isEditing ? 'Cancel editing' : 'Edit document', + onAction: () => { + if (isEditing) { + doc.finishEditing(); + } else { + doc.startEditing(); + } + }, }, - }, - ] + ], + } : undefined, - [ - { - label: isExpanded ? 'Collapse all fields' : 'Expand all fields', - onAction: () => { - if (isExpanded) { - doc.collapse(); - } else { - doc.expand(); - } + { + telemetryLabel: 'Document Item', + items: [ + { + label: isExpanded ? 'Collapse all fields' : 'Expand all fields', + onAction: () => { + if (isExpanded) { + doc.collapse(); + } else { + doc.expand(); + } + }, }, - }, - { - label: 'Copy document', - onAction: () => { - copyToClipboard?.(doc); + { + label: 'Copy document', + onAction: () => { + copyToClipboard?.(doc); + }, }, - }, - ...(isEditable - ? [ - { + isEditable + ? { label: 'Clone document...', onAction: () => { const clonedDoc = doc.generateObject({ @@ -59,16 +63,21 @@ export function useDocumentItemContextMenu({ }); void openInsertDocumentDialog?.(clonedDoc, true); }, - }, - { - label: 'Delete document', - onAction: () => { - doc.markForDeletion(); - }, - }, - ] - : []), - ], + } + : undefined, + ], + }, + { + telemetryLabel: 'Document Item Delete', + items: [ + { + label: 'Delete document', + onAction: () => { + doc.markForDeletion(); + }, + }, + ], + }, ], [ doc, diff --git a/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx b/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx index 594e4a9bba6..c6c457bc539 100644 --- a/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx +++ b/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx @@ -20,7 +20,7 @@ import { ButtonVariant, cx, Placeholder, - useContextMenuItems, + useContextMenuGroups, } from '@mongodb-js/compass-components'; import { ConnectionsNavigationTree } from '@mongodb-js/compass-connections-navigation'; import type { MapDispatchToProps, MapStateToProps } from 'react-redux'; @@ -508,12 +508,16 @@ const ConnectionsNavigation: React.FC = ({ [onCollapseAll, onNewConnection, openConnectionImportExportModal] ); - const contextMenuRef = useContextMenuItems( - () => - connectionListTitleActions.map(({ label, action }) => ({ - label, - onAction: () => onConnectionListTitleAction(action), - })), + const contextMenuRef = useContextMenuGroups( + () => [ + { + telemetryLabel: 'Connection List Title Actions', + items: connectionListTitleActions.map(({ label, action }) => ({ + label, + onAction: () => onConnectionListTitleAction(action), + })), + }, + ], [connectionListTitleActions, onConnectionListTitleAction] ); diff --git a/packages/compass-telemetry/src/telemetry-events.ts b/packages/compass-telemetry/src/telemetry-events.ts index 6dc51fe9c37..dadcc8efb40 100644 --- a/packages/compass-telemetry/src/telemetry-events.ts +++ b/packages/compass-telemetry/src/telemetry-events.ts @@ -2951,6 +2951,31 @@ type DataModelingDiagramRelationshipDeleted = CommonEvent<{ }; }>; +/** + * This event is fired when the context menu is opened. + * + * @category Context Menu + */ +type ContextMenuOpened = CommonEvent<{ + name: 'Context Menu Opened'; + payload: { + item_groups: string[]; + }; +}>; + +/** + * This event is fired when a context menu item is clicked. + * + * @category Context Menu + */ +type ContextMenuItemClicked = CommonEvent<{ + name: 'Context Menu Item Clicked'; + payload: { + item_group: string; + item_label: string; + }; +}>; + export type TelemetryEvent = | AggregationCanceledEvent | AggregationCopiedEvent @@ -3102,4 +3127,6 @@ export type TelemetryEvent = | DataModelingDiagramImported | DataModelingDiagramRelationshipAdded | DataModelingDiagramRelationshipEdited - | DataModelingDiagramRelationshipDeleted; + | DataModelingDiagramRelationshipDeleted + | ContextMenuOpened + | ContextMenuItemClicked; diff --git a/packages/compass/src/app/components/home.tsx b/packages/compass/src/app/components/home.tsx index 6d4c3796d2d..bc5be4406d5 100644 --- a/packages/compass/src/app/components/home.tsx +++ b/packages/compass/src/app/components/home.tsx @@ -189,6 +189,19 @@ export default function ThemedHome( }); } }} + onContextMenuOpen={(itemGroups) => { + if (itemGroups.length > 0) { + track('Context Menu Opened', { + item_groups: itemGroups.map((group) => group.telemetryLabel), + }); + } + }} + onContextMenuItemClick={(itemGroup, item) => { + track('Context Menu Item Clicked', { + item_group: itemGroup.telemetryLabel, + item_label: item.label, + }); + }} utmSource="compass" utmMedium="product" onSignalMount={(id) => { From c05df661431401b924824f216553cfcc351392bd Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 5 Aug 2025 15:23:20 +0200 Subject: [PATCH 2/6] fix: lint/test issues --- .../src/virtual-list/virtual-list.tsx | 1 - .../use-document-item-context-menu.tsx | 24 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/compass-connections-navigation/src/virtual-list/virtual-list.tsx b/packages/compass-connections-navigation/src/virtual-list/virtual-list.tsx index ad342101796..7b4c2e8d264 100644 --- a/packages/compass-connections-navigation/src/virtual-list/virtual-list.tsx +++ b/packages/compass-connections-navigation/src/virtual-list/virtual-list.tsx @@ -15,7 +15,6 @@ import { mergeProps, useFocusRing, useId, - type ContextMenuItem, } from '@mongodb-js/compass-components'; import { type SidebarActionableItem, type SidebarTreeItem } from '../tree-data'; import { type Actions } from '../constants'; diff --git a/packages/compass-crud/src/components/use-document-item-context-menu.tsx b/packages/compass-crud/src/components/use-document-item-context-menu.tsx index e21245c0b09..a9e0c90c524 100644 --- a/packages/compass-crud/src/components/use-document-item-context-menu.tsx +++ b/packages/compass-crud/src/components/use-document-item-context-menu.tsx @@ -67,17 +67,19 @@ export function useDocumentItemContextMenu({ : undefined, ], }, - { - telemetryLabel: 'Document Item Delete', - items: [ - { - label: 'Delete document', - onAction: () => { - doc.markForDeletion(); - }, - }, - ], - }, + isEditable + ? { + telemetryLabel: 'Document Item Delete', + items: [ + { + label: 'Delete document', + onAction: () => { + doc.markForDeletion(); + }, + }, + ], + } + : undefined, ], [ doc, From 4ce4a3d350f8f6f0fced2d6d915136c1996b8975 Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 7 Aug 2025 12:02:28 +0200 Subject: [PATCH 3/6] changes from feedback --- .../src/context-menu-provider.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index a8ee6486e40..54b0bc93247 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -57,6 +57,14 @@ export function ContextMenuProvider({ [close] ); + const handleContextMenuOpen = useCallback( + (itemGroups: ContextMenuItemGroup[]) => { + onContextMenuOpen?.(itemGroups); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + useEffect(() => { // Don't set up event listeners if we have a parent context if (parentContext || disabled) return; @@ -70,9 +78,7 @@ export function ContextMenuProvider({ return; } - if (onContextMenuOpen) { - onContextMenuOpen(itemGroups); - } + handleContextMenuOpen(itemGroups); setMenu({ isOpen: true, @@ -104,7 +110,7 @@ export function ContextMenuProvider({ capture: true, }); }; - }, [disabled, handleClosingEvent, onContextMenuOpen, parentContext]); + }, [disabled, handleClosingEvent, handleContextMenuOpen, parentContext]); const value = useMemo( () => ({ From aabca6033bc661c5477e3188c265598693174378 Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 7 Aug 2025 14:43:50 +0200 Subject: [PATCH 4/6] fix: just ignore inside the effect instead --- .../src/context-menu-provider.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 54b0bc93247..35c9a402f63 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -57,14 +57,6 @@ export function ContextMenuProvider({ [close] ); - const handleContextMenuOpen = useCallback( - (itemGroups: ContextMenuItemGroup[]) => { - onContextMenuOpen?.(itemGroups); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - useEffect(() => { // Don't set up event listeners if we have a parent context if (parentContext || disabled) return; @@ -78,7 +70,7 @@ export function ContextMenuProvider({ return; } - handleContextMenuOpen(itemGroups); + onContextMenuOpen?.(itemGroups); setMenu({ isOpen: true, @@ -110,7 +102,8 @@ export function ContextMenuProvider({ capture: true, }); }; - }, [disabled, handleClosingEvent, handleContextMenuOpen, parentContext]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [disabled, handleClosingEvent, parentContext]); const value = useMemo( () => ({ From 9014d4ea55bff9007fbf77397a9e256bee187a76 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 8 Aug 2025 14:39:30 +0200 Subject: [PATCH 5/6] fix: use ref --- .../compass-context-menu/src/context-menu-provider.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 35c9a402f63..b36c783ce14 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -5,6 +5,7 @@ import React, { useMemo, createContext, useContext, + useRef, } from 'react'; import type { @@ -48,6 +49,9 @@ export function ContextMenuProvider({ [setMenu] ); + const onContextMenuOpenRef = useRef(onContextMenuOpen); + onContextMenuOpenRef.current = onContextMenuOpen; + const handleClosingEvent = useCallback( (event: Event) => { if (!event.defaultPrevented) { @@ -70,7 +74,7 @@ export function ContextMenuProvider({ return; } - onContextMenuOpen?.(itemGroups); + onContextMenuOpenRef.current?.(itemGroups); setMenu({ isOpen: true, @@ -102,8 +106,7 @@ export function ContextMenuProvider({ capture: true, }); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [disabled, handleClosingEvent, parentContext]); + }, [disabled, handleClosingEvent, onContextMenuOpenRef, parentContext]); const value = useMemo( () => ({ From ddc3060962636e895029724aab8910d820a34b0d Mon Sep 17 00:00:00 2001 From: gagik Date: Mon, 11 Aug 2025 14:53:40 +0200 Subject: [PATCH 6/6] chore: add compass web entry points --- packages/compass-web/src/entrypoint.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/compass-web/src/entrypoint.tsx b/packages/compass-web/src/entrypoint.tsx index 7ec34abc395..f7e4fa05e82 100644 --- a/packages/compass-web/src/entrypoint.tsx +++ b/packages/compass-web/src/entrypoint.tsx @@ -337,6 +337,19 @@ const CompassWeb = ({ }); } }} + onContextMenuOpen={(itemGroups) => { + if (itemGroups.length > 0) { + onTrackRef.current?.('Context Menu Opened', { + item_groups: itemGroups.map((group) => group.telemetryLabel), + }); + } + }} + onContextMenuItemClick={(itemGroup, item) => { + onTrackRef.current?.('Context Menu Item Clicked', { + item_group: itemGroup.telemetryLabel, + item_label: item.label, + }); + }} onSignalMount={(id) => { onTrackRef.current?.('Signal Shown', { id }); }}