From 297a943a79bc4621fb5c5188c2e9ed7d1a110f90 Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 20 May 2025 16:59:43 +0200 Subject: [PATCH 01/62] feat(compass-context-menu): add a headless context menu package --- package-lock.json | 104 ++++++++++++++++++ packages/compass-context-menu/.depcheckrc | 8 ++ packages/compass-context-menu/.eslintignore | 2 + packages/compass-context-menu/.eslintrc.js | 9 ++ packages/compass-context-menu/.mocharc.js | 2 + packages/compass-context-menu/package.json | 72 ++++++++++++ .../src/context-menu-content.ts | 23 ++++ .../src/context-menu-provider.tsx | 68 ++++++++++++ .../compass-context-menu/src/context-menu.tsx | 22 ++++ packages/compass-context-menu/src/types.ts | 21 ++++ .../src/use-context-menu.tsx | 55 +++++++++ .../compass-context-menu/tsconfig-lint.json | 5 + packages/compass-context-menu/tsconfig.json | 8 ++ 13 files changed, 399 insertions(+) create mode 100644 packages/compass-context-menu/.depcheckrc create mode 100644 packages/compass-context-menu/.eslintignore create mode 100644 packages/compass-context-menu/.eslintrc.js create mode 100644 packages/compass-context-menu/.mocharc.js create mode 100644 packages/compass-context-menu/package.json create mode 100644 packages/compass-context-menu/src/context-menu-content.ts create mode 100644 packages/compass-context-menu/src/context-menu-provider.tsx create mode 100644 packages/compass-context-menu/src/context-menu.tsx create mode 100644 packages/compass-context-menu/src/types.ts create mode 100644 packages/compass-context-menu/src/use-context-menu.tsx create mode 100644 packages/compass-context-menu/tsconfig-lint.json create mode 100644 packages/compass-context-menu/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 3cfca4a376d..f006b787bbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7893,6 +7893,10 @@ "resolved": "packages/compass-connections-navigation", "link": true }, + "node_modules/@mongodb-js/compass-context-menu": { + "resolved": "packages/compass-context-menu", + "link": true + }, "node_modules/@mongodb-js/compass-crud": { "resolved": "packages/compass-crud", "link": true @@ -44219,6 +44223,62 @@ "node": ">=0.3.1" } }, + "packages/compass-context-menu": { + "name": "@mongodb-js/compass-context-menu", + "version": "0.0.1", + "license": "SSPL", + "dependencies": { + "react": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.3.8", + "@mongodb-js/mocha-config-compass": "^1.6.8", + "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/tsconfig-compass": "^1.2.8", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "sinon": "^9.2.3", + "typescript": "^5.0.4" + } + }, + "packages/compass-context-menu/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "packages/compass-context-menu/node_modules/sinon": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", + "integrity": "sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==", + "deprecated": "16.1.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/samsam": "^5.3.1", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "packages/compass-crud": { "name": "@mongodb-js/compass-crud", "version": "13.56.1", @@ -56727,6 +56787,50 @@ } } }, + "@mongodb-js/compass-context-menu": { + "version": "file:packages/compass-context-menu", + "requires": { + "@mongodb-js/eslint-config-compass": "^1.3.8", + "@mongodb-js/mocha-config-compass": "^1.6.8", + "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/tsconfig-compass": "^1.2.8", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "react": "^17.0.2", + "sinon": "^9.2.3", + "typescript": "^5.0.4" + }, + "dependencies": { + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, + "sinon": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", + "integrity": "sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/samsam": "^5.3.1", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + } + } + } + }, "@mongodb-js/compass-crud": { "version": "file:packages/compass-crud", "requires": { diff --git a/packages/compass-context-menu/.depcheckrc b/packages/compass-context-menu/.depcheckrc new file mode 100644 index 00000000000..ab0ef21b740 --- /dev/null +++ b/packages/compass-context-menu/.depcheckrc @@ -0,0 +1,8 @@ +ignores: + - '@mongodb-js/prettier-config-compass' + - '@mongodb-js/tsconfig-compass' + - '@types/chai' + - '@types/sinon-chai' + - 'sinon' +ignore-patterns: + - 'dist' diff --git a/packages/compass-context-menu/.eslintignore b/packages/compass-context-menu/.eslintignore new file mode 100644 index 00000000000..85a8a75e68c --- /dev/null +++ b/packages/compass-context-menu/.eslintignore @@ -0,0 +1,2 @@ +.nyc-output +dist diff --git a/packages/compass-context-menu/.eslintrc.js b/packages/compass-context-menu/.eslintrc.js new file mode 100644 index 00000000000..9c3ab95632f --- /dev/null +++ b/packages/compass-context-menu/.eslintrc.js @@ -0,0 +1,9 @@ +'use strict'; +module.exports = { + root: true, + extends: ['@mongodb-js/eslint-config-compass'], + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig-lint.json'], + }, +}; diff --git a/packages/compass-context-menu/.mocharc.js b/packages/compass-context-menu/.mocharc.js new file mode 100644 index 00000000000..e7eaccd61fa --- /dev/null +++ b/packages/compass-context-menu/.mocharc.js @@ -0,0 +1,2 @@ +'use strict'; +module.exports = require('@mongodb-js/mocha-config-compass'); diff --git a/packages/compass-context-menu/package.json b/packages/compass-context-menu/package.json new file mode 100644 index 00000000000..3cf186e0270 --- /dev/null +++ b/packages/compass-context-menu/package.json @@ -0,0 +1,72 @@ +{ + "name": "@mongodb-js/compass-context-menu", + "author": { + "name": "MongoDB Inc", + "email": "compass@mongodb.com" + }, + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/COMPASS/issues", + "email": "compass@mongodb.com" + }, + "homepage": "https://github.com/mongodb-js/compass", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://github.com/mongodb-js/compass.git" + }, + "files": [ + "dist" + ], + "license": "SSPL", + "main": "dist/index.js", + "compass:main": "src/index.ts", + "exports": { + "import": "./dist/.esm-wrapper.mjs", + "require": "./dist/index.js" + }, + "compass:exports": { + ".": "./src/index.ts" + }, + "types": "./dist/index.d.ts", + "scripts": { + "bootstrap": "npm run compile", + "prepublishOnly": "npm run compile && compass-scripts check-exports-exist", + "compile": "tsc -p tsconfig.json && gen-esm-wrapper . ./dist/.esm-wrapper.mjs", + "typecheck": "tsc -p tsconfig-lint.json --noEmit", + "eslint": "eslint-compass", + "prettier": "prettier-compass", + "lint": "npm run eslint . && npm run prettier -- --check .", + "depcheck": "compass-scripts check-peer-deps && depcheck", + "check": "npm run typecheck && npm run lint && npm run depcheck", + "check-ci": "npm run check", + "test": "mocha", + "test-cov": "nyc --compact=false --produce-source-map=false -x \"**/*.spec.*\" --reporter=lcov --reporter=text --reporter=html npm run test", + "test-watch": "npm run test -- --watch", + "test-ci": "npm run test-cov", + "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." + }, + "dependencies": { + "react": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.3.8", + "@mongodb-js/mocha-config-compass": "^1.6.8", + "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/tsconfig-compass": "^1.2.8", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "sinon": "^9.2.3", + "typescript": "^5.0.4" + } +} diff --git a/packages/compass-context-menu/src/context-menu-content.ts b/packages/compass-context-menu/src/context-menu-content.ts new file mode 100644 index 00000000000..6856f1fe684 --- /dev/null +++ b/packages/compass-context-menu/src/context-menu-content.ts @@ -0,0 +1,23 @@ +const CONTEXT_MENUS_SYMBOL = Symbol('context_menus'); + +export type EnhancedMouseEvent = MouseEvent & { + [CONTEXT_MENUS_SYMBOL]?: React.ComponentType[]; +}; + +export function getContextMenuContent( + event: EnhancedMouseEvent +): React.ComponentType[] { + return event[CONTEXT_MENUS_SYMBOL] ?? []; +} + +export function appendContextMenuContent( + event: EnhancedMouseEvent, + content: React.ComponentType +) { + // Initialize if not already patched + if (event[CONTEXT_MENUS_SYMBOL] === undefined) { + event[CONTEXT_MENUS_SYMBOL] = [content]; + return; + } + event[CONTEXT_MENUS_SYMBOL].push(content); +} diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx new file mode 100644 index 00000000000..7a7b59bf16a --- /dev/null +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -0,0 +1,68 @@ +import React, { + useCallback, + useEffect, + useState, + useMemo, + createContext, +} from 'react'; +import type { ContextMenuContext, MenuState } from './types'; +import { ContextMenu } from './context-menu'; +import type { EnhancedMouseEvent } from './context-menu-content'; +import { getContextMenuContent } from './context-menu-content'; + +export const Context = createContext(null); + +export function ContextMenuProvider({ + children, +}: React.PropsWithChildren) { + const [menu, setMenu] = useState({ isOpen: false }); + const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]); + + useEffect(() => { + function handleContextMenu(event: MouseEvent) { + event.preventDefault(); + setMenu({ + isOpen: true, + children: getContextMenuContent(event as EnhancedMouseEvent).map( + (Content, index) => + ), + position: { + // TODO: Fix handling offset while scrolling + x: event.clientX, + y: event.clientY, + }, + }); + } + document.addEventListener('contextmenu', handleContextMenu); + + function handleClosingEvent(event: Event) { + if (!event.defaultPrevented) { + setMenu({ isOpen: false }); + } + } + document.addEventListener('click', handleClosingEvent); + window.addEventListener('resize', handleClosingEvent); + + return () => { + document.removeEventListener('contextmenu', handleContextMenu); + document.removeEventListener('click', handleClosingEvent); + window.removeEventListener('resize', handleClosingEvent); + }; + }, [setMenu]); + + const value = useMemo( + () => ({ + close, + }), + [close] + ); + + return ( + <> + {children} + {menu.isOpen && ( + {menu.children} + )} + + ); +} diff --git a/packages/compass-context-menu/src/context-menu.tsx b/packages/compass-context-menu/src/context-menu.tsx new file mode 100644 index 00000000000..b053bc4963a --- /dev/null +++ b/packages/compass-context-menu/src/context-menu.tsx @@ -0,0 +1,22 @@ +import { createPortal } from 'react-dom'; +import React from 'react'; + +type ContextMenuProps = React.PropsWithChildren<{ + position: { + x: number; + y: number; + }; +}>; + +export function ContextMenu({ children, position }: ContextMenuProps) { + const container = document.getElementById('context-menu-container'); + if (container === null) { + throw new Error('Expected a container for the context menu in the DOM'); + } + return createPortal( +
+ {children} +
, + container + ); +} diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts new file mode 100644 index 00000000000..07efb491ac6 --- /dev/null +++ b/packages/compass-context-menu/src/types.ts @@ -0,0 +1,21 @@ +export type MenuState = + | { + isOpen: false; + } + | { + isOpen: true; + children: React.ReactNode; + position: { + x: number; + y: number; + }; + }; + +export type ContextMenuContext = { + close(): void; +}; + +export type MenuItem = { + label: string; + onAction: (event: Event) => void; +}; diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx new file mode 100644 index 00000000000..d7ccc114a90 --- /dev/null +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -0,0 +1,55 @@ +import React, { useContext, useMemo, useRef } from 'react'; +import { Context } from './context-menu-provider'; +import { appendContextMenuContent } from './context-menu-content'; +import type { MenuItem } from './types'; + +/** + * @returns an object with methods to {@link register} content for the menu and {@link close} the menu + */ +export function useContextMenu({ + Menu, +}: { + Menu: React.ComponentType<{ + items: MenuItem[]; + }>; +}) { + // Get the close function from the ContextProvider + const context = useContext(Context); + const previous = useRef void]>( + null + ); + + return useMemo(() => { + if (!context) { + throw new Error('useContextMenu called outside of the provider'); + } + + return { + close: context.close.bind(context), + /** + * @returns a callback ref, passed onto the element responsible for triggering the menu. + */ + register(content: React.ComponentType) { + function listener(event: MouseEvent) { + appendContextMenuContent(event, content); + } + return (trigger: HTMLElement | null) => { + if (previous.current) { + const [previousTrigger, previousListener] = previous.current; + previousTrigger.removeEventListener( + 'contextmenu', + previousListener + ); + } + if (trigger) { + trigger.addEventListener('contextmenu', listener); + previous.current = [trigger, listener]; + } + }; + }, + registerItems(items: MenuItem[]) { + return this.register(() => ); + }, + }; + }, [context, Menu]); +} diff --git a/packages/compass-context-menu/tsconfig-lint.json b/packages/compass-context-menu/tsconfig-lint.json new file mode 100644 index 00000000000..6bdef84f322 --- /dev/null +++ b/packages/compass-context-menu/tsconfig-lint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/compass-context-menu/tsconfig.json b/packages/compass-context-menu/tsconfig.json new file mode 100644 index 00000000000..79bc84584ce --- /dev/null +++ b/packages/compass-context-menu/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@mongodb-js/tsconfig-compass/tsconfig.react.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["./src/**/*.spec.*"] +} From 68385135f237ac6811ad22055a28678637c4e440 Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 13:41:30 +0200 Subject: [PATCH 02/62] wip --- .../src/context-menu-provider.tsx | 4 +- .../src/use-context-menu.spec.tsx | 144 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 packages/compass-context-menu/src/use-context-menu.spec.tsx diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 7a7b59bf16a..499d9c273c1 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -14,7 +14,9 @@ export const Context = createContext(null); export function ContextMenuProvider({ children, -}: React.PropsWithChildren) { +}: { + children: React.ReactNode; +}) { const [menu, setMenu] = useState({ isOpen: false }); const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]); diff --git a/packages/compass-context-menu/src/use-context-menu.spec.tsx b/packages/compass-context-menu/src/use-context-menu.spec.tsx new file mode 100644 index 00000000000..7337463e819 --- /dev/null +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { + render, + screen, + cleanup, + userEvent, +} from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { useContextMenu } from './use-context-menu'; +import { ContextMenuProvider } from './context-menu-provider'; +import type { MenuItem } from './types'; + +describe('useContextMenu', function () { + const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => ( +
+ {items.map((item, idx) => ( +
+ {item.label} +
+ ))} +
+ ); + + const TestComponent = ({ + onRegister, + }: { + onRegister?: (ref: any) => void; + }) => { + const contextMenu = useContextMenu({ Menu: TestMenu }); + const items: MenuItem[] = [ + { + label: 'Test Item', + onAction: () => { + /* noop */ + }, + }, + ]; + const ref = contextMenu.registerItems(items); + + React.useEffect(() => { + onRegister?.(ref); + }, [ref, onRegister]); + + return ( +
+ Test Component +
+ ); + }; + + afterEach(cleanup); + + describe('when used outside provider', function () { + it('throws an error', function () { + expect(() => { + render(); + }).to.throw('useContextMenu called outside of the provider'); + }); + }); + + describe('when used inside provider', function () { + beforeEach(() => { + // Create the container for the context menu portal + const container = document.createElement('div'); + container.id = 'context-menu-container'; + document.body.appendChild(container); + }); + + afterEach(() => { + // Clean up the container + const container = document.getElementById('context-menu-container'); + if (container) { + document.body.removeChild(container); + } + }); + + it('renders without error', function () { + render( + + + + ); + + expect(screen.getByTestId('test-trigger')).to.exist; + }); + + it('registers context menu event listener', function () { + const onRegister = sinon.spy(); + + render( + + + + ); + + expect(onRegister).to.have.been.calledOnce; + expect(onRegister.firstCall.args[0]).to.be.a('function'); + }); + + it('shows context menu on right click', function () { + render( + + + + ); + + const trigger = screen.getByTestId('test-trigger'); + userEvent.click(trigger, { button: 2 }); + + // The menu should be rendered in the portal + expect(screen.getByTestId('menu-item-Test Item')).to.exist; + }); + + it('cleans up previous event listener when ref changes', function () { + const removeEventListenerSpy = sinon.spy(); + const addEventListenerSpy = sinon.spy(); + + const { rerender } = render( + + + + ); + + // Simulate ref change + const ref = screen.getByTestId('test-trigger'); + Object.defineProperty(ref, 'addEventListener', { + value: addEventListenerSpy, + }); + Object.defineProperty(ref, 'removeEventListener', { + value: removeEventListenerSpy, + }); + + rerender( + + + + ); + + expect(removeEventListenerSpy).to.have.been.calledWith('contextmenu'); + expect(addEventListenerSpy).to.have.been.calledWith('contextmenu'); + }); + }); +}); From 8e30a83b02f6c897c960905d4e2b310b3d2aba48 Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 15:16:18 +0200 Subject: [PATCH 03/62] fix: add tests --- packages/compass-context-menu/.mocharc.js | 2 +- .../src/use-context-menu.spec.tsx | 58 +++++++------------ .../src/use-context-menu.tsx | 6 +- 3 files changed, 26 insertions(+), 40 deletions(-) diff --git a/packages/compass-context-menu/.mocharc.js b/packages/compass-context-menu/.mocharc.js index e7eaccd61fa..5a33f216327 100644 --- a/packages/compass-context-menu/.mocharc.js +++ b/packages/compass-context-menu/.mocharc.js @@ -1,2 +1,2 @@ 'use strict'; -module.exports = require('@mongodb-js/mocha-config-compass'); +module.exports = require('@mongodb-js/mocha-config-compass/react'); 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 7337463e819..8a5cb4b6fee 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -1,36 +1,37 @@ import React from 'react'; -import { - render, - screen, - cleanup, - userEvent, -} from '@mongodb-js/testing-library-compass'; +import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; import type { MenuItem } from './types'; +type TestMenuItem = MenuItem & { id: number }; + describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC<{ items: TestMenuItem[] }> = ({ items }) => (
{items.map((item, idx) => ( -
+
{item.label}
))}
); - const TestComponent = ({ - onRegister, - }: { - onRegister?: (ref: any) => void; - }) => { + const TestComponent = () => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const items: MenuItem[] = [ + const items: TestMenuItem[] = [ { - label: 'Test Item', + id: 1, + label: 'Test A', + onAction: () => { + /* noop */ + }, + }, + { + id: 2, + label: 'Test B', onAction: () => { /* noop */ }, @@ -38,10 +39,6 @@ describe('useContextMenu', function () { ]; const ref = contextMenu.registerItems(items); - React.useEffect(() => { - onRegister?.(ref); - }, [ref, onRegister]); - return (
Test Component @@ -49,8 +46,6 @@ describe('useContextMenu', function () { ); }; - afterEach(cleanup); - describe('when used outside provider', function () { it('throws an error', function () { expect(() => { @@ -59,7 +54,7 @@ describe('useContextMenu', function () { }); }); - describe('when used inside provider', function () { + describe('with valid provider', function () { beforeEach(() => { // Create the container for the context menu portal const container = document.createElement('div'); @@ -85,19 +80,6 @@ describe('useContextMenu', function () { expect(screen.getByTestId('test-trigger')).to.exist; }); - it('registers context menu event listener', function () { - const onRegister = sinon.spy(); - - render( - - - - ); - - expect(onRegister).to.have.been.calledOnce; - expect(onRegister.firstCall.args[0]).to.be.a('function'); - }); - it('shows context menu on right click', function () { render( @@ -105,11 +87,15 @@ describe('useContextMenu', function () { ); + expect(screen.queryByTestId('menu-item-1')).not.to.exist; + expect(screen.queryByTestId('menu-item-2')).not.to.exist; + const trigger = screen.getByTestId('test-trigger'); userEvent.click(trigger, { button: 2 }); // The menu should be rendered in the portal - expect(screen.getByTestId('menu-item-Test Item')).to.exist; + expect(screen.getByTestId('menu-item-1')).to.exist; + expect(screen.getByTestId('menu-item-2')).to.exist; }); it('cleans up previous event listener when ref changes', function () { diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index d7ccc114a90..37993b04f15 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -6,11 +6,11 @@ import type { MenuItem } from './types'; /** * @returns an object with methods to {@link register} content for the menu and {@link close} the menu */ -export function useContextMenu({ +export function useContextMenu({ Menu, }: { Menu: React.ComponentType<{ - items: MenuItem[]; + items: T[]; }>; }) { // Get the close function from the ContextProvider @@ -47,7 +47,7 @@ export function useContextMenu({ } }; }, - registerItems(items: MenuItem[]) { + registerItems(items: T[]) { return this.register(() => ); }, }; From 7d17984cae90058b8ed9fd0e15d2c5525d9c64a7 Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 16:50:00 +0200 Subject: [PATCH 04/62] fix: add tests and fix types --- packages/compass-context-menu/src/types.ts | 2 +- .../src/use-context-menu.spec.tsx | 219 ++++++++++++++---- 2 files changed, 175 insertions(+), 46 deletions(-) diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index 07efb491ac6..e9ac549ba63 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -17,5 +17,5 @@ export type ContextMenuContext = { export type MenuItem = { label: string; - onAction: (event: Event) => void; + onAction: (event: React.KeyboardEvent | React.MouseEvent) => void; }; 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 8a5cb4b6fee..1d099272104 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -6,39 +6,48 @@ import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; import type { MenuItem } from './types'; -type TestMenuItem = MenuItem & { id: number }; - describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: TestMenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => (
{items.map((item, idx) => ( -
+
item.onAction?.(event)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + item.onAction?.(event); + } + }} + > {item.label}
))}
); - const TestComponent = () => { + const TestComponent = ({ + onRegister, + onAction, + }: { + onRegister?: (ref: any) => void; + onAction?: (id) => void; + }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const items: TestMenuItem[] = [ - { - id: 1, - label: 'Test A', - onAction: () => { - /* noop */ - }, - }, + const items: MenuItem[] = [ { - id: 2, - label: 'Test B', - onAction: () => { - /* noop */ - }, + label: 'Test Item', + onAction: () => onAction?.(1), }, ]; const ref = contextMenu.registerItems(items); + React.useEffect(() => { + onRegister?.(ref); + }, [ref, onRegister]); + return (
Test Component @@ -46,6 +55,60 @@ describe('useContextMenu', function () { ); }; + // Add new test components for nested context menu scenario + const ParentComponent = ({ + onAction, + children, + }: { + onAction?: (id: number) => void; + children?: React.ReactNode; + }) => { + const contextMenu = useContextMenu({ Menu: TestMenu }); + const parentItems: MenuItem[] = [ + { + label: 'Parent Item 1', + onAction: () => onAction?.(1), + }, + { + label: 'Parent Item 2', + onAction: () => onAction?.(2), + }, + ]; + const ref = contextMenu.registerItems(parentItems); + + return ( +
+
Parent Component
+ {children} +
+ ); + }; + + const ChildComponent = ({ + onAction, + }: { + onAction?: (id: number) => void; + }) => { + const contextMenu = useContextMenu({ Menu: TestMenu }); + const childItems: MenuItem[] = [ + { + label: 'Child Item 1', + onAction: () => onAction?.(1), + }, + { + label: 'Child Item 2', + onAction: () => onAction?.(2), + }, + ]; + const ref = contextMenu.registerItems(childItems); + + return ( +
+ Child Component +
+ ); + }; + describe('when used outside provider', function () { it('throws an error', function () { expect(() => { @@ -54,7 +117,7 @@ describe('useContextMenu', function () { }); }); - describe('with valid provider', function () { + describe('with a valid provider', function () { beforeEach(() => { // Create the container for the context menu portal const container = document.createElement('div'); @@ -80,6 +143,19 @@ describe('useContextMenu', function () { expect(screen.getByTestId('test-trigger')).to.exist; }); + it('registers context menu event listener', function () { + const onRegister = sinon.spy(); + + render( + + + + ); + + expect(onRegister).to.have.been.calledOnce; + expect(onRegister.firstCall.args[0]).to.be.a('function'); + }); + it('shows context menu on right click', function () { render( @@ -87,44 +163,97 @@ describe('useContextMenu', function () { ); - expect(screen.queryByTestId('menu-item-1')).not.to.exist; - expect(screen.queryByTestId('menu-item-2')).not.to.exist; - const trigger = screen.getByTestId('test-trigger'); userEvent.click(trigger, { button: 2 }); // The menu should be rendered in the portal - expect(screen.getByTestId('menu-item-1')).to.exist; - expect(screen.getByTestId('menu-item-2')).to.exist; + expect(screen.getByTestId('menu-item-Test Item')).to.exist; }); - it('cleans up previous event listener when ref changes', function () { - const removeEventListenerSpy = sinon.spy(); - const addEventListenerSpy = sinon.spy(); + describe('with nested context menus', function () { + it('shows only parent items when right clicking parent area', function () { + render( + + + + ); - const { rerender } = render( - - - - ); + const parentTrigger = screen.getByTestId('parent-trigger'); + userEvent.click(parentTrigger, { button: 2 }); + + // Should show parent items + expect(screen.getByTestId('menu-item-Parent Item 1')).to.exist; + expect(screen.getByTestId('menu-item-Parent Item 2')).to.exist; - // Simulate ref change - const ref = screen.getByTestId('test-trigger'); - Object.defineProperty(ref, 'addEventListener', { - value: addEventListenerSpy, + // Should not show child items + expect(() => screen.getByTestId('menu-item-Child Item 1')).to.throw; + expect(() => screen.getByTestId('menu-item-Child Item 2')).to.throw; }); - Object.defineProperty(ref, 'removeEventListener', { - value: removeEventListenerSpy, + + it('shows both parent and child items when right clicking child area', function () { + render( + + + + + + ); + + const childTrigger = screen.getByTestId('child-trigger'); + userEvent.click(childTrigger, { button: 2 }); + + // Should show both parent and child items + expect(screen.getByTestId('menu-item-Parent Item 1')).to.exist; + expect(screen.getByTestId('menu-item-Parent Item 2')).to.exist; + expect(screen.getByTestId('menu-item-Child Item 1')).to.exist; + expect(screen.getByTestId('menu-item-Child Item 2')).to.exist; }); - rerender( - - - - ); + it('triggers only the child action when clicking child menu item', function () { + const parentOnAction = sinon.spy(); + const childOnAction = sinon.spy(); + + render( + + + + + + ); + + const childTrigger = screen.getByTestId('child-trigger'); + userEvent.click(childTrigger, { button: 2 }); + + const childItem1 = screen.getByTestId('menu-item-Child Item 1'); + userEvent.click(childItem1); - expect(removeEventListenerSpy).to.have.been.calledWith('contextmenu'); - expect(addEventListenerSpy).to.have.been.calledWith('contextmenu'); + expect(childOnAction).to.have.been.calledOnceWithExactly(1); + expect(parentOnAction).to.not.have.been.called; + expect(() => screen.getByTestId('test-menu')).to.throw; + }); + + it('triggers only the parent action when clicking a parent menu item from child context', function () { + const parentOnAction = sinon.spy(); + const childOnAction = sinon.spy(); + + render( + + + + + + ); + + const childTrigger = screen.getByTestId('child-trigger'); + userEvent.click(childTrigger, { button: 2 }); + + const parentItem1 = screen.getByTestId('menu-item-Parent Item 1'); + userEvent.click(parentItem1); + + expect(parentOnAction).to.have.been.calledOnceWithExactly(1); + expect(childOnAction).to.not.have.been.called; + expect(() => screen.getByTestId('test-menu')).to.throw; + }); }); }); }); From 6004593f9a0c8a458436eab3c9212fdc7d177e37 Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 17:07:53 +0200 Subject: [PATCH 05/62] refactor: minor stylistic changes --- .../src/context-menu-provider.tsx | 3 +- .../src/use-context-menu.tsx | 53 +++++++++++-------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 499d9c273c1..43d9e893367 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -35,13 +35,14 @@ export function ContextMenuProvider({ }, }); } - document.addEventListener('contextmenu', handleContextMenu); function handleClosingEvent(event: Event) { if (!event.defaultPrevented) { setMenu({ isOpen: false }); } } + + document.addEventListener('contextmenu', handleContextMenu); document.addEventListener('click', handleClosingEvent); window.addEventListener('resize', handleClosingEvent); diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index 37993b04f15..91f51c2f849 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -3,16 +3,25 @@ import { Context } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; import type { MenuItem } from './types'; -/** - * @returns an object with methods to {@link register} content for the menu and {@link close} the menu - */ +export type ContextMenuMethods = { + /** + * Close the context menu. + */ + close: () => void; + /** + * Register the menu items for the context menu. + * @returns a callback ref to be passed onto the element responsible for triggering the menu. + */ + registerItems: (items: T[]) => (trigger: HTMLElement | null) => void; +}; + export function useContextMenu({ Menu, }: { Menu: React.ComponentType<{ items: T[]; }>; -}) { +}): ContextMenuMethods { // Get the close function from the ContextProvider const context = useContext(Context); const previous = useRef void]>( @@ -24,31 +33,29 @@ export function useContextMenu({ throw new Error('useContextMenu called outside of the provider'); } + const register = (content: React.ComponentType) => { + function listener(event: MouseEvent) { + appendContextMenuContent(event, content); + } + return (trigger: HTMLElement | null) => { + if (previous.current) { + const [previousTrigger, previousListener] = previous.current; + previousTrigger.removeEventListener('contextmenu', previousListener); + } + if (trigger) { + trigger.addEventListener('contextmenu', listener); + previous.current = [trigger, listener]; + } + }; + }; + return { close: context.close.bind(context), /** * @returns a callback ref, passed onto the element responsible for triggering the menu. */ - register(content: React.ComponentType) { - function listener(event: MouseEvent) { - appendContextMenuContent(event, content); - } - return (trigger: HTMLElement | null) => { - if (previous.current) { - const [previousTrigger, previousListener] = previous.current; - previousTrigger.removeEventListener( - 'contextmenu', - previousListener - ); - } - if (trigger) { - trigger.addEventListener('contextmenu', listener); - previous.current = [trigger, listener]; - } - }; - }, registerItems(items: T[]) { - return this.register(() => ); + return register(() => ); }, }; }, [context, Menu]); From 43023f4173ec9eb95f0c756c8997d54762c2af38 Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 22 May 2025 11:59:55 +0200 Subject: [PATCH 06/62] fix: export types and rename MenuItem --- packages/compass-context-menu/src/index.ts | 2 ++ packages/compass-context-menu/src/types.ts | 2 +- .../compass-context-menu/src/use-context-menu.spec.tsx | 10 +++++----- packages/compass-context-menu/src/use-context-menu.tsx | 6 +++--- 4 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 packages/compass-context-menu/src/index.ts diff --git a/packages/compass-context-menu/src/index.ts b/packages/compass-context-menu/src/index.ts new file mode 100644 index 00000000000..d60a97e04b3 --- /dev/null +++ b/packages/compass-context-menu/src/index.ts @@ -0,0 +1,2 @@ +export { useContextMenu } from './use-context-menu'; +export type { ContextMenuItem } from './types'; diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index e9ac549ba63..f453930dcb3 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -15,7 +15,7 @@ export type ContextMenuContext = { close(): void; }; -export type MenuItem = { +export type ContextMenuItem = { label: string; onAction: (event: React.KeyboardEvent | React.MouseEvent) => void; }; 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 1d099272104..eb857db9aeb 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -4,10 +4,10 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; -import type { MenuItem } from './types'; +import type { ContextMenuItem } from './types'; describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC<{ items: ContextMenuItem[] }> = ({ items }) => (
{items.map((item, idx) => (
void; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const items: MenuItem[] = [ + const items: ContextMenuItem[] = [ { label: 'Test Item', onAction: () => onAction?.(1), @@ -64,7 +64,7 @@ describe('useContextMenu', function () { children?: React.ReactNode; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const parentItems: MenuItem[] = [ + const parentItems: ContextMenuItem[] = [ { label: 'Parent Item 1', onAction: () => onAction?.(1), @@ -90,7 +90,7 @@ describe('useContextMenu', function () { onAction?: (id: number) => void; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const childItems: MenuItem[] = [ + const childItems: ContextMenuItem[] = [ { label: 'Child Item 1', onAction: () => onAction?.(1), diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index 91f51c2f849..a0c97dead9b 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -1,9 +1,9 @@ import React, { useContext, useMemo, useRef } from 'react'; import { Context } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; -import type { MenuItem } from './types'; +import type { ContextMenuItem } from './types'; -export type ContextMenuMethods = { +export type ContextMenuMethods = { /** * Close the context menu. */ @@ -15,7 +15,7 @@ export type ContextMenuMethods = { registerItems: (items: T[]) => (trigger: HTMLElement | null) => void; }; -export function useContextMenu({ +export function useContextMenu({ Menu, }: { Menu: React.ComponentType<{ From 971b0fdca53b556f73888e7acc0a7bd8e5781084 Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 22 May 2025 12:13:53 +0200 Subject: [PATCH 07/62] fix: basic UI implementation --- .../src/components/context-menu.tsx | 54 +++++++++++++++++++ packages/compass-context-menu/src/index.ts | 2 + packages/compass-context-menu/src/types.ts | 2 +- .../src/use-context-menu.spec.tsx | 10 ++-- .../src/use-context-menu.tsx | 12 +++-- 5 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 packages/compass-components/src/components/context-menu.tsx create mode 100644 packages/compass-context-menu/src/index.ts diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx new file mode 100644 index 00000000000..9bb9440a217 --- /dev/null +++ b/packages/compass-components/src/components/context-menu.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { css, cx } from '@leafygreen-ui/emotion'; +import { Menu, MenuItem, MenuSeparator } from './leafygreen'; +import type { ContextMenuItem } from '@mongodb-js/compass-context-menu'; +import { useContextMenu } from '@mongodb-js/compass-context-menu'; + +const menuStyle = css({ + position: 'fixed', + zIndex: 9999, +}); + +export type ContextMenuProps = { + items: ContextMenuItem[]; + className?: string; + 'data-testid'?: string; +}; + +export function ContextMenu({ + items, + className, + 'data-testid': dataTestId, +}: ContextMenuProps) { + return ( + + {items.map((item, idx) => { + const { label, onAction } = item; + const isLastItem = idx === items.length - 1; + + return ( + <> + {!isLastItem && } + { + evt.stopPropagation(); + onAction?.(evt); + }} + > + {label} + + + ); + })} + + ); +} + +export function useContextMenuItems( + items: ContextMenuItem[] +): React.RefCallback { + const contextMenu = useContextMenu({ Menu: ContextMenu }); + return contextMenu.registerItems(items); +} diff --git a/packages/compass-context-menu/src/index.ts b/packages/compass-context-menu/src/index.ts new file mode 100644 index 00000000000..d60a97e04b3 --- /dev/null +++ b/packages/compass-context-menu/src/index.ts @@ -0,0 +1,2 @@ +export { useContextMenu } from './use-context-menu'; +export type { ContextMenuItem } from './types'; diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index e9ac549ba63..f453930dcb3 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -15,7 +15,7 @@ export type ContextMenuContext = { close(): void; }; -export type MenuItem = { +export type ContextMenuItem = { label: string; onAction: (event: React.KeyboardEvent | React.MouseEvent) => void; }; 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 1d099272104..eb857db9aeb 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -4,10 +4,10 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; -import type { MenuItem } from './types'; +import type { ContextMenuItem } from './types'; describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC<{ items: ContextMenuItem[] }> = ({ items }) => (
{items.map((item, idx) => (
void; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const items: MenuItem[] = [ + const items: ContextMenuItem[] = [ { label: 'Test Item', onAction: () => onAction?.(1), @@ -64,7 +64,7 @@ describe('useContextMenu', function () { children?: React.ReactNode; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const parentItems: MenuItem[] = [ + const parentItems: ContextMenuItem[] = [ { label: 'Parent Item 1', onAction: () => onAction?.(1), @@ -90,7 +90,7 @@ describe('useContextMenu', function () { onAction?: (id: number) => void; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const childItems: MenuItem[] = [ + const childItems: ContextMenuItem[] = [ { label: 'Child Item 1', onAction: () => onAction?.(1), diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index 91f51c2f849..fa9e20e7537 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -1,9 +1,9 @@ import React, { useContext, useMemo, useRef } from 'react'; import { Context } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; -import type { MenuItem } from './types'; +import type { ContextMenuItem } from './types'; -export type ContextMenuMethods = { +export type ContextMenuMethods = { /** * Close the context menu. */ @@ -12,10 +12,10 @@ export type ContextMenuMethods = { * Register the menu items for the context menu. * @returns a callback ref to be passed onto the element responsible for triggering the menu. */ - registerItems: (items: T[]) => (trigger: HTMLElement | null) => void; + registerItems: (items: T[]) => React.RefCallback; }; -export function useContextMenu({ +export function useContextMenu({ Menu, }: { Menu: React.ComponentType<{ @@ -33,7 +33,9 @@ export function useContextMenu({ throw new Error('useContextMenu called outside of the provider'); } - const register = (content: React.ComponentType) => { + const register = ( + content: React.ComponentType + ): React.RefCallback => { function listener(event: MouseEvent) { appendContextMenuContent(event, content); } From 0ad7a63fe23680212cc8be9e00c199a7b144dd2d Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 22 May 2025 12:14:37 +0200 Subject: [PATCH 08/62] fix: use React.RefCallback --- packages/compass-context-menu/src/use-context-menu.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index a0c97dead9b..fa9e20e7537 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -12,7 +12,7 @@ export type ContextMenuMethods = { * Register the menu items for the context menu. * @returns a callback ref to be passed onto the element responsible for triggering the menu. */ - registerItems: (items: T[]) => (trigger: HTMLElement | null) => void; + registerItems: (items: T[]) => React.RefCallback; }; export function useContextMenu({ @@ -33,7 +33,9 @@ export function useContextMenu({ throw new Error('useContextMenu called outside of the provider'); } - const register = (content: React.ComponentType) => { + const register = ( + content: React.ComponentType + ): React.RefCallback => { function listener(event: MouseEvent) { appendContextMenuContent(event, content); } From aa9f0cf6dc8ecb7f43cff89b4be35dd286cf29db Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 23 May 2025 12:08:50 +0200 Subject: [PATCH 09/62] fix: switch to item-based organization --- .../src/components/context-menu.spec.tsx | 149 ++++++++++++++++++ .../src/components/context-menu.tsx | 68 ++++---- packages/compass-components/src/index.ts | 5 + .../src/compass-context-menu.tsx | 24 +++ .../src/context-menu-content.ts | 13 +- .../src/context-menu-provider.tsx | 42 +++-- .../compass-context-menu/src/context-menu.tsx | 22 --- packages/compass-context-menu/src/index.ts | 3 +- packages/compass-context-menu/src/types.ts | 9 +- .../src/use-context-menu.spec.tsx | 63 ++++---- .../src/use-context-menu.tsx | 59 ++++--- .../src/components/desktop-welcome-tab.tsx | 73 ++++++--- .../compass/src/app/components/entrypoint.tsx | 6 +- 13 files changed, 381 insertions(+), 155 deletions(-) create mode 100644 packages/compass-components/src/components/context-menu.spec.tsx create mode 100644 packages/compass-context-menu/src/compass-context-menu.tsx delete mode 100644 packages/compass-context-menu/src/context-menu.tsx diff --git a/packages/compass-components/src/components/context-menu.spec.tsx b/packages/compass-components/src/components/context-menu.spec.tsx new file mode 100644 index 00000000000..e3f84335acf --- /dev/null +++ b/packages/compass-components/src/components/context-menu.spec.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { ContextMenuProvider } from '@mongodb-js/compass-context-menu'; +import { useContextMenuItems, ContextMenu } from './context-menu'; +import type { ContextMenuItem } from '@mongodb-js/compass-context-menu'; + +describe('useContextMenuItems', function () { + const TestComponent = ({ items }: { items: ContextMenuItem[] }) => { + const ref = useContextMenuItems(items); + + return ( +
+ Test Component +
+ ); + }; + + describe('when used outside provider', function () { + it('throws an error', function () { + const items = [ + { + label: 'Test Item', + onAction: () => {}, + }, + ]; + + expect(() => { + render(); + }).to.throw('useContextMenu called outside of the provider'); + }); + }); + + describe('with a valid provider', function () { + beforeEach(() => { + // Create the container for the context menu portal + const container = document.createElement('div'); + container.id = 'context-menu-container'; + document.body.appendChild(container); + }); + + afterEach(() => { + // Clean up the container + const container = document.getElementById('context-menu-container'); + if (container) { + document.body.removeChild(container); + } + }); + + it('renders without error', function () { + const items = [ + { + label: 'Test Item', + onAction: () => {}, + }, + ]; + + render( + + + + ); + + expect(screen.getByTestId('test-trigger')).to.exist; + }); + + it('shows context menu with items on right click', function () { + const items = [ + { + label: 'Test Item 1', + onAction: () => {}, + }, + { + label: 'Test Item 2', + onAction: () => {}, + }, + ]; + + render( + + + + ); + + const trigger = screen.getByTestId('test-trigger'); + userEvent.click(trigger, { button: 2 }); + + // The menu items should be rendered + expect(screen.getByTestId('context-menu-item-Test Item 1')).to.exist; + expect(screen.getByTestId('context-menu-item-Test Item 2')).to.exist; + }); + + it('triggers the correct action when menu item is clicked', function () { + const onAction = sinon.spy(); + const items = [ + { + label: 'Test Item 1', + onAction: () => onAction(1), + }, + { + label: 'Test Item 2', + onAction: () => onAction(2), + }, + ]; + + render( + + + + ); + + const trigger = screen.getByTestId('test-trigger'); + userEvent.click(trigger, { button: 2 }); + + const menuItem = screen.getByTestId('context-menu-item-Test Item 2'); + userEvent.click(menuItem); + + expect(onAction).to.have.been.calledOnceWithExactly(2); + }); + + it('renders menu items with separators', function () { + const items = [ + { + label: 'Test Item 1', + onAction: () => {}, + }, + { + label: 'Test Item 2', + onAction: () => {}, + }, + ]; + + render( + + + + ); + + const trigger = screen.getByTestId('test-trigger'); + userEvent.click(trigger, { button: 2 }); + + // Should find both menu items and a separator between them + expect(screen.getByTestId('context-menu-item-Test Item 1')).to.exist; + expect(screen.getByRole('separator')).to.exist; + expect(screen.getByTestId('context-menu-item-Test Item 2')).to.exist; + }); + }); +}); diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index 9bb9440a217..9790f306772 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -1,45 +1,53 @@ import React from 'react'; -import { css, cx } from '@leafygreen-ui/emotion'; import { Menu, MenuItem, MenuSeparator } from './leafygreen'; import type { ContextMenuItem } from '@mongodb-js/compass-context-menu'; import { useContextMenu } from '@mongodb-js/compass-context-menu'; +import { ContextMenuProvider as ContextMenuProviderBase } from '@mongodb-js/compass-context-menu'; +import type { ContextMenuItemGroup } from '@mongodb-js/compass-context-menu/dist/types'; -const menuStyle = css({ - position: 'fixed', - zIndex: 9999, -}); +export function ContextMenuProvider({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} export type ContextMenuProps = { - items: ContextMenuItem[]; + itemGroups: ContextMenuItemGroup[]; className?: string; 'data-testid'?: string; }; -export function ContextMenu({ - items, - className, - 'data-testid': dataTestId, -}: ContextMenuProps) { +export function ContextMenu({ itemGroups }: ContextMenuProps) { return ( - - {items.map((item, idx) => { - const { label, onAction } = item; - const isLastItem = idx === items.length - 1; - + + {itemGroups.map((itemGroup: ContextMenuItemGroup, groupIndex: number) => { return ( - <> - {!isLastItem && } - { - evt.stopPropagation(); - onAction?.(evt); - }} - > - {label} - - +
+ {itemGroup.items.map((item: ContextMenuItem, itemIndex: number) => { + return ( + { + console.log('clicked', evt); + item.onAction?.(evt); + }} + > + {item.label} {itemIndex} {groupIndex} + + ); + })} + {groupIndex < itemGroups.length - 1 && ( + + )} +
); })}
@@ -49,6 +57,6 @@ export function ContextMenu({ export function useContextMenuItems( items: ContextMenuItem[] ): React.RefCallback { - const contextMenu = useContextMenu({ Menu: ContextMenu }); + const contextMenu = useContextMenu(); return contextMenu.registerItems(items); } diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 1857adc3c99..7cabba8e671 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -93,6 +93,11 @@ export { ModalHeader } from './components/modals/modal-header'; export { FormModal } from './components/modals/form-modal'; export { InfoModal } from './components/modals/info-modal'; +export { + ContextMenuProvider, + useContextMenuItems, +} from './components/context-menu'; + export type { FileInputBackend, ItemAction, diff --git a/packages/compass-context-menu/src/compass-context-menu.tsx b/packages/compass-context-menu/src/compass-context-menu.tsx new file mode 100644 index 00000000000..113b441a25e --- /dev/null +++ b/packages/compass-context-menu/src/compass-context-menu.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +type ContextMenuProps = React.PropsWithChildren<{ + position: { + x: number; + y: number; + }; +}>; + +export function ContextMenu({ children, position }: ContextMenuProps) { + console.log('ContextMenu', position); + return ( +
+ {children} +
+ ); +} diff --git a/packages/compass-context-menu/src/context-menu-content.ts b/packages/compass-context-menu/src/context-menu-content.ts index 6856f1fe684..c301983a679 100644 --- a/packages/compass-context-menu/src/context-menu-content.ts +++ b/packages/compass-context-menu/src/context-menu-content.ts @@ -1,23 +1,24 @@ +import type { ContextMenuItemGroup } from './types'; + const CONTEXT_MENUS_SYMBOL = Symbol('context_menus'); export type EnhancedMouseEvent = MouseEvent & { - [CONTEXT_MENUS_SYMBOL]?: React.ComponentType[]; + [CONTEXT_MENUS_SYMBOL]?: ContextMenuItemGroup[]; }; export function getContextMenuContent( event: EnhancedMouseEvent -): React.ComponentType[] { +): ContextMenuItemGroup[] { return event[CONTEXT_MENUS_SYMBOL] ?? []; } export function appendContextMenuContent( event: EnhancedMouseEvent, - content: React.ComponentType + content: ContextMenuItemGroup ) { // Initialize if not already patched - if (event[CONTEXT_MENUS_SYMBOL] === undefined) { - event[CONTEXT_MENUS_SYMBOL] = [content]; - return; + if (!event[CONTEXT_MENUS_SYMBOL]) { + event[CONTEXT_MENUS_SYMBOL] = []; } event[CONTEXT_MENUS_SYMBOL].push(content); } diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 43d9e893367..621ac7281bc 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -5,8 +5,12 @@ import React, { useMemo, createContext, } from 'react'; -import type { ContextMenuContext, MenuState } from './types'; -import { ContextMenu } from './context-menu'; +import type { + ContextMenuContext, + ContextMenuItemGroup, + ContextMenuState, +} from './types'; +import { ContextMenu } from './compass-context-menu'; import type { EnhancedMouseEvent } from './context-menu-content'; import { getContextMenuContent } from './context-menu-content'; @@ -14,20 +18,22 @@ export const Context = createContext(null); export function ContextMenuProvider({ children, + wrapper, }: { children: React.ReactNode; + wrapper: React.ComponentType<{ itemGroups: ContextMenuItemGroup[] }>; }) { - const [menu, setMenu] = useState({ isOpen: false }); + const [menu, setMenu] = useState({ isOpen: false }); const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]); useEffect(() => { function handleContextMenu(event: MouseEvent) { + console.log('handleContextMenu', event); event.preventDefault(); + setMenu({ isOpen: true, - children: getContextMenuContent(event as EnhancedMouseEvent).map( - (Content, index) => - ), + itemGroups: getContextMenuContent(event as EnhancedMouseEvent), position: { // TODO: Fix handling offset while scrolling x: event.clientX, @@ -37,16 +43,20 @@ export function ContextMenuProvider({ } function handleClosingEvent(event: Event) { + console.log('handleClosingEvent', event); if (!event.defaultPrevented) { + console.log('setting menu to false'); setMenu({ isOpen: false }); } } + console.log('adding event listeners'); document.addEventListener('contextmenu', handleContextMenu); - document.addEventListener('click', handleClosingEvent); + window.addEventListener('click', handleClosingEvent); window.addEventListener('resize', handleClosingEvent); return () => { + console.log('removing event listeners'); document.removeEventListener('contextmenu', handleContextMenu); document.removeEventListener('click', handleClosingEvent); window.removeEventListener('resize', handleClosingEvent); @@ -60,12 +70,18 @@ export function ContextMenuProvider({ [close] ); + const Wrapper = wrapper ?? React.Fragment; + return ( - <> - {children} - {menu.isOpen && ( - {menu.children} - )} - + + <> + {children} + {menu.isOpen && ( + + + + )} + + ); } diff --git a/packages/compass-context-menu/src/context-menu.tsx b/packages/compass-context-menu/src/context-menu.tsx deleted file mode 100644 index b053bc4963a..00000000000 --- a/packages/compass-context-menu/src/context-menu.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { createPortal } from 'react-dom'; -import React from 'react'; - -type ContextMenuProps = React.PropsWithChildren<{ - position: { - x: number; - y: number; - }; -}>; - -export function ContextMenu({ children, position }: ContextMenuProps) { - const container = document.getElementById('context-menu-container'); - if (container === null) { - throw new Error('Expected a container for the context menu in the DOM'); - } - return createPortal( -
- {children} -
, - container - ); -} diff --git a/packages/compass-context-menu/src/index.ts b/packages/compass-context-menu/src/index.ts index d60a97e04b3..f024b578aa9 100644 --- a/packages/compass-context-menu/src/index.ts +++ b/packages/compass-context-menu/src/index.ts @@ -1,2 +1,3 @@ export { useContextMenu } from './use-context-menu'; -export type { ContextMenuItem } from './types'; +export { ContextMenuProvider } from './context-menu-provider'; +export type { ContextMenuItem, ContextMenuItemGroup } from './types'; diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index f453930dcb3..a0019fa88df 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -1,10 +1,15 @@ -export type MenuState = +export interface ContextMenuItemGroup { + items: ContextMenuItem[]; + originListener: (event: MouseEvent) => void; +} + +export type ContextMenuState = | { isOpen: false; } | { isOpen: true; - children: React.ReactNode; + itemGroups: ContextMenuItemGroup[]; position: { x: number; y: number; 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 eb857db9aeb..b9ef7b5cd06 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -4,27 +4,31 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; -import type { ContextMenuItem } from './types'; +import type { ContextMenuItem, ContextMenuItemGroup } from './types'; describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: ContextMenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC<{ itemGroups: ContextMenuItemGroup[] }> = ({ + itemGroups, + }) => (
- {items.map((item, idx) => ( -
item.onAction?.(event)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - item.onAction?.(event); - } - }} - > - {item.label} -
- ))} + {itemGroups.flatMap((group, groupIdx) => + group.items.map((item, idx) => ( +
item.onAction?.(event)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + item.onAction?.(event); + } + }} + > + {item.label} +
+ )) + )}
); @@ -33,9 +37,9 @@ describe('useContextMenu', function () { onAction, }: { onRegister?: (ref: any) => void; - onAction?: (id) => void; + onAction?: (id: number) => void; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const items: ContextMenuItem[] = [ { label: 'Test Item', @@ -55,7 +59,6 @@ describe('useContextMenu', function () { ); }; - // Add new test components for nested context menu scenario const ParentComponent = ({ onAction, children, @@ -63,7 +66,7 @@ describe('useContextMenu', function () { onAction?: (id: number) => void; children?: React.ReactNode; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const parentItems: ContextMenuItem[] = [ { label: 'Parent Item 1', @@ -89,7 +92,7 @@ describe('useContextMenu', function () { }: { onAction?: (id: number) => void; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const childItems: ContextMenuItem[] = [ { label: 'Child Item 1', @@ -135,7 +138,7 @@ describe('useContextMenu', function () { it('renders without error', function () { render( - + ); @@ -147,7 +150,7 @@ describe('useContextMenu', function () { const onRegister = sinon.spy(); render( - + ); @@ -158,7 +161,7 @@ describe('useContextMenu', function () { it('shows context menu on right click', function () { render( - + ); @@ -173,7 +176,7 @@ describe('useContextMenu', function () { describe('with nested context menus', function () { it('shows only parent items when right clicking parent area', function () { render( - + ); @@ -192,7 +195,7 @@ describe('useContextMenu', function () { it('shows both parent and child items when right clicking child area', function () { render( - + @@ -214,7 +217,7 @@ describe('useContextMenu', function () { const childOnAction = sinon.spy(); render( - + @@ -237,7 +240,7 @@ describe('useContextMenu', function () { const childOnAction = sinon.spy(); render( - + diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index fa9e20e7537..02b94383faa 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -1,9 +1,10 @@ -import React, { useContext, useMemo, useRef } from 'react'; +import type { RefCallback } from 'react'; +import { useContext, useMemo, useRef } from 'react'; import { Context } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; import type { ContextMenuItem } from './types'; -export type ContextMenuMethods = { +export type ContextMenuMethods = { /** * Close the context menu. */ @@ -12,17 +13,10 @@ export type ContextMenuMethods = { * Register the menu items for the context menu. * @returns a callback ref to be passed onto the element responsible for triggering the menu. */ - registerItems: (items: T[]) => React.RefCallback; + registerItems: (items: ContextMenuItem[]) => RefCallback; }; -export function useContextMenu({ - Menu, -}: { - Menu: React.ComponentType<{ - items: T[]; - }>; -}): ContextMenuMethods { - // Get the close function from the ContextProvider +export function useContextMenu(): ContextMenuMethods { const context = useContext(Context); const previous = useRef void]>( null @@ -33,32 +27,33 @@ export function useContextMenu({ throw new Error('useContextMenu called outside of the provider'); } - const register = ( - content: React.ComponentType - ): React.RefCallback => { - function listener(event: MouseEvent) { - appendContextMenuContent(event, content); - } - return (trigger: HTMLElement | null) => { - if (previous.current) { - const [previousTrigger, previousListener] = previous.current; - previousTrigger.removeEventListener('contextmenu', previousListener); - } - if (trigger) { - trigger.addEventListener('contextmenu', listener); - previous.current = [trigger, listener]; - } - }; - }; - return { close: context.close.bind(context), /** * @returns a callback ref, passed onto the element responsible for triggering the menu. */ - registerItems(items: T[]) { - return register(() => ); + registerItems(items: ContextMenuItem[]) { + function listener(event: MouseEvent): void { + appendContextMenuContent(event, { + items, + originListener: listener, + }); + } + + return (trigger: HTMLElement | null) => { + if (previous.current) { + const [previousTrigger, previousListener] = previous.current; + previousTrigger.removeEventListener( + 'contextmenu', + previousListener + ); + } + if (trigger) { + trigger.addEventListener('contextmenu', listener); + previous.current = [trigger, listener]; + } + }; }, }; - }, [context, Menu]); + }, [context]); } diff --git a/packages/compass-welcome/src/components/desktop-welcome-tab.tsx b/packages/compass-welcome/src/components/desktop-welcome-tab.tsx index 768a73e0b92..9c39d1005f7 100644 --- a/packages/compass-welcome/src/components/desktop-welcome-tab.tsx +++ b/packages/compass-welcome/src/components/desktop-welcome-tab.tsx @@ -14,6 +14,7 @@ import { cx, useDarkMode, Icon, + useContextMenuItems, } from '@mongodb-js/compass-components'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; import { useConnectionActions } from '@mongodb-js/compass-connections/provider'; @@ -66,11 +67,22 @@ const createClusterButtonLightModeStyles = css({ }); function AtlasHelpSection(): React.ReactElement { - const track = useTelemetry(); const darkMode = useDarkMode(); + const track = useTelemetry(); + const contextRef = useContextMenuItems([ + { + label: '1', + onAction: () => track('Atlas Link Clicked', { screen: 'connect' }), + }, + { + label: '2', + onAction: () => track('Atlas Link Clicked', { screen: 'connect' }), + }, + ]); return (
-
- -
+
); } +function TestClusterButton() { + const track = useTelemetry(); + const darkMode = useDarkMode(); + const contextRef = useContextMenuItems([ + { + label: '123', + onAction: () => track('Atlas Link Clicked', { screen: 'connect' }), + }, + ]); + return ( +
+ +
+ ); +} const welcomeTabStyles = css({ display: 'flex', alignItems: 'center', @@ -125,9 +151,20 @@ export default function DesktopWelcomeTab() { const enableCreatingNewConnections = usePreference( 'enableCreatingNewConnections' ); + const track = useTelemetry(); + const contextRef = useContextMenuItems([ + { + label: '4', + onAction: () => track('Atlas Link Clicked', { screen: 'connect' }), + }, + { + label: '5', + onAction: () => track('Atlas Link Clicked', { screen: 'connect' }), + }, + ]); return ( -
+

Welcome to MongoDB Compass

diff --git a/packages/compass/src/app/components/entrypoint.tsx b/packages/compass/src/app/components/entrypoint.tsx index 493580e6564..1bbcb163d43 100644 --- a/packages/compass/src/app/components/entrypoint.tsx +++ b/packages/compass/src/app/components/entrypoint.tsx @@ -30,6 +30,8 @@ import { createIpcSendTrack, } from '@mongodb-js/compass-telemetry'; import { DataModelStorageServiceProviderElectron } from '@mongodb-js/compass-data-modeling/renderer'; +import { Context } from '@segment/analytics-node'; +import { ContextMenuProvider } from '@mongodb-js/compass-components'; const WithPreferencesAndLoggerProviders: React.FC = ({ children }) => { const loggerProviderValue = useRef({ @@ -101,7 +103,9 @@ export const CompassElectron = (props: React.ComponentProps) => { - + + + From 58df56aa142486422235df04077c702bc7053d0a Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 23 May 2025 13:41:54 +0200 Subject: [PATCH 10/62] fix: cleanup and switch to menu prop --- .../src/components/context-menu.spec.tsx | 120 +++++++++++------- .../src/components/context-menu.tsx | 96 +++++++++----- .../src/compass-context-menu.tsx | 24 ---- .../src/context-menu-provider.tsx | 61 +++++---- packages/compass-context-menu/src/index.ts | 6 +- packages/compass-context-menu/src/types.ts | 24 ++-- .../src/use-context-menu.spec.tsx | 8 +- .../src/components/desktop-welcome-tab.tsx | 6 +- 8 files changed, 186 insertions(+), 159 deletions(-) delete mode 100644 packages/compass-context-menu/src/compass-context-menu.tsx diff --git a/packages/compass-components/src/components/context-menu.spec.tsx b/packages/compass-components/src/components/context-menu.spec.tsx index e3f84335acf..14177575d9d 100644 --- a/packages/compass-components/src/components/context-menu.spec.tsx +++ b/packages/compass-components/src/components/context-menu.spec.tsx @@ -7,12 +7,23 @@ import { useContextMenuItems, ContextMenu } from './context-menu'; import type { ContextMenuItem } from '@mongodb-js/compass-context-menu'; describe('useContextMenuItems', function () { - const TestComponent = ({ items }: { items: ContextMenuItem[] }) => { + const menuTestTriggerId = 'test-trigger'; + + const TestComponent = ({ + items, + children, + 'data-testid': dataTestId = menuTestTriggerId, + }: { + items: ContextMenuItem[]; + children?: React.ReactNode; + 'data-testid'?: string; + }) => { const ref = useContextMenuItems(items); return ( -
+
Test Component + {children}
); }; @@ -33,21 +44,6 @@ describe('useContextMenuItems', function () { }); describe('with a valid provider', function () { - beforeEach(() => { - // Create the container for the context menu portal - const container = document.createElement('div'); - container.id = 'context-menu-container'; - document.body.appendChild(container); - }); - - afterEach(() => { - // Clean up the container - const container = document.getElementById('context-menu-container'); - if (container) { - document.body.removeChild(container); - } - }); - it('renders without error', function () { const items = [ { @@ -62,7 +58,7 @@ describe('useContextMenuItems', function () { ); - expect(screen.getByTestId('test-trigger')).to.exist; + expect(screen.getByTestId(menuTestTriggerId)).to.exist; }); it('shows context menu with items on right click', function () { @@ -83,12 +79,12 @@ describe('useContextMenuItems', function () { ); - const trigger = screen.getByTestId('test-trigger'); + const trigger = screen.getByTestId(menuTestTriggerId); userEvent.click(trigger, { button: 2 }); // The menu items should be rendered - expect(screen.getByTestId('context-menu-item-Test Item 1')).to.exist; - expect(screen.getByTestId('context-menu-item-Test Item 2')).to.exist; + expect(screen.getByTestId('menu-group-0-item-0')).to.exist; + expect(screen.getByTestId('menu-group-0-item-1')).to.exist; }); it('triggers the correct action when menu item is clicked', function () { @@ -110,40 +106,68 @@ describe('useContextMenuItems', function () { ); - const trigger = screen.getByTestId('test-trigger'); + const trigger = screen.getByTestId(menuTestTriggerId); userEvent.click(trigger, { button: 2 }); - const menuItem = screen.getByTestId('context-menu-item-Test Item 2'); + const menuItem = screen.getByTestId('menu-group-0-item-1'); userEvent.click(menuItem); expect(onAction).to.have.been.calledOnceWithExactly(2); }); - it('renders menu items with separators', function () { - const items = [ - { - label: 'Test Item 1', - onAction: () => {}, - }, - { - label: 'Test Item 2', - onAction: () => {}, - }, - ]; - - render( - - - - ); - - const trigger = screen.getByTestId('test-trigger'); - userEvent.click(trigger, { button: 2 }); - - // Should find both menu items and a separator between them - expect(screen.getByTestId('context-menu-item-Test Item 1')).to.exist; - expect(screen.getByRole('separator')).to.exist; - expect(screen.getByTestId('context-menu-item-Test Item 2')).to.exist; + describe('with nested components', function () { + const childTriggerId = 'child-trigger'; + + beforeEach(function () { + const items = [ + { + label: 'Test Item 1', + onAction: () => {}, + }, + { + label: 'Test Item 2', + onAction: () => {}, + }, + ]; + + const childItems = [ + { + label: 'Child Item 1', + onAction: () => {}, + }, + ]; + + render( + + + + + + ); + }); + + it('renders menu items with separators', function () { + const trigger = screen.getByTestId(childTriggerId); + userEvent.click(trigger, { button: 2 }); + + // Should find the menu item and the separator + expect(screen.getByTestId('menu-group-0').children.length).to.equal(2); + expect( + screen.getByTestId('menu-group-0').children.item(0)?.textContent + ).to.equal('Child Item 1'); + + expect(screen.getByTestId('menu-group-0-separator')).to.exist; + + expect(screen.getByTestId('menu-group-1').children.length).to.equal(2); + expect( + screen.getByTestId('menu-group-1').children.item(0)?.textContent + ).to.equal('Test Item 1'); + expect( + screen.getByTestId('menu-group-1').children.item(1)?.textContent + ).to.equal('Test Item 2'); + + expect(screen.queryByTestId('menu-group-1-separator')).not.to.exist; + }); }); }); }); diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index 9790f306772..c3fdae09d68 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -1,9 +1,12 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Menu, MenuItem, MenuSeparator } from './leafygreen'; import type { ContextMenuItem } from '@mongodb-js/compass-context-menu'; import { useContextMenu } from '@mongodb-js/compass-context-menu'; import { ContextMenuProvider as ContextMenuProviderBase } from '@mongodb-js/compass-context-menu'; -import type { ContextMenuItemGroup } from '@mongodb-js/compass-context-menu/dist/types'; +import type { + ContextMenuItemGroup, + ContextMenuWrapperProps, +} from '@mongodb-js/compass-context-menu/dist/types'; export function ContextMenuProvider({ children, @@ -17,40 +20,65 @@ export function ContextMenuProvider({ ); } -export type ContextMenuProps = { - itemGroups: ContextMenuItemGroup[]; - className?: string; - 'data-testid'?: string; -}; +export function ContextMenu({ menu }: ContextMenuWrapperProps) { + const position = menu.position; + const itemGroups = menu.itemGroups; + + useEffect(() => { + if (!menu.isOpen) { + menu.close(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [menu.isOpen]); -export function ContextMenu({ itemGroups }: ContextMenuProps) { return ( - - {itemGroups.map((itemGroup: ContextMenuItemGroup, groupIndex: number) => { - return ( -
- {itemGroup.items.map((item: ContextMenuItem, itemIndex: number) => { - return ( - { - console.log('clicked', evt); - item.onAction?.(evt); - }} - > - {item.label} {itemIndex} {groupIndex} - - ); - })} - {groupIndex < itemGroups.length - 1 && ( - - )} -
- ); - })} -
+
+ + {itemGroups.map( + (itemGroup: ContextMenuItemGroup, groupIndex: number) => { + return ( +
+ {itemGroup.items.map( + (item: ContextMenuItem, itemIndex: number) => { + return ( + { + item.onAction?.(evt); + menu.close(); + }} + > + {item.label} + + ); + } + )} + {groupIndex < itemGroups.length - 1 && ( +
+ +
+ )} +
+ ); + } + )} +
+
); } diff --git a/packages/compass-context-menu/src/compass-context-menu.tsx b/packages/compass-context-menu/src/compass-context-menu.tsx deleted file mode 100644 index 113b441a25e..00000000000 --- a/packages/compass-context-menu/src/compass-context-menu.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -type ContextMenuProps = React.PropsWithChildren<{ - position: { - x: number; - y: number; - }; -}>; - -export function ContextMenu({ children, position }: ContextMenuProps) { - console.log('ContextMenu', position); - return ( -
- {children} -
- ); -} diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 621ac7281bc..5dd1cba2cff 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -5,12 +5,7 @@ import React, { useMemo, createContext, } from 'react'; -import type { - ContextMenuContext, - ContextMenuItemGroup, - ContextMenuState, -} from './types'; -import { ContextMenu } from './compass-context-menu'; +import type { ContextMenuContext, ContextMenuState } from './types'; import type { EnhancedMouseEvent } from './context-menu-content'; import { getContextMenuContent } from './context-menu-content'; @@ -21,19 +16,39 @@ export function ContextMenuProvider({ wrapper, }: { children: React.ReactNode; - wrapper: React.ComponentType<{ itemGroups: ContextMenuItemGroup[] }>; + wrapper: React.ComponentType<{ + menu: ContextMenuState & { close: () => void }; + }>; }) { - const [menu, setMenu] = useState({ isOpen: false }); - const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]); + const [menu, setMenu] = useState({ + isOpen: false, + itemGroups: [], + position: { x: 0, y: 0 }, + }); + const close = useCallback(() => setMenu({ ...menu, isOpen: false }), [menu]); + + const handleClosingEvent = useCallback( + (event: Event) => { + if (!event.defaultPrevented) { + setMenu({ ...menu, isOpen: false }); + } + }, + [menu] + ); useEffect(() => { function handleContextMenu(event: MouseEvent) { - console.log('handleContextMenu', event); event.preventDefault(); + const itemGroups = getContextMenuContent(event as EnhancedMouseEvent); + + if (itemGroups.length === 0) { + return; + } + setMenu({ isOpen: true, - itemGroups: getContextMenuContent(event as EnhancedMouseEvent), + itemGroups, position: { // TODO: Fix handling offset while scrolling x: event.clientX, @@ -42,26 +57,14 @@ export function ContextMenuProvider({ }); } - function handleClosingEvent(event: Event) { - console.log('handleClosingEvent', event); - if (!event.defaultPrevented) { - console.log('setting menu to false'); - setMenu({ isOpen: false }); - } - } - - console.log('adding event listeners'); document.addEventListener('contextmenu', handleContextMenu); - window.addEventListener('click', handleClosingEvent); window.addEventListener('resize', handleClosingEvent); return () => { - console.log('removing event listeners'); document.removeEventListener('contextmenu', handleContextMenu); - document.removeEventListener('click', handleClosingEvent); window.removeEventListener('resize', handleClosingEvent); }; - }, [setMenu]); + }, [handleClosingEvent]); const value = useMemo( () => ({ @@ -74,14 +77,8 @@ export function ContextMenuProvider({ return ( - <> - {children} - {menu.isOpen && ( - - - - )} - + {children} + ); } diff --git a/packages/compass-context-menu/src/index.ts b/packages/compass-context-menu/src/index.ts index f024b578aa9..75d933ef767 100644 --- a/packages/compass-context-menu/src/index.ts +++ b/packages/compass-context-menu/src/index.ts @@ -1,3 +1,7 @@ export { useContextMenu } from './use-context-menu'; export { ContextMenuProvider } from './context-menu-provider'; -export type { ContextMenuItem, ContextMenuItemGroup } from './types'; +export type { + ContextMenuItem, + ContextMenuItemGroup, + ContextMenuWrapperProps, +} from './types'; diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index a0019fa88df..91e8d65cdcc 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -3,18 +3,18 @@ export interface ContextMenuItemGroup { originListener: (event: MouseEvent) => void; } -export type ContextMenuState = - | { - isOpen: false; - } - | { - isOpen: true; - itemGroups: ContextMenuItemGroup[]; - position: { - x: number; - y: number; - }; - }; +export type ContextMenuState = { + isOpen: boolean; + itemGroups: ContextMenuItemGroup[]; + position: { + x: number; + y: number; + }; +}; + +export type ContextMenuWrapperProps = { + menu: ContextMenuState & { close: () => void }; +}; export type ContextMenuContext = { close(): void; 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 b9ef7b5cd06..cfe0bfc7ef4 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -4,14 +4,12 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; -import type { ContextMenuItem, ContextMenuItemGroup } from './types'; +import type { ContextMenuItem, ContextMenuWrapperProps } from './types'; describe('useContextMenu', function () { - const TestMenu: React.FC<{ itemGroups: ContextMenuItemGroup[] }> = ({ - itemGroups, - }) => ( + const TestMenu: React.FC = ({ menu }) => (
- {itemGroups.flatMap((group, groupIdx) => + {menu.itemGroups.flatMap((group, groupIdx) => group.items.map((item, idx) => (
track('Atlas Link Clicked', { screen: 'connect' }), + onAction: () => console.log('Atlas Link Clicked', { screen: 'connect' }), }, { label: '2', - onAction: () => track('Atlas Link Clicked', { screen: 'connect' }), + onAction: () => console.log('Atlas Link Clicked', { screen: 'connect' }), }, ]); @@ -111,7 +111,7 @@ function TestClusterButton() { const contextRef = useContextMenuItems([ { label: '123', - onAction: () => track('Atlas Link Clicked', { screen: 'connect' }), + onAction: () => console.log('Atlas Link Clicked', { screen: 'connect' }), }, ]); return ( From a54c7384b434ccfce970eacd5e32db7597d45555 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 23 May 2025 13:50:50 +0200 Subject: [PATCH 11/62] refactor: use item groups instead of React elements, use wrapper, keep menu state consistent The refactor is meant to make the Leafygreen integration more straightforward: 1. We stick to item groups and instead have a single wrapper to handle any rendering differences between groups. This allows the wrapper to always have context of all items when rendering which is useful when inserting menu seperators in Leafygreen. Also encourages consistent UI (while allowing per-case customization if needed at wrapper-level). We could introduce itemWrappers instead of itemGroups but having one wrapper handling all seems cleaner to me. 2. More of the responsibility is moved to a parent wrapper component that will house the context menu. This allows us to standardize the right click menu and make better use of Leafygreen's menu component including its click handling (which has been removed from the context menu library). 3. Menu state (i.e. position) is now preserved even closed; this is useful for leafygreen's menu to animate in the same position instead of losing the position all together. --- .../src/context-menu-content.ts | 13 ++-- .../src/context-menu-provider.tsx | 57 ++++++++++------- packages/compass-context-menu/src/index.ts | 7 ++- packages/compass-context-menu/src/types.ts | 29 +++++---- .../src/use-context-menu.spec.tsx | 61 ++++++++++--------- .../src/use-context-menu.tsx | 59 +++++++++--------- 6 files changed, 124 insertions(+), 102 deletions(-) diff --git a/packages/compass-context-menu/src/context-menu-content.ts b/packages/compass-context-menu/src/context-menu-content.ts index 6856f1fe684..c301983a679 100644 --- a/packages/compass-context-menu/src/context-menu-content.ts +++ b/packages/compass-context-menu/src/context-menu-content.ts @@ -1,23 +1,24 @@ +import type { ContextMenuItemGroup } from './types'; + const CONTEXT_MENUS_SYMBOL = Symbol('context_menus'); export type EnhancedMouseEvent = MouseEvent & { - [CONTEXT_MENUS_SYMBOL]?: React.ComponentType[]; + [CONTEXT_MENUS_SYMBOL]?: ContextMenuItemGroup[]; }; export function getContextMenuContent( event: EnhancedMouseEvent -): React.ComponentType[] { +): ContextMenuItemGroup[] { return event[CONTEXT_MENUS_SYMBOL] ?? []; } export function appendContextMenuContent( event: EnhancedMouseEvent, - content: React.ComponentType + content: ContextMenuItemGroup ) { // Initialize if not already patched - if (event[CONTEXT_MENUS_SYMBOL] === undefined) { - event[CONTEXT_MENUS_SYMBOL] = [content]; - return; + if (!event[CONTEXT_MENUS_SYMBOL]) { + event[CONTEXT_MENUS_SYMBOL] = []; } event[CONTEXT_MENUS_SYMBOL].push(content); } diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 43d9e893367..5dd1cba2cff 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -5,8 +5,7 @@ import React, { useMemo, createContext, } from 'react'; -import type { ContextMenuContext, MenuState } from './types'; -import { ContextMenu } from './context-menu'; +import type { ContextMenuContext, ContextMenuState } from './types'; import type { EnhancedMouseEvent } from './context-menu-content'; import { getContextMenuContent } from './context-menu-content'; @@ -14,20 +13,42 @@ export const Context = createContext(null); export function ContextMenuProvider({ children, + wrapper, }: { children: React.ReactNode; + wrapper: React.ComponentType<{ + menu: ContextMenuState & { close: () => void }; + }>; }) { - const [menu, setMenu] = useState({ isOpen: false }); - const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]); + const [menu, setMenu] = useState({ + isOpen: false, + itemGroups: [], + position: { x: 0, y: 0 }, + }); + const close = useCallback(() => setMenu({ ...menu, isOpen: false }), [menu]); + + const handleClosingEvent = useCallback( + (event: Event) => { + if (!event.defaultPrevented) { + setMenu({ ...menu, isOpen: false }); + } + }, + [menu] + ); useEffect(() => { function handleContextMenu(event: MouseEvent) { event.preventDefault(); + + const itemGroups = getContextMenuContent(event as EnhancedMouseEvent); + + if (itemGroups.length === 0) { + return; + } + setMenu({ isOpen: true, - children: getContextMenuContent(event as EnhancedMouseEvent).map( - (Content, index) => - ), + itemGroups, position: { // TODO: Fix handling offset while scrolling x: event.clientX, @@ -36,22 +57,14 @@ export function ContextMenuProvider({ }); } - function handleClosingEvent(event: Event) { - if (!event.defaultPrevented) { - setMenu({ isOpen: false }); - } - } - document.addEventListener('contextmenu', handleContextMenu); - document.addEventListener('click', handleClosingEvent); window.addEventListener('resize', handleClosingEvent); return () => { document.removeEventListener('contextmenu', handleContextMenu); - document.removeEventListener('click', handleClosingEvent); window.removeEventListener('resize', handleClosingEvent); }; - }, [setMenu]); + }, [handleClosingEvent]); const value = useMemo( () => ({ @@ -60,12 +73,12 @@ export function ContextMenuProvider({ [close] ); + const Wrapper = wrapper ?? React.Fragment; + return ( - <> - {children} - {menu.isOpen && ( - {menu.children} - )} - + + {children} + + ); } diff --git a/packages/compass-context-menu/src/index.ts b/packages/compass-context-menu/src/index.ts index d60a97e04b3..75d933ef767 100644 --- a/packages/compass-context-menu/src/index.ts +++ b/packages/compass-context-menu/src/index.ts @@ -1,2 +1,7 @@ export { useContextMenu } from './use-context-menu'; -export type { ContextMenuItem } from './types'; +export { ContextMenuProvider } from './context-menu-provider'; +export type { + ContextMenuItem, + ContextMenuItemGroup, + ContextMenuWrapperProps, +} from './types'; diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index f453930dcb3..91e8d65cdcc 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -1,15 +1,20 @@ -export type MenuState = - | { - isOpen: false; - } - | { - isOpen: true; - children: React.ReactNode; - position: { - x: number; - y: number; - }; - }; +export interface ContextMenuItemGroup { + items: ContextMenuItem[]; + originListener: (event: MouseEvent) => void; +} + +export type ContextMenuState = { + isOpen: boolean; + itemGroups: ContextMenuItemGroup[]; + position: { + x: number; + y: number; + }; +}; + +export type ContextMenuWrapperProps = { + menu: ContextMenuState & { close: () => void }; +}; export type ContextMenuContext = { close(): void; 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 eb857db9aeb..cfe0bfc7ef4 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -4,27 +4,29 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; -import type { ContextMenuItem } from './types'; +import type { ContextMenuItem, ContextMenuWrapperProps } from './types'; describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: ContextMenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC = ({ menu }) => (
- {items.map((item, idx) => ( -
item.onAction?.(event)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - item.onAction?.(event); - } - }} - > - {item.label} -
- ))} + {menu.itemGroups.flatMap((group, groupIdx) => + group.items.map((item, idx) => ( +
item.onAction?.(event)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + item.onAction?.(event); + } + }} + > + {item.label} +
+ )) + )}
); @@ -33,9 +35,9 @@ describe('useContextMenu', function () { onAction, }: { onRegister?: (ref: any) => void; - onAction?: (id) => void; + onAction?: (id: number) => void; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const items: ContextMenuItem[] = [ { label: 'Test Item', @@ -55,7 +57,6 @@ describe('useContextMenu', function () { ); }; - // Add new test components for nested context menu scenario const ParentComponent = ({ onAction, children, @@ -63,7 +64,7 @@ describe('useContextMenu', function () { onAction?: (id: number) => void; children?: React.ReactNode; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const parentItems: ContextMenuItem[] = [ { label: 'Parent Item 1', @@ -89,7 +90,7 @@ describe('useContextMenu', function () { }: { onAction?: (id: number) => void; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const childItems: ContextMenuItem[] = [ { label: 'Child Item 1', @@ -135,7 +136,7 @@ describe('useContextMenu', function () { it('renders without error', function () { render( - + ); @@ -147,7 +148,7 @@ describe('useContextMenu', function () { const onRegister = sinon.spy(); render( - + ); @@ -158,7 +159,7 @@ describe('useContextMenu', function () { it('shows context menu on right click', function () { render( - + ); @@ -173,7 +174,7 @@ describe('useContextMenu', function () { describe('with nested context menus', function () { it('shows only parent items when right clicking parent area', function () { render( - + ); @@ -192,7 +193,7 @@ describe('useContextMenu', function () { it('shows both parent and child items when right clicking child area', function () { render( - + @@ -214,7 +215,7 @@ describe('useContextMenu', function () { const childOnAction = sinon.spy(); render( - + @@ -237,7 +238,7 @@ describe('useContextMenu', function () { const childOnAction = sinon.spy(); render( - + diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index fa9e20e7537..a60aeba4c69 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -1,4 +1,5 @@ -import React, { useContext, useMemo, useRef } from 'react'; +import type { RefCallback } from 'react'; +import { useContext, useMemo, useRef } from 'react'; import { Context } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; import type { ContextMenuItem } from './types'; @@ -12,17 +13,12 @@ export type ContextMenuMethods = { * Register the menu items for the context menu. * @returns a callback ref to be passed onto the element responsible for triggering the menu. */ - registerItems: (items: T[]) => React.RefCallback; + registerItems: (items: T[]) => RefCallback; }; -export function useContextMenu({ - Menu, -}: { - Menu: React.ComponentType<{ - items: T[]; - }>; -}): ContextMenuMethods { - // Get the close function from the ContextProvider +export function useContextMenu< + T extends ContextMenuItem = ContextMenuItem +>(): ContextMenuMethods { const context = useContext(Context); const previous = useRef void]>( null @@ -33,32 +29,33 @@ export function useContextMenu({ throw new Error('useContextMenu called outside of the provider'); } - const register = ( - content: React.ComponentType - ): React.RefCallback => { - function listener(event: MouseEvent) { - appendContextMenuContent(event, content); - } - return (trigger: HTMLElement | null) => { - if (previous.current) { - const [previousTrigger, previousListener] = previous.current; - previousTrigger.removeEventListener('contextmenu', previousListener); - } - if (trigger) { - trigger.addEventListener('contextmenu', listener); - previous.current = [trigger, listener]; - } - }; - }; - return { close: context.close.bind(context), /** * @returns a callback ref, passed onto the element responsible for triggering the menu. */ - registerItems(items: T[]) { - return register(() => ); + registerItems(items: ContextMenuItem[]) { + function listener(event: MouseEvent): void { + appendContextMenuContent(event, { + items, + originListener: listener, + }); + } + + return (trigger: HTMLElement | null) => { + if (previous.current) { + const [previousTrigger, previousListener] = previous.current; + previousTrigger.removeEventListener( + 'contextmenu', + previousListener + ); + } + if (trigger) { + trigger.addEventListener('contextmenu', listener); + previous.current = [trigger, listener]; + } + }; }, }; - }, [context, Menu]); + }, [context]); } From 56d11b60c1c6136466a7f14a3508d01789ae86bb Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 23 May 2025 15:17:47 +0200 Subject: [PATCH 12/62] fix: revert test environment --- .../src/components/desktop-welcome-tab.tsx | 73 +++++-------------- 1 file changed, 18 insertions(+), 55 deletions(-) diff --git a/packages/compass-welcome/src/components/desktop-welcome-tab.tsx b/packages/compass-welcome/src/components/desktop-welcome-tab.tsx index 59eaeba9da7..768a73e0b92 100644 --- a/packages/compass-welcome/src/components/desktop-welcome-tab.tsx +++ b/packages/compass-welcome/src/components/desktop-welcome-tab.tsx @@ -14,7 +14,6 @@ import { cx, useDarkMode, Icon, - useContextMenuItems, } from '@mongodb-js/compass-components'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; import { useConnectionActions } from '@mongodb-js/compass-connections/provider'; @@ -67,22 +66,11 @@ const createClusterButtonLightModeStyles = css({ }); function AtlasHelpSection(): React.ReactElement { - const darkMode = useDarkMode(); const track = useTelemetry(); - const contextRef = useContextMenuItems([ - { - label: '1', - onAction: () => console.log('Atlas Link Clicked', { screen: 'connect' }), - }, - { - label: '2', - onAction: () => console.log('Atlas Link Clicked', { screen: 'connect' }), - }, - ]); + const darkMode = useDarkMode(); return (
- +
+ +
); } -function TestClusterButton() { - const track = useTelemetry(); - const darkMode = useDarkMode(); - const contextRef = useContextMenuItems([ - { - label: '123', - onAction: () => console.log('Atlas Link Clicked', { screen: 'connect' }), - }, - ]); - return ( -
- -
- ); -} const welcomeTabStyles = css({ display: 'flex', alignItems: 'center', @@ -151,20 +125,9 @@ export default function DesktopWelcomeTab() { const enableCreatingNewConnections = usePreference( 'enableCreatingNewConnections' ); - const track = useTelemetry(); - const contextRef = useContextMenuItems([ - { - label: '4', - onAction: () => track('Atlas Link Clicked', { screen: 'connect' }), - }, - { - label: '5', - onAction: () => track('Atlas Link Clicked', { screen: 'connect' }), - }, - ]); return ( -
+

Welcome to MongoDB Compass

From 743f0688320a1ad0c87782ac828f24b8c96d1284 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 23 May 2025 16:54:04 +0200 Subject: [PATCH 13/62] fix: justify start to make it prefer right way popups and remove comment --- .../compass-components/src/components/context-menu.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index c3fdae09d68..c78519e399c 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -28,8 +28,7 @@ export function ContextMenu({ menu }: ContextMenuWrapperProps) { if (!menu.isOpen) { menu.close(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [menu.isOpen]); + }, [menu, menu.isOpen]); return (
- + {itemGroups.map( (itemGroup: ContextMenuItemGroup, groupIndex: number) => { return ( From ab14b78be3f20211d9f189789d3906afd2dd908a Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 23 May 2025 17:19:26 +0200 Subject: [PATCH 14/62] fix: remove redundant context import --- packages/compass/src/app/components/entrypoint.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/compass/src/app/components/entrypoint.tsx b/packages/compass/src/app/components/entrypoint.tsx index 1bbcb163d43..866b3414188 100644 --- a/packages/compass/src/app/components/entrypoint.tsx +++ b/packages/compass/src/app/components/entrypoint.tsx @@ -30,7 +30,6 @@ import { createIpcSendTrack, } from '@mongodb-js/compass-telemetry'; import { DataModelStorageServiceProviderElectron } from '@mongodb-js/compass-data-modeling/renderer'; -import { Context } from '@segment/analytics-node'; import { ContextMenuProvider } from '@mongodb-js/compass-components'; const WithPreferencesAndLoggerProviders: React.FC = ({ children }) => { From 1d279caf13cf4a1fe03dc39fda901441f8d28c3d Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 20 May 2025 16:59:43 +0200 Subject: [PATCH 15/62] feat(compass-context-menu): add a headless context menu package --- package-lock.json | 104 ++++++++++++++++++ packages/compass-context-menu/.depcheckrc | 8 ++ packages/compass-context-menu/.eslintignore | 2 + packages/compass-context-menu/.eslintrc.js | 9 ++ packages/compass-context-menu/.mocharc.js | 2 + packages/compass-context-menu/package.json | 72 ++++++++++++ .../src/context-menu-content.ts | 23 ++++ .../src/context-menu-provider.tsx | 68 ++++++++++++ .../compass-context-menu/src/context-menu.tsx | 22 ++++ packages/compass-context-menu/src/types.ts | 21 ++++ .../src/use-context-menu.tsx | 55 +++++++++ .../compass-context-menu/tsconfig-lint.json | 5 + packages/compass-context-menu/tsconfig.json | 8 ++ 13 files changed, 399 insertions(+) create mode 100644 packages/compass-context-menu/.depcheckrc create mode 100644 packages/compass-context-menu/.eslintignore create mode 100644 packages/compass-context-menu/.eslintrc.js create mode 100644 packages/compass-context-menu/.mocharc.js create mode 100644 packages/compass-context-menu/package.json create mode 100644 packages/compass-context-menu/src/context-menu-content.ts create mode 100644 packages/compass-context-menu/src/context-menu-provider.tsx create mode 100644 packages/compass-context-menu/src/context-menu.tsx create mode 100644 packages/compass-context-menu/src/types.ts create mode 100644 packages/compass-context-menu/src/use-context-menu.tsx create mode 100644 packages/compass-context-menu/tsconfig-lint.json create mode 100644 packages/compass-context-menu/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 284ef8ff00f..136ac1527de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7864,6 +7864,10 @@ "resolved": "packages/compass-connections-navigation", "link": true }, + "node_modules/@mongodb-js/compass-context-menu": { + "resolved": "packages/compass-context-menu", + "link": true + }, "node_modules/@mongodb-js/compass-crud": { "resolved": "packages/compass-crud", "link": true @@ -44105,6 +44109,62 @@ "node": ">=0.3.1" } }, + "packages/compass-context-menu": { + "name": "@mongodb-js/compass-context-menu", + "version": "0.0.1", + "license": "SSPL", + "dependencies": { + "react": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.3.8", + "@mongodb-js/mocha-config-compass": "^1.6.8", + "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/tsconfig-compass": "^1.2.8", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "sinon": "^9.2.3", + "typescript": "^5.0.4" + } + }, + "packages/compass-context-menu/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "packages/compass-context-menu/node_modules/sinon": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", + "integrity": "sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==", + "deprecated": "16.1.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/samsam": "^5.3.1", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "packages/compass-crud": { "name": "@mongodb-js/compass-crud", "version": "13.56.1", @@ -56549,6 +56609,50 @@ } } }, + "@mongodb-js/compass-context-menu": { + "version": "file:packages/compass-context-menu", + "requires": { + "@mongodb-js/eslint-config-compass": "^1.3.8", + "@mongodb-js/mocha-config-compass": "^1.6.8", + "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/tsconfig-compass": "^1.2.8", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "react": "^17.0.2", + "sinon": "^9.2.3", + "typescript": "^5.0.4" + }, + "dependencies": { + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, + "sinon": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", + "integrity": "sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/samsam": "^5.3.1", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + } + } + } + }, "@mongodb-js/compass-crud": { "version": "file:packages/compass-crud", "requires": { diff --git a/packages/compass-context-menu/.depcheckrc b/packages/compass-context-menu/.depcheckrc new file mode 100644 index 00000000000..ab0ef21b740 --- /dev/null +++ b/packages/compass-context-menu/.depcheckrc @@ -0,0 +1,8 @@ +ignores: + - '@mongodb-js/prettier-config-compass' + - '@mongodb-js/tsconfig-compass' + - '@types/chai' + - '@types/sinon-chai' + - 'sinon' +ignore-patterns: + - 'dist' diff --git a/packages/compass-context-menu/.eslintignore b/packages/compass-context-menu/.eslintignore new file mode 100644 index 00000000000..85a8a75e68c --- /dev/null +++ b/packages/compass-context-menu/.eslintignore @@ -0,0 +1,2 @@ +.nyc-output +dist diff --git a/packages/compass-context-menu/.eslintrc.js b/packages/compass-context-menu/.eslintrc.js new file mode 100644 index 00000000000..9c3ab95632f --- /dev/null +++ b/packages/compass-context-menu/.eslintrc.js @@ -0,0 +1,9 @@ +'use strict'; +module.exports = { + root: true, + extends: ['@mongodb-js/eslint-config-compass'], + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig-lint.json'], + }, +}; diff --git a/packages/compass-context-menu/.mocharc.js b/packages/compass-context-menu/.mocharc.js new file mode 100644 index 00000000000..e7eaccd61fa --- /dev/null +++ b/packages/compass-context-menu/.mocharc.js @@ -0,0 +1,2 @@ +'use strict'; +module.exports = require('@mongodb-js/mocha-config-compass'); diff --git a/packages/compass-context-menu/package.json b/packages/compass-context-menu/package.json new file mode 100644 index 00000000000..3cf186e0270 --- /dev/null +++ b/packages/compass-context-menu/package.json @@ -0,0 +1,72 @@ +{ + "name": "@mongodb-js/compass-context-menu", + "author": { + "name": "MongoDB Inc", + "email": "compass@mongodb.com" + }, + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/COMPASS/issues", + "email": "compass@mongodb.com" + }, + "homepage": "https://github.com/mongodb-js/compass", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://github.com/mongodb-js/compass.git" + }, + "files": [ + "dist" + ], + "license": "SSPL", + "main": "dist/index.js", + "compass:main": "src/index.ts", + "exports": { + "import": "./dist/.esm-wrapper.mjs", + "require": "./dist/index.js" + }, + "compass:exports": { + ".": "./src/index.ts" + }, + "types": "./dist/index.d.ts", + "scripts": { + "bootstrap": "npm run compile", + "prepublishOnly": "npm run compile && compass-scripts check-exports-exist", + "compile": "tsc -p tsconfig.json && gen-esm-wrapper . ./dist/.esm-wrapper.mjs", + "typecheck": "tsc -p tsconfig-lint.json --noEmit", + "eslint": "eslint-compass", + "prettier": "prettier-compass", + "lint": "npm run eslint . && npm run prettier -- --check .", + "depcheck": "compass-scripts check-peer-deps && depcheck", + "check": "npm run typecheck && npm run lint && npm run depcheck", + "check-ci": "npm run check", + "test": "mocha", + "test-cov": "nyc --compact=false --produce-source-map=false -x \"**/*.spec.*\" --reporter=lcov --reporter=text --reporter=html npm run test", + "test-watch": "npm run test -- --watch", + "test-ci": "npm run test-cov", + "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." + }, + "dependencies": { + "react": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.3.8", + "@mongodb-js/mocha-config-compass": "^1.6.8", + "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/tsconfig-compass": "^1.2.8", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "sinon": "^9.2.3", + "typescript": "^5.0.4" + } +} diff --git a/packages/compass-context-menu/src/context-menu-content.ts b/packages/compass-context-menu/src/context-menu-content.ts new file mode 100644 index 00000000000..6856f1fe684 --- /dev/null +++ b/packages/compass-context-menu/src/context-menu-content.ts @@ -0,0 +1,23 @@ +const CONTEXT_MENUS_SYMBOL = Symbol('context_menus'); + +export type EnhancedMouseEvent = MouseEvent & { + [CONTEXT_MENUS_SYMBOL]?: React.ComponentType[]; +}; + +export function getContextMenuContent( + event: EnhancedMouseEvent +): React.ComponentType[] { + return event[CONTEXT_MENUS_SYMBOL] ?? []; +} + +export function appendContextMenuContent( + event: EnhancedMouseEvent, + content: React.ComponentType +) { + // Initialize if not already patched + if (event[CONTEXT_MENUS_SYMBOL] === undefined) { + event[CONTEXT_MENUS_SYMBOL] = [content]; + return; + } + event[CONTEXT_MENUS_SYMBOL].push(content); +} diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx new file mode 100644 index 00000000000..7a7b59bf16a --- /dev/null +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -0,0 +1,68 @@ +import React, { + useCallback, + useEffect, + useState, + useMemo, + createContext, +} from 'react'; +import type { ContextMenuContext, MenuState } from './types'; +import { ContextMenu } from './context-menu'; +import type { EnhancedMouseEvent } from './context-menu-content'; +import { getContextMenuContent } from './context-menu-content'; + +export const Context = createContext(null); + +export function ContextMenuProvider({ + children, +}: React.PropsWithChildren) { + const [menu, setMenu] = useState({ isOpen: false }); + const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]); + + useEffect(() => { + function handleContextMenu(event: MouseEvent) { + event.preventDefault(); + setMenu({ + isOpen: true, + children: getContextMenuContent(event as EnhancedMouseEvent).map( + (Content, index) => + ), + position: { + // TODO: Fix handling offset while scrolling + x: event.clientX, + y: event.clientY, + }, + }); + } + document.addEventListener('contextmenu', handleContextMenu); + + function handleClosingEvent(event: Event) { + if (!event.defaultPrevented) { + setMenu({ isOpen: false }); + } + } + document.addEventListener('click', handleClosingEvent); + window.addEventListener('resize', handleClosingEvent); + + return () => { + document.removeEventListener('contextmenu', handleContextMenu); + document.removeEventListener('click', handleClosingEvent); + window.removeEventListener('resize', handleClosingEvent); + }; + }, [setMenu]); + + const value = useMemo( + () => ({ + close, + }), + [close] + ); + + return ( + <> + {children} + {menu.isOpen && ( + {menu.children} + )} + + ); +} diff --git a/packages/compass-context-menu/src/context-menu.tsx b/packages/compass-context-menu/src/context-menu.tsx new file mode 100644 index 00000000000..b053bc4963a --- /dev/null +++ b/packages/compass-context-menu/src/context-menu.tsx @@ -0,0 +1,22 @@ +import { createPortal } from 'react-dom'; +import React from 'react'; + +type ContextMenuProps = React.PropsWithChildren<{ + position: { + x: number; + y: number; + }; +}>; + +export function ContextMenu({ children, position }: ContextMenuProps) { + const container = document.getElementById('context-menu-container'); + if (container === null) { + throw new Error('Expected a container for the context menu in the DOM'); + } + return createPortal( +
+ {children} +
, + container + ); +} diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts new file mode 100644 index 00000000000..07efb491ac6 --- /dev/null +++ b/packages/compass-context-menu/src/types.ts @@ -0,0 +1,21 @@ +export type MenuState = + | { + isOpen: false; + } + | { + isOpen: true; + children: React.ReactNode; + position: { + x: number; + y: number; + }; + }; + +export type ContextMenuContext = { + close(): void; +}; + +export type MenuItem = { + label: string; + onAction: (event: Event) => void; +}; diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx new file mode 100644 index 00000000000..d7ccc114a90 --- /dev/null +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -0,0 +1,55 @@ +import React, { useContext, useMemo, useRef } from 'react'; +import { Context } from './context-menu-provider'; +import { appendContextMenuContent } from './context-menu-content'; +import type { MenuItem } from './types'; + +/** + * @returns an object with methods to {@link register} content for the menu and {@link close} the menu + */ +export function useContextMenu({ + Menu, +}: { + Menu: React.ComponentType<{ + items: MenuItem[]; + }>; +}) { + // Get the close function from the ContextProvider + const context = useContext(Context); + const previous = useRef void]>( + null + ); + + return useMemo(() => { + if (!context) { + throw new Error('useContextMenu called outside of the provider'); + } + + return { + close: context.close.bind(context), + /** + * @returns a callback ref, passed onto the element responsible for triggering the menu. + */ + register(content: React.ComponentType) { + function listener(event: MouseEvent) { + appendContextMenuContent(event, content); + } + return (trigger: HTMLElement | null) => { + if (previous.current) { + const [previousTrigger, previousListener] = previous.current; + previousTrigger.removeEventListener( + 'contextmenu', + previousListener + ); + } + if (trigger) { + trigger.addEventListener('contextmenu', listener); + previous.current = [trigger, listener]; + } + }; + }, + registerItems(items: MenuItem[]) { + return this.register(() => ); + }, + }; + }, [context, Menu]); +} diff --git a/packages/compass-context-menu/tsconfig-lint.json b/packages/compass-context-menu/tsconfig-lint.json new file mode 100644 index 00000000000..6bdef84f322 --- /dev/null +++ b/packages/compass-context-menu/tsconfig-lint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/compass-context-menu/tsconfig.json b/packages/compass-context-menu/tsconfig.json new file mode 100644 index 00000000000..79bc84584ce --- /dev/null +++ b/packages/compass-context-menu/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@mongodb-js/tsconfig-compass/tsconfig.react.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["./src/**/*.spec.*"] +} From 1efdb2ecc1ed8a7d8211bc61ef391238273405a7 Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 13:41:30 +0200 Subject: [PATCH 16/62] wip --- .../src/context-menu-provider.tsx | 4 +- .../src/use-context-menu.spec.tsx | 144 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 packages/compass-context-menu/src/use-context-menu.spec.tsx diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 7a7b59bf16a..499d9c273c1 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -14,7 +14,9 @@ export const Context = createContext(null); export function ContextMenuProvider({ children, -}: React.PropsWithChildren) { +}: { + children: React.ReactNode; +}) { const [menu, setMenu] = useState({ isOpen: false }); const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]); diff --git a/packages/compass-context-menu/src/use-context-menu.spec.tsx b/packages/compass-context-menu/src/use-context-menu.spec.tsx new file mode 100644 index 00000000000..7337463e819 --- /dev/null +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { + render, + screen, + cleanup, + userEvent, +} from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { useContextMenu } from './use-context-menu'; +import { ContextMenuProvider } from './context-menu-provider'; +import type { MenuItem } from './types'; + +describe('useContextMenu', function () { + const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => ( +
+ {items.map((item, idx) => ( +
+ {item.label} +
+ ))} +
+ ); + + const TestComponent = ({ + onRegister, + }: { + onRegister?: (ref: any) => void; + }) => { + const contextMenu = useContextMenu({ Menu: TestMenu }); + const items: MenuItem[] = [ + { + label: 'Test Item', + onAction: () => { + /* noop */ + }, + }, + ]; + const ref = contextMenu.registerItems(items); + + React.useEffect(() => { + onRegister?.(ref); + }, [ref, onRegister]); + + return ( +
+ Test Component +
+ ); + }; + + afterEach(cleanup); + + describe('when used outside provider', function () { + it('throws an error', function () { + expect(() => { + render(); + }).to.throw('useContextMenu called outside of the provider'); + }); + }); + + describe('when used inside provider', function () { + beforeEach(() => { + // Create the container for the context menu portal + const container = document.createElement('div'); + container.id = 'context-menu-container'; + document.body.appendChild(container); + }); + + afterEach(() => { + // Clean up the container + const container = document.getElementById('context-menu-container'); + if (container) { + document.body.removeChild(container); + } + }); + + it('renders without error', function () { + render( + + + + ); + + expect(screen.getByTestId('test-trigger')).to.exist; + }); + + it('registers context menu event listener', function () { + const onRegister = sinon.spy(); + + render( + + + + ); + + expect(onRegister).to.have.been.calledOnce; + expect(onRegister.firstCall.args[0]).to.be.a('function'); + }); + + it('shows context menu on right click', function () { + render( + + + + ); + + const trigger = screen.getByTestId('test-trigger'); + userEvent.click(trigger, { button: 2 }); + + // The menu should be rendered in the portal + expect(screen.getByTestId('menu-item-Test Item')).to.exist; + }); + + it('cleans up previous event listener when ref changes', function () { + const removeEventListenerSpy = sinon.spy(); + const addEventListenerSpy = sinon.spy(); + + const { rerender } = render( + + + + ); + + // Simulate ref change + const ref = screen.getByTestId('test-trigger'); + Object.defineProperty(ref, 'addEventListener', { + value: addEventListenerSpy, + }); + Object.defineProperty(ref, 'removeEventListener', { + value: removeEventListenerSpy, + }); + + rerender( + + + + ); + + expect(removeEventListenerSpy).to.have.been.calledWith('contextmenu'); + expect(addEventListenerSpy).to.have.been.calledWith('contextmenu'); + }); + }); +}); From 0f5303bb41171aa96d959cba498984d0678a0026 Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 15:16:18 +0200 Subject: [PATCH 17/62] fix: add tests --- packages/compass-context-menu/.mocharc.js | 2 +- .../src/use-context-menu.spec.tsx | 58 +++++++------------ .../src/use-context-menu.tsx | 6 +- 3 files changed, 26 insertions(+), 40 deletions(-) diff --git a/packages/compass-context-menu/.mocharc.js b/packages/compass-context-menu/.mocharc.js index e7eaccd61fa..5a33f216327 100644 --- a/packages/compass-context-menu/.mocharc.js +++ b/packages/compass-context-menu/.mocharc.js @@ -1,2 +1,2 @@ 'use strict'; -module.exports = require('@mongodb-js/mocha-config-compass'); +module.exports = require('@mongodb-js/mocha-config-compass/react'); 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 7337463e819..8a5cb4b6fee 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -1,36 +1,37 @@ import React from 'react'; -import { - render, - screen, - cleanup, - userEvent, -} from '@mongodb-js/testing-library-compass'; +import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; import type { MenuItem } from './types'; +type TestMenuItem = MenuItem & { id: number }; + describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC<{ items: TestMenuItem[] }> = ({ items }) => (
{items.map((item, idx) => ( -
+
{item.label}
))}
); - const TestComponent = ({ - onRegister, - }: { - onRegister?: (ref: any) => void; - }) => { + const TestComponent = () => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const items: MenuItem[] = [ + const items: TestMenuItem[] = [ { - label: 'Test Item', + id: 1, + label: 'Test A', + onAction: () => { + /* noop */ + }, + }, + { + id: 2, + label: 'Test B', onAction: () => { /* noop */ }, @@ -38,10 +39,6 @@ describe('useContextMenu', function () { ]; const ref = contextMenu.registerItems(items); - React.useEffect(() => { - onRegister?.(ref); - }, [ref, onRegister]); - return (
Test Component @@ -49,8 +46,6 @@ describe('useContextMenu', function () { ); }; - afterEach(cleanup); - describe('when used outside provider', function () { it('throws an error', function () { expect(() => { @@ -59,7 +54,7 @@ describe('useContextMenu', function () { }); }); - describe('when used inside provider', function () { + describe('with valid provider', function () { beforeEach(() => { // Create the container for the context menu portal const container = document.createElement('div'); @@ -85,19 +80,6 @@ describe('useContextMenu', function () { expect(screen.getByTestId('test-trigger')).to.exist; }); - it('registers context menu event listener', function () { - const onRegister = sinon.spy(); - - render( - - - - ); - - expect(onRegister).to.have.been.calledOnce; - expect(onRegister.firstCall.args[0]).to.be.a('function'); - }); - it('shows context menu on right click', function () { render( @@ -105,11 +87,15 @@ describe('useContextMenu', function () { ); + expect(screen.queryByTestId('menu-item-1')).not.to.exist; + expect(screen.queryByTestId('menu-item-2')).not.to.exist; + const trigger = screen.getByTestId('test-trigger'); userEvent.click(trigger, { button: 2 }); // The menu should be rendered in the portal - expect(screen.getByTestId('menu-item-Test Item')).to.exist; + expect(screen.getByTestId('menu-item-1')).to.exist; + expect(screen.getByTestId('menu-item-2')).to.exist; }); it('cleans up previous event listener when ref changes', function () { diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index d7ccc114a90..37993b04f15 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -6,11 +6,11 @@ import type { MenuItem } from './types'; /** * @returns an object with methods to {@link register} content for the menu and {@link close} the menu */ -export function useContextMenu({ +export function useContextMenu({ Menu, }: { Menu: React.ComponentType<{ - items: MenuItem[]; + items: T[]; }>; }) { // Get the close function from the ContextProvider @@ -47,7 +47,7 @@ export function useContextMenu({ } }; }, - registerItems(items: MenuItem[]) { + registerItems(items: T[]) { return this.register(() => ); }, }; From 33b2a41af24cf04760ac4e24f75873c8402e7f36 Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 16:50:00 +0200 Subject: [PATCH 18/62] fix: add tests and fix types --- packages/compass-context-menu/src/types.ts | 2 +- .../src/use-context-menu.spec.tsx | 219 ++++++++++++++---- 2 files changed, 175 insertions(+), 46 deletions(-) diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index 07efb491ac6..e9ac549ba63 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -17,5 +17,5 @@ export type ContextMenuContext = { export type MenuItem = { label: string; - onAction: (event: Event) => void; + onAction: (event: React.KeyboardEvent | React.MouseEvent) => void; }; 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 8a5cb4b6fee..1d099272104 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -6,39 +6,48 @@ import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; import type { MenuItem } from './types'; -type TestMenuItem = MenuItem & { id: number }; - describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: TestMenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => (
{items.map((item, idx) => ( -
+
item.onAction?.(event)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + item.onAction?.(event); + } + }} + > {item.label}
))}
); - const TestComponent = () => { + const TestComponent = ({ + onRegister, + onAction, + }: { + onRegister?: (ref: any) => void; + onAction?: (id) => void; + }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const items: TestMenuItem[] = [ - { - id: 1, - label: 'Test A', - onAction: () => { - /* noop */ - }, - }, + const items: MenuItem[] = [ { - id: 2, - label: 'Test B', - onAction: () => { - /* noop */ - }, + label: 'Test Item', + onAction: () => onAction?.(1), }, ]; const ref = contextMenu.registerItems(items); + React.useEffect(() => { + onRegister?.(ref); + }, [ref, onRegister]); + return (
Test Component @@ -46,6 +55,60 @@ describe('useContextMenu', function () { ); }; + // Add new test components for nested context menu scenario + const ParentComponent = ({ + onAction, + children, + }: { + onAction?: (id: number) => void; + children?: React.ReactNode; + }) => { + const contextMenu = useContextMenu({ Menu: TestMenu }); + const parentItems: MenuItem[] = [ + { + label: 'Parent Item 1', + onAction: () => onAction?.(1), + }, + { + label: 'Parent Item 2', + onAction: () => onAction?.(2), + }, + ]; + const ref = contextMenu.registerItems(parentItems); + + return ( +
+
Parent Component
+ {children} +
+ ); + }; + + const ChildComponent = ({ + onAction, + }: { + onAction?: (id: number) => void; + }) => { + const contextMenu = useContextMenu({ Menu: TestMenu }); + const childItems: MenuItem[] = [ + { + label: 'Child Item 1', + onAction: () => onAction?.(1), + }, + { + label: 'Child Item 2', + onAction: () => onAction?.(2), + }, + ]; + const ref = contextMenu.registerItems(childItems); + + return ( +
+ Child Component +
+ ); + }; + describe('when used outside provider', function () { it('throws an error', function () { expect(() => { @@ -54,7 +117,7 @@ describe('useContextMenu', function () { }); }); - describe('with valid provider', function () { + describe('with a valid provider', function () { beforeEach(() => { // Create the container for the context menu portal const container = document.createElement('div'); @@ -80,6 +143,19 @@ describe('useContextMenu', function () { expect(screen.getByTestId('test-trigger')).to.exist; }); + it('registers context menu event listener', function () { + const onRegister = sinon.spy(); + + render( + + + + ); + + expect(onRegister).to.have.been.calledOnce; + expect(onRegister.firstCall.args[0]).to.be.a('function'); + }); + it('shows context menu on right click', function () { render( @@ -87,44 +163,97 @@ describe('useContextMenu', function () { ); - expect(screen.queryByTestId('menu-item-1')).not.to.exist; - expect(screen.queryByTestId('menu-item-2')).not.to.exist; - const trigger = screen.getByTestId('test-trigger'); userEvent.click(trigger, { button: 2 }); // The menu should be rendered in the portal - expect(screen.getByTestId('menu-item-1')).to.exist; - expect(screen.getByTestId('menu-item-2')).to.exist; + expect(screen.getByTestId('menu-item-Test Item')).to.exist; }); - it('cleans up previous event listener when ref changes', function () { - const removeEventListenerSpy = sinon.spy(); - const addEventListenerSpy = sinon.spy(); + describe('with nested context menus', function () { + it('shows only parent items when right clicking parent area', function () { + render( + + + + ); - const { rerender } = render( - - - - ); + const parentTrigger = screen.getByTestId('parent-trigger'); + userEvent.click(parentTrigger, { button: 2 }); + + // Should show parent items + expect(screen.getByTestId('menu-item-Parent Item 1')).to.exist; + expect(screen.getByTestId('menu-item-Parent Item 2')).to.exist; - // Simulate ref change - const ref = screen.getByTestId('test-trigger'); - Object.defineProperty(ref, 'addEventListener', { - value: addEventListenerSpy, + // Should not show child items + expect(() => screen.getByTestId('menu-item-Child Item 1')).to.throw; + expect(() => screen.getByTestId('menu-item-Child Item 2')).to.throw; }); - Object.defineProperty(ref, 'removeEventListener', { - value: removeEventListenerSpy, + + it('shows both parent and child items when right clicking child area', function () { + render( + + + + + + ); + + const childTrigger = screen.getByTestId('child-trigger'); + userEvent.click(childTrigger, { button: 2 }); + + // Should show both parent and child items + expect(screen.getByTestId('menu-item-Parent Item 1')).to.exist; + expect(screen.getByTestId('menu-item-Parent Item 2')).to.exist; + expect(screen.getByTestId('menu-item-Child Item 1')).to.exist; + expect(screen.getByTestId('menu-item-Child Item 2')).to.exist; }); - rerender( - - - - ); + it('triggers only the child action when clicking child menu item', function () { + const parentOnAction = sinon.spy(); + const childOnAction = sinon.spy(); + + render( + + + + + + ); + + const childTrigger = screen.getByTestId('child-trigger'); + userEvent.click(childTrigger, { button: 2 }); + + const childItem1 = screen.getByTestId('menu-item-Child Item 1'); + userEvent.click(childItem1); - expect(removeEventListenerSpy).to.have.been.calledWith('contextmenu'); - expect(addEventListenerSpy).to.have.been.calledWith('contextmenu'); + expect(childOnAction).to.have.been.calledOnceWithExactly(1); + expect(parentOnAction).to.not.have.been.called; + expect(() => screen.getByTestId('test-menu')).to.throw; + }); + + it('triggers only the parent action when clicking a parent menu item from child context', function () { + const parentOnAction = sinon.spy(); + const childOnAction = sinon.spy(); + + render( + + + + + + ); + + const childTrigger = screen.getByTestId('child-trigger'); + userEvent.click(childTrigger, { button: 2 }); + + const parentItem1 = screen.getByTestId('menu-item-Parent Item 1'); + userEvent.click(parentItem1); + + expect(parentOnAction).to.have.been.calledOnceWithExactly(1); + expect(childOnAction).to.not.have.been.called; + expect(() => screen.getByTestId('test-menu')).to.throw; + }); }); }); }); From 4a2f0327b37be0b3e506db54288f3e0a16daf168 Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 17:07:53 +0200 Subject: [PATCH 19/62] refactor: minor stylistic changes --- .../src/context-menu-provider.tsx | 3 +- .../src/use-context-menu.tsx | 53 +++++++++++-------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 499d9c273c1..43d9e893367 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -35,13 +35,14 @@ export function ContextMenuProvider({ }, }); } - document.addEventListener('contextmenu', handleContextMenu); function handleClosingEvent(event: Event) { if (!event.defaultPrevented) { setMenu({ isOpen: false }); } } + + document.addEventListener('contextmenu', handleContextMenu); document.addEventListener('click', handleClosingEvent); window.addEventListener('resize', handleClosingEvent); diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index 37993b04f15..91f51c2f849 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -3,16 +3,25 @@ import { Context } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; import type { MenuItem } from './types'; -/** - * @returns an object with methods to {@link register} content for the menu and {@link close} the menu - */ +export type ContextMenuMethods = { + /** + * Close the context menu. + */ + close: () => void; + /** + * Register the menu items for the context menu. + * @returns a callback ref to be passed onto the element responsible for triggering the menu. + */ + registerItems: (items: T[]) => (trigger: HTMLElement | null) => void; +}; + export function useContextMenu({ Menu, }: { Menu: React.ComponentType<{ items: T[]; }>; -}) { +}): ContextMenuMethods { // Get the close function from the ContextProvider const context = useContext(Context); const previous = useRef void]>( @@ -24,31 +33,29 @@ export function useContextMenu({ throw new Error('useContextMenu called outside of the provider'); } + const register = (content: React.ComponentType) => { + function listener(event: MouseEvent) { + appendContextMenuContent(event, content); + } + return (trigger: HTMLElement | null) => { + if (previous.current) { + const [previousTrigger, previousListener] = previous.current; + previousTrigger.removeEventListener('contextmenu', previousListener); + } + if (trigger) { + trigger.addEventListener('contextmenu', listener); + previous.current = [trigger, listener]; + } + }; + }; + return { close: context.close.bind(context), /** * @returns a callback ref, passed onto the element responsible for triggering the menu. */ - register(content: React.ComponentType) { - function listener(event: MouseEvent) { - appendContextMenuContent(event, content); - } - return (trigger: HTMLElement | null) => { - if (previous.current) { - const [previousTrigger, previousListener] = previous.current; - previousTrigger.removeEventListener( - 'contextmenu', - previousListener - ); - } - if (trigger) { - trigger.addEventListener('contextmenu', listener); - previous.current = [trigger, listener]; - } - }; - }, registerItems(items: T[]) { - return this.register(() => ); + return register(() => ); }, }; }, [context, Menu]); From 8e1feb3f6ba7591fa1b6bae14e3465fa04cb300b Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 22 May 2025 11:59:55 +0200 Subject: [PATCH 20/62] fix: export types and rename MenuItem --- packages/compass-context-menu/src/index.ts | 2 ++ packages/compass-context-menu/src/types.ts | 2 +- .../compass-context-menu/src/use-context-menu.spec.tsx | 10 +++++----- packages/compass-context-menu/src/use-context-menu.tsx | 6 +++--- 4 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 packages/compass-context-menu/src/index.ts diff --git a/packages/compass-context-menu/src/index.ts b/packages/compass-context-menu/src/index.ts new file mode 100644 index 00000000000..d60a97e04b3 --- /dev/null +++ b/packages/compass-context-menu/src/index.ts @@ -0,0 +1,2 @@ +export { useContextMenu } from './use-context-menu'; +export type { ContextMenuItem } from './types'; diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index e9ac549ba63..f453930dcb3 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -15,7 +15,7 @@ export type ContextMenuContext = { close(): void; }; -export type MenuItem = { +export type ContextMenuItem = { label: string; onAction: (event: React.KeyboardEvent | React.MouseEvent) => void; }; 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 1d099272104..eb857db9aeb 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -4,10 +4,10 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; -import type { MenuItem } from './types'; +import type { ContextMenuItem } from './types'; describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC<{ items: ContextMenuItem[] }> = ({ items }) => (
{items.map((item, idx) => (
void; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const items: MenuItem[] = [ + const items: ContextMenuItem[] = [ { label: 'Test Item', onAction: () => onAction?.(1), @@ -64,7 +64,7 @@ describe('useContextMenu', function () { children?: React.ReactNode; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const parentItems: MenuItem[] = [ + const parentItems: ContextMenuItem[] = [ { label: 'Parent Item 1', onAction: () => onAction?.(1), @@ -90,7 +90,7 @@ describe('useContextMenu', function () { onAction?: (id: number) => void; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const childItems: MenuItem[] = [ + const childItems: ContextMenuItem[] = [ { label: 'Child Item 1', onAction: () => onAction?.(1), diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index 91f51c2f849..a0c97dead9b 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -1,9 +1,9 @@ import React, { useContext, useMemo, useRef } from 'react'; import { Context } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; -import type { MenuItem } from './types'; +import type { ContextMenuItem } from './types'; -export type ContextMenuMethods = { +export type ContextMenuMethods = { /** * Close the context menu. */ @@ -15,7 +15,7 @@ export type ContextMenuMethods = { registerItems: (items: T[]) => (trigger: HTMLElement | null) => void; }; -export function useContextMenu({ +export function useContextMenu({ Menu, }: { Menu: React.ComponentType<{ From 1f55000a7789f08ab8e1b40eb34611f4737aee95 Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 22 May 2025 12:14:37 +0200 Subject: [PATCH 21/62] fix: use React.RefCallback --- packages/compass-context-menu/src/use-context-menu.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index a0c97dead9b..fa9e20e7537 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -12,7 +12,7 @@ export type ContextMenuMethods = { * Register the menu items for the context menu. * @returns a callback ref to be passed onto the element responsible for triggering the menu. */ - registerItems: (items: T[]) => (trigger: HTMLElement | null) => void; + registerItems: (items: T[]) => React.RefCallback; }; export function useContextMenu({ @@ -33,7 +33,9 @@ export function useContextMenu({ throw new Error('useContextMenu called outside of the provider'); } - const register = (content: React.ComponentType) => { + const register = ( + content: React.ComponentType + ): React.RefCallback => { function listener(event: MouseEvent) { appendContextMenuContent(event, content); } From 9618e8e187e761e80fdc421a9516e69db87badd7 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 23 May 2025 13:50:50 +0200 Subject: [PATCH 22/62] refactor: use item groups instead of React elements, use wrapper, keep menu state consistent The refactor is meant to make the Leafygreen integration more straightforward: 1. We stick to item groups and instead have a single wrapper to handle any rendering differences between groups. This allows the wrapper to always have context of all items when rendering which is useful when inserting menu seperators in Leafygreen. Also encourages consistent UI (while allowing per-case customization if needed at wrapper-level). We could introduce itemWrappers instead of itemGroups but having one wrapper handling all seems cleaner to me. 2. More of the responsibility is moved to a parent wrapper component that will house the context menu. This allows us to standardize the right click menu and make better use of Leafygreen's menu component including its click handling (which has been removed from the context menu library). 3. Menu state (i.e. position) is now preserved even closed; this is useful for leafygreen's menu to animate in the same position instead of losing the position all together. --- .../src/context-menu-content.ts | 13 ++-- .../src/context-menu-provider.tsx | 57 ++++++++++------- packages/compass-context-menu/src/index.ts | 7 ++- packages/compass-context-menu/src/types.ts | 29 +++++---- .../src/use-context-menu.spec.tsx | 61 ++++++++++--------- .../src/use-context-menu.tsx | 59 +++++++++--------- 6 files changed, 124 insertions(+), 102 deletions(-) diff --git a/packages/compass-context-menu/src/context-menu-content.ts b/packages/compass-context-menu/src/context-menu-content.ts index 6856f1fe684..c301983a679 100644 --- a/packages/compass-context-menu/src/context-menu-content.ts +++ b/packages/compass-context-menu/src/context-menu-content.ts @@ -1,23 +1,24 @@ +import type { ContextMenuItemGroup } from './types'; + const CONTEXT_MENUS_SYMBOL = Symbol('context_menus'); export type EnhancedMouseEvent = MouseEvent & { - [CONTEXT_MENUS_SYMBOL]?: React.ComponentType[]; + [CONTEXT_MENUS_SYMBOL]?: ContextMenuItemGroup[]; }; export function getContextMenuContent( event: EnhancedMouseEvent -): React.ComponentType[] { +): ContextMenuItemGroup[] { return event[CONTEXT_MENUS_SYMBOL] ?? []; } export function appendContextMenuContent( event: EnhancedMouseEvent, - content: React.ComponentType + content: ContextMenuItemGroup ) { // Initialize if not already patched - if (event[CONTEXT_MENUS_SYMBOL] === undefined) { - event[CONTEXT_MENUS_SYMBOL] = [content]; - return; + if (!event[CONTEXT_MENUS_SYMBOL]) { + event[CONTEXT_MENUS_SYMBOL] = []; } event[CONTEXT_MENUS_SYMBOL].push(content); } diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 43d9e893367..5dd1cba2cff 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -5,8 +5,7 @@ import React, { useMemo, createContext, } from 'react'; -import type { ContextMenuContext, MenuState } from './types'; -import { ContextMenu } from './context-menu'; +import type { ContextMenuContext, ContextMenuState } from './types'; import type { EnhancedMouseEvent } from './context-menu-content'; import { getContextMenuContent } from './context-menu-content'; @@ -14,20 +13,42 @@ export const Context = createContext(null); export function ContextMenuProvider({ children, + wrapper, }: { children: React.ReactNode; + wrapper: React.ComponentType<{ + menu: ContextMenuState & { close: () => void }; + }>; }) { - const [menu, setMenu] = useState({ isOpen: false }); - const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]); + const [menu, setMenu] = useState({ + isOpen: false, + itemGroups: [], + position: { x: 0, y: 0 }, + }); + const close = useCallback(() => setMenu({ ...menu, isOpen: false }), [menu]); + + const handleClosingEvent = useCallback( + (event: Event) => { + if (!event.defaultPrevented) { + setMenu({ ...menu, isOpen: false }); + } + }, + [menu] + ); useEffect(() => { function handleContextMenu(event: MouseEvent) { event.preventDefault(); + + const itemGroups = getContextMenuContent(event as EnhancedMouseEvent); + + if (itemGroups.length === 0) { + return; + } + setMenu({ isOpen: true, - children: getContextMenuContent(event as EnhancedMouseEvent).map( - (Content, index) => - ), + itemGroups, position: { // TODO: Fix handling offset while scrolling x: event.clientX, @@ -36,22 +57,14 @@ export function ContextMenuProvider({ }); } - function handleClosingEvent(event: Event) { - if (!event.defaultPrevented) { - setMenu({ isOpen: false }); - } - } - document.addEventListener('contextmenu', handleContextMenu); - document.addEventListener('click', handleClosingEvent); window.addEventListener('resize', handleClosingEvent); return () => { document.removeEventListener('contextmenu', handleContextMenu); - document.removeEventListener('click', handleClosingEvent); window.removeEventListener('resize', handleClosingEvent); }; - }, [setMenu]); + }, [handleClosingEvent]); const value = useMemo( () => ({ @@ -60,12 +73,12 @@ export function ContextMenuProvider({ [close] ); + const Wrapper = wrapper ?? React.Fragment; + return ( - <> - {children} - {menu.isOpen && ( - {menu.children} - )} - + + {children} + + ); } diff --git a/packages/compass-context-menu/src/index.ts b/packages/compass-context-menu/src/index.ts index d60a97e04b3..75d933ef767 100644 --- a/packages/compass-context-menu/src/index.ts +++ b/packages/compass-context-menu/src/index.ts @@ -1,2 +1,7 @@ export { useContextMenu } from './use-context-menu'; -export type { ContextMenuItem } from './types'; +export { ContextMenuProvider } from './context-menu-provider'; +export type { + ContextMenuItem, + ContextMenuItemGroup, + ContextMenuWrapperProps, +} from './types'; diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index f453930dcb3..91e8d65cdcc 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -1,15 +1,20 @@ -export type MenuState = - | { - isOpen: false; - } - | { - isOpen: true; - children: React.ReactNode; - position: { - x: number; - y: number; - }; - }; +export interface ContextMenuItemGroup { + items: ContextMenuItem[]; + originListener: (event: MouseEvent) => void; +} + +export type ContextMenuState = { + isOpen: boolean; + itemGroups: ContextMenuItemGroup[]; + position: { + x: number; + y: number; + }; +}; + +export type ContextMenuWrapperProps = { + menu: ContextMenuState & { close: () => void }; +}; export type ContextMenuContext = { close(): void; 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 eb857db9aeb..cfe0bfc7ef4 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -4,27 +4,29 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; -import type { ContextMenuItem } from './types'; +import type { ContextMenuItem, ContextMenuWrapperProps } from './types'; describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: ContextMenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC = ({ menu }) => (
- {items.map((item, idx) => ( -
item.onAction?.(event)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - item.onAction?.(event); - } - }} - > - {item.label} -
- ))} + {menu.itemGroups.flatMap((group, groupIdx) => + group.items.map((item, idx) => ( +
item.onAction?.(event)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + item.onAction?.(event); + } + }} + > + {item.label} +
+ )) + )}
); @@ -33,9 +35,9 @@ describe('useContextMenu', function () { onAction, }: { onRegister?: (ref: any) => void; - onAction?: (id) => void; + onAction?: (id: number) => void; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const items: ContextMenuItem[] = [ { label: 'Test Item', @@ -55,7 +57,6 @@ describe('useContextMenu', function () { ); }; - // Add new test components for nested context menu scenario const ParentComponent = ({ onAction, children, @@ -63,7 +64,7 @@ describe('useContextMenu', function () { onAction?: (id: number) => void; children?: React.ReactNode; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const parentItems: ContextMenuItem[] = [ { label: 'Parent Item 1', @@ -89,7 +90,7 @@ describe('useContextMenu', function () { }: { onAction?: (id: number) => void; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const childItems: ContextMenuItem[] = [ { label: 'Child Item 1', @@ -135,7 +136,7 @@ describe('useContextMenu', function () { it('renders without error', function () { render( - + ); @@ -147,7 +148,7 @@ describe('useContextMenu', function () { const onRegister = sinon.spy(); render( - + ); @@ -158,7 +159,7 @@ describe('useContextMenu', function () { it('shows context menu on right click', function () { render( - + ); @@ -173,7 +174,7 @@ describe('useContextMenu', function () { describe('with nested context menus', function () { it('shows only parent items when right clicking parent area', function () { render( - + ); @@ -192,7 +193,7 @@ describe('useContextMenu', function () { it('shows both parent and child items when right clicking child area', function () { render( - + @@ -214,7 +215,7 @@ describe('useContextMenu', function () { const childOnAction = sinon.spy(); render( - + @@ -237,7 +238,7 @@ describe('useContextMenu', function () { const childOnAction = sinon.spy(); render( - + diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index fa9e20e7537..a60aeba4c69 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -1,4 +1,5 @@ -import React, { useContext, useMemo, useRef } from 'react'; +import type { RefCallback } from 'react'; +import { useContext, useMemo, useRef } from 'react'; import { Context } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; import type { ContextMenuItem } from './types'; @@ -12,17 +13,12 @@ export type ContextMenuMethods = { * Register the menu items for the context menu. * @returns a callback ref to be passed onto the element responsible for triggering the menu. */ - registerItems: (items: T[]) => React.RefCallback; + registerItems: (items: T[]) => RefCallback; }; -export function useContextMenu({ - Menu, -}: { - Menu: React.ComponentType<{ - items: T[]; - }>; -}): ContextMenuMethods { - // Get the close function from the ContextProvider +export function useContextMenu< + T extends ContextMenuItem = ContextMenuItem +>(): ContextMenuMethods { const context = useContext(Context); const previous = useRef void]>( null @@ -33,32 +29,33 @@ export function useContextMenu({ throw new Error('useContextMenu called outside of the provider'); } - const register = ( - content: React.ComponentType - ): React.RefCallback => { - function listener(event: MouseEvent) { - appendContextMenuContent(event, content); - } - return (trigger: HTMLElement | null) => { - if (previous.current) { - const [previousTrigger, previousListener] = previous.current; - previousTrigger.removeEventListener('contextmenu', previousListener); - } - if (trigger) { - trigger.addEventListener('contextmenu', listener); - previous.current = [trigger, listener]; - } - }; - }; - return { close: context.close.bind(context), /** * @returns a callback ref, passed onto the element responsible for triggering the menu. */ - registerItems(items: T[]) { - return register(() => ); + registerItems(items: ContextMenuItem[]) { + function listener(event: MouseEvent): void { + appendContextMenuContent(event, { + items, + originListener: listener, + }); + } + + return (trigger: HTMLElement | null) => { + if (previous.current) { + const [previousTrigger, previousListener] = previous.current; + previousTrigger.removeEventListener( + 'contextmenu', + previousListener + ); + } + if (trigger) { + trigger.addEventListener('contextmenu', listener); + previous.current = [trigger, listener]; + } + }; }, }; - }, [context, Menu]); + }, [context]); } From ca1fb86e94310726b48829e599e2409f82ceb3ac Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 23 May 2025 17:12:31 +0200 Subject: [PATCH 23/62] fix: delete redundant context menu --- .../compass-context-menu/src/context-menu.tsx | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 packages/compass-context-menu/src/context-menu.tsx diff --git a/packages/compass-context-menu/src/context-menu.tsx b/packages/compass-context-menu/src/context-menu.tsx deleted file mode 100644 index b053bc4963a..00000000000 --- a/packages/compass-context-menu/src/context-menu.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { createPortal } from 'react-dom'; -import React from 'react'; - -type ContextMenuProps = React.PropsWithChildren<{ - position: { - x: number; - y: number; - }; -}>; - -export function ContextMenu({ children, position }: ContextMenuProps) { - const container = document.getElementById('context-menu-container'); - if (container === null) { - throw new Error('Expected a container for the context menu in the DOM'); - } - return createPortal( -
- {children} -
, - container - ); -} From aacdf1dc4f93b88dba7fce497ddbe8d262ee18cb Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 20 May 2025 16:59:43 +0200 Subject: [PATCH 24/62] feat(compass-context-menu): add a headless context menu package --- package-lock.json | 104 ++++++++++++++++++ packages/compass-context-menu/.depcheckrc | 8 ++ packages/compass-context-menu/.eslintignore | 2 + packages/compass-context-menu/.eslintrc.js | 9 ++ packages/compass-context-menu/.mocharc.js | 2 + packages/compass-context-menu/package.json | 72 ++++++++++++ .../src/context-menu-content.ts | 23 ++++ .../src/context-menu-provider.tsx | 68 ++++++++++++ .../compass-context-menu/src/context-menu.tsx | 22 ++++ packages/compass-context-menu/src/types.ts | 21 ++++ .../src/use-context-menu.tsx | 55 +++++++++ .../compass-context-menu/tsconfig-lint.json | 5 + packages/compass-context-menu/tsconfig.json | 8 ++ 13 files changed, 399 insertions(+) create mode 100644 packages/compass-context-menu/.depcheckrc create mode 100644 packages/compass-context-menu/.eslintignore create mode 100644 packages/compass-context-menu/.eslintrc.js create mode 100644 packages/compass-context-menu/.mocharc.js create mode 100644 packages/compass-context-menu/package.json create mode 100644 packages/compass-context-menu/src/context-menu-content.ts create mode 100644 packages/compass-context-menu/src/context-menu-provider.tsx create mode 100644 packages/compass-context-menu/src/context-menu.tsx create mode 100644 packages/compass-context-menu/src/types.ts create mode 100644 packages/compass-context-menu/src/use-context-menu.tsx create mode 100644 packages/compass-context-menu/tsconfig-lint.json create mode 100644 packages/compass-context-menu/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 80e503995ba..a205c4ec913 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7864,6 +7864,10 @@ "resolved": "packages/compass-connections-navigation", "link": true }, + "node_modules/@mongodb-js/compass-context-menu": { + "resolved": "packages/compass-context-menu", + "link": true + }, "node_modules/@mongodb-js/compass-crud": { "resolved": "packages/compass-crud", "link": true @@ -43201,6 +43205,62 @@ "node": ">=0.3.1" } }, + "packages/compass-context-menu": { + "name": "@mongodb-js/compass-context-menu", + "version": "0.0.1", + "license": "SSPL", + "dependencies": { + "react": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.3.8", + "@mongodb-js/mocha-config-compass": "^1.6.8", + "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/tsconfig-compass": "^1.2.8", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "sinon": "^9.2.3", + "typescript": "^5.0.4" + } + }, + "packages/compass-context-menu/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "packages/compass-context-menu/node_modules/sinon": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", + "integrity": "sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==", + "deprecated": "16.1.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/samsam": "^5.3.1", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "packages/compass-crud": { "name": "@mongodb-js/compass-crud", "version": "13.57.0", @@ -55646,6 +55706,50 @@ } } }, + "@mongodb-js/compass-context-menu": { + "version": "file:packages/compass-context-menu", + "requires": { + "@mongodb-js/eslint-config-compass": "^1.3.8", + "@mongodb-js/mocha-config-compass": "^1.6.8", + "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/tsconfig-compass": "^1.2.8", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "react": "^17.0.2", + "sinon": "^9.2.3", + "typescript": "^5.0.4" + }, + "dependencies": { + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, + "sinon": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", + "integrity": "sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/samsam": "^5.3.1", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + } + } + } + }, "@mongodb-js/compass-crud": { "version": "file:packages/compass-crud", "requires": { diff --git a/packages/compass-context-menu/.depcheckrc b/packages/compass-context-menu/.depcheckrc new file mode 100644 index 00000000000..ab0ef21b740 --- /dev/null +++ b/packages/compass-context-menu/.depcheckrc @@ -0,0 +1,8 @@ +ignores: + - '@mongodb-js/prettier-config-compass' + - '@mongodb-js/tsconfig-compass' + - '@types/chai' + - '@types/sinon-chai' + - 'sinon' +ignore-patterns: + - 'dist' diff --git a/packages/compass-context-menu/.eslintignore b/packages/compass-context-menu/.eslintignore new file mode 100644 index 00000000000..85a8a75e68c --- /dev/null +++ b/packages/compass-context-menu/.eslintignore @@ -0,0 +1,2 @@ +.nyc-output +dist diff --git a/packages/compass-context-menu/.eslintrc.js b/packages/compass-context-menu/.eslintrc.js new file mode 100644 index 00000000000..9c3ab95632f --- /dev/null +++ b/packages/compass-context-menu/.eslintrc.js @@ -0,0 +1,9 @@ +'use strict'; +module.exports = { + root: true, + extends: ['@mongodb-js/eslint-config-compass'], + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig-lint.json'], + }, +}; diff --git a/packages/compass-context-menu/.mocharc.js b/packages/compass-context-menu/.mocharc.js new file mode 100644 index 00000000000..e7eaccd61fa --- /dev/null +++ b/packages/compass-context-menu/.mocharc.js @@ -0,0 +1,2 @@ +'use strict'; +module.exports = require('@mongodb-js/mocha-config-compass'); diff --git a/packages/compass-context-menu/package.json b/packages/compass-context-menu/package.json new file mode 100644 index 00000000000..3cf186e0270 --- /dev/null +++ b/packages/compass-context-menu/package.json @@ -0,0 +1,72 @@ +{ + "name": "@mongodb-js/compass-context-menu", + "author": { + "name": "MongoDB Inc", + "email": "compass@mongodb.com" + }, + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/COMPASS/issues", + "email": "compass@mongodb.com" + }, + "homepage": "https://github.com/mongodb-js/compass", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://github.com/mongodb-js/compass.git" + }, + "files": [ + "dist" + ], + "license": "SSPL", + "main": "dist/index.js", + "compass:main": "src/index.ts", + "exports": { + "import": "./dist/.esm-wrapper.mjs", + "require": "./dist/index.js" + }, + "compass:exports": { + ".": "./src/index.ts" + }, + "types": "./dist/index.d.ts", + "scripts": { + "bootstrap": "npm run compile", + "prepublishOnly": "npm run compile && compass-scripts check-exports-exist", + "compile": "tsc -p tsconfig.json && gen-esm-wrapper . ./dist/.esm-wrapper.mjs", + "typecheck": "tsc -p tsconfig-lint.json --noEmit", + "eslint": "eslint-compass", + "prettier": "prettier-compass", + "lint": "npm run eslint . && npm run prettier -- --check .", + "depcheck": "compass-scripts check-peer-deps && depcheck", + "check": "npm run typecheck && npm run lint && npm run depcheck", + "check-ci": "npm run check", + "test": "mocha", + "test-cov": "nyc --compact=false --produce-source-map=false -x \"**/*.spec.*\" --reporter=lcov --reporter=text --reporter=html npm run test", + "test-watch": "npm run test -- --watch", + "test-ci": "npm run test-cov", + "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." + }, + "dependencies": { + "react": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.3.8", + "@mongodb-js/mocha-config-compass": "^1.6.8", + "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/tsconfig-compass": "^1.2.8", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "sinon": "^9.2.3", + "typescript": "^5.0.4" + } +} diff --git a/packages/compass-context-menu/src/context-menu-content.ts b/packages/compass-context-menu/src/context-menu-content.ts new file mode 100644 index 00000000000..6856f1fe684 --- /dev/null +++ b/packages/compass-context-menu/src/context-menu-content.ts @@ -0,0 +1,23 @@ +const CONTEXT_MENUS_SYMBOL = Symbol('context_menus'); + +export type EnhancedMouseEvent = MouseEvent & { + [CONTEXT_MENUS_SYMBOL]?: React.ComponentType[]; +}; + +export function getContextMenuContent( + event: EnhancedMouseEvent +): React.ComponentType[] { + return event[CONTEXT_MENUS_SYMBOL] ?? []; +} + +export function appendContextMenuContent( + event: EnhancedMouseEvent, + content: React.ComponentType +) { + // Initialize if not already patched + if (event[CONTEXT_MENUS_SYMBOL] === undefined) { + event[CONTEXT_MENUS_SYMBOL] = [content]; + return; + } + event[CONTEXT_MENUS_SYMBOL].push(content); +} diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx new file mode 100644 index 00000000000..7a7b59bf16a --- /dev/null +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -0,0 +1,68 @@ +import React, { + useCallback, + useEffect, + useState, + useMemo, + createContext, +} from 'react'; +import type { ContextMenuContext, MenuState } from './types'; +import { ContextMenu } from './context-menu'; +import type { EnhancedMouseEvent } from './context-menu-content'; +import { getContextMenuContent } from './context-menu-content'; + +export const Context = createContext(null); + +export function ContextMenuProvider({ + children, +}: React.PropsWithChildren) { + const [menu, setMenu] = useState({ isOpen: false }); + const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]); + + useEffect(() => { + function handleContextMenu(event: MouseEvent) { + event.preventDefault(); + setMenu({ + isOpen: true, + children: getContextMenuContent(event as EnhancedMouseEvent).map( + (Content, index) => + ), + position: { + // TODO: Fix handling offset while scrolling + x: event.clientX, + y: event.clientY, + }, + }); + } + document.addEventListener('contextmenu', handleContextMenu); + + function handleClosingEvent(event: Event) { + if (!event.defaultPrevented) { + setMenu({ isOpen: false }); + } + } + document.addEventListener('click', handleClosingEvent); + window.addEventListener('resize', handleClosingEvent); + + return () => { + document.removeEventListener('contextmenu', handleContextMenu); + document.removeEventListener('click', handleClosingEvent); + window.removeEventListener('resize', handleClosingEvent); + }; + }, [setMenu]); + + const value = useMemo( + () => ({ + close, + }), + [close] + ); + + return ( + <> + {children} + {menu.isOpen && ( + {menu.children} + )} + + ); +} diff --git a/packages/compass-context-menu/src/context-menu.tsx b/packages/compass-context-menu/src/context-menu.tsx new file mode 100644 index 00000000000..b053bc4963a --- /dev/null +++ b/packages/compass-context-menu/src/context-menu.tsx @@ -0,0 +1,22 @@ +import { createPortal } from 'react-dom'; +import React from 'react'; + +type ContextMenuProps = React.PropsWithChildren<{ + position: { + x: number; + y: number; + }; +}>; + +export function ContextMenu({ children, position }: ContextMenuProps) { + const container = document.getElementById('context-menu-container'); + if (container === null) { + throw new Error('Expected a container for the context menu in the DOM'); + } + return createPortal( +
+ {children} +
, + container + ); +} diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts new file mode 100644 index 00000000000..07efb491ac6 --- /dev/null +++ b/packages/compass-context-menu/src/types.ts @@ -0,0 +1,21 @@ +export type MenuState = + | { + isOpen: false; + } + | { + isOpen: true; + children: React.ReactNode; + position: { + x: number; + y: number; + }; + }; + +export type ContextMenuContext = { + close(): void; +}; + +export type MenuItem = { + label: string; + onAction: (event: Event) => void; +}; diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx new file mode 100644 index 00000000000..d7ccc114a90 --- /dev/null +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -0,0 +1,55 @@ +import React, { useContext, useMemo, useRef } from 'react'; +import { Context } from './context-menu-provider'; +import { appendContextMenuContent } from './context-menu-content'; +import type { MenuItem } from './types'; + +/** + * @returns an object with methods to {@link register} content for the menu and {@link close} the menu + */ +export function useContextMenu({ + Menu, +}: { + Menu: React.ComponentType<{ + items: MenuItem[]; + }>; +}) { + // Get the close function from the ContextProvider + const context = useContext(Context); + const previous = useRef void]>( + null + ); + + return useMemo(() => { + if (!context) { + throw new Error('useContextMenu called outside of the provider'); + } + + return { + close: context.close.bind(context), + /** + * @returns a callback ref, passed onto the element responsible for triggering the menu. + */ + register(content: React.ComponentType) { + function listener(event: MouseEvent) { + appendContextMenuContent(event, content); + } + return (trigger: HTMLElement | null) => { + if (previous.current) { + const [previousTrigger, previousListener] = previous.current; + previousTrigger.removeEventListener( + 'contextmenu', + previousListener + ); + } + if (trigger) { + trigger.addEventListener('contextmenu', listener); + previous.current = [trigger, listener]; + } + }; + }, + registerItems(items: MenuItem[]) { + return this.register(() => ); + }, + }; + }, [context, Menu]); +} diff --git a/packages/compass-context-menu/tsconfig-lint.json b/packages/compass-context-menu/tsconfig-lint.json new file mode 100644 index 00000000000..6bdef84f322 --- /dev/null +++ b/packages/compass-context-menu/tsconfig-lint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/compass-context-menu/tsconfig.json b/packages/compass-context-menu/tsconfig.json new file mode 100644 index 00000000000..79bc84584ce --- /dev/null +++ b/packages/compass-context-menu/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@mongodb-js/tsconfig-compass/tsconfig.react.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["./src/**/*.spec.*"] +} From cb99706c351a314da3f718f1e7aada9a06ce12fa Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 13:41:30 +0200 Subject: [PATCH 25/62] wip --- .../src/context-menu-provider.tsx | 4 +- .../src/use-context-menu.spec.tsx | 144 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 packages/compass-context-menu/src/use-context-menu.spec.tsx diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 7a7b59bf16a..499d9c273c1 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -14,7 +14,9 @@ export const Context = createContext(null); export function ContextMenuProvider({ children, -}: React.PropsWithChildren) { +}: { + children: React.ReactNode; +}) { const [menu, setMenu] = useState({ isOpen: false }); const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]); diff --git a/packages/compass-context-menu/src/use-context-menu.spec.tsx b/packages/compass-context-menu/src/use-context-menu.spec.tsx new file mode 100644 index 00000000000..7337463e819 --- /dev/null +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { + render, + screen, + cleanup, + userEvent, +} from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { useContextMenu } from './use-context-menu'; +import { ContextMenuProvider } from './context-menu-provider'; +import type { MenuItem } from './types'; + +describe('useContextMenu', function () { + const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => ( +
+ {items.map((item, idx) => ( +
+ {item.label} +
+ ))} +
+ ); + + const TestComponent = ({ + onRegister, + }: { + onRegister?: (ref: any) => void; + }) => { + const contextMenu = useContextMenu({ Menu: TestMenu }); + const items: MenuItem[] = [ + { + label: 'Test Item', + onAction: () => { + /* noop */ + }, + }, + ]; + const ref = contextMenu.registerItems(items); + + React.useEffect(() => { + onRegister?.(ref); + }, [ref, onRegister]); + + return ( +
+ Test Component +
+ ); + }; + + afterEach(cleanup); + + describe('when used outside provider', function () { + it('throws an error', function () { + expect(() => { + render(); + }).to.throw('useContextMenu called outside of the provider'); + }); + }); + + describe('when used inside provider', function () { + beforeEach(() => { + // Create the container for the context menu portal + const container = document.createElement('div'); + container.id = 'context-menu-container'; + document.body.appendChild(container); + }); + + afterEach(() => { + // Clean up the container + const container = document.getElementById('context-menu-container'); + if (container) { + document.body.removeChild(container); + } + }); + + it('renders without error', function () { + render( + + + + ); + + expect(screen.getByTestId('test-trigger')).to.exist; + }); + + it('registers context menu event listener', function () { + const onRegister = sinon.spy(); + + render( + + + + ); + + expect(onRegister).to.have.been.calledOnce; + expect(onRegister.firstCall.args[0]).to.be.a('function'); + }); + + it('shows context menu on right click', function () { + render( + + + + ); + + const trigger = screen.getByTestId('test-trigger'); + userEvent.click(trigger, { button: 2 }); + + // The menu should be rendered in the portal + expect(screen.getByTestId('menu-item-Test Item')).to.exist; + }); + + it('cleans up previous event listener when ref changes', function () { + const removeEventListenerSpy = sinon.spy(); + const addEventListenerSpy = sinon.spy(); + + const { rerender } = render( + + + + ); + + // Simulate ref change + const ref = screen.getByTestId('test-trigger'); + Object.defineProperty(ref, 'addEventListener', { + value: addEventListenerSpy, + }); + Object.defineProperty(ref, 'removeEventListener', { + value: removeEventListenerSpy, + }); + + rerender( + + + + ); + + expect(removeEventListenerSpy).to.have.been.calledWith('contextmenu'); + expect(addEventListenerSpy).to.have.been.calledWith('contextmenu'); + }); + }); +}); From da22b51b2bd887bb34b824eabee9c3b1cd2a59e1 Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 15:16:18 +0200 Subject: [PATCH 26/62] fix: add tests --- packages/compass-context-menu/.mocharc.js | 2 +- .../src/use-context-menu.spec.tsx | 58 +++++++------------ .../src/use-context-menu.tsx | 6 +- 3 files changed, 26 insertions(+), 40 deletions(-) diff --git a/packages/compass-context-menu/.mocharc.js b/packages/compass-context-menu/.mocharc.js index e7eaccd61fa..5a33f216327 100644 --- a/packages/compass-context-menu/.mocharc.js +++ b/packages/compass-context-menu/.mocharc.js @@ -1,2 +1,2 @@ 'use strict'; -module.exports = require('@mongodb-js/mocha-config-compass'); +module.exports = require('@mongodb-js/mocha-config-compass/react'); 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 7337463e819..8a5cb4b6fee 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -1,36 +1,37 @@ import React from 'react'; -import { - render, - screen, - cleanup, - userEvent, -} from '@mongodb-js/testing-library-compass'; +import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; import type { MenuItem } from './types'; +type TestMenuItem = MenuItem & { id: number }; + describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC<{ items: TestMenuItem[] }> = ({ items }) => (
{items.map((item, idx) => ( -
+
{item.label}
))}
); - const TestComponent = ({ - onRegister, - }: { - onRegister?: (ref: any) => void; - }) => { + const TestComponent = () => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const items: MenuItem[] = [ + const items: TestMenuItem[] = [ { - label: 'Test Item', + id: 1, + label: 'Test A', + onAction: () => { + /* noop */ + }, + }, + { + id: 2, + label: 'Test B', onAction: () => { /* noop */ }, @@ -38,10 +39,6 @@ describe('useContextMenu', function () { ]; const ref = contextMenu.registerItems(items); - React.useEffect(() => { - onRegister?.(ref); - }, [ref, onRegister]); - return (
Test Component @@ -49,8 +46,6 @@ describe('useContextMenu', function () { ); }; - afterEach(cleanup); - describe('when used outside provider', function () { it('throws an error', function () { expect(() => { @@ -59,7 +54,7 @@ describe('useContextMenu', function () { }); }); - describe('when used inside provider', function () { + describe('with valid provider', function () { beforeEach(() => { // Create the container for the context menu portal const container = document.createElement('div'); @@ -85,19 +80,6 @@ describe('useContextMenu', function () { expect(screen.getByTestId('test-trigger')).to.exist; }); - it('registers context menu event listener', function () { - const onRegister = sinon.spy(); - - render( - - - - ); - - expect(onRegister).to.have.been.calledOnce; - expect(onRegister.firstCall.args[0]).to.be.a('function'); - }); - it('shows context menu on right click', function () { render( @@ -105,11 +87,15 @@ describe('useContextMenu', function () { ); + expect(screen.queryByTestId('menu-item-1')).not.to.exist; + expect(screen.queryByTestId('menu-item-2')).not.to.exist; + const trigger = screen.getByTestId('test-trigger'); userEvent.click(trigger, { button: 2 }); // The menu should be rendered in the portal - expect(screen.getByTestId('menu-item-Test Item')).to.exist; + expect(screen.getByTestId('menu-item-1')).to.exist; + expect(screen.getByTestId('menu-item-2')).to.exist; }); it('cleans up previous event listener when ref changes', function () { diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index d7ccc114a90..37993b04f15 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -6,11 +6,11 @@ import type { MenuItem } from './types'; /** * @returns an object with methods to {@link register} content for the menu and {@link close} the menu */ -export function useContextMenu({ +export function useContextMenu({ Menu, }: { Menu: React.ComponentType<{ - items: MenuItem[]; + items: T[]; }>; }) { // Get the close function from the ContextProvider @@ -47,7 +47,7 @@ export function useContextMenu({ } }; }, - registerItems(items: MenuItem[]) { + registerItems(items: T[]) { return this.register(() => ); }, }; From 78ff94cac09c3457adae201d09746c9ccadf93cb Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 16:50:00 +0200 Subject: [PATCH 27/62] fix: add tests and fix types --- packages/compass-context-menu/src/types.ts | 2 +- .../src/use-context-menu.spec.tsx | 219 ++++++++++++++---- 2 files changed, 175 insertions(+), 46 deletions(-) diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index 07efb491ac6..e9ac549ba63 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -17,5 +17,5 @@ export type ContextMenuContext = { export type MenuItem = { label: string; - onAction: (event: Event) => void; + onAction: (event: React.KeyboardEvent | React.MouseEvent) => void; }; 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 8a5cb4b6fee..1d099272104 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -6,39 +6,48 @@ import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; import type { MenuItem } from './types'; -type TestMenuItem = MenuItem & { id: number }; - describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: TestMenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => (
{items.map((item, idx) => ( -
+
item.onAction?.(event)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + item.onAction?.(event); + } + }} + > {item.label}
))}
); - const TestComponent = () => { + const TestComponent = ({ + onRegister, + onAction, + }: { + onRegister?: (ref: any) => void; + onAction?: (id) => void; + }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const items: TestMenuItem[] = [ - { - id: 1, - label: 'Test A', - onAction: () => { - /* noop */ - }, - }, + const items: MenuItem[] = [ { - id: 2, - label: 'Test B', - onAction: () => { - /* noop */ - }, + label: 'Test Item', + onAction: () => onAction?.(1), }, ]; const ref = contextMenu.registerItems(items); + React.useEffect(() => { + onRegister?.(ref); + }, [ref, onRegister]); + return (
Test Component @@ -46,6 +55,60 @@ describe('useContextMenu', function () { ); }; + // Add new test components for nested context menu scenario + const ParentComponent = ({ + onAction, + children, + }: { + onAction?: (id: number) => void; + children?: React.ReactNode; + }) => { + const contextMenu = useContextMenu({ Menu: TestMenu }); + const parentItems: MenuItem[] = [ + { + label: 'Parent Item 1', + onAction: () => onAction?.(1), + }, + { + label: 'Parent Item 2', + onAction: () => onAction?.(2), + }, + ]; + const ref = contextMenu.registerItems(parentItems); + + return ( +
+
Parent Component
+ {children} +
+ ); + }; + + const ChildComponent = ({ + onAction, + }: { + onAction?: (id: number) => void; + }) => { + const contextMenu = useContextMenu({ Menu: TestMenu }); + const childItems: MenuItem[] = [ + { + label: 'Child Item 1', + onAction: () => onAction?.(1), + }, + { + label: 'Child Item 2', + onAction: () => onAction?.(2), + }, + ]; + const ref = contextMenu.registerItems(childItems); + + return ( +
+ Child Component +
+ ); + }; + describe('when used outside provider', function () { it('throws an error', function () { expect(() => { @@ -54,7 +117,7 @@ describe('useContextMenu', function () { }); }); - describe('with valid provider', function () { + describe('with a valid provider', function () { beforeEach(() => { // Create the container for the context menu portal const container = document.createElement('div'); @@ -80,6 +143,19 @@ describe('useContextMenu', function () { expect(screen.getByTestId('test-trigger')).to.exist; }); + it('registers context menu event listener', function () { + const onRegister = sinon.spy(); + + render( + + + + ); + + expect(onRegister).to.have.been.calledOnce; + expect(onRegister.firstCall.args[0]).to.be.a('function'); + }); + it('shows context menu on right click', function () { render( @@ -87,44 +163,97 @@ describe('useContextMenu', function () { ); - expect(screen.queryByTestId('menu-item-1')).not.to.exist; - expect(screen.queryByTestId('menu-item-2')).not.to.exist; - const trigger = screen.getByTestId('test-trigger'); userEvent.click(trigger, { button: 2 }); // The menu should be rendered in the portal - expect(screen.getByTestId('menu-item-1')).to.exist; - expect(screen.getByTestId('menu-item-2')).to.exist; + expect(screen.getByTestId('menu-item-Test Item')).to.exist; }); - it('cleans up previous event listener when ref changes', function () { - const removeEventListenerSpy = sinon.spy(); - const addEventListenerSpy = sinon.spy(); + describe('with nested context menus', function () { + it('shows only parent items when right clicking parent area', function () { + render( + + + + ); - const { rerender } = render( - - - - ); + const parentTrigger = screen.getByTestId('parent-trigger'); + userEvent.click(parentTrigger, { button: 2 }); + + // Should show parent items + expect(screen.getByTestId('menu-item-Parent Item 1')).to.exist; + expect(screen.getByTestId('menu-item-Parent Item 2')).to.exist; - // Simulate ref change - const ref = screen.getByTestId('test-trigger'); - Object.defineProperty(ref, 'addEventListener', { - value: addEventListenerSpy, + // Should not show child items + expect(() => screen.getByTestId('menu-item-Child Item 1')).to.throw; + expect(() => screen.getByTestId('menu-item-Child Item 2')).to.throw; }); - Object.defineProperty(ref, 'removeEventListener', { - value: removeEventListenerSpy, + + it('shows both parent and child items when right clicking child area', function () { + render( + + + + + + ); + + const childTrigger = screen.getByTestId('child-trigger'); + userEvent.click(childTrigger, { button: 2 }); + + // Should show both parent and child items + expect(screen.getByTestId('menu-item-Parent Item 1')).to.exist; + expect(screen.getByTestId('menu-item-Parent Item 2')).to.exist; + expect(screen.getByTestId('menu-item-Child Item 1')).to.exist; + expect(screen.getByTestId('menu-item-Child Item 2')).to.exist; }); - rerender( - - - - ); + it('triggers only the child action when clicking child menu item', function () { + const parentOnAction = sinon.spy(); + const childOnAction = sinon.spy(); + + render( + + + + + + ); + + const childTrigger = screen.getByTestId('child-trigger'); + userEvent.click(childTrigger, { button: 2 }); + + const childItem1 = screen.getByTestId('menu-item-Child Item 1'); + userEvent.click(childItem1); - expect(removeEventListenerSpy).to.have.been.calledWith('contextmenu'); - expect(addEventListenerSpy).to.have.been.calledWith('contextmenu'); + expect(childOnAction).to.have.been.calledOnceWithExactly(1); + expect(parentOnAction).to.not.have.been.called; + expect(() => screen.getByTestId('test-menu')).to.throw; + }); + + it('triggers only the parent action when clicking a parent menu item from child context', function () { + const parentOnAction = sinon.spy(); + const childOnAction = sinon.spy(); + + render( + + + + + + ); + + const childTrigger = screen.getByTestId('child-trigger'); + userEvent.click(childTrigger, { button: 2 }); + + const parentItem1 = screen.getByTestId('menu-item-Parent Item 1'); + userEvent.click(parentItem1); + + expect(parentOnAction).to.have.been.calledOnceWithExactly(1); + expect(childOnAction).to.not.have.been.called; + expect(() => screen.getByTestId('test-menu')).to.throw; + }); }); }); }); From a24e89cf1206972ac7d4a799e8837d21ae10250a Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 21 May 2025 17:07:53 +0200 Subject: [PATCH 28/62] refactor: minor stylistic changes --- .../src/context-menu-provider.tsx | 3 +- .../src/use-context-menu.tsx | 53 +++++++++++-------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 499d9c273c1..43d9e893367 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -35,13 +35,14 @@ export function ContextMenuProvider({ }, }); } - document.addEventListener('contextmenu', handleContextMenu); function handleClosingEvent(event: Event) { if (!event.defaultPrevented) { setMenu({ isOpen: false }); } } + + document.addEventListener('contextmenu', handleContextMenu); document.addEventListener('click', handleClosingEvent); window.addEventListener('resize', handleClosingEvent); diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index 37993b04f15..91f51c2f849 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -3,16 +3,25 @@ import { Context } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; import type { MenuItem } from './types'; -/** - * @returns an object with methods to {@link register} content for the menu and {@link close} the menu - */ +export type ContextMenuMethods = { + /** + * Close the context menu. + */ + close: () => void; + /** + * Register the menu items for the context menu. + * @returns a callback ref to be passed onto the element responsible for triggering the menu. + */ + registerItems: (items: T[]) => (trigger: HTMLElement | null) => void; +}; + export function useContextMenu({ Menu, }: { Menu: React.ComponentType<{ items: T[]; }>; -}) { +}): ContextMenuMethods { // Get the close function from the ContextProvider const context = useContext(Context); const previous = useRef void]>( @@ -24,31 +33,29 @@ export function useContextMenu({ throw new Error('useContextMenu called outside of the provider'); } + const register = (content: React.ComponentType) => { + function listener(event: MouseEvent) { + appendContextMenuContent(event, content); + } + return (trigger: HTMLElement | null) => { + if (previous.current) { + const [previousTrigger, previousListener] = previous.current; + previousTrigger.removeEventListener('contextmenu', previousListener); + } + if (trigger) { + trigger.addEventListener('contextmenu', listener); + previous.current = [trigger, listener]; + } + }; + }; + return { close: context.close.bind(context), /** * @returns a callback ref, passed onto the element responsible for triggering the menu. */ - register(content: React.ComponentType) { - function listener(event: MouseEvent) { - appendContextMenuContent(event, content); - } - return (trigger: HTMLElement | null) => { - if (previous.current) { - const [previousTrigger, previousListener] = previous.current; - previousTrigger.removeEventListener( - 'contextmenu', - previousListener - ); - } - if (trigger) { - trigger.addEventListener('contextmenu', listener); - previous.current = [trigger, listener]; - } - }; - }, registerItems(items: T[]) { - return this.register(() => ); + return register(() => ); }, }; }, [context, Menu]); From f3869eac0cab0fa2e0ddd1dd494cc418109b5bf0 Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 22 May 2025 11:59:55 +0200 Subject: [PATCH 29/62] fix: export types and rename MenuItem --- packages/compass-context-menu/src/index.ts | 2 ++ packages/compass-context-menu/src/types.ts | 2 +- .../compass-context-menu/src/use-context-menu.spec.tsx | 10 +++++----- packages/compass-context-menu/src/use-context-menu.tsx | 6 +++--- 4 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 packages/compass-context-menu/src/index.ts diff --git a/packages/compass-context-menu/src/index.ts b/packages/compass-context-menu/src/index.ts new file mode 100644 index 00000000000..d60a97e04b3 --- /dev/null +++ b/packages/compass-context-menu/src/index.ts @@ -0,0 +1,2 @@ +export { useContextMenu } from './use-context-menu'; +export type { ContextMenuItem } from './types'; diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index e9ac549ba63..f453930dcb3 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -15,7 +15,7 @@ export type ContextMenuContext = { close(): void; }; -export type MenuItem = { +export type ContextMenuItem = { label: string; onAction: (event: React.KeyboardEvent | React.MouseEvent) => void; }; 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 1d099272104..eb857db9aeb 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -4,10 +4,10 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; -import type { MenuItem } from './types'; +import type { ContextMenuItem } from './types'; describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC<{ items: ContextMenuItem[] }> = ({ items }) => (
{items.map((item, idx) => (
void; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const items: MenuItem[] = [ + const items: ContextMenuItem[] = [ { label: 'Test Item', onAction: () => onAction?.(1), @@ -64,7 +64,7 @@ describe('useContextMenu', function () { children?: React.ReactNode; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const parentItems: MenuItem[] = [ + const parentItems: ContextMenuItem[] = [ { label: 'Parent Item 1', onAction: () => onAction?.(1), @@ -90,7 +90,7 @@ describe('useContextMenu', function () { onAction?: (id: number) => void; }) => { const contextMenu = useContextMenu({ Menu: TestMenu }); - const childItems: MenuItem[] = [ + const childItems: ContextMenuItem[] = [ { label: 'Child Item 1', onAction: () => onAction?.(1), diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index 91f51c2f849..a0c97dead9b 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -1,9 +1,9 @@ import React, { useContext, useMemo, useRef } from 'react'; import { Context } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; -import type { MenuItem } from './types'; +import type { ContextMenuItem } from './types'; -export type ContextMenuMethods = { +export type ContextMenuMethods = { /** * Close the context menu. */ @@ -15,7 +15,7 @@ export type ContextMenuMethods = { registerItems: (items: T[]) => (trigger: HTMLElement | null) => void; }; -export function useContextMenu({ +export function useContextMenu({ Menu, }: { Menu: React.ComponentType<{ From c2d9ac141d35f3b6b945d71beb79795072989d79 Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 22 May 2025 12:14:37 +0200 Subject: [PATCH 30/62] fix: use React.RefCallback --- packages/compass-context-menu/src/use-context-menu.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index a0c97dead9b..fa9e20e7537 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -12,7 +12,7 @@ export type ContextMenuMethods = { * Register the menu items for the context menu. * @returns a callback ref to be passed onto the element responsible for triggering the menu. */ - registerItems: (items: T[]) => (trigger: HTMLElement | null) => void; + registerItems: (items: T[]) => React.RefCallback; }; export function useContextMenu({ @@ -33,7 +33,9 @@ export function useContextMenu({ throw new Error('useContextMenu called outside of the provider'); } - const register = (content: React.ComponentType) => { + const register = ( + content: React.ComponentType + ): React.RefCallback => { function listener(event: MouseEvent) { appendContextMenuContent(event, content); } From 1c6ef03024612cf493f60fe3a124748a39e8dd47 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 23 May 2025 13:50:50 +0200 Subject: [PATCH 31/62] refactor: use item groups instead of React elements, use wrapper, keep menu state consistent The refactor is meant to make the Leafygreen integration more straightforward: 1. We stick to item groups and instead have a single wrapper to handle any rendering differences between groups. This allows the wrapper to always have context of all items when rendering which is useful when inserting menu seperators in Leafygreen. Also encourages consistent UI (while allowing per-case customization if needed at wrapper-level). We could introduce itemWrappers instead of itemGroups but having one wrapper handling all seems cleaner to me. 2. More of the responsibility is moved to a parent wrapper component that will house the context menu. This allows us to standardize the right click menu and make better use of Leafygreen's menu component including its click handling (which has been removed from the context menu library). 3. Menu state (i.e. position) is now preserved even closed; this is useful for leafygreen's menu to animate in the same position instead of losing the position all together. --- .../src/context-menu-content.ts | 13 ++-- .../src/context-menu-provider.tsx | 57 ++++++++++------- packages/compass-context-menu/src/index.ts | 7 ++- packages/compass-context-menu/src/types.ts | 29 +++++---- .../src/use-context-menu.spec.tsx | 61 ++++++++++--------- .../src/use-context-menu.tsx | 59 +++++++++--------- 6 files changed, 124 insertions(+), 102 deletions(-) diff --git a/packages/compass-context-menu/src/context-menu-content.ts b/packages/compass-context-menu/src/context-menu-content.ts index 6856f1fe684..c301983a679 100644 --- a/packages/compass-context-menu/src/context-menu-content.ts +++ b/packages/compass-context-menu/src/context-menu-content.ts @@ -1,23 +1,24 @@ +import type { ContextMenuItemGroup } from './types'; + const CONTEXT_MENUS_SYMBOL = Symbol('context_menus'); export type EnhancedMouseEvent = MouseEvent & { - [CONTEXT_MENUS_SYMBOL]?: React.ComponentType[]; + [CONTEXT_MENUS_SYMBOL]?: ContextMenuItemGroup[]; }; export function getContextMenuContent( event: EnhancedMouseEvent -): React.ComponentType[] { +): ContextMenuItemGroup[] { return event[CONTEXT_MENUS_SYMBOL] ?? []; } export function appendContextMenuContent( event: EnhancedMouseEvent, - content: React.ComponentType + content: ContextMenuItemGroup ) { // Initialize if not already patched - if (event[CONTEXT_MENUS_SYMBOL] === undefined) { - event[CONTEXT_MENUS_SYMBOL] = [content]; - return; + if (!event[CONTEXT_MENUS_SYMBOL]) { + event[CONTEXT_MENUS_SYMBOL] = []; } event[CONTEXT_MENUS_SYMBOL].push(content); } diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 43d9e893367..5dd1cba2cff 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -5,8 +5,7 @@ import React, { useMemo, createContext, } from 'react'; -import type { ContextMenuContext, MenuState } from './types'; -import { ContextMenu } from './context-menu'; +import type { ContextMenuContext, ContextMenuState } from './types'; import type { EnhancedMouseEvent } from './context-menu-content'; import { getContextMenuContent } from './context-menu-content'; @@ -14,20 +13,42 @@ export const Context = createContext(null); export function ContextMenuProvider({ children, + wrapper, }: { children: React.ReactNode; + wrapper: React.ComponentType<{ + menu: ContextMenuState & { close: () => void }; + }>; }) { - const [menu, setMenu] = useState({ isOpen: false }); - const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]); + const [menu, setMenu] = useState({ + isOpen: false, + itemGroups: [], + position: { x: 0, y: 0 }, + }); + const close = useCallback(() => setMenu({ ...menu, isOpen: false }), [menu]); + + const handleClosingEvent = useCallback( + (event: Event) => { + if (!event.defaultPrevented) { + setMenu({ ...menu, isOpen: false }); + } + }, + [menu] + ); useEffect(() => { function handleContextMenu(event: MouseEvent) { event.preventDefault(); + + const itemGroups = getContextMenuContent(event as EnhancedMouseEvent); + + if (itemGroups.length === 0) { + return; + } + setMenu({ isOpen: true, - children: getContextMenuContent(event as EnhancedMouseEvent).map( - (Content, index) => - ), + itemGroups, position: { // TODO: Fix handling offset while scrolling x: event.clientX, @@ -36,22 +57,14 @@ export function ContextMenuProvider({ }); } - function handleClosingEvent(event: Event) { - if (!event.defaultPrevented) { - setMenu({ isOpen: false }); - } - } - document.addEventListener('contextmenu', handleContextMenu); - document.addEventListener('click', handleClosingEvent); window.addEventListener('resize', handleClosingEvent); return () => { document.removeEventListener('contextmenu', handleContextMenu); - document.removeEventListener('click', handleClosingEvent); window.removeEventListener('resize', handleClosingEvent); }; - }, [setMenu]); + }, [handleClosingEvent]); const value = useMemo( () => ({ @@ -60,12 +73,12 @@ export function ContextMenuProvider({ [close] ); + const Wrapper = wrapper ?? React.Fragment; + return ( - <> - {children} - {menu.isOpen && ( - {menu.children} - )} - + + {children} + + ); } diff --git a/packages/compass-context-menu/src/index.ts b/packages/compass-context-menu/src/index.ts index d60a97e04b3..75d933ef767 100644 --- a/packages/compass-context-menu/src/index.ts +++ b/packages/compass-context-menu/src/index.ts @@ -1,2 +1,7 @@ export { useContextMenu } from './use-context-menu'; -export type { ContextMenuItem } from './types'; +export { ContextMenuProvider } from './context-menu-provider'; +export type { + ContextMenuItem, + ContextMenuItemGroup, + ContextMenuWrapperProps, +} from './types'; diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index f453930dcb3..91e8d65cdcc 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -1,15 +1,20 @@ -export type MenuState = - | { - isOpen: false; - } - | { - isOpen: true; - children: React.ReactNode; - position: { - x: number; - y: number; - }; - }; +export interface ContextMenuItemGroup { + items: ContextMenuItem[]; + originListener: (event: MouseEvent) => void; +} + +export type ContextMenuState = { + isOpen: boolean; + itemGroups: ContextMenuItemGroup[]; + position: { + x: number; + y: number; + }; +}; + +export type ContextMenuWrapperProps = { + menu: ContextMenuState & { close: () => void }; +}; export type ContextMenuContext = { close(): void; 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 eb857db9aeb..cfe0bfc7ef4 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -4,27 +4,29 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; -import type { ContextMenuItem } from './types'; +import type { ContextMenuItem, ContextMenuWrapperProps } from './types'; describe('useContextMenu', function () { - const TestMenu: React.FC<{ items: ContextMenuItem[] }> = ({ items }) => ( + const TestMenu: React.FC = ({ menu }) => (
- {items.map((item, idx) => ( -
item.onAction?.(event)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - item.onAction?.(event); - } - }} - > - {item.label} -
- ))} + {menu.itemGroups.flatMap((group, groupIdx) => + group.items.map((item, idx) => ( +
item.onAction?.(event)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + item.onAction?.(event); + } + }} + > + {item.label} +
+ )) + )}
); @@ -33,9 +35,9 @@ describe('useContextMenu', function () { onAction, }: { onRegister?: (ref: any) => void; - onAction?: (id) => void; + onAction?: (id: number) => void; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const items: ContextMenuItem[] = [ { label: 'Test Item', @@ -55,7 +57,6 @@ describe('useContextMenu', function () { ); }; - // Add new test components for nested context menu scenario const ParentComponent = ({ onAction, children, @@ -63,7 +64,7 @@ describe('useContextMenu', function () { onAction?: (id: number) => void; children?: React.ReactNode; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const parentItems: ContextMenuItem[] = [ { label: 'Parent Item 1', @@ -89,7 +90,7 @@ describe('useContextMenu', function () { }: { onAction?: (id: number) => void; }) => { - const contextMenu = useContextMenu({ Menu: TestMenu }); + const contextMenu = useContextMenu(); const childItems: ContextMenuItem[] = [ { label: 'Child Item 1', @@ -135,7 +136,7 @@ describe('useContextMenu', function () { it('renders without error', function () { render( - + ); @@ -147,7 +148,7 @@ describe('useContextMenu', function () { const onRegister = sinon.spy(); render( - + ); @@ -158,7 +159,7 @@ describe('useContextMenu', function () { it('shows context menu on right click', function () { render( - + ); @@ -173,7 +174,7 @@ describe('useContextMenu', function () { describe('with nested context menus', function () { it('shows only parent items when right clicking parent area', function () { render( - + ); @@ -192,7 +193,7 @@ describe('useContextMenu', function () { it('shows both parent and child items when right clicking child area', function () { render( - + @@ -214,7 +215,7 @@ describe('useContextMenu', function () { const childOnAction = sinon.spy(); render( - + @@ -237,7 +238,7 @@ describe('useContextMenu', function () { const childOnAction = sinon.spy(); render( - + diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index fa9e20e7537..a60aeba4c69 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -1,4 +1,5 @@ -import React, { useContext, useMemo, useRef } from 'react'; +import type { RefCallback } from 'react'; +import { useContext, useMemo, useRef } from 'react'; import { Context } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; import type { ContextMenuItem } from './types'; @@ -12,17 +13,12 @@ export type ContextMenuMethods = { * Register the menu items for the context menu. * @returns a callback ref to be passed onto the element responsible for triggering the menu. */ - registerItems: (items: T[]) => React.RefCallback; + registerItems: (items: T[]) => RefCallback; }; -export function useContextMenu({ - Menu, -}: { - Menu: React.ComponentType<{ - items: T[]; - }>; -}): ContextMenuMethods { - // Get the close function from the ContextProvider +export function useContextMenu< + T extends ContextMenuItem = ContextMenuItem +>(): ContextMenuMethods { const context = useContext(Context); const previous = useRef void]>( null @@ -33,32 +29,33 @@ export function useContextMenu({ throw new Error('useContextMenu called outside of the provider'); } - const register = ( - content: React.ComponentType - ): React.RefCallback => { - function listener(event: MouseEvent) { - appendContextMenuContent(event, content); - } - return (trigger: HTMLElement | null) => { - if (previous.current) { - const [previousTrigger, previousListener] = previous.current; - previousTrigger.removeEventListener('contextmenu', previousListener); - } - if (trigger) { - trigger.addEventListener('contextmenu', listener); - previous.current = [trigger, listener]; - } - }; - }; - return { close: context.close.bind(context), /** * @returns a callback ref, passed onto the element responsible for triggering the menu. */ - registerItems(items: T[]) { - return register(() => ); + registerItems(items: ContextMenuItem[]) { + function listener(event: MouseEvent): void { + appendContextMenuContent(event, { + items, + originListener: listener, + }); + } + + return (trigger: HTMLElement | null) => { + if (previous.current) { + const [previousTrigger, previousListener] = previous.current; + previousTrigger.removeEventListener( + 'contextmenu', + previousListener + ); + } + if (trigger) { + trigger.addEventListener('contextmenu', listener); + previous.current = [trigger, listener]; + } + }; }, }; - }, [context, Menu]); + }, [context]); } From 3d14d6df00a8c1909853f878bf086fb6bcda7323 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 23 May 2025 17:12:31 +0200 Subject: [PATCH 32/62] fix: delete redundant context menu --- .../compass-context-menu/src/context-menu.tsx | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 packages/compass-context-menu/src/context-menu.tsx diff --git a/packages/compass-context-menu/src/context-menu.tsx b/packages/compass-context-menu/src/context-menu.tsx deleted file mode 100644 index b053bc4963a..00000000000 --- a/packages/compass-context-menu/src/context-menu.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { createPortal } from 'react-dom'; -import React from 'react'; - -type ContextMenuProps = React.PropsWithChildren<{ - position: { - x: number; - y: number; - }; -}>; - -export function ContextMenu({ children, position }: ContextMenuProps) { - const container = document.getElementById('context-menu-container'); - if (container === null) { - throw new Error('Expected a container for the context menu in the DOM'); - } - return createPortal( -
- {children} -
, - container - ); -} From ee658e1f394f49713edb63647193dc0641bcaaeb Mon Sep 17 00:00:00 2001 From: gagik Date: Mon, 2 Jun 2025 13:51:48 +0200 Subject: [PATCH 33/62] fix: remove unused dep --- package-lock.json | 4 ++-- packages/compass-context-menu/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a205c4ec913..57ab1929a9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43216,11 +43216,11 @@ "@mongodb-js/eslint-config-compass": "^1.3.8", "@mongodb-js/mocha-config-compass": "^1.6.8", "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/testing-library-compass": "^1.3.1", "@mongodb-js/tsconfig-compass": "^1.2.8", "@types/chai": "^4.2.21", "@types/mocha": "^9.0.0", "@types/react": "^17.0.5", - "@types/react-dom": "^17.0.10", "@types/sinon-chai": "^3.2.5", "chai": "^4.3.6", "depcheck": "^1.4.1", @@ -55712,11 +55712,11 @@ "@mongodb-js/eslint-config-compass": "^1.3.8", "@mongodb-js/mocha-config-compass": "^1.6.8", "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/testing-library-compass": "^1.3.1", "@mongodb-js/tsconfig-compass": "^1.2.8", "@types/chai": "^4.2.21", "@types/mocha": "^9.0.0", "@types/react": "^17.0.5", - "@types/react-dom": "^17.0.10", "@types/sinon-chai": "^3.2.5", "chai": "^4.3.6", "depcheck": "^1.4.1", diff --git a/packages/compass-context-menu/package.json b/packages/compass-context-menu/package.json index 3cf186e0270..67099115f92 100644 --- a/packages/compass-context-menu/package.json +++ b/packages/compass-context-menu/package.json @@ -55,11 +55,11 @@ "@mongodb-js/eslint-config-compass": "^1.3.8", "@mongodb-js/mocha-config-compass": "^1.6.8", "@mongodb-js/prettier-config-compass": "^1.2.8", + "@mongodb-js/testing-library-compass": "^1.3.1", "@mongodb-js/tsconfig-compass": "^1.2.8", "@types/chai": "^4.2.21", "@types/mocha": "^9.0.0", "@types/react": "^17.0.5", - "@types/react-dom": "^17.0.10", "@types/sinon-chai": "^3.2.5", "chai": "^4.3.6", "depcheck": "^1.4.1", From 59d82d6365740cb48029b607d077d8a48687f48a Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 6 Jun 2025 10:06:20 +0200 Subject: [PATCH 34/62] test: compas crud --- package-lock.json | 169 +++++++++++++- .../src/components/context-menu.tsx | 2 +- .../components/document-list/element.spec.tsx | 126 +++++++++++ .../src/components/document-list/element.tsx | 37 +++ packages/compass-crud/package.json | 2 +- .../components/document-json-view-item.tsx | 86 +++++++ .../document-list-view-item.spec.tsx | 213 ++++++++++++++++++ .../components/document-list-view-item.tsx | 100 ++++++++ .../components/document-list-view.spec.tsx | 10 +- .../virtualized-document-json-view.tsx | 44 ++-- .../virtualized-document-list-view.spec.tsx | 27 ++- .../virtualized-document-list-view.tsx | 37 +-- 12 files changed, 794 insertions(+), 59 deletions(-) create mode 100644 packages/compass-components/src/components/document-list/element.spec.tsx create mode 100644 packages/compass-crud/src/components/document-json-view-item.tsx create mode 100644 packages/compass-crud/src/components/document-list-view-item.spec.tsx create mode 100644 packages/compass-crud/src/components/document-list-view-item.tsx diff --git a/package-lock.json b/package-lock.json index 57ab1929a9d..24151b8e443 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43318,7 +43318,7 @@ "mongodb-instance-model": "^12.31.0", "nyc": "^15.1.0", "react-dom": "^17.0.2", - "sinon": "^8.1.1", + "sinon": "^17.0.1", "typescript": "^5.0.4" } }, @@ -43334,6 +43334,55 @@ "bson": "^4.6.3 || ^5 || ^6" } }, + "packages/compass-crud/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "packages/compass-crud/node_modules/@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "packages/compass-crud/node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "packages/compass-crud/node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "packages/compass-crud/node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, "packages/compass-crud/node_modules/mongodb-query-parser": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/mongodb-query-parser/-/mongodb-query-parser-4.3.0.tgz", @@ -43349,6 +43398,20 @@ "bson": "^4.6.3 || ^5 || ^6" } }, + "packages/compass-crud/node_modules/nise": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, "packages/compass-crud/node_modules/numeral": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", @@ -43357,6 +43420,32 @@ "node": "*" } }, + "packages/compass-crud/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "packages/compass-crud/node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "packages/compass-data-modeling": { "name": "@mongodb-js/compass-data-modeling", "version": "1.8.0", @@ -55803,7 +55892,7 @@ "react-dom": "^17.0.2", "reflux": "^0.4.1", "semver": "^7.6.2", - "sinon": "^8.1.1", + "sinon": "^17.0.1", "typescript": "^5.0.4" }, "dependencies": { @@ -55815,6 +55904,49 @@ "acorn": "^8.1.0" } }, + "@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1" + } + }, + "@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + }, + "dependencies": { + "type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true + } + } + }, + "just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, "mongodb-query-parser": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/mongodb-query-parser/-/mongodb-query-parser-4.3.0.tgz", @@ -55826,10 +55958,43 @@ "lodash": "^4.17.21" } }, + "nise": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, "numeral": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==" + }, + "path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true + }, + "sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + } } } }, diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index c78519e399c..288db7393a5 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -28,7 +28,7 @@ export function ContextMenu({ menu }: ContextMenuWrapperProps) { if (!menu.isOpen) { menu.close(); } - }, [menu, menu.isOpen]); + }, [menu.isOpen]); return (
{ + return render({element}); + }; + + it('copies field and value when "Copy field & value" is clicked', function () { + renderWithContextMenu( + {}} + /> + ); + + // Open context menu and click the copy option + const elementNode = screen.getByTestId('hadron-document-element'); + userEvent.click(elementNode, { button: 2 }); + userEvent.click(screen.getByText('Copy field & value'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(clipboardWriteTextStub).to.have.been.calledWith('field: "value"'); + }); + + it('shows "Open URL in browser" for URL string values', function () { + const urlDoc = new HadronDocument({ link: 'https://mongodb.com' }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const urlElement = urlDoc.elements.at(0)!; + + renderWithContextMenu( + {}} + /> + ); + + // Open context menu + const elementNode = screen.getByTestId('hadron-document-element'); + userEvent.click(elementNode, { button: 2 }); + + // Check if the menu item exists + expect(screen.getByText('Open URL in browser')).to.exist; + }); + + it('opens URL in new tab when "Open URL in browser" is clicked', function () { + const urlDoc = new HadronDocument({ link: 'https://mongodb.com' }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const urlElement = urlDoc.elements.at(0)!; + + renderWithContextMenu( + {}} + /> + ); + + // Open context menu and click the open URL option + const elementNode = screen.getByTestId('hadron-document-element'); + userEvent.click(elementNode, { button: 2 }); + userEvent.click(screen.getByText('Open URL in browser'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(windowOpenStub).to.have.been.calledWith( + 'https://mongodb.com', + '_blank', + 'noopener' + ); + }); + + it('does not show "Open URL in browser" for non-URL string values', function () { + renderWithContextMenu( + {}} + /> + ); + + // Open context menu + const elementNode = screen.getByTestId('hadron-document-element'); + userEvent.click(elementNode, { button: 2 }); + + // Check that the menu item doesn't exist + expect(screen.queryByText('Open URL in browser')).to.not.exist; + }); + }); +}); diff --git a/packages/compass-components/src/components/document-list/element.tsx b/packages/compass-components/src/components/document-list/element.tsx index f41a90a0bf3..39ca9cfd86a 100644 --- a/packages/compass-components/src/components/document-list/element.tsx +++ b/packages/compass-components/src/components/document-list/element.tsx @@ -15,6 +15,7 @@ import { ElementEvents, ElementEditor, DEFAULT_VISIBLE_ELEMENTS, + objectToIdiomaticEJSON, } from 'hadron-document'; import BSONValue from '../bson-value'; import { spacing } from '@leafygreen-ui/tokens'; @@ -28,6 +29,7 @@ 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'; function getEditorByType(type: HadronElementType['type']) { switch (type) { @@ -409,6 +411,16 @@ export const calculateShowMoreToggleOffset = ({ return spacerWidth + editableOffset + expandIconSize; }; +// Helper function to check if a string is a URL +const isValidUrl = (str: string): boolean => { + try { + const url = new URL(str); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + export const HadronElement: React.FunctionComponent<{ value: HadronElementType; editable: boolean; @@ -447,6 +459,29 @@ export const HadronElement: React.FunctionComponent<{ collapse, } = useHadronElement(element); + // Add context menu hook for the field + const fieldContextMenuRef = useContextMenuItems([ + { + label: 'Copy field & value', + onAction: () => { + const fieldStr = `${key.value}: ${objectToIdiomaticEJSON( + value.originalValue + )}`; + void navigator.clipboard.writeText(fieldStr); + }, + }, + ...(type.value === 'String' && isValidUrl(value.value) + ? [ + { + label: 'Open URL in browser', + onAction: () => { + window.open(value.value, '_blank', 'noopener'); + }, + }, + ] + : []), + ]); + const toggleExpanded = () => { expanded ? collapse() : expand(); }; @@ -489,6 +524,7 @@ export const HadronElement: React.FunctionComponent<{ : elementInvalidLightMode; const elementProps = { + ref: fieldContextMenuRef, className: cx( hadronElement, darkMode ? hadronElementDarkMode : hadronElementLightMode, @@ -527,6 +563,7 @@ export const HadronElement: React.FunctionComponent<{ data-field={key.value} data-id={element.uuid} {...elementProps} + ref={fieldContextMenuRef} > {editable && (
diff --git a/packages/compass-crud/package.json b/packages/compass-crud/package.json index 70194456a54..d5ee9a25892 100644 --- a/packages/compass-crud/package.json +++ b/packages/compass-crud/package.json @@ -66,7 +66,7 @@ "mongodb-instance-model": "^12.31.0", "nyc": "^15.1.0", "react-dom": "^17.0.2", - "sinon": "^8.1.1", + "sinon": "^17.0.1", "typescript": "^5.0.4" }, "dependencies": { diff --git a/packages/compass-crud/src/components/document-json-view-item.tsx b/packages/compass-crud/src/components/document-json-view-item.tsx new file mode 100644 index 00000000000..f368b476f09 --- /dev/null +++ b/packages/compass-crud/src/components/document-json-view-item.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import type HadronDocument from 'hadron-document'; +import { css, KeylineCard, spacing } from '@mongodb-js/compass-components'; + +import JSONEditor, { type JSONEditorProps } from './json-editor'; +import { useContextMenuItems } from '@mongodb-js/compass-components'; + +const keylineCardStyles = css({ + overflow: 'hidden', + position: 'relative', +}); + +export type DocumentJsonViewItemProps = { + doc: HadronDocument; + docRef: React.Ref; + docIndex: number; + namespace: string; + isEditable: boolean; + isTimeSeries?: boolean; + scrollTriggerRef?: React.Ref; +} & Pick< + JSONEditorProps, + | 'copyToClipboard' + | 'removeDocument' + | 'replaceDocument' + | 'updateDocument' + | 'openInsertDocumentDialog' +>; + +const DocumentJsonViewItem: React.FC = ({ + doc, + docRef, + docIndex, + namespace, + isEditable, + isTimeSeries, + scrollTriggerRef, + copyToClipboard, + removeDocument, + replaceDocument, + updateDocument, + openInsertDocumentDialog, +}) => { + const ref = useContextMenuItems([ + { + label: 'Update document', + onAction: () => { + updateDocument?.(doc); + }, + }, + { + label: 'Copy document', + onAction: () => { + copyToClipboard?.(doc); + }, + }, + { + label: 'Delete document', + onAction: () => { + removeDocument?.(doc); + }, + }, + ]); + + return ( +
+ + {scrollTriggerRef && docIndex === 0 &&
} + + +
+ ); +}; + +export { DocumentJsonViewItem }; diff --git a/packages/compass-crud/src/components/document-list-view-item.spec.tsx b/packages/compass-crud/src/components/document-list-view-item.spec.tsx new file mode 100644 index 00000000000..e00046b478e --- /dev/null +++ b/packages/compass-crud/src/components/document-list-view-item.spec.tsx @@ -0,0 +1,213 @@ +import React from 'react'; +import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import HadronDocument from 'hadron-document'; +import { DocumentListViewItem } from './document-list-view-item'; +import { ContextMenuProvider } from '@mongodb-js/compass-components'; + +describe('DocumentListViewItem', function () { + describe('document context menu', function () { + let doc: HadronDocument; + let copyToClipboardStub: sinon.SinonStub; + let openInsertDocumentDialogStub: sinon.SinonStub; + let collapseStub: sinon.SinonStub; + let expandStub: sinon.SinonStub; + let startEditingStub: sinon.SinonStub; + let markForDeletionStub: sinon.SinonStub; + let generateObjectStub: sinon.SinonStub; + + beforeEach(function () { + doc = new HadronDocument({ + _id: 1, + name: 'test', + url: 'https://mongodb.com', + nested: { field: 'value' }, + }); + + copyToClipboardStub = sinon.stub(); + openInsertDocumentDialogStub = sinon.stub(); + + // Set up document methods as stubs + collapseStub = sinon.stub(doc, 'collapse'); + expandStub = sinon.stub(doc, 'expand'); + startEditingStub = sinon.stub(doc, 'startEditing'); + markForDeletionStub = sinon.stub(doc, 'markForDeletion'); + generateObjectStub = sinon.stub(doc, 'generateObject').returns({ + _id: 1, + name: 'test', + url: 'https://mongodb.com', + nested: { field: 'value' }, + }); + }); + + /** + * Renders the element with the context menu provider and returns a reference to the first child of the container. + */ + const renderWithContextMenu = (doc: HadronDocument): HTMLElement => { + const { container } = render( + + + + ); + return container.firstChild as HTMLElement; + }; + + afterEach(function () { + sinon.restore(); + }); + + it('shows "Expand all fields" when document is collapsed', function () { + doc.expanded = false; + + const container = renderWithContextMenu(doc); + + // Right-click to open context menu + userEvent.click(container, { button: 2 }); + + expect(screen.getByText('Expand all fields')).to.exist; + }); + + it('shows "Collapse all fields" when document is expanded', function () { + doc.expanded = true; + + const container = renderWithContextMenu(doc); + + // Right-click to open context menu + userEvent.click(container, { button: 2 }); + + expect(screen.getByText('Collapse all fields')).to.exist; + }); + + it('expands document when "Expand all fields" is clicked', function () { + doc.expanded = false; + + const container = renderWithContextMenu(doc); + + // Right-click to open context menu + userEvent.click(container, { button: 2 }); + + // Click expand option + userEvent.click(screen.getByText('Expand all fields')); + + expect(expandStub).to.have.been.calledOnce; + }); + + it('collapses document when "Collapse all fields" is clicked', function () { + doc.expanded = true; + + const container = renderWithContextMenu(doc); + + // Right-click to open context menu + userEvent.click(container, { button: 2 }); + + // Click collapse option + userEvent.click(screen.getByText('Collapse all fields'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(collapseStub).to.have.been.calledOnce; + }); + + it('shows "Edit document" when document is not in editing mode', function () { + doc.editing = false; + + const container = renderWithContextMenu(doc); + + // Right-click to open context menu + userEvent.click(container, { button: 2 }); + + expect(screen.getByText('Edit document')).to.exist; + }); + + it('does not show "Edit document" when document is in editing mode', function () { + doc.editing = true; + + const container = renderWithContextMenu(doc); + + // Right-click to open context menu + userEvent.click(container, { button: 2 }); + + expect(screen.queryByText('Edit document')).to.not.exist; + }); + + it('starts editing when "Edit document" is clicked', function () { + doc.editing = false; + + const container = renderWithContextMenu(doc); + + // Right-click to open context menu + userEvent.click(container, { button: 2 }); + + // Click edit option + userEvent.click(screen.getByText('Edit document'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(startEditingStub).to.have.been.calledOnce; + }); + + it('calls copyToClipboard when "Copy document" is clicked', function () { + const container = renderWithContextMenu(doc); + + // Right-click to open context menu + userEvent.click(container, { button: 2 }); + + // Click copy option + userEvent.click(screen.getByText('Copy document'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(copyToClipboardStub).to.have.been.calledWith(doc); + }); + + it('opens insert dialog with cloned document when "Clone document..." is clicked', async function () { + const container = renderWithContextMenu(doc); + + // Right-click to open context menu + userEvent.click(container, { button: 2 }); + + // Click clone option + userEvent.click(screen.getByText('Clone document...'), undefined, { + skipPointerEventsCheck: true, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(generateObjectStub).to.have.been.calledWith({ + excludeInternalFields: true, + }); + + expect(openInsertDocumentDialogStub).to.have.been.calledWith( + { + _id: 1, + name: 'test', + url: 'https://mongodb.com', + nested: { field: 'value' }, + }, + true + ); + }); + + it('marks document for deletion when "Delete document" is clicked', function () { + const container = renderWithContextMenu(doc); + + // Right-click to open context menu + userEvent.click(container, { button: 2 }); + + // Click delete option + userEvent.click(screen.getByText('Delete document'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(markForDeletionStub).to.have.been.calledOnce; + }); + }); +}); diff --git a/packages/compass-crud/src/components/document-list-view-item.tsx b/packages/compass-crud/src/components/document-list-view-item.tsx new file mode 100644 index 00000000000..493f7bc52c4 --- /dev/null +++ b/packages/compass-crud/src/components/document-list-view-item.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import type HadronDocument from 'hadron-document'; +import { KeylineCard } from '@mongodb-js/compass-components'; +import Document, { type DocumentProps } from './document'; +import { useContextMenuItems } from '@mongodb-js/compass-components'; + +export type DocumentListViewItemProps = { + doc: HadronDocument; + docRef: React.Ref; + docIndex: number; + isEditable: boolean; + isTimeSeries?: boolean; + scrollTriggerRef?: React.Ref; +} & Pick< + DocumentProps, + | 'copyToClipboard' + | 'removeDocument' + | 'replaceDocument' + | 'updateDocument' + | 'openInsertDocumentDialog' +>; + +const DocumentListViewItem: React.FC = ({ + doc, + docRef, + docIndex, + isEditable, + isTimeSeries, + scrollTriggerRef, + copyToClipboard, + removeDocument, + replaceDocument, + updateDocument, + openInsertDocumentDialog, +}) => { + const ref = useContextMenuItems([ + { + label: doc.expanded ? 'Collapse all fields' : 'Expand all fields', + onAction: () => { + if (doc.expanded) { + doc.collapse(); + } else { + doc.expand(); + } + }, + }, + ...(!doc.editing + ? [ + { + label: 'Edit document', + onAction: () => { + doc.startEditing(); + }, + }, + ] + : []), + { + label: 'Copy document', + onAction: () => { + copyToClipboard?.(doc); + }, + }, + { + label: 'Clone document...', + onAction: () => { + const clonedDoc = doc.generateObject({ + excludeInternalFields: true, + }); + openInsertDocumentDialog?.(clonedDoc, true); + }, + }, + { + label: 'Delete document', + onAction: () => { + doc.markForDeletion(); + }, + }, + ]); + + return ( +
+ + {scrollTriggerRef && docIndex === 0 &&
} + + +
+ ); +}; + +export { DocumentListViewItem }; diff --git a/packages/compass-crud/src/components/document-list-view.spec.tsx b/packages/compass-crud/src/components/document-list-view.spec.tsx index 536f48e66c2..0465df67545 100644 --- a/packages/compass-crud/src/components/document-list-view.spec.tsx +++ b/packages/compass-crud/src/components/document-list-view.spec.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { mount } from 'enzyme'; import HadronDocument from 'hadron-document'; import { expect } from 'chai'; -import sinon from 'sinon'; import DocumentListView from './document-list-view'; +import { ContextMenuProvider } from '@mongodb-js/compass-components'; describe('', function () { describe('#render', function () { @@ -16,12 +16,8 @@ describe('', function () { docs={hadronDocs} isEditable={false} isTimeSeries={false} - copyToClipboard={sinon.spy()} - removeDocument={sinon.spy()} - replaceDocument={sinon.spy()} - updateDocument={sinon.spy()} - openInsertDocumentDialog={sinon.spy()} - /> + />, + { wrappingComponent: ContextMenuProvider } ); it('renders all the documents', function () { diff --git a/packages/compass-crud/src/components/virtualized-document-json-view.tsx b/packages/compass-crud/src/components/virtualized-document-json-view.tsx index 8cffcf8f26f..609bb657836 100644 --- a/packages/compass-crud/src/components/virtualized-document-json-view.tsx +++ b/packages/compass-crud/src/components/virtualized-document-json-view.tsx @@ -2,19 +2,14 @@ import React, { useCallback } from 'react'; import type HadronDocument from 'hadron-document'; import { css, - KeylineCard, spacing, VirtualList, type VirtualListRef, type VirtualListItemRenderer, } from '@mongodb-js/compass-components'; -import JSONEditor, { type JSONEditorProps } from './json-editor'; - -const keylineCardStyles = css({ - overflow: 'hidden', - position: 'relative', -}); +import type { JSONEditorProps } from './json-editor'; +import { DocumentJsonViewItem } from './document-json-view-item'; const spacingStyles = css({ padding: spacing[400], @@ -75,23 +70,26 @@ const VirtualizedDocumentJsonView: React.FC< listRef, }) => { const renderItem: VirtualListItemRenderer = useCallback( - (doc, docRef, docIndex) => { + ( + doc: HadronDocument, + docRef: React.Ref, + docIndex: number + ) => { return ( - - {scrollTriggerRef && docIndex === 0 &&
} - - + ); }, [ diff --git a/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx b/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx index a9d291f6479..9f9ecb44e86 100644 --- a/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx +++ b/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx @@ -2,14 +2,17 @@ import React from 'react'; import { expect } from 'chai'; import HadronDocument from 'hadron-document'; import { - render, screen, cleanup, within, act, userEvent, + render, } from '@mongodb-js/testing-library-compass'; -import { type VirtualListRef } from '@mongodb-js/compass-components'; +import { + ContextMenuProvider, + type VirtualListRef, +} from '@mongodb-js/compass-components'; import VirtualizedDocumentListView from './virtualized-document-list-view'; @@ -36,12 +39,20 @@ const getDocs = () => [ ]; describe('VirtualizedDocumentListView', function () { + const renderWithContextMenu = (element: React.ReactElement) => { + return render(element, { + wrapper: ContextMenuProvider, + }); + }; + afterEach(function () { cleanup(); }); it('renders the list of provided BSON objects', function () { - render(); + renderWithContextMenu( + + ); expect(screen.getByTitle('1')).to.be.visible; expect(screen.getByTitle('Doc1')).to.be.visible; @@ -50,7 +61,7 @@ describe('VirtualizedDocumentListView', function () { }); it('renders the list of provided HadronDocuments', function () { - render( + renderWithContextMenu( new HadronDocument(doc))} isEditable={false} @@ -64,7 +75,7 @@ describe('VirtualizedDocumentListView', function () { }); it('renders a readonly list when isEditable is false', function () { - render( + renderWithContextMenu( new HadronDocument(doc))} isEditable={false} @@ -75,7 +86,7 @@ describe('VirtualizedDocumentListView', function () { }); it('renders an editable list when isEditable is true', function () { - render( + renderWithContextMenu( new HadronDocument(doc))} isEditable={true} @@ -91,7 +102,7 @@ describe('VirtualizedDocumentListView', function () { (_, idx) => new HadronDocument(createBigDocument(idx)) ); const listRef: VirtualListRef = React.createRef(); - render( + renderWithContextMenu( = useCallback( - (doc, docRef, docIndex) => { + ( + doc: HadronDocument, + docRef: React.Ref, + docIndex: number + ) => { return ( - - {scrollTriggerRef && docIndex === 0 &&
} - - + ); }, [ From 1ad46a7b47029ace184bfe1e8c993011578e934e Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 10 Jun 2025 17:13:08 +0200 Subject: [PATCH 35/62] fix: enforce no nesting, adjsut enzyme test and move setup to testing-library --- configs/testing-library-compass/src/index.tsx | 77 ++++++++++--------- .../src/context-menu-provider.spec.tsx | 45 +++++++++++ .../src/context-menu-provider.tsx | 18 ++++- .../components/document-list-view.spec.tsx | 25 +++--- 4 files changed, 119 insertions(+), 46 deletions(-) create mode 100644 packages/compass-context-menu/src/context-menu-provider.spec.tsx diff --git a/configs/testing-library-compass/src/index.tsx b/configs/testing-library-compass/src/index.tsx index 6f3bf3bb79d..d01c55bba42 100644 --- a/configs/testing-library-compass/src/index.tsx +++ b/configs/testing-library-compass/src/index.tsx @@ -45,7 +45,10 @@ import { ReadOnlyPreferenceAccess, } from 'compass-preferences-model/provider'; import { TelemetryProvider } from '@mongodb-js/compass-telemetry/provider'; -import { CompassComponentsProvider } from '@mongodb-js/compass-components'; +import { + CompassComponentsProvider, + ContextMenuProvider, +} from '@mongodb-js/compass-components'; import { TestEnvCurrentConnectionContext, ConnectionInfoProvider, @@ -349,41 +352,43 @@ function createWrapper( - - - { - // noop - }) - } - onExtraConnectionDataRequest={ - options.onExtraConnectionDataRequest ?? - (() => { - return Promise.resolve([{}, null] as [any, null]); - }) - } - onAutoconnectInfoRequest={ - options.onAutoconnectInfoRequest - } - preloadStorageConnectionInfos={connections} - > - - - - {children} - - - - - - + + + + { + // noop + }) + } + onExtraConnectionDataRequest={ + options.onExtraConnectionDataRequest ?? + (() => { + return Promise.resolve([{}, null] as [any, null]); + }) + } + onAutoconnectInfoRequest={ + options.onAutoconnectInfoRequest + } + preloadStorageConnectionInfos={connections} + > + + + + {children} + + + + + + + diff --git a/packages/compass-context-menu/src/context-menu-provider.spec.tsx b/packages/compass-context-menu/src/context-menu-provider.spec.tsx new file mode 100644 index 00000000000..7e39fdee800 --- /dev/null +++ b/packages/compass-context-menu/src/context-menu-provider.spec.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { render } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import { ContextMenuProvider } from './context-menu-provider'; +import type { ContextMenuWrapperProps } from './types'; + +describe('ContextMenuProvider', function () { + const TestMenu: React.FC = () => ( +
Test Menu
+ ); + + const TestComponent = () => ( +
Test Content
+ ); + + describe('when nested', function () { + it('throws an error when providers are nested', function () { + expect(() => { + render( + +
+ + + +
+
+ ); + }).to.throw( + 'Duplicated ContextMenuProvider found. Please remove the nested provider.' + ); + }); + }); + + describe('when not nested', function () { + it('renders without error', function () { + render( + + + + ); + + expect(document.querySelector('[data-testid="test-content"]')).to.exist; + }); + }); +}); diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 5dd1cba2cff..0ae7c5f0e06 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -4,6 +4,7 @@ import React, { useState, useMemo, createContext, + useContext, } from 'react'; import type { ContextMenuContext, ContextMenuState } from './types'; import type { EnhancedMouseEvent } from './context-menu-content'; @@ -20,6 +21,9 @@ export function ContextMenuProvider({ menu: ContextMenuState & { close: () => void }; }>; }) { + // Check if there's already a parent context menu provider + const parentContext = useContext(Context); + const [menu, setMenu] = useState({ isOpen: false, itemGroups: [], @@ -37,6 +41,11 @@ export function ContextMenuProvider({ ); useEffect(() => { + // If there's a parent provider, don't add event listeners + if (parentContext) { + return; + } + function handleContextMenu(event: MouseEvent) { event.preventDefault(); @@ -64,7 +73,7 @@ export function ContextMenuProvider({ document.removeEventListener('contextmenu', handleContextMenu); window.removeEventListener('resize', handleClosingEvent); }; - }, [handleClosingEvent]); + }, [handleClosingEvent, parentContext]); const value = useMemo( () => ({ @@ -73,6 +82,13 @@ export function ContextMenuProvider({ [close] ); + // Prevent accidental nested providers + if (parentContext) { + throw new Error( + 'Duplicated ContextMenuProvider found. Please remove the nested provider.' + ); + } + const Wrapper = wrapper ?? React.Fragment; return ( diff --git a/packages/compass-crud/src/components/document-list-view.spec.tsx b/packages/compass-crud/src/components/document-list-view.spec.tsx index 0465df67545..01710d92cf5 100644 --- a/packages/compass-crud/src/components/document-list-view.spec.tsx +++ b/packages/compass-crud/src/components/document-list-view.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { mount } from 'enzyme'; +import { ReactWrapper, mount } from 'enzyme'; import HadronDocument from 'hadron-document'; import { expect } from 'chai'; @@ -11,14 +11,21 @@ describe('', function () { context('when the documents have objects for ids', function () { const docs = [{ _id: { name: 'test-1' } }, { _id: { name: 'test-2' } }]; const hadronDocs = docs.map((doc) => new HadronDocument(doc)); - const component = mount( - , - { wrappingComponent: ContextMenuProvider } - ); + let component: ReactWrapper; + beforeEach(function () { + component = mount( + , + { wrappingComponent: ContextMenuProvider } + ); + }); + + afterEach(function () { + component?.unmount(); + }); it('renders all the documents', function () { const wrapper = component.find('[data-testid="readonly-document"]'); From 4730c189e9b0ba768e7b772d4d3ffd7d5bfe5818 Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 10 Jun 2025 17:15:25 +0200 Subject: [PATCH 36/62] fix: add tests and fix bug with menu auto-closing --- .../src/components/context-menu.tsx | 2 +- .../src/context-menu-provider.spec.tsx | 45 +++++++++++++++++++ .../src/context-menu-provider.tsx | 18 +++++++- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 packages/compass-context-menu/src/context-menu-provider.spec.tsx diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index c78519e399c..288db7393a5 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -28,7 +28,7 @@ export function ContextMenu({ menu }: ContextMenuWrapperProps) { if (!menu.isOpen) { menu.close(); } - }, [menu, menu.isOpen]); + }, [menu.isOpen]); return (
= () => ( +
Test Menu
+ ); + + const TestComponent = () => ( +
Test Content
+ ); + + describe('when nested', function () { + it('throws an error when providers are nested', function () { + expect(() => { + render( + +
+ + + +
+
+ ); + }).to.throw( + 'Duplicated ContextMenuProvider found. Please remove the nested provider.' + ); + }); + }); + + describe('when not nested', function () { + it('renders without error', function () { + render( + + + + ); + + expect(document.querySelector('[data-testid="test-content"]')).to.exist; + }); + }); +}); diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 5dd1cba2cff..0ae7c5f0e06 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -4,6 +4,7 @@ import React, { useState, useMemo, createContext, + useContext, } from 'react'; import type { ContextMenuContext, ContextMenuState } from './types'; import type { EnhancedMouseEvent } from './context-menu-content'; @@ -20,6 +21,9 @@ export function ContextMenuProvider({ menu: ContextMenuState & { close: () => void }; }>; }) { + // Check if there's already a parent context menu provider + const parentContext = useContext(Context); + const [menu, setMenu] = useState({ isOpen: false, itemGroups: [], @@ -37,6 +41,11 @@ export function ContextMenuProvider({ ); useEffect(() => { + // If there's a parent provider, don't add event listeners + if (parentContext) { + return; + } + function handleContextMenu(event: MouseEvent) { event.preventDefault(); @@ -64,7 +73,7 @@ export function ContextMenuProvider({ document.removeEventListener('contextmenu', handleContextMenu); window.removeEventListener('resize', handleClosingEvent); }; - }, [handleClosingEvent]); + }, [handleClosingEvent, parentContext]); const value = useMemo( () => ({ @@ -73,6 +82,13 @@ export function ContextMenuProvider({ [close] ); + // Prevent accidental nested providers + if (parentContext) { + throw new Error( + 'Duplicated ContextMenuProvider found. Please remove the nested provider.' + ); + } + const Wrapper = wrapper ?? React.Fragment; return ( From eb657699844edde0af7ba583f8fa153ef08f6fb2 Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 10 Jun 2025 17:13:08 +0200 Subject: [PATCH 37/62] fix: enforce no nesting, adjsut enzyme test and move setup to testing-library --- configs/testing-library-compass/src/index.tsx | 77 ++++++++++--------- .../components/document-list-view.spec.tsx | 30 ++++---- 2 files changed, 58 insertions(+), 49 deletions(-) diff --git a/configs/testing-library-compass/src/index.tsx b/configs/testing-library-compass/src/index.tsx index 6f3bf3bb79d..d01c55bba42 100644 --- a/configs/testing-library-compass/src/index.tsx +++ b/configs/testing-library-compass/src/index.tsx @@ -45,7 +45,10 @@ import { ReadOnlyPreferenceAccess, } from 'compass-preferences-model/provider'; import { TelemetryProvider } from '@mongodb-js/compass-telemetry/provider'; -import { CompassComponentsProvider } from '@mongodb-js/compass-components'; +import { + CompassComponentsProvider, + ContextMenuProvider, +} from '@mongodb-js/compass-components'; import { TestEnvCurrentConnectionContext, ConnectionInfoProvider, @@ -349,41 +352,43 @@ function createWrapper( - - - { - // noop - }) - } - onExtraConnectionDataRequest={ - options.onExtraConnectionDataRequest ?? - (() => { - return Promise.resolve([{}, null] as [any, null]); - }) - } - onAutoconnectInfoRequest={ - options.onAutoconnectInfoRequest - } - preloadStorageConnectionInfos={connections} - > - - - - {children} - - - - - - + + + + { + // noop + }) + } + onExtraConnectionDataRequest={ + options.onExtraConnectionDataRequest ?? + (() => { + return Promise.resolve([{}, null] as [any, null]); + }) + } + onAutoconnectInfoRequest={ + options.onAutoconnectInfoRequest + } + preloadStorageConnectionInfos={connections} + > + + + + {children} + + + + + + + diff --git a/packages/compass-crud/src/components/document-list-view.spec.tsx b/packages/compass-crud/src/components/document-list-view.spec.tsx index 536f48e66c2..ed257306567 100644 --- a/packages/compass-crud/src/components/document-list-view.spec.tsx +++ b/packages/compass-crud/src/components/document-list-view.spec.tsx @@ -1,28 +1,32 @@ import React from 'react'; import { mount } from 'enzyme'; +import type { ReactWrapper } from 'enzyme'; import HadronDocument from 'hadron-document'; import { expect } from 'chai'; -import sinon from 'sinon'; import DocumentListView from './document-list-view'; +import { ContextMenuProvider } from '@mongodb-js/compass-components'; describe('', function () { describe('#render', function () { context('when the documents have objects for ids', function () { const docs = [{ _id: { name: 'test-1' } }, { _id: { name: 'test-2' } }]; const hadronDocs = docs.map((doc) => new HadronDocument(doc)); - const component = mount( - - ); + let component: ReactWrapper; + beforeEach(function () { + component = mount( + , + { wrappingComponent: ContextMenuProvider } + ); + }); + + afterEach(function () { + component?.unmount(); + }); it('renders all the documents', function () { const wrapper = component.find('[data-testid="readonly-document"]'); From 37dff3a43d9f6baca341d210a2b43d81a9a2c08f Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 10 Jun 2025 17:16:49 +0200 Subject: [PATCH 38/62] fix: separate type import --- .../compass-crud/src/components/document-list-view.spec.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/compass-crud/src/components/document-list-view.spec.tsx b/packages/compass-crud/src/components/document-list-view.spec.tsx index 01710d92cf5..ed257306567 100644 --- a/packages/compass-crud/src/components/document-list-view.spec.tsx +++ b/packages/compass-crud/src/components/document-list-view.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { ReactWrapper, mount } from 'enzyme'; +import { mount } from 'enzyme'; +import type { ReactWrapper } from 'enzyme'; import HadronDocument from 'hadron-document'; import { expect } from 'chai'; From 61e39c39dd8ba31b92dfaac739a195afa181556b Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 10 Jun 2025 17:27:10 +0200 Subject: [PATCH 39/62] fix: remove redundant providers --- .../components/document-list/element.spec.tsx | 13 ++---- .../document-list-view-item.spec.tsx | 43 +++++++++---------- .../src/components/editable-document.spec.tsx | 4 +- .../virtualized-document-list-view.spec.tsx | 25 +++-------- 4 files changed, 34 insertions(+), 51 deletions(-) diff --git a/packages/compass-components/src/components/document-list/element.spec.tsx b/packages/compass-components/src/components/document-list/element.spec.tsx index 15a31845e3f..88fa527f257 100644 --- a/packages/compass-components/src/components/document-list/element.spec.tsx +++ b/packages/compass-components/src/components/document-list/element.spec.tsx @@ -5,7 +5,6 @@ import sinon from 'sinon'; import HadronDocument from 'hadron-document'; import { HadronElement } from './element'; import type { Element } from 'hadron-document'; -import { ContextMenuProvider } from '@mongodb-js/compass-components'; describe('HadronElement', function () { describe('context menu', function () { @@ -27,12 +26,8 @@ describe('HadronElement', function () { clipboardWriteTextStub.restore(); }); - const renderWithContextMenu = (element: JSX.Element) => { - return render({element}); - }; - it('copies field and value when "Copy field & value" is clicked', function () { - renderWithContextMenu( + render( { + const renderDocumentListViewItem = (doc: HadronDocument): HTMLElement => { const { container } = render( - - - + ); return container.firstChild as HTMLElement; }; @@ -67,7 +64,7 @@ describe('DocumentListViewItem', function () { it('shows "Expand all fields" when document is collapsed', function () { doc.expanded = false; - const container = renderWithContextMenu(doc); + const container = renderDocumentListViewItem(doc); // Right-click to open context menu userEvent.click(container, { button: 2 }); @@ -78,7 +75,7 @@ describe('DocumentListViewItem', function () { it('shows "Collapse all fields" when document is expanded', function () { doc.expanded = true; - const container = renderWithContextMenu(doc); + const container = renderDocumentListViewItem(doc); // Right-click to open context menu userEvent.click(container, { button: 2 }); @@ -89,7 +86,7 @@ describe('DocumentListViewItem', function () { it('expands document when "Expand all fields" is clicked', function () { doc.expanded = false; - const container = renderWithContextMenu(doc); + const container = renderDocumentListViewItem(doc); // Right-click to open context menu userEvent.click(container, { button: 2 }); @@ -103,7 +100,7 @@ describe('DocumentListViewItem', function () { it('collapses document when "Collapse all fields" is clicked', function () { doc.expanded = true; - const container = renderWithContextMenu(doc); + const container = renderDocumentListViewItem(doc); // Right-click to open context menu userEvent.click(container, { button: 2 }); @@ -119,7 +116,7 @@ describe('DocumentListViewItem', function () { it('shows "Edit document" when document is not in editing mode', function () { doc.editing = false; - const container = renderWithContextMenu(doc); + const container = renderDocumentListViewItem(doc); // Right-click to open context menu userEvent.click(container, { button: 2 }); @@ -130,7 +127,7 @@ describe('DocumentListViewItem', function () { it('does not show "Edit document" when document is in editing mode', function () { doc.editing = true; - const container = renderWithContextMenu(doc); + const container = renderDocumentListViewItem(doc); // Right-click to open context menu userEvent.click(container, { button: 2 }); @@ -141,7 +138,7 @@ describe('DocumentListViewItem', function () { it('starts editing when "Edit document" is clicked', function () { doc.editing = false; - const container = renderWithContextMenu(doc); + const container = renderDocumentListViewItem(doc); // Right-click to open context menu userEvent.click(container, { button: 2 }); @@ -155,7 +152,7 @@ describe('DocumentListViewItem', function () { }); it('calls copyToClipboard when "Copy document" is clicked', function () { - const container = renderWithContextMenu(doc); + const container = renderDocumentListViewItem(doc); // Right-click to open context menu userEvent.click(container, { button: 2 }); @@ -169,7 +166,7 @@ describe('DocumentListViewItem', function () { }); it('opens insert dialog with cloned document when "Clone document..." is clicked', async function () { - const container = renderWithContextMenu(doc); + const container = renderDocumentListViewItem(doc); // Right-click to open context menu userEvent.click(container, { button: 2 }); @@ -197,7 +194,7 @@ describe('DocumentListViewItem', function () { }); it('marks document for deletion when "Delete document" is clicked', function () { - const container = renderWithContextMenu(doc); + const container = renderDocumentListViewItem(doc); // Right-click to open context menu userEvent.click(container, { button: 2 }); diff --git a/packages/compass-crud/src/components/editable-document.spec.tsx b/packages/compass-crud/src/components/editable-document.spec.tsx index 19667f66d2e..25643935ce4 100644 --- a/packages/compass-crud/src/components/editable-document.spec.tsx +++ b/packages/compass-crud/src/components/editable-document.spec.tsx @@ -6,6 +6,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import EditableDocument from './editable-document'; +import { ContextMenuProvider } from '@mongodb-js/compass-context-menu'; describe('', function () { describe('#render', function () { @@ -22,7 +23,8 @@ describe('', function () { updateDocument={sinon.spy(action)} copyToClipboard={sinon.spy(action)} openInsertDocumentDialog={sinon.spy(action)} - /> + />, + { wrappingComponent: ContextMenuProvider } ); }); diff --git a/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx b/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx index 9f9ecb44e86..aa2a03acba6 100644 --- a/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx +++ b/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx @@ -9,10 +9,7 @@ import { userEvent, render, } from '@mongodb-js/testing-library-compass'; -import { - ContextMenuProvider, - type VirtualListRef, -} from '@mongodb-js/compass-components'; +import { type VirtualListRef } from '@mongodb-js/compass-components'; import VirtualizedDocumentListView from './virtualized-document-list-view'; @@ -39,20 +36,12 @@ const getDocs = () => [ ]; describe('VirtualizedDocumentListView', function () { - const renderWithContextMenu = (element: React.ReactElement) => { - return render(element, { - wrapper: ContextMenuProvider, - }); - }; - afterEach(function () { cleanup(); }); it('renders the list of provided BSON objects', function () { - renderWithContextMenu( - - ); + render(); expect(screen.getByTitle('1')).to.be.visible; expect(screen.getByTitle('Doc1')).to.be.visible; @@ -61,7 +50,7 @@ describe('VirtualizedDocumentListView', function () { }); it('renders the list of provided HadronDocuments', function () { - renderWithContextMenu( + render( new HadronDocument(doc))} isEditable={false} @@ -75,7 +64,7 @@ describe('VirtualizedDocumentListView', function () { }); it('renders a readonly list when isEditable is false', function () { - renderWithContextMenu( + render( new HadronDocument(doc))} isEditable={false} @@ -86,7 +75,7 @@ describe('VirtualizedDocumentListView', function () { }); it('renders an editable list when isEditable is true', function () { - renderWithContextMenu( + render( new HadronDocument(doc))} isEditable={true} @@ -102,7 +91,7 @@ describe('VirtualizedDocumentListView', function () { (_, idx) => new HadronDocument(createBigDocument(idx)) ); const listRef: VirtualListRef = React.createRef(); - renderWithContextMenu( + render( Date: Tue, 10 Jun 2025 17:49:43 +0200 Subject: [PATCH 40/62] fix: remove unnecesary order change --- .../src/components/virtualized-document-list-view.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx b/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx index aa2a03acba6..a9d291f6479 100644 --- a/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx +++ b/packages/compass-crud/src/components/virtualized-document-list-view.spec.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { expect } from 'chai'; import HadronDocument from 'hadron-document'; import { + render, screen, cleanup, within, act, userEvent, - render, } from '@mongodb-js/testing-library-compass'; import { type VirtualListRef } from '@mongodb-js/compass-components'; From 220a300c065917a7ee4ef4f289dedda782e9987e Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 10 Jun 2025 18:04:57 +0200 Subject: [PATCH 41/62] fix: move to compass components provider --- configs/testing-library-compass/src/index.tsx | 82 ++++++++----------- .../compass-components-provider.tsx | 21 +++-- packages/compass-components/src/index.ts | 5 +- .../compass/src/app/components/entrypoint.tsx | 4 +- 4 files changed, 50 insertions(+), 62 deletions(-) diff --git a/configs/testing-library-compass/src/index.tsx b/configs/testing-library-compass/src/index.tsx index d01c55bba42..3739137c602 100644 --- a/configs/testing-library-compass/src/index.tsx +++ b/configs/testing-library-compass/src/index.tsx @@ -45,10 +45,7 @@ import { ReadOnlyPreferenceAccess, } from 'compass-preferences-model/provider'; import { TelemetryProvider } from '@mongodb-js/compass-telemetry/provider'; -import { - CompassComponentsProvider, - ContextMenuProvider, -} from '@mongodb-js/compass-components'; +import { CompassComponentsProvider } from '@mongodb-js/compass-components'; import { TestEnvCurrentConnectionContext, ConnectionInfoProvider, @@ -352,43 +349,41 @@ function createWrapper( - - - - { - // noop - }) - } - onExtraConnectionDataRequest={ - options.onExtraConnectionDataRequest ?? - (() => { - return Promise.resolve([{}, null] as [any, null]); - }) - } - onAutoconnectInfoRequest={ - options.onAutoconnectInfoRequest - } - preloadStorageConnectionInfos={connections} - > - - - - {children} - - - - - - - + + + { + // noop + }) + } + onExtraConnectionDataRequest={ + options.onExtraConnectionDataRequest ?? + (() => { + return Promise.resolve([{}, null] as [any, null]); + }) + } + onAutoconnectInfoRequest={ + options.onAutoconnectInfoRequest + } + preloadStorageConnectionInfos={connections} + > + + + + {children} + + + + + + @@ -756,11 +751,6 @@ function createPluginTestHelpers< */ const fireEvent = testingLibraryFireEvent; -/** - * @deprecated @testing-library/react installs these hooks automatically - */ -const cleanup = rtlCleanup; - /** * @deprecated @testing-library/react-hooks installs these hooks automatically */ diff --git a/packages/compass-components/src/components/compass-components-provider.tsx b/packages/compass-components/src/components/compass-components-provider.tsx index 18bac239d30..7a2fdf20117 100644 --- a/packages/compass-components/src/components/compass-components-provider.tsx +++ b/packages/compass-components/src/components/compass-components-provider.tsx @@ -6,6 +6,7 @@ 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'; type GuideCueProviderProps = React.ComponentProps; @@ -135,15 +136,17 @@ export const CompassComponentsProvider = ({ > - - {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/index.ts b/packages/compass-components/src/index.ts index e6f8068c130..69ce000a74f 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -93,10 +93,7 @@ export { ModalHeader } from './components/modals/modal-header'; export { FormModal } from './components/modals/form-modal'; export { InfoModal } from './components/modals/info-modal'; -export { - ContextMenuProvider, - useContextMenuItems, -} from './components/context-menu'; +export { useContextMenuItems } from './components/context-menu'; export type { FileInputBackend, diff --git a/packages/compass/src/app/components/entrypoint.tsx b/packages/compass/src/app/components/entrypoint.tsx index 866b3414188..28a79b35c37 100644 --- a/packages/compass/src/app/components/entrypoint.tsx +++ b/packages/compass/src/app/components/entrypoint.tsx @@ -102,9 +102,7 @@ export const CompassElectron = (props: React.ComponentProps) => { - - - + From 74f3d6ebacbc766f2987762dd8be6afe02b7f009 Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 10 Jun 2025 18:07:30 +0200 Subject: [PATCH 42/62] fix: remove unintended deletion --- configs/testing-library-compass/src/index.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/configs/testing-library-compass/src/index.tsx b/configs/testing-library-compass/src/index.tsx index 3739137c602..6f3bf3bb79d 100644 --- a/configs/testing-library-compass/src/index.tsx +++ b/configs/testing-library-compass/src/index.tsx @@ -751,6 +751,11 @@ function createPluginTestHelpers< */ const fireEvent = testingLibraryFireEvent; +/** + * @deprecated @testing-library/react installs these hooks automatically + */ +const cleanup = rtlCleanup; + /** * @deprecated @testing-library/react-hooks installs these hooks automatically */ From 4f05381b57cb2c9f62f9b224d35a5d3869cb2648 Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 10 Jun 2025 18:30:27 +0200 Subject: [PATCH 43/62] fix: adjust tests --- .../components/content-with-fallback.spec.tsx | 5 +- .../src/components/context-menu.spec.tsx | 210 +++++++++--------- .../src/components/context-menu.tsx | 1 + .../compass/src/app/components/entrypoint.tsx | 1 - 4 files changed, 104 insertions(+), 113 deletions(-) diff --git a/packages/compass-components/src/components/content-with-fallback.spec.tsx b/packages/compass-components/src/components/content-with-fallback.spec.tsx index bd4daa3861c..5b989316f5b 100644 --- a/packages/compass-components/src/components/content-with-fallback.spec.tsx +++ b/packages/compass-components/src/components/content-with-fallback.spec.tsx @@ -58,7 +58,10 @@ describe('ContentWithFallback', function () { { container } ); - expect(container).to.be.empty; + expect(container.children.length).to.equal(1); + expect(container.children[0].getAttribute('data-testid')).to.equal( + 'context-menu' + ); }); it('should render fallback when the timeout passes', async function () { diff --git a/packages/compass-components/src/components/context-menu.spec.tsx b/packages/compass-components/src/components/context-menu.spec.tsx index 14177575d9d..d2ecaa63cdf 100644 --- a/packages/compass-components/src/components/context-menu.spec.tsx +++ b/packages/compass-components/src/components/context-menu.spec.tsx @@ -28,40 +28,88 @@ describe('useContextMenuItems', function () { ); }; - describe('when used outside provider', function () { - it('throws an error', function () { - const items = [ - { - label: 'Test Item', - onAction: () => {}, - }, - ]; - - expect(() => { - render(); - }).to.throw('useContextMenu called outside of the provider'); - }); - }); - - describe('with a valid provider', function () { - it('renders without error', function () { - const items = [ - { - label: 'Test Item', - onAction: () => {}, - }, - ]; - + it('errors if the component is double wrapped', function () { + const items = [ + { + label: 'Test Item', + onAction: () => {}, + }, + ]; + + expect(() => { render( ); + }).to.throw( + 'Duplicated ContextMenuProvider found. Please remove the nested provider.' + ); + }); - expect(screen.getByTestId(menuTestTriggerId)).to.exist; - }); + it('renders without error', function () { + const items = [ + { + label: 'Test Item', + onAction: () => {}, + }, + ]; + + render(); + + expect(screen.getByTestId(menuTestTriggerId)).to.exist; + }); + + it('shows context menu with items on right click', function () { + const items = [ + { + label: 'Test Item 1', + onAction: () => {}, + }, + { + label: 'Test Item 2', + onAction: () => {}, + }, + ]; + + render(); + + const trigger = screen.getByTestId(menuTestTriggerId); + userEvent.click(trigger, { button: 2 }); + + // The menu items should be rendered + expect(screen.getByTestId('menu-group-0-item-0')).to.exist; + expect(screen.getByTestId('menu-group-0-item-1')).to.exist; + }); + + it('triggers the correct action when menu item is clicked', function () { + const onAction = sinon.spy(); + const items = [ + { + label: 'Test Item 1', + onAction: () => onAction(1), + }, + { + label: 'Test Item 2', + onAction: () => onAction(2), + }, + ]; + + render(); + + const trigger = screen.getByTestId(menuTestTriggerId); + userEvent.click(trigger, { button: 2 }); - it('shows context menu with items on right click', function () { + const menuItem = screen.getByTestId('menu-group-0-item-1'); + userEvent.click(menuItem); + + expect(onAction).to.have.been.calledOnceWithExactly(2); + }); + + describe('with nested components', function () { + const childTriggerId = 'child-trigger'; + + beforeEach(function () { const items = [ { label: 'Test Item 1', @@ -73,101 +121,41 @@ describe('useContextMenuItems', function () { }, ]; - render( - - - - ); - - const trigger = screen.getByTestId(menuTestTriggerId); - userEvent.click(trigger, { button: 2 }); - - // The menu items should be rendered - expect(screen.getByTestId('menu-group-0-item-0')).to.exist; - expect(screen.getByTestId('menu-group-0-item-1')).to.exist; - }); - - it('triggers the correct action when menu item is clicked', function () { - const onAction = sinon.spy(); - const items = [ + const childItems = [ { - label: 'Test Item 1', - onAction: () => onAction(1), - }, - { - label: 'Test Item 2', - onAction: () => onAction(2), + label: 'Child Item 1', + onAction: () => {}, }, ]; render( - - - + + + ); + }); - const trigger = screen.getByTestId(menuTestTriggerId); + it('renders menu items with separators', function () { + const trigger = screen.getByTestId(childTriggerId); userEvent.click(trigger, { button: 2 }); - const menuItem = screen.getByTestId('menu-group-0-item-1'); - userEvent.click(menuItem); + // Should find the menu item and the separator + expect(screen.getByTestId('menu-group-0').children.length).to.equal(2); + expect( + screen.getByTestId('menu-group-0').children.item(0)?.textContent + ).to.equal('Child Item 1'); - expect(onAction).to.have.been.calledOnceWithExactly(2); - }); + expect(screen.getByTestId('menu-group-0-separator')).to.exist; + + expect(screen.getByTestId('menu-group-1').children.length).to.equal(2); + expect( + screen.getByTestId('menu-group-1').children.item(0)?.textContent + ).to.equal('Test Item 1'); + expect( + screen.getByTestId('menu-group-1').children.item(1)?.textContent + ).to.equal('Test Item 2'); - describe('with nested components', function () { - const childTriggerId = 'child-trigger'; - - beforeEach(function () { - const items = [ - { - label: 'Test Item 1', - onAction: () => {}, - }, - { - label: 'Test Item 2', - onAction: () => {}, - }, - ]; - - const childItems = [ - { - label: 'Child Item 1', - onAction: () => {}, - }, - ]; - - render( - - - - - - ); - }); - - it('renders menu items with separators', function () { - const trigger = screen.getByTestId(childTriggerId); - userEvent.click(trigger, { button: 2 }); - - // Should find the menu item and the separator - expect(screen.getByTestId('menu-group-0').children.length).to.equal(2); - expect( - screen.getByTestId('menu-group-0').children.item(0)?.textContent - ).to.equal('Child Item 1'); - - expect(screen.getByTestId('menu-group-0-separator')).to.exist; - - expect(screen.getByTestId('menu-group-1').children.length).to.equal(2); - expect( - screen.getByTestId('menu-group-1').children.item(0)?.textContent - ).to.equal('Test Item 1'); - expect( - screen.getByTestId('menu-group-1').children.item(1)?.textContent - ).to.equal('Test Item 2'); - - expect(screen.queryByTestId('menu-group-1-separator')).not.to.exist; - }); + expect(screen.queryByTestId('menu-group-1-separator')).not.to.exist; }); }); }); diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index 288db7393a5..c12ff5fdd3e 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -32,6 +32,7 @@ export function ContextMenu({ menu }: ContextMenuWrapperProps) { return (
{ const loggerProviderValue = useRef({ From 3f6e8acf01e3b38caa0f98122f475ec7700bed5a Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 10 Jun 2025 18:33:53 +0200 Subject: [PATCH 44/62] fix: use compass components provider --- .../compass-crud/src/components/document-list-view.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compass-crud/src/components/document-list-view.spec.tsx b/packages/compass-crud/src/components/document-list-view.spec.tsx index ed257306567..5938ac2ab06 100644 --- a/packages/compass-crud/src/components/document-list-view.spec.tsx +++ b/packages/compass-crud/src/components/document-list-view.spec.tsx @@ -5,7 +5,7 @@ import HadronDocument from 'hadron-document'; import { expect } from 'chai'; import DocumentListView from './document-list-view'; -import { ContextMenuProvider } from '@mongodb-js/compass-components'; +import { CompassComponentsProvider } from '@mongodb-js/compass-components'; describe('', function () { describe('#render', function () { @@ -20,7 +20,7 @@ describe('', function () { isEditable={false} isTimeSeries={false} />, - { wrappingComponent: ContextMenuProvider } + { wrappingComponent: CompassComponentsProvider } ); }); From 28636763b07f09f8f52d21105207fdaf84286377 Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 10 Jun 2025 19:03:32 +0200 Subject: [PATCH 45/62] fix: use a shared hook and minimize test duplication --- .../document-json-view-item.spec.tsx | 107 +++++++ .../components/document-json-view-item.tsx | 45 ++- .../document-list-view-item.spec.tsx | 280 ++++++------------ .../components/document-list-view-item.tsx | 36 ++- .../use-document-item-context-menu.spec.tsx | 255 ++++++++++++++++ .../use-document-item-context-menu.tsx | 67 +++++ 6 files changed, 572 insertions(+), 218 deletions(-) create mode 100644 packages/compass-crud/src/components/document-json-view-item.spec.tsx create mode 100644 packages/compass-crud/src/components/use-document-item-context-menu.spec.tsx create mode 100644 packages/compass-crud/src/components/use-document-item-context-menu.tsx diff --git a/packages/compass-crud/src/components/document-json-view-item.spec.tsx b/packages/compass-crud/src/components/document-json-view-item.spec.tsx new file mode 100644 index 00000000000..767ab08350b --- /dev/null +++ b/packages/compass-crud/src/components/document-json-view-item.spec.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import HadronDocument from 'hadron-document'; +import { DocumentJsonViewItem } from './document-json-view-item'; + +describe('DocumentJsonViewItem', function () { + let doc: HadronDocument; + let copyToClipboardStub: sinon.SinonStub; + let openInsertDocumentDialogStub: sinon.SinonStub; + + beforeEach(function () { + doc = new HadronDocument({ + _id: 1, + name: 'test', + url: 'https://mongodb.com', + nested: { field: 'value' }, + }); + + copyToClipboardStub = sinon.stub(); + openInsertDocumentDialogStub = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('renders the JSON editor component', function () { + render( + + ); + + // Should render without error + expect(document.querySelector('[data-testid="editable-json"]')).to.exist; + }); + + it('renders context menu when right-clicked', function () { + const { container } = render( + + ); + + const element = container.firstChild as HTMLElement; + + // Right-click to open context menu + userEvent.click(element, { button: 2 }); + + // Should show context menu with expected items + expect(screen.getByText('Copy document')).to.exist; + expect(screen.getByText('Clone document...')).to.exist; + expect(screen.getByText('Delete document')).to.exist; + }); + + it('renders scroll trigger when docIndex is 0', function () { + const scrollTriggerRef = React.createRef(); + + render( + + ); + + expect(scrollTriggerRef.current).to.exist; + }); + + it('does not render scroll trigger when docIndex is not 0', function () { + const scrollTriggerRef = React.createRef(); + + render( + + ); + + expect(scrollTriggerRef.current).to.be.null; + }); +}); diff --git a/packages/compass-crud/src/components/document-json-view-item.tsx b/packages/compass-crud/src/components/document-json-view-item.tsx index f368b476f09..f89ccce2240 100644 --- a/packages/compass-crud/src/components/document-json-view-item.tsx +++ b/packages/compass-crud/src/components/document-json-view-item.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type HadronDocument from 'hadron-document'; -import { css, KeylineCard, spacing } from '@mongodb-js/compass-components'; +import { css, KeylineCard } from '@mongodb-js/compass-components'; import JSONEditor, { type JSONEditorProps } from './json-editor'; import { useContextMenuItems } from '@mongodb-js/compass-components'; @@ -43,23 +43,50 @@ const DocumentJsonViewItem: React.FC = ({ }) => { const ref = useContextMenuItems([ { - label: 'Update document', + label: doc.expanded ? 'Collapse all fields' : 'Expand all fields', onAction: () => { - updateDocument?.(doc); + if (doc.expanded) { + doc.collapse(); + } else { + doc.expand(); + } }, }, + ...(isEditable && !doc.editing + ? [ + { + label: 'Edit document', + onAction: () => { + doc.startEditing(); + }, + }, + ] + : []), { label: 'Copy document', onAction: () => { copyToClipboard?.(doc); }, }, - { - label: 'Delete document', - onAction: () => { - removeDocument?.(doc); - }, - }, + ...(isEditable + ? [ + { + label: 'Clone document...', + onAction: () => { + const clonedDoc = doc.generateObject({ + excludeInternalFields: true, + }); + openInsertDocumentDialog?.(clonedDoc, true); + }, + }, + { + label: 'Delete document', + onAction: () => { + doc.markForDeletion(); + }, + }, + ] + : []), ]); return ( diff --git a/packages/compass-crud/src/components/document-list-view-item.spec.tsx b/packages/compass-crud/src/components/document-list-view-item.spec.tsx index 22cc990d00a..5ad9ffa8798 100644 --- a/packages/compass-crud/src/components/document-list-view-item.spec.tsx +++ b/packages/compass-crud/src/components/document-list-view-item.spec.tsx @@ -6,205 +6,99 @@ import HadronDocument from 'hadron-document'; import { DocumentListViewItem } from './document-list-view-item'; describe('DocumentListViewItem', function () { - describe('document context menu', function () { - let doc: HadronDocument; - let copyToClipboardStub: sinon.SinonStub; - let openInsertDocumentDialogStub: sinon.SinonStub; - let collapseStub: sinon.SinonStub; - let expandStub: sinon.SinonStub; - let startEditingStub: sinon.SinonStub; - let markForDeletionStub: sinon.SinonStub; - let generateObjectStub: sinon.SinonStub; - - beforeEach(function () { - doc = new HadronDocument({ - _id: 1, - name: 'test', - url: 'https://mongodb.com', - nested: { field: 'value' }, - }); - - copyToClipboardStub = sinon.stub(); - openInsertDocumentDialogStub = sinon.stub(); - - // Set up document methods as stubs - collapseStub = sinon.stub(doc, 'collapse'); - expandStub = sinon.stub(doc, 'expand'); - startEditingStub = sinon.stub(doc, 'startEditing'); - markForDeletionStub = sinon.stub(doc, 'markForDeletion'); - generateObjectStub = sinon.stub(doc, 'generateObject').returns({ - _id: 1, - name: 'test', - url: 'https://mongodb.com', - nested: { field: 'value' }, - }); + let doc: HadronDocument; + let copyToClipboardStub: sinon.SinonStub; + let openInsertDocumentDialogStub: sinon.SinonStub; + + beforeEach(function () { + doc = new HadronDocument({ + _id: 1, + name: 'test', + url: 'https://mongodb.com', + nested: { field: 'value' }, }); - /** - * Renders the element and returns a reference to the first child of the container. - */ - const renderDocumentListViewItem = (doc: HadronDocument): HTMLElement => { - const { container } = render( - - ); - return container.firstChild as HTMLElement; - }; - - afterEach(function () { - sinon.restore(); - }); - - it('shows "Expand all fields" when document is collapsed', function () { - doc.expanded = false; - - const container = renderDocumentListViewItem(doc); - - // Right-click to open context menu - userEvent.click(container, { button: 2 }); - - expect(screen.getByText('Expand all fields')).to.exist; - }); - - it('shows "Collapse all fields" when document is expanded', function () { - doc.expanded = true; - - const container = renderDocumentListViewItem(doc); - - // Right-click to open context menu - userEvent.click(container, { button: 2 }); - - expect(screen.getByText('Collapse all fields')).to.exist; - }); - - it('expands document when "Expand all fields" is clicked', function () { - doc.expanded = false; - - const container = renderDocumentListViewItem(doc); - - // Right-click to open context menu - userEvent.click(container, { button: 2 }); - - // Click expand option - userEvent.click(screen.getByText('Expand all fields')); - - expect(expandStub).to.have.been.calledOnce; - }); - - it('collapses document when "Collapse all fields" is clicked', function () { - doc.expanded = true; - - const container = renderDocumentListViewItem(doc); - - // Right-click to open context menu - userEvent.click(container, { button: 2 }); - - // Click collapse option - userEvent.click(screen.getByText('Collapse all fields'), undefined, { - skipPointerEventsCheck: true, - }); - - expect(collapseStub).to.have.been.calledOnce; - }); - - it('shows "Edit document" when document is not in editing mode', function () { - doc.editing = false; - - const container = renderDocumentListViewItem(doc); - - // Right-click to open context menu - userEvent.click(container, { button: 2 }); - - expect(screen.getByText('Edit document')).to.exist; - }); - - it('does not show "Edit document" when document is in editing mode', function () { - doc.editing = true; - - const container = renderDocumentListViewItem(doc); - - // Right-click to open context menu - userEvent.click(container, { button: 2 }); - - expect(screen.queryByText('Edit document')).to.not.exist; - }); - - it('starts editing when "Edit document" is clicked', function () { - doc.editing = false; - - const container = renderDocumentListViewItem(doc); - - // Right-click to open context menu - userEvent.click(container, { button: 2 }); - - // Click edit option - userEvent.click(screen.getByText('Edit document'), undefined, { - skipPointerEventsCheck: true, - }); - - expect(startEditingStub).to.have.been.calledOnce; - }); - - it('calls copyToClipboard when "Copy document" is clicked', function () { - const container = renderDocumentListViewItem(doc); - - // Right-click to open context menu - userEvent.click(container, { button: 2 }); - - // Click copy option - userEvent.click(screen.getByText('Copy document'), undefined, { - skipPointerEventsCheck: true, - }); - - expect(copyToClipboardStub).to.have.been.calledWith(doc); - }); - - it('opens insert dialog with cloned document when "Clone document..." is clicked', async function () { - const container = renderDocumentListViewItem(doc); - - // Right-click to open context menu - userEvent.click(container, { button: 2 }); - - // Click clone option - userEvent.click(screen.getByText('Clone document...'), undefined, { - skipPointerEventsCheck: true, - }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - expect(generateObjectStub).to.have.been.calledWith({ - excludeInternalFields: true, - }); + copyToClipboardStub = sinon.stub(); + openInsertDocumentDialogStub = sinon.stub(); + }); - expect(openInsertDocumentDialogStub).to.have.been.calledWith( - { - _id: 1, - name: 'test', - url: 'https://mongodb.com', - nested: { field: 'value' }, - }, - true - ); - }); + afterEach(function () { + sinon.restore(); + }); - it('marks document for deletion when "Delete document" is clicked', function () { - const container = renderDocumentListViewItem(doc); + it('renders the document component', function () { + render( + + ); + + // Should render without error + expect(document.querySelector('[data-testid="editable-document"]')).to + .exist; + }); - // Right-click to open context menu - userEvent.click(container, { button: 2 }); + it('renders context menu when right-clicked', function () { + const { container } = render( + + ); + + const element = container.firstChild as HTMLElement; + + // Right-click to open context menu + userEvent.click(element, { button: 2 }); + + // Should show context menu with expected items + expect(screen.getByText('Copy document')).to.exist; + expect(screen.getByText('Clone document...')).to.exist; + expect(screen.getByText('Delete document')).to.exist; + }); - // Click delete option - userEvent.click(screen.getByText('Delete document'), undefined, { - skipPointerEventsCheck: true, - }); + it('renders scroll trigger when docIndex is 0', function () { + const scrollTriggerRef = React.createRef(); + + render( + + ); + + expect(scrollTriggerRef.current).to.exist; + }); - expect(markForDeletionStub).to.have.been.calledOnce; - }); + it('does not render scroll trigger when docIndex is not 0', function () { + const scrollTriggerRef = React.createRef(); + + render( + + ); + + expect(scrollTriggerRef.current).to.be.null; }); }); diff --git a/packages/compass-crud/src/components/document-list-view-item.tsx b/packages/compass-crud/src/components/document-list-view-item.tsx index 493f7bc52c4..ffe859d908f 100644 --- a/packages/compass-crud/src/components/document-list-view-item.tsx +++ b/packages/compass-crud/src/components/document-list-view-item.tsx @@ -44,7 +44,7 @@ const DocumentListViewItem: React.FC = ({ } }, }, - ...(!doc.editing + ...(isEditable && !doc.editing ? [ { label: 'Edit document', @@ -60,21 +60,25 @@ const DocumentListViewItem: React.FC = ({ copyToClipboard?.(doc); }, }, - { - label: 'Clone document...', - onAction: () => { - const clonedDoc = doc.generateObject({ - excludeInternalFields: true, - }); - openInsertDocumentDialog?.(clonedDoc, true); - }, - }, - { - label: 'Delete document', - onAction: () => { - doc.markForDeletion(); - }, - }, + ...(isEditable + ? [ + { + label: 'Clone document...', + onAction: () => { + const clonedDoc = doc.generateObject({ + excludeInternalFields: true, + }); + openInsertDocumentDialog?.(clonedDoc, true); + }, + }, + { + label: 'Delete document', + onAction: () => { + doc.markForDeletion(); + }, + }, + ] + : []), ]); return ( diff --git a/packages/compass-crud/src/components/use-document-item-context-menu.spec.tsx b/packages/compass-crud/src/components/use-document-item-context-menu.spec.tsx new file mode 100644 index 00000000000..0edf522414e --- /dev/null +++ b/packages/compass-crud/src/components/use-document-item-context-menu.spec.tsx @@ -0,0 +1,255 @@ +import React from 'react'; +import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import HadronDocument from 'hadron-document'; +import { useDocumentItemContextMenu } from './use-document-item-context-menu'; + +// Test component that uses the hook +const TestComponent: React.FC<{ + doc: HadronDocument; + isEditable: boolean; + copyToClipboard?: (doc: HadronDocument) => void; + openInsertDocumentDialog?: ( + doc: Record, + cloned: boolean + ) => void; +}> = ({ doc, isEditable, copyToClipboard, openInsertDocumentDialog }) => { + const ref = useDocumentItemContextMenu({ + doc, + isEditable, + copyToClipboard, + openInsertDocumentDialog, + }); + + return ( +
+ Test Content +
+ ); +}; + +describe('useDocumentItemContextMenu', function () { + let doc: HadronDocument; + let copyToClipboardStub: sinon.SinonStub; + let openInsertDocumentDialogStub: sinon.SinonStub; + let collapseStub: sinon.SinonStub; + let expandStub: sinon.SinonStub; + let startEditingStub: sinon.SinonStub; + let markForDeletionStub: sinon.SinonStub; + let generateObjectStub: sinon.SinonStub; + + beforeEach(function () { + doc = new HadronDocument({ + _id: 1, + name: 'test', + nested: { field: 'value' }, + }); + + copyToClipboardStub = sinon.stub(); + openInsertDocumentDialogStub = sinon.stub(); + + // Set up document methods as stubs + collapseStub = sinon.stub(doc, 'collapse'); + expandStub = sinon.stub(doc, 'expand'); + startEditingStub = sinon.stub(doc, 'startEditing'); + markForDeletionStub = sinon.stub(doc, 'markForDeletion'); + generateObjectStub = sinon.stub(doc, 'generateObject').returns({ + _id: 1, + name: 'test', + nested: { field: 'value' }, + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('when editable', function () { + it('shows all menu items when document is editable and not editing', function () { + doc.expanded = false; + doc.editing = false; + + render( + + ); + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Should show all operations + expect(screen.getByText('Expand all fields')).to.exist; + expect(screen.getByText('Edit document')).to.exist; + expect(screen.getByText('Copy document')).to.exist; + expect(screen.getByText('Clone document...')).to.exist; + expect(screen.getByText('Delete document')).to.exist; + }); + + it('hides edit document when document is editing', function () { + doc.expanded = false; + doc.editing = true; + + render( + + ); + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Should hide edit document when editing + expect(screen.queryByText('Edit document')).to.not.exist; + // But show other operations + expect(screen.getByText('Expand all fields')).to.exist; + expect(screen.getByText('Copy document')).to.exist; + expect(screen.getByText('Clone document...')).to.exist; + expect(screen.getByText('Delete document')).to.exist; + }); + }); + + describe('when read-only', function () { + it('shows only non-mutating operations when not editable', function () { + doc.expanded = false; + doc.editing = false; + + render( + + ); + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Should show non-mutating operations + expect(screen.getByText('Expand all fields')).to.exist; + expect(screen.getByText('Copy document')).to.exist; + + // Should hide mutating operations + expect(screen.queryByText('Edit document')).to.not.exist; + expect(screen.queryByText('Clone document...')).to.not.exist; + expect(screen.queryByText('Delete document')).to.not.exist; + }); + + it('collapses document when collapse is clicked', function () { + doc.expanded = true; + + // Render with expanded document + render( + + ); + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click collapse + userEvent.click(screen.getByText('Collapse all fields'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(collapseStub).to.have.been.calledOnce; + }); + }); + + describe('functionality', function () { + beforeEach(function () { + render( + + ); + }); + + it('toggles expand/collapse correctly', function () { + doc.expanded = false; + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click expand + userEvent.click(screen.getByText('Expand all fields')); + + expect(expandStub).to.have.been.calledOnce; + }); + + it('starts editing when edit is clicked', function () { + doc.editing = false; + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click edit + userEvent.click(screen.getByText('Edit document'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(startEditingStub).to.have.been.calledOnce; + }); + + it('calls copyToClipboard when copy is clicked', function () { + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click copy + userEvent.click(screen.getByText('Copy document'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(copyToClipboardStub).to.have.been.calledWith(doc); + }); + + it('calls openInsertDocumentDialog with cloned document when clone is clicked', function () { + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click clone + userEvent.click(screen.getByText('Clone document...'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(generateObjectStub).to.have.been.calledWith({ + excludeInternalFields: true, + }); + expect(openInsertDocumentDialogStub).to.have.been.calledWith( + { + _id: 1, + name: 'test', + nested: { field: 'value' }, + }, + true + ); + }); + + it('marks document for deletion when delete is clicked', function () { + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click delete + userEvent.click(screen.getByText('Delete document'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(markForDeletionStub).to.have.been.calledOnce; + }); + }); +}); 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 new file mode 100644 index 00000000000..f457afd7dd7 --- /dev/null +++ b/packages/compass-crud/src/components/use-document-item-context-menu.tsx @@ -0,0 +1,67 @@ +import type HadronDocument from 'hadron-document'; +import { useContextMenuItems } from '@mongodb-js/compass-components'; + +export interface UseDocumentItemContextMenuProps { + doc: HadronDocument; + isEditable: boolean; + copyToClipboard?: (doc: HadronDocument) => void; + openInsertDocumentDialog?: ( + doc: Record, + cloned: boolean + ) => void; +} + +export function useDocumentItemContextMenu({ + doc, + isEditable, + copyToClipboard, + openInsertDocumentDialog, +}: UseDocumentItemContextMenuProps) { + return useContextMenuItems([ + { + label: doc.expanded ? 'Collapse all fields' : 'Expand all fields', + onAction: () => { + if (doc.expanded) { + doc.collapse(); + } else { + doc.expand(); + } + }, + }, + ...(isEditable && !doc.editing + ? [ + { + label: 'Edit document', + onAction: () => { + doc.startEditing(); + }, + }, + ] + : []), + { + label: 'Copy document', + onAction: () => { + copyToClipboard?.(doc); + }, + }, + ...(isEditable + ? [ + { + label: 'Clone document...', + onAction: () => { + const clonedDoc = doc.generateObject({ + excludeInternalFields: true, + }); + openInsertDocumentDialog?.(clonedDoc, true); + }, + }, + { + label: 'Delete document', + onAction: () => { + doc.markForDeletion(); + }, + }, + ] + : []), + ]); +} From 02365932fe1b5f54c789a94caa32dd65645d0c0b Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 17 Jun 2025 14:38:26 +0200 Subject: [PATCH 46/62] fix: use the shared hook --- .../components/document-json-view-item.tsx | 55 +++---------------- .../components/document-list-view-item.tsx | 55 +++---------------- 2 files changed, 14 insertions(+), 96 deletions(-) diff --git a/packages/compass-crud/src/components/document-json-view-item.tsx b/packages/compass-crud/src/components/document-json-view-item.tsx index f89ccce2240..003f82ab73a 100644 --- a/packages/compass-crud/src/components/document-json-view-item.tsx +++ b/packages/compass-crud/src/components/document-json-view-item.tsx @@ -3,7 +3,7 @@ import type HadronDocument from 'hadron-document'; import { css, KeylineCard } from '@mongodb-js/compass-components'; import JSONEditor, { type JSONEditorProps } from './json-editor'; -import { useContextMenuItems } from '@mongodb-js/compass-components'; +import { useDocumentItemContextMenu } from './use-document-item-context-menu'; const keylineCardStyles = css({ overflow: 'hidden', @@ -41,53 +41,12 @@ const DocumentJsonViewItem: React.FC = ({ updateDocument, openInsertDocumentDialog, }) => { - const ref = useContextMenuItems([ - { - label: doc.expanded ? 'Collapse all fields' : 'Expand all fields', - onAction: () => { - if (doc.expanded) { - doc.collapse(); - } else { - doc.expand(); - } - }, - }, - ...(isEditable && !doc.editing - ? [ - { - label: 'Edit document', - onAction: () => { - doc.startEditing(); - }, - }, - ] - : []), - { - label: 'Copy document', - onAction: () => { - copyToClipboard?.(doc); - }, - }, - ...(isEditable - ? [ - { - label: 'Clone document...', - onAction: () => { - const clonedDoc = doc.generateObject({ - excludeInternalFields: true, - }); - openInsertDocumentDialog?.(clonedDoc, true); - }, - }, - { - label: 'Delete document', - onAction: () => { - doc.markForDeletion(); - }, - }, - ] - : []), - ]); + const ref = useDocumentItemContextMenu({ + doc, + isEditable, + copyToClipboard, + openInsertDocumentDialog, + }); return (
diff --git a/packages/compass-crud/src/components/document-list-view-item.tsx b/packages/compass-crud/src/components/document-list-view-item.tsx index ffe859d908f..288ce2b8621 100644 --- a/packages/compass-crud/src/components/document-list-view-item.tsx +++ b/packages/compass-crud/src/components/document-list-view-item.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type HadronDocument from 'hadron-document'; import { KeylineCard } from '@mongodb-js/compass-components'; import Document, { type DocumentProps } from './document'; -import { useContextMenuItems } from '@mongodb-js/compass-components'; +import { useDocumentItemContextMenu } from './use-document-item-context-menu'; export type DocumentListViewItemProps = { doc: HadronDocument; @@ -33,53 +33,12 @@ const DocumentListViewItem: React.FC = ({ updateDocument, openInsertDocumentDialog, }) => { - const ref = useContextMenuItems([ - { - label: doc.expanded ? 'Collapse all fields' : 'Expand all fields', - onAction: () => { - if (doc.expanded) { - doc.collapse(); - } else { - doc.expand(); - } - }, - }, - ...(isEditable && !doc.editing - ? [ - { - label: 'Edit document', - onAction: () => { - doc.startEditing(); - }, - }, - ] - : []), - { - label: 'Copy document', - onAction: () => { - copyToClipboard?.(doc); - }, - }, - ...(isEditable - ? [ - { - label: 'Clone document...', - onAction: () => { - const clonedDoc = doc.generateObject({ - excludeInternalFields: true, - }); - openInsertDocumentDialog?.(clonedDoc, true); - }, - }, - { - label: 'Delete document', - onAction: () => { - doc.markForDeletion(); - }, - }, - ] - : []), - ]); + const ref = useDocumentItemContextMenu({ + doc, + isEditable, + copyToClipboard, + openInsertDocumentDialog, + }); return (
From 7ced8fcbd35187cc71bc60bef91b722e02f451a4 Mon Sep 17 00:00:00 2001 From: gagik Date: Mon, 16 Jun 2025 15:17:04 +0200 Subject: [PATCH 47/62] refactor: turn cell render into a functional component --- .../components/table-view/cell-renderer.tsx | 429 ++++++++---------- 1 file changed, 196 insertions(+), 233 deletions(-) diff --git a/packages/compass-crud/src/components/table-view/cell-renderer.tsx b/packages/compass-crud/src/components/table-view/cell-renderer.tsx index 86f7051852d..7e60bd27a0d 100644 --- a/packages/compass-crud/src/components/table-view/cell-renderer.tsx +++ b/packages/compass-crud/src/components/table-view/cell-renderer.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { BSONValue, css, @@ -10,7 +9,6 @@ import { withDarkMode, } from '@mongodb-js/compass-components'; import { Element } from 'hadron-document'; -import type { ICellRendererReactComp } from 'ag-grid-react'; import type { ICellRendererParams } from 'ag-grid-community'; import type { GridActions, TableHeaderType } from '../../stores/grid-store'; import type { CrudActions } from '../../stores/crud-store'; @@ -95,168 +93,148 @@ export type CellRendererProps = Omit & { /** * The custom cell renderer that renders a cell in the table view. */ -class CellRenderer - extends React.Component - implements ICellRendererReactComp -{ - element: Element; - isEmpty: boolean; - isDeleted: boolean; - editable: boolean; - - constructor(props: CellRendererProps) { - super(props); - - this.isEmpty = props.value === undefined || props.value === null; - this.isDeleted = false; - this.element = props.value; - +const CellRenderer: React.FC = ({ + value, + context, + column, + node, + parentType, + elementAdded, + elementRemoved, + elementTypeChanged, + drillDown, + api, + darkMode, +}) => { + const element = value as Element; + + const isEmpty = element === undefined || element === null; + const [isDeleted, setIsDeleted] = React.useState(false); + const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + + const isEditable = React.useMemo(() => { /* Can't get the editable() function from here, so have to reevaluate */ - this.editable = true; - if (props.context.path.length > 0 && props.column.getColId() !== '$_id') { - const parent = props.node.data.hadronDocument.getChild( - props.context.path - ); - if ( - !parent || - (props.parentType && parent.currentType !== props.parentType) - ) { - this.editable = false; + let editable = true; + if (context.path.length > 0 && column.getColId() !== '$_id') { + const parent = node.data.hadronDocument.getChild(context.path); + if (!parent || (parentType && parent.currentType !== parentType)) { + editable = false; } else if (parent.currentType === 'Array') { let maxKey = 0; if (parent.elements.lastElement) { maxKey = +parent.elements.lastElement.currentKey + 1; } - if (+props.column.getColId() > maxKey) { - this.editable = false; + if (+column.getColId() > maxKey) { + editable = false; } } } - } - - componentDidMount() { - if (!this.isEmpty) { - this.subscribeElementEvents(); - } - } - - componentWillUnmount() { - if (!this.isEmpty) { - this.unsubscribeElementEvents(); - } - } - - subscribeElementEvents() { - this.element.on(Element.Events.Added, this.handleElementEvent); - this.element.on(Element.Events.Converted, this.handleElementEvent); - this.element.on(Element.Events.Edited, this.handleElementEvent); - this.element.on(Element.Events.Reverted, this.handleElementEvent); - } - - unsubscribeElementEvents() { - this.element.removeListener(Element.Events.Added, this.handleElementEvent); - this.element.removeListener( - Element.Events.Converted, - this.handleElementEvent - ); - this.element.removeListener(Element.Events.Edited, this.handleElementEvent); - this.element.removeListener( - Element.Events.Reverted, - this.handleElementEvent - ); - } - - handleElementEvent = () => { - this.forceUpdate(); - }; - - handleUndo = (event: React.MouseEvent) => { - event.stopPropagation(); - const oid = this.props.node.data.hadronDocument.getStringId(); - if (this.element.isAdded()) { - this.isDeleted = true; - const isArray = - !this.element.parent?.isRoot() && - this.element.parent?.currentType === 'Array'; - this.props.elementRemoved(String(this.element.currentKey), oid, isArray); - } else if (this.element.isRemoved()) { - this.props.elementAdded( - String(this.element.currentKey), - this.element.currentType, - oid - ); - } else { - this.props.elementTypeChanged( - String(this.element.currentKey), - this.element.type, - oid - ); + return editable; + }, [context.path, column, node.data.hadronDocument, parentType]); + + const handleElementEvent = React.useCallback(() => { + forceUpdate(); + }, []); + + // Subscribe to element events + React.useEffect(() => { + if (!isEmpty && element) { + element.on(Element.Events.Added, handleElementEvent); + element.on(Element.Events.Converted, handleElementEvent); + element.on(Element.Events.Edited, handleElementEvent); + element.on(Element.Events.Reverted, handleElementEvent); + + return () => { + element.removeListener(Element.Events.Added, handleElementEvent); + element.removeListener(Element.Events.Converted, handleElementEvent); + element.removeListener(Element.Events.Edited, handleElementEvent); + element.removeListener(Element.Events.Reverted, handleElementEvent); + }; } - this.element.revert(); - }; - - handleDrillDown(event: React.MouseEvent) { - event.stopPropagation(); - this.props.drillDown(this.props.node.data.hadronDocument, this.element); - } - - handleClicked() { - if (this.props.node.data.state === 'editing') { - this.props.api.startEditingCell({ - rowIndex: this.props.node.rowIndex, - colKey: this.props.column.getColId(), + }, [isEmpty, element, handleElementEvent]); + + const handleUndo = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + const oid: string = node.data.hadronDocument.getStringId(); + if (element.isAdded()) { + setIsDeleted(true); + const isArray = + !element.parent?.isRoot() && element.parent?.currentType === 'Array'; + elementRemoved(String(element.currentKey), oid, isArray); + } else if (element.isRemoved()) { + elementAdded(String(element.currentKey), element.currentType, oid); + } else { + elementTypeChanged(String(element.currentKey), element.type, oid); + } + element.revert(); + }, + [ + element, + node.data.hadronDocument, + elementRemoved, + elementAdded, + elementTypeChanged, + ] + ); + + const handleDrillDown = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + drillDown(node.data.hadronDocument, element); + }, + [drillDown, node.data.hadronDocument, element] + ); + + const handleClicked = React.useCallback(() => { + if (node.data.state === 'editing') { + api.startEditingCell({ + rowIndex: node.rowIndex, + colKey: column.getColId(), }); } - } + }, [node, api, column]); - refresh() { - return true; - } - - renderInvalidCell() { - let valueClass = `${VALUE_CLASS}-is-${this.element.currentType.toLowerCase()}`; + const renderInvalidCell = React.useCallback(() => { + let valueClass = `${VALUE_CLASS}-is-${element.currentType.toLowerCase()}`; valueClass = `${valueClass} ${INVALID_VALUE}`; - /* Return internal div because invalid cells should only hightlight text? */ - - return
{this.element.currentValue}
; - } + return
{element.currentValue}
; + }, [element]); - getLength(): number | undefined { - if (this.element.currentType === 'Object') { - return Object.keys(this.element.generateObject() as object).length; + const getLength = React.useCallback((): number | undefined => { + if (element.currentType === 'Object') { + return Object.keys(element.generateObject() as object).length; } - if (this.element.currentType === 'Array') { - return this.element.elements!.size; + if (element.currentType === 'Array' && element.elements) { + return element.elements.size; } - } + }, [element]); - renderValidCell() { + const renderValidCell = React.useCallback(() => { let className = VALUE_BASE; - let element: string | JSX.Element = ''; - if (this.element.isAdded()) { + let elementContent: string | JSX.Element = ''; + if (element.isAdded()) { className = `${className} ${VALUE_BASE}-${ADDED}`; - } else if (this.element.isEdited()) { + } else if (element.isEdited()) { className = `${className} ${VALUE_BASE}-${EDITED}`; } - if (this.element.currentType === 'Object') { - element = `{} ${this.getLength() as number} fields`; - } else if (this.element.currentType === 'Array') { - element = `[] ${this.getLength() as number} elements`; + if (element.currentType === 'Object') { + elementContent = `{} ${getLength() as number} fields`; + } else if (element.currentType === 'Array') { + elementContent = `[] ${getLength() as number} elements`; } else { - element = ( - + elementContent = ( + //@ts-expect-error Types for this are currently not consistent + ); } return (
- {this.props.value.decrypted && ( + {element.decrypted && ( )} - {element} + {elementContent}
); - } - - renderUndo(canUndo: boolean, canExpand: boolean) { - let undoButtonClass = `${BUTTON_CLASS} ${BUTTON_CLASS}-undo`; - if (canUndo && canExpand) { - undoButtonClass = `${undoButtonClass} ${BUTTON_CLASS}-left`; - } + }, [element, getLength]); - if (!canUndo) { - return null; - } - return ( - - - - ); - } + const renderUndo = React.useCallback( + (canUndo: boolean, canExpand: boolean) => { + let undoButtonClass = `${BUTTON_CLASS} ${BUTTON_CLASS}-undo`; + if (canUndo && canExpand) { + undoButtonClass = `${undoButtonClass} ${BUTTON_CLASS}-left`; + } - renderExpand(canExpand: boolean) { - if (!canExpand) { - return null; - } - return ( - + if (!canUndo) { + return null; + } + return ( - + - - ); - } - - render() { - let element; - let className = BEM_BASE; - let canUndo = false; - let canExpand = false; - - if (!this.editable) { - element = ''; - className = `${className}-${UNEDITABLE}`; - } else if (this.isEmpty || this.isDeleted) { - element = 'No field'; - className = `${className}-${EMPTY}`; - } else if (!this.element.isCurrentTypeValid()) { - element = this.renderInvalidCell(); - className = `${className}-${INVALID}`; + ); + }, + [handleUndo] + ); + + const renderExpand = React.useCallback( + (canExpand: boolean) => { + if (!canExpand) { + return null; + } + return ( + + + + + + ); + }, + [handleDrillDown] + ); + + // Render logic + let elementToRender; + let className = BEM_BASE; + let canUndo = false; + let canExpand = false; + + if (!isEditable) { + elementToRender = ''; + className = `${className}-${UNEDITABLE}`; + } else if (isEmpty || isDeleted) { + elementToRender = 'No field'; + className = `${className}-${EMPTY}`; + } else if (!element.isCurrentTypeValid()) { + elementToRender = renderInvalidCell(); + className = `${className}-${INVALID}`; + canUndo = true; + } else if (element.isRemoved()) { + elementToRender = 'Deleted field'; + className = `${className}-${DELETED}`; + canUndo = true; + } else { + elementToRender = renderValidCell(); + if (element.isAdded()) { + className = `${className}-${ADDED}`; canUndo = true; - } else if (this.element.isRemoved()) { - element = 'Deleted field'; - className = `${className}-${DELETED}`; + } else if (element.isModified()) { + className = `${className}-${EDITED}`; canUndo = true; - } else { - element = this.renderValidCell(); - if (this.element.isAdded()) { - className = `${className}-${ADDED}`; - canUndo = true; - } else if (this.element.isModified()) { - className = `${className}-${EDITED}`; - canUndo = true; - } - canExpand = - this.element.currentType === 'Object' || - this.element.currentType === 'Array'; } + canExpand = + element.currentType === 'Object' || element.currentType === 'Array'; + } - return ( - // `ag-grid` renders this component outside of the context chain - // so we re-supply the dark mode theme here. - + return ( + // `ag-grid` renders this component outside of the context chain + // so we re-supply the dark mode theme here. + +
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus*/} -
- {this.renderUndo(canUndo, canExpand)} - {this.renderExpand(canExpand)} - {element} +
+ {renderUndo(canUndo, canExpand)} + {renderExpand(canExpand)} + {elementToRender}
- - ); - } - - static propTypes = { - api: PropTypes.any, - value: PropTypes.any, - node: PropTypes.any, - column: PropTypes.any, - context: PropTypes.any, - parentType: PropTypes.any.isRequired, - elementAdded: PropTypes.func.isRequired, - elementRemoved: PropTypes.func.isRequired, - elementTypeChanged: PropTypes.func.isRequired, - drillDown: PropTypes.func.isRequired, - tz: PropTypes.string.isRequired, - darkMode: PropTypes.bool, - }; - - static displayName = 'CellRenderer'; -} +
+ + ); +}; export default withDarkMode(CellRenderer); From 5d932a07330665b831c56193a1236a8dd409a0b3 Mon Sep 17 00:00:00 2001 From: gagik Date: Mon, 16 Jun 2025 18:21:31 +0200 Subject: [PATCH 48/62] refactor: untangle usage of nested if statements --- .../components/table-view/cell-renderer.tsx | 398 +++++++++++------- 1 file changed, 237 insertions(+), 161 deletions(-) diff --git a/packages/compass-crud/src/components/table-view/cell-renderer.tsx b/packages/compass-crud/src/components/table-view/cell-renderer.tsx index 7e60bd27a0d..4af6d3ada32 100644 --- a/packages/compass-crud/src/components/table-view/cell-renderer.tsx +++ b/packages/compass-crud/src/components/table-view/cell-renderer.tsx @@ -1,4 +1,10 @@ -import React from 'react'; +import React, { + useMemo, + useCallback, + useEffect, + useReducer, + useState, +} from 'react'; import { BSONValue, css, @@ -8,7 +14,7 @@ import { spacing, withDarkMode, } from '@mongodb-js/compass-components'; -import { Element } from 'hadron-document'; +import { type Document, Element } from 'hadron-document'; import type { ICellRendererParams } from 'ag-grid-community'; import type { GridActions, TableHeaderType } from '../../stores/grid-store'; import type { CrudActions } from '../../stores/crud-store'; @@ -59,6 +65,11 @@ const UNEDITABLE = 'is-uneditable'; */ const INVALID = 'is-invalid'; +/** + * The valid constant. + */ +const VALID = 'valid'; + /** * The deleted constant. */ @@ -79,6 +90,141 @@ const decrypdedIconStyles = css({ display: 'flex', }); +interface CellContentProps { + element: Element | undefined | null; + cellState: + | typeof UNEDITABLE + | typeof EMPTY + | typeof INVALID + | typeof DELETED + | typeof ADDED + | typeof EDITED + | typeof VALID; + onUndo: (event: React.MouseEvent) => void; + onExpand: (event: React.MouseEvent) => void; +} + +const CellContent: React.FC = ({ + element, + cellState, + onUndo, + onExpand, +}) => { + const [, forceUpdate] = useReducer((x: number) => x + 1, 0); + const isEmpty = element === undefined || element === null; + const handleElementEvent = useCallback(() => { + forceUpdate(); + }, []); + + // Subscribe to element events + useEffect(() => { + if (!isEmpty && element) { + element.on(Element.Events.Added, handleElementEvent); + element.on(Element.Events.Converted, handleElementEvent); + element.on(Element.Events.Edited, handleElementEvent); + element.on(Element.Events.Reverted, handleElementEvent); + + return () => { + element.removeListener(Element.Events.Added, handleElementEvent); + element.removeListener(Element.Events.Converted, handleElementEvent); + element.removeListener(Element.Events.Edited, handleElementEvent); + element.removeListener(Element.Events.Reverted, handleElementEvent); + }; + } + }, [isEmpty, element, handleElementEvent]); + + const elementLength = useMemo((): number | undefined => { + if (!element) { + return undefined; + } + + if (element.currentType === 'Object') { + return Object.keys(element.generateObject() as object).length; + } + if (element.currentType === 'Array' && element.elements) { + return element.elements.size; + } + }, [element]); + + const renderContent = useCallback(() => { + if (cellState === EMPTY || !element) { + return 'No field'; + } + + if (cellState === UNEDITABLE) { + return ''; + } + + if (cellState === DELETED) { + return 'Deleted field'; + } + + if (cellState === INVALID) { + let valueClass = `${VALUE_CLASS}-is-${element.currentType.toLowerCase()}`; + valueClass = `${valueClass} ${INVALID_VALUE}`; + + return
{element.currentValue}
; + } + + let className = VALUE_BASE; + let elementContent: string | JSX.Element = ''; + if (cellState === ADDED || cellState === EDITED) { + className = `${className} ${VALUE_BASE}-${cellState}`; + } + + const isArrayOrObject = + element.currentType === 'Array' || element.currentType === 'Object'; + + if (elementLength !== undefined && isArrayOrObject) { + if (element.currentType === 'Object') { + elementContent = `{} ${elementLength} fields`; + } else if (element.currentType === 'Array') { + elementContent = `[] ${elementLength} elements`; + } + } else { + elementContent = ( + //@ts-expect-error Types for this are currently not consistent + + ); + } + + return ( +
+
+ {element.decrypted && ( + + + + )} + {elementContent} +
+
+ ); + }, [element, elementLength, cellState]); + + const canUndo = + cellState === ADDED || + cellState === EDITED || + cellState === INVALID || + cellState === DELETED; + + const canExpand = + (cellState === VALID || cellState === ADDED || cellState === EDITED) && + (element?.currentType === 'Object' || element?.currentType === 'Array'); + + return ( + <> + {canUndo && } + {canExpand && } + {renderContent()} + + ); +}; + export type CellRendererProps = Omit & { context: GridContext; parentType: TableHeaderType; @@ -106,13 +252,12 @@ const CellRenderer: React.FC = ({ api, darkMode, }) => { - const element = value as Element; + const element = value as Element | undefined | null; const isEmpty = element === undefined || element === null; - const [isDeleted, setIsDeleted] = React.useState(false); - const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + const [isDeleted, setIsDeleted] = useState(false); - const isEditable = React.useMemo(() => { + const isEditable = useMemo(() => { /* Can't get the editable() function from here, so have to reevaluate */ let editable = true; if (context.path.length > 0 && column.getColId() !== '$_id') { @@ -132,30 +277,12 @@ const CellRenderer: React.FC = ({ return editable; }, [context.path, column, node.data.hadronDocument, parentType]); - const handleElementEvent = React.useCallback(() => { - forceUpdate(); - }, []); - - // Subscribe to element events - React.useEffect(() => { - if (!isEmpty && element) { - element.on(Element.Events.Added, handleElementEvent); - element.on(Element.Events.Converted, handleElementEvent); - element.on(Element.Events.Edited, handleElementEvent); - element.on(Element.Events.Reverted, handleElementEvent); - - return () => { - element.removeListener(Element.Events.Added, handleElementEvent); - element.removeListener(Element.Events.Converted, handleElementEvent); - element.removeListener(Element.Events.Edited, handleElementEvent); - element.removeListener(Element.Events.Reverted, handleElementEvent); - }; - } - }, [isEmpty, element, handleElementEvent]); - - const handleUndo = React.useCallback( + const handleUndo = useCallback( (event: React.MouseEvent) => { event.stopPropagation(); + if (!element) { + return; + } const oid: string = node.data.hadronDocument.getStringId(); if (element.isAdded()) { setIsDeleted(true); @@ -178,15 +305,18 @@ const CellRenderer: React.FC = ({ ] ); - const handleDrillDown = React.useCallback( + const handleDrillDown = useCallback( (event: React.MouseEvent) => { event.stopPropagation(); - drillDown(node.data.hadronDocument, element); + if (!element) { + return; + } + drillDown(node.data.hadronDocument as Document, element); }, [drillDown, node.data.hadronDocument, element] ); - const handleClicked = React.useCallback(() => { + const handleClicked = useCallback(() => { if (node.data.state === 'editing') { api.startEditingCell({ rowIndex: node.rowIndex, @@ -195,138 +325,30 @@ const CellRenderer: React.FC = ({ } }, [node, api, column]); - const renderInvalidCell = React.useCallback(() => { - let valueClass = `${VALUE_CLASS}-is-${element.currentType.toLowerCase()}`; - valueClass = `${valueClass} ${INVALID_VALUE}`; - - return
{element.currentValue}
; - }, [element]); - - const getLength = React.useCallback((): number | undefined => { - if (element.currentType === 'Object') { - return Object.keys(element.generateObject() as object).length; - } - if (element.currentType === 'Array' && element.elements) { - return element.elements.size; - } - }, [element]); - - const renderValidCell = React.useCallback(() => { - let className = VALUE_BASE; - let elementContent: string | JSX.Element = ''; - if (element.isAdded()) { - className = `${className} ${VALUE_BASE}-${ADDED}`; - } else if (element.isEdited()) { - className = `${className} ${VALUE_BASE}-${EDITED}`; - } - - if (element.currentType === 'Object') { - elementContent = `{} ${getLength() as number} fields`; - } else if (element.currentType === 'Array') { - elementContent = `[] ${getLength() as number} elements`; - } else { - elementContent = ( - //@ts-expect-error Types for this are currently not consistent - - ); - } - - return ( -
-
- {element.decrypted && ( - - - - )} - {elementContent} -
-
- ); - }, [element, getLength]); - - const renderUndo = React.useCallback( - (canUndo: boolean, canExpand: boolean) => { - let undoButtonClass = `${BUTTON_CLASS} ${BUTTON_CLASS}-undo`; - if (canUndo && canExpand) { - undoButtonClass = `${undoButtonClass} ${BUTTON_CLASS}-left`; - } - - if (!canUndo) { - return null; - } - return ( - - - - ); - }, - [handleUndo] - ); - - const renderExpand = React.useCallback( - (canExpand: boolean) => { - if (!canExpand) { - return null; - } - return ( - - - - - - ); - }, - [handleDrillDown] - ); - - // Render logic - let elementToRender; - let className = BEM_BASE; - let canUndo = false; - let canExpand = false; + // Determine cell state + let cellState: + | typeof UNEDITABLE + | typeof EMPTY + | typeof INVALID + | typeof DELETED + | typeof ADDED + | typeof EDITED + | typeof VALID; if (!isEditable) { - elementToRender = ''; - className = `${className}-${UNEDITABLE}`; + cellState = UNEDITABLE; } else if (isEmpty || isDeleted) { - elementToRender = 'No field'; - className = `${className}-${EMPTY}`; + cellState = EMPTY; } else if (!element.isCurrentTypeValid()) { - elementToRender = renderInvalidCell(); - className = `${className}-${INVALID}`; - canUndo = true; + cellState = INVALID; } else if (element.isRemoved()) { - elementToRender = 'Deleted field'; - className = `${className}-${DELETED}`; - canUndo = true; + cellState = DELETED; + } else if (element.isAdded()) { + cellState = ADDED; + } else if (element.isModified()) { + cellState = EDITED; } else { - elementToRender = renderValidCell(); - if (element.isAdded()) { - className = `${className}-${ADDED}`; - canUndo = true; - } else if (element.isModified()) { - className = `${className}-${EDITED}`; - canUndo = true; - } - canExpand = - element.currentType === 'Object' || element.currentType === 'Array'; + cellState = VALID; } return ( @@ -335,10 +357,19 @@ const CellRenderer: React.FC = ({
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus*/} -
- {renderUndo(canUndo, canExpand)} - {renderExpand(canExpand)} - {elementToRender} +
+
@@ -346,3 +377,48 @@ const CellRenderer: React.FC = ({ }; export default withDarkMode(CellRenderer); + +interface CellUndoButtonProps { + alignLeft: boolean; + onClick: (event: React.MouseEvent) => void; +} + +const CellUndoButton: React.FC = ({ + alignLeft, + onClick, +}) => { + let undoButtonClass = `${BUTTON_CLASS} ${BUTTON_CLASS}-undo`; + if (alignLeft) { + undoButtonClass = `${undoButtonClass} ${BUTTON_CLASS}-left`; + } + + return ( + + + + ); +}; + +interface CellExpandButtonProps { + onClick: (event: React.MouseEvent) => void; +} + +const CellExpandButton: React.FC = ({ onClick }) => { + return ( + + + + ); +}; From fc6068e9c7409d1788d14b64cbda16fe3415ee58 Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 17 Jun 2025 13:13:53 +0200 Subject: [PATCH 49/62] fix: use CellState wherever possible --- .../components/table-view/cell-renderer.tsx | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/compass-crud/src/components/table-view/cell-renderer.tsx b/packages/compass-crud/src/components/table-view/cell-renderer.tsx index 4af6d3ada32..32ca17e889f 100644 --- a/packages/compass-crud/src/components/table-view/cell-renderer.tsx +++ b/packages/compass-crud/src/components/table-view/cell-renderer.tsx @@ -277,6 +277,32 @@ const CellRenderer: React.FC = ({ return editable; }, [context.path, column, node.data.hadronDocument, parentType]); + // Determine cell state + let cellState: + | typeof UNEDITABLE + | typeof EMPTY + | typeof INVALID + | typeof DELETED + | typeof ADDED + | typeof EDITED + | typeof VALID; + + if (!isEditable) { + cellState = UNEDITABLE; + } else if (isEmpty || isDeleted) { + cellState = EMPTY; + } else if (!element.isCurrentTypeValid()) { + cellState = INVALID; + } else if (element.isRemoved()) { + cellState = DELETED; + } else if (element.isAdded()) { + cellState = ADDED; + } else if (element.isModified()) { + cellState = EDITED; + } else { + cellState = VALID; + } + const handleUndo = useCallback( (event: React.MouseEvent) => { event.stopPropagation(); @@ -284,12 +310,12 @@ const CellRenderer: React.FC = ({ return; } const oid: string = node.data.hadronDocument.getStringId(); - if (element.isAdded()) { + if (cellState === ADDED) { setIsDeleted(true); const isArray = !element.parent?.isRoot() && element.parent?.currentType === 'Array'; elementRemoved(String(element.currentKey), oid, isArray); - } else if (element.isRemoved()) { + } else if (cellState === DELETED) { elementAdded(String(element.currentKey), element.currentType, oid); } else { elementTypeChanged(String(element.currentKey), element.type, oid); @@ -325,32 +351,6 @@ const CellRenderer: React.FC = ({ } }, [node, api, column]); - // Determine cell state - let cellState: - | typeof UNEDITABLE - | typeof EMPTY - | typeof INVALID - | typeof DELETED - | typeof ADDED - | typeof EDITED - | typeof VALID; - - if (!isEditable) { - cellState = UNEDITABLE; - } else if (isEmpty || isDeleted) { - cellState = EMPTY; - } else if (!element.isCurrentTypeValid()) { - cellState = INVALID; - } else if (element.isRemoved()) { - cellState = DELETED; - } else if (element.isAdded()) { - cellState = ADDED; - } else if (element.isModified()) { - cellState = EDITED; - } else { - cellState = VALID; - } - return ( // `ag-grid` renders this component outside of the context chain // so we re-supply the dark mode theme here. From 16bf358ced1b806d1b10df50c24615b5d8d69043 Mon Sep 17 00:00:00 2001 From: gagik Date: Tue, 17 Jun 2025 14:37:08 +0200 Subject: [PATCH 50/62] wip --- .../components/table-view/cell-renderer.tsx | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/compass-crud/src/components/table-view/cell-renderer.tsx b/packages/compass-crud/src/components/table-view/cell-renderer.tsx index 32ca17e889f..dd423f889f3 100644 --- a/packages/compass-crud/src/components/table-view/cell-renderer.tsx +++ b/packages/compass-crud/src/components/table-view/cell-renderer.tsx @@ -13,8 +13,10 @@ import { LeafyGreenProvider, spacing, withDarkMode, + useContextMenuItems, } from '@mongodb-js/compass-components'; import { type Document, Element } from 'hadron-document'; +import { objectToIdiomaticEJSON } from 'hadron-document'; import type { ICellRendererParams } from 'ag-grid-community'; import type { GridActions, TableHeaderType } from '../../stores/grid-store'; import type { CrudActions } from '../../stores/crud-store'; @@ -257,6 +259,72 @@ const CellRenderer: React.FC = ({ const isEmpty = element === undefined || element === null; const [isDeleted, setIsDeleted] = useState(false); + // Helper function to check if a string is a URL + const isValidUrl = useCallback((str: string): boolean => { + try { + const url = new URL(str); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + }, []); + + // Add context menu functionality + const contextMenuRef = useContextMenuItems([ + ...(element && !isEmpty + ? [ + { + label: 'Copy field & value', + onAction: () => { + const fieldName = column.getColId(); + const fieldStr = `${fieldName}: ${objectToIdiomaticEJSON( + element.currentValue + )}`; + void navigator.clipboard.writeText(fieldStr); + }, + }, + ] + : []), + ...(element && + element.currentType === 'String' && + isValidUrl(element.currentValue) + ? [ + { + label: 'Open URL in browser', + onAction: () => { + window.open(element.currentValue, '_blank', 'noopener'); + }, + }, + ] + : []), + ...(element && + (element.currentType === 'Object' || element.currentType === 'Array') + ? [ + { + label: 'Expand field', + onAction: () => { + handleDrillDown({ + stopPropagation: () => {}, + } as React.MouseEvent); + }, + }, + ] + : []), + ...(cellState === ADDED || + cellState === EDITED || + cellState === INVALID || + cellState === DELETED + ? [ + { + label: 'Undo changes', + onAction: () => { + handleUndo({ stopPropagation: () => {} } as React.MouseEvent); + }, + }, + ] + : []), + ]); + const isEditable = useMemo(() => { /* Can't get the editable() function from here, so have to reevaluate */ let editable = true; @@ -328,6 +396,7 @@ const CellRenderer: React.FC = ({ elementRemoved, elementAdded, elementTypeChanged, + cellState, ] ); @@ -355,7 +424,7 @@ const CellRenderer: React.FC = ({ // `ag-grid` renders this component outside of the context chain // so we re-supply the dark mode theme here. -
+
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus*/}
Date: Wed, 18 Jun 2025 11:15:05 +0200 Subject: [PATCH 51/62] wip --- .../src/components/document-list/element.tsx | 37 +------- .../src/hooks/use-element-context-menu.ts | 62 ++++++++++++ packages/compass-components/src/index.ts | 1 + .../src/context-menu-provider.tsx | 12 ++- packages/compass-context-menu/src/index.ts | 5 +- packages/compass-context-menu/src/types.ts | 2 +- .../src/use-context-menu.tsx | 4 +- .../components/table-view/cell-renderer.tsx | 95 ++++--------------- .../table-view/full-width-cell-renderer.tsx | 51 +++++----- 9 files changed, 127 insertions(+), 142 deletions(-) create mode 100644 packages/compass-components/src/hooks/use-element-context-menu.ts diff --git a/packages/compass-components/src/components/document-list/element.tsx b/packages/compass-components/src/components/document-list/element.tsx index 5f5c7de1a28..7c3879ecba9 100644 --- a/packages/compass-components/src/components/document-list/element.tsx +++ b/packages/compass-components/src/components/document-list/element.tsx @@ -29,7 +29,7 @@ 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 { useFieldContextMenu } from '../../hooks/use-element-context-menu'; function getEditorByType(type: HadronElementType['type']) { switch (type) { @@ -411,16 +411,6 @@ export const calculateShowMoreToggleOffset = ({ return spacerWidth + editableOffset + expandIconSize; }; -// Helper function to check if a string is a URL -const isValidUrl = (str: string): boolean => { - try { - const url = new URL(str); - return url.protocol === 'http:' || url.protocol === 'https:'; - } catch { - return false; - } -}; - export const HadronElement: React.FunctionComponent<{ value: HadronElementType; editable: boolean; @@ -460,27 +450,10 @@ export const HadronElement: React.FunctionComponent<{ } = useHadronElement(element); // Add context menu hook for the field - const fieldContextMenuRef = useContextMenuItems([ - { - label: 'Copy field & value', - onAction: () => { - const fieldStr = `${key.value}: ${objectToIdiomaticEJSON( - value.originalValue - )}`; - void navigator.clipboard.writeText(fieldStr); - }, - }, - ...(type.value === 'String' && isValidUrl(value.value) - ? [ - { - label: 'Open URL in browser', - onAction: () => { - window.open(value.value, '_blank', 'noopener'); - }, - }, - ] - : []), - ]); + const fieldContextMenuRef = useFieldContextMenu({ + element, + fieldName: key.value, + }); const toggleExpanded = () => { if (expanded) { diff --git a/packages/compass-components/src/hooks/use-element-context-menu.ts b/packages/compass-components/src/hooks/use-element-context-menu.ts new file mode 100644 index 00000000000..4e0948d8ddc --- /dev/null +++ b/packages/compass-components/src/hooks/use-element-context-menu.ts @@ -0,0 +1,62 @@ +import { useContextMenuItems } from '../components/context-menu'; +import { Element } from 'hadron-document'; +import { objectToIdiomaticEJSON } from 'hadron-document'; + +// Helper function to check if a string is a URL +export const isValidUrl = (str: string): boolean => { + try { + const url = new URL(str); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +export interface useFieldContextMenuProps { + element: Element | undefined | null; + fieldName: string; +} + +export function useFieldContextMenu({ + element, + fieldName, +}: useFieldContextMenuProps) { + return useContextMenuItems([ + ...(element + ? [ + { + label: 'Copy field & value', + onAction: () => { + const fieldStr = `${fieldName}: ${objectToIdiomaticEJSON( + element.currentValue + )}`; + void navigator.clipboard.writeText(fieldStr); + }, + }, + { + label: 'Copy value', + onAction: () => { + const valueStr = objectToIdiomaticEJSON(element.currentValue); + void navigator.clipboard.writeText(valueStr); + }, + }, + ...(element.currentType === 'String' && + typeof element.currentValue === 'string' && + isValidUrl(element.currentValue) + ? [ + { + label: 'Open URL in browser', + onAction: () => { + window.open( + element.currentValue as string, + '_blank', + 'noopener' + ); + }, + }, + ] + : []), + ] + : []), + ]); +} diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 69ce000a74f..cfe2a7494ca 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -215,3 +215,4 @@ export { export { SelectList } from './components/select-list'; export { ParagraphSkeleton } from '@leafygreen-ui/skeleton-loader'; export { InsightsChip } from './components/insights-chip'; +export { useFieldContextMenu } from './hooks/use-element-context-menu'; diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 0ae7c5f0e06..1bdb82ed48c 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -6,11 +6,13 @@ import React, { createContext, useContext, } from 'react'; -import type { ContextMenuContext, ContextMenuState } from './types'; +import type { ContextMenuContextType, ContextMenuState } from './types'; import type { EnhancedMouseEvent } from './context-menu-content'; import { getContextMenuContent } from './context-menu-content'; -export const Context = createContext(null); +export const ContextMenuContext = createContext( + null +); export function ContextMenuProvider({ children, @@ -22,7 +24,7 @@ export function ContextMenuProvider({ }>; }) { // Check if there's already a parent context menu provider - const parentContext = useContext(Context); + const parentContext = useContext(ContextMenuContext); const [menu, setMenu] = useState({ isOpen: false, @@ -92,9 +94,9 @@ export function ContextMenuProvider({ const Wrapper = wrapper ?? React.Fragment; return ( - + {children} - + ); } diff --git a/packages/compass-context-menu/src/index.ts b/packages/compass-context-menu/src/index.ts index 75d933ef767..7a0e27824fb 100644 --- a/packages/compass-context-menu/src/index.ts +++ b/packages/compass-context-menu/src/index.ts @@ -1,5 +1,8 @@ export { useContextMenu } from './use-context-menu'; -export { ContextMenuProvider } from './context-menu-provider'; +export { + ContextMenuProvider, + ContextMenuContext, +} from './context-menu-provider'; export type { ContextMenuItem, ContextMenuItemGroup, diff --git a/packages/compass-context-menu/src/types.ts b/packages/compass-context-menu/src/types.ts index 91e8d65cdcc..163abe56132 100644 --- a/packages/compass-context-menu/src/types.ts +++ b/packages/compass-context-menu/src/types.ts @@ -16,7 +16,7 @@ export type ContextMenuWrapperProps = { menu: ContextMenuState & { close: () => void }; }; -export type ContextMenuContext = { +export type ContextMenuContextType = { close(): void; }; diff --git a/packages/compass-context-menu/src/use-context-menu.tsx b/packages/compass-context-menu/src/use-context-menu.tsx index a60aeba4c69..a0b874b656c 100644 --- a/packages/compass-context-menu/src/use-context-menu.tsx +++ b/packages/compass-context-menu/src/use-context-menu.tsx @@ -1,6 +1,6 @@ import type { RefCallback } from 'react'; import { useContext, useMemo, useRef } from 'react'; -import { Context } from './context-menu-provider'; +import { ContextMenuContext } from './context-menu-provider'; import { appendContextMenuContent } from './context-menu-content'; import type { ContextMenuItem } from './types'; @@ -19,7 +19,7 @@ export type ContextMenuMethods = { export function useContextMenu< T extends ContextMenuItem = ContextMenuItem >(): ContextMenuMethods { - const context = useContext(Context); + const context = useContext(ContextMenuContext); const previous = useRef void]>( null ); diff --git a/packages/compass-crud/src/components/table-view/cell-renderer.tsx b/packages/compass-crud/src/components/table-view/cell-renderer.tsx index dd423f889f3..0ae6e755e7d 100644 --- a/packages/compass-crud/src/components/table-view/cell-renderer.tsx +++ b/packages/compass-crud/src/components/table-view/cell-renderer.tsx @@ -10,13 +10,12 @@ import { css, Icon, IconButton, - LeafyGreenProvider, spacing, withDarkMode, - useContextMenuItems, + useFieldContextMenu, + LeafyGreenProvider, } from '@mongodb-js/compass-components'; import { type Document, Element } from 'hadron-document'; -import { objectToIdiomaticEJSON } from 'hadron-document'; import type { ICellRendererParams } from 'ag-grid-community'; import type { GridActions, TableHeaderType } from '../../stores/grid-store'; import type { CrudActions } from '../../stores/crud-store'; @@ -94,6 +93,7 @@ const decrypdedIconStyles = css({ interface CellContentProps { element: Element | undefined | null; + fieldName: string; cellState: | typeof UNEDITABLE | typeof EMPTY @@ -111,6 +111,7 @@ const CellContent: React.FC = ({ cellState, onUndo, onExpand, + fieldName, }) => { const [, forceUpdate] = useReducer((x: number) => x + 1, 0); const isEmpty = element === undefined || element === null; @@ -118,6 +119,12 @@ const CellContent: React.FC = ({ forceUpdate(); }, []); + // Context menu functionality + const cellContextMenuRef = useFieldContextMenu({ + element, + fieldName, + }); + // Subscribe to element events useEffect(() => { if (!isEmpty && element) { @@ -148,7 +155,7 @@ const CellContent: React.FC = ({ } }, [element]); - const renderContent = useCallback(() => { + const renderContent = useMemo(() => { if (cellState === EMPTY || !element) { return 'No field'; } @@ -192,7 +199,7 @@ const CellContent: React.FC = ({ return (
-
+
{element.decrypted && ( = ({
); - }, [element, elementLength, cellState]); + }, [element, elementLength, cellState, cellContextMenuRef]); const canUndo = cellState === ADDED || @@ -222,7 +229,7 @@ const CellContent: React.FC = ({ <> {canUndo && } {canExpand && } - {renderContent()} + {renderContent} ); }; @@ -256,75 +263,8 @@ const CellRenderer: React.FC = ({ }) => { const element = value as Element | undefined | null; - const isEmpty = element === undefined || element === null; const [isDeleted, setIsDeleted] = useState(false); - // Helper function to check if a string is a URL - const isValidUrl = useCallback((str: string): boolean => { - try { - const url = new URL(str); - return url.protocol === 'http:' || url.protocol === 'https:'; - } catch { - return false; - } - }, []); - - // Add context menu functionality - const contextMenuRef = useContextMenuItems([ - ...(element && !isEmpty - ? [ - { - label: 'Copy field & value', - onAction: () => { - const fieldName = column.getColId(); - const fieldStr = `${fieldName}: ${objectToIdiomaticEJSON( - element.currentValue - )}`; - void navigator.clipboard.writeText(fieldStr); - }, - }, - ] - : []), - ...(element && - element.currentType === 'String' && - isValidUrl(element.currentValue) - ? [ - { - label: 'Open URL in browser', - onAction: () => { - window.open(element.currentValue, '_blank', 'noopener'); - }, - }, - ] - : []), - ...(element && - (element.currentType === 'Object' || element.currentType === 'Array') - ? [ - { - label: 'Expand field', - onAction: () => { - handleDrillDown({ - stopPropagation: () => {}, - } as React.MouseEvent); - }, - }, - ] - : []), - ...(cellState === ADDED || - cellState === EDITED || - cellState === INVALID || - cellState === DELETED - ? [ - { - label: 'Undo changes', - onAction: () => { - handleUndo({ stopPropagation: () => {} } as React.MouseEvent); - }, - }, - ] - : []), - ]); - const isEditable = useMemo(() => { /* Can't get the editable() function from here, so have to reevaluate */ let editable = true; @@ -357,7 +297,7 @@ const CellRenderer: React.FC = ({ if (!isEditable) { cellState = UNEDITABLE; - } else if (isEmpty || isDeleted) { + } else if (!element || isDeleted) { cellState = EMPTY; } else if (!element.isCurrentTypeValid()) { cellState = INVALID; @@ -393,10 +333,10 @@ const CellRenderer: React.FC = ({ [ element, node.data.hadronDocument, + cellState, elementRemoved, elementAdded, elementTypeChanged, - cellState, ] ); @@ -424,7 +364,7 @@ const CellRenderer: React.FC = ({ // `ag-grid` renders this component outside of the context chain // so we re-supply the dark mode theme here. -
+
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus*/}
= ({ > - { - this.props.api.stopEditing(); - if (force) { - void this.props.replaceDocument(this.doc); - } else { - void this.props.updateDocument(this.doc); - } - }} - onDelete={() => { - this.props.api.stopEditing(); - void this.props.removeDocument(this.doc); - }} - onCancel={() => { - if (this.state.mode === 'editing') { - this.handleCancelUpdate(); - } else { - this.handleCancelRemove(); - } - }} - /> + + { + this.props.api.stopEditing(); + if (force) { + void this.props.replaceDocument(this.doc); + } else { + void this.props.updateDocument(this.doc); + } + }} + onDelete={() => { + this.props.api.stopEditing(); + void this.props.removeDocument(this.doc); + }} + onCancel={() => { + if (this.state.mode === 'editing') { + this.handleCancelUpdate(); + } else { + this.handleCancelRemove(); + } + }} + /> + ); } From 6b6e3981b77d32e36ff46b319dfdae3f1babdf1f Mon Sep 17 00:00:00 2001 From: gagik Date: Wed, 18 Jun 2025 11:22:45 +0200 Subject: [PATCH 52/62] fix: throw early on --- .../src/context-menu-provider.tsx | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 0ae7c5f0e06..4c90da83f63 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -24,6 +24,13 @@ export function ContextMenuProvider({ // Check if there's already a parent context menu provider const parentContext = useContext(Context); + // Prevent accidental nested providers + if (parentContext) { + throw new Error( + 'Duplicated ContextMenuProvider found. Please remove the nested provider.' + ); + } + const [menu, setMenu] = useState({ isOpen: false, itemGroups: [], @@ -41,11 +48,6 @@ export function ContextMenuProvider({ ); useEffect(() => { - // If there's a parent provider, don't add event listeners - if (parentContext) { - return; - } - function handleContextMenu(event: MouseEvent) { event.preventDefault(); @@ -73,7 +75,7 @@ export function ContextMenuProvider({ document.removeEventListener('contextmenu', handleContextMenu); window.removeEventListener('resize', handleClosingEvent); }; - }, [handleClosingEvent, parentContext]); + }, [handleClosingEvent]); const value = useMemo( () => ({ @@ -82,13 +84,6 @@ export function ContextMenuProvider({ [close] ); - // Prevent accidental nested providers - if (parentContext) { - throw new Error( - 'Duplicated ContextMenuProvider found. Please remove the nested provider.' - ); - } - const Wrapper = wrapper ?? React.Fragment; return ( From 6dadf25564cdfa7708ae962c16f31642e1e53124 Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 19 Jun 2025 10:12:09 +0200 Subject: [PATCH 53/62] fix: correct wrapper use --- .../src/components/context-menu.spec.tsx | 2 +- .../src/components/context-menu.tsx | 2 +- .../src/context-menu-provider.spec.tsx | 6 +++--- .../src/use-context-menu.spec.tsx | 14 +++++++------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/compass-components/src/components/context-menu.spec.tsx b/packages/compass-components/src/components/context-menu.spec.tsx index d2ecaa63cdf..aa4aee700df 100644 --- a/packages/compass-components/src/components/context-menu.spec.tsx +++ b/packages/compass-components/src/components/context-menu.spec.tsx @@ -38,7 +38,7 @@ describe('useContextMenuItems', function () { expect(() => { render( - + ); diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index c12ff5fdd3e..0cb8d38b6a3 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -14,7 +14,7 @@ export function ContextMenuProvider({ children: React.ReactNode; }) { return ( - + {children} ); diff --git a/packages/compass-context-menu/src/context-menu-provider.spec.tsx b/packages/compass-context-menu/src/context-menu-provider.spec.tsx index 7e39fdee800..0cd6f23e492 100644 --- a/packages/compass-context-menu/src/context-menu-provider.spec.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.spec.tsx @@ -17,9 +17,9 @@ describe('ContextMenuProvider', function () { it('throws an error when providers are nested', function () { expect(() => { render( - +
- +
@@ -34,7 +34,7 @@ describe('ContextMenuProvider', function () { describe('when not nested', function () { it('renders without error', function () { render( - + ); 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 cfe0bfc7ef4..fb17fa0e66a 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -136,7 +136,7 @@ describe('useContextMenu', function () { it('renders without error', function () { render( - + ); @@ -148,7 +148,7 @@ describe('useContextMenu', function () { const onRegister = sinon.spy(); render( - + ); @@ -159,7 +159,7 @@ describe('useContextMenu', function () { it('shows context menu on right click', function () { render( - + ); @@ -174,7 +174,7 @@ describe('useContextMenu', function () { describe('with nested context menus', function () { it('shows only parent items when right clicking parent area', function () { render( - + ); @@ -193,7 +193,7 @@ describe('useContextMenu', function () { it('shows both parent and child items when right clicking child area', function () { render( - + @@ -215,7 +215,7 @@ describe('useContextMenu', function () { const childOnAction = sinon.spy(); render( - + @@ -238,7 +238,7 @@ describe('useContextMenu', function () { const childOnAction = sinon.spy(); render( - + From 4dafa7be32311839954ddecda6ef78b4c10e2374 Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 19 Jun 2025 17:55:34 +0200 Subject: [PATCH 54/62] fix: use render directly for compass-context-menu --- package-lock.json | 5 ++++- packages/compass-components/src/components/context-menu.tsx | 2 +- packages/compass-context-menu/package.json | 1 + .../compass-context-menu/src/context-menu-provider.spec.tsx | 2 +- packages/compass-context-menu/src/render.ts | 5 +++++ packages/compass-context-menu/src/use-context-menu.spec.tsx | 3 ++- 6 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 packages/compass-context-menu/src/render.ts diff --git a/package-lock.json b/package-lock.json index 3bafa1def9b..74c7fb5a9dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13059,6 +13059,7 @@ "version": "12.1.5", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.0.0", @@ -43994,6 +43995,7 @@ "@mongodb-js/prettier-config-compass": "^1.2.8", "@mongodb-js/testing-library-compass": "^1.3.1", "@mongodb-js/tsconfig-compass": "^1.2.8", + "@testing-library/react": "^12.1.5", "@types/chai": "^4.2.21", "@types/mocha": "^9.0.0", "@types/react": "^17.0.5", @@ -57130,6 +57132,7 @@ "@mongodb-js/prettier-config-compass": "^1.2.8", "@mongodb-js/testing-library-compass": "^1.3.1", "@mongodb-js/tsconfig-compass": "^1.2.8", + "@testing-library/react": "^12.1.5", "@types/chai": "^4.2.21", "@types/mocha": "^9.0.0", "@types/react": "^17.0.5", @@ -64989,7 +64992,7 @@ "requires": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.0.0", - "@types/react-dom": "^17.0.25" + "@types/react-dom": "<18.0.0" } }, "@testing-library/react-hooks": { diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index 0cb8d38b6a3..b15715e128c 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -6,7 +6,7 @@ import { ContextMenuProvider as ContextMenuProviderBase } from '@mongodb-js/comp import type { ContextMenuItemGroup, ContextMenuWrapperProps, -} from '@mongodb-js/compass-context-menu/dist/types'; +} from '@mongodb-js/compass-context-menu'; export function ContextMenuProvider({ children, diff --git a/packages/compass-context-menu/package.json b/packages/compass-context-menu/package.json index 67099115f92..b810c1a2aff 100644 --- a/packages/compass-context-menu/package.json +++ b/packages/compass-context-menu/package.json @@ -57,6 +57,7 @@ "@mongodb-js/prettier-config-compass": "^1.2.8", "@mongodb-js/testing-library-compass": "^1.3.1", "@mongodb-js/tsconfig-compass": "^1.2.8", + "@testing-library/react": "^12.1.5", "@types/chai": "^4.2.21", "@types/mocha": "^9.0.0", "@types/react": "^17.0.5", diff --git a/packages/compass-context-menu/src/context-menu-provider.spec.tsx b/packages/compass-context-menu/src/context-menu-provider.spec.tsx index 0cd6f23e492..3173b88f7f6 100644 --- a/packages/compass-context-menu/src/context-menu-provider.spec.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render } from '@mongodb-js/testing-library-compass'; +import { render } from './render'; import { expect } from 'chai'; import { ContextMenuProvider } from './context-menu-provider'; import type { ContextMenuWrapperProps } from './types'; diff --git a/packages/compass-context-menu/src/render.ts b/packages/compass-context-menu/src/render.ts new file mode 100644 index 00000000000..35e6c857ea9 --- /dev/null +++ b/packages/compass-context-menu/src/render.ts @@ -0,0 +1,5 @@ +// We need to import from testing-library/react directly because the wrapping done +// by testing-library-compass already sets up the context menu provider which is not +// useful for our tests. +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +export { render } from '@testing-library/react'; 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 fb17fa0e66a..fadaffd73a6 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { render } from './render'; +import { screen, userEvent } from '@mongodb-js/testing-library-compass'; import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; From 915c59763c60398ca89103d673b78cdc039b03ff Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 19 Jun 2025 18:21:53 +0200 Subject: [PATCH 55/62] fix: use testingLibrary's render This reverts commit 4dafa7be32311839954ddecda6ef78b4c10e2374 and uses testing-library instead. --- package-lock.json | 5 +---- packages/compass-context-menu/package.json | 1 - .../src/context-menu-provider.spec.tsx | 5 ++++- packages/compass-context-menu/src/render.ts | 5 ----- .../compass-context-menu/src/use-context-menu.spec.tsx | 10 ++++++++-- 5 files changed, 13 insertions(+), 13 deletions(-) delete mode 100644 packages/compass-context-menu/src/render.ts diff --git a/package-lock.json b/package-lock.json index 74c7fb5a9dd..3bafa1def9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13059,7 +13059,6 @@ "version": "12.1.5", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.0.0", @@ -43995,7 +43994,6 @@ "@mongodb-js/prettier-config-compass": "^1.2.8", "@mongodb-js/testing-library-compass": "^1.3.1", "@mongodb-js/tsconfig-compass": "^1.2.8", - "@testing-library/react": "^12.1.5", "@types/chai": "^4.2.21", "@types/mocha": "^9.0.0", "@types/react": "^17.0.5", @@ -57132,7 +57130,6 @@ "@mongodb-js/prettier-config-compass": "^1.2.8", "@mongodb-js/testing-library-compass": "^1.3.1", "@mongodb-js/tsconfig-compass": "^1.2.8", - "@testing-library/react": "^12.1.5", "@types/chai": "^4.2.21", "@types/mocha": "^9.0.0", "@types/react": "^17.0.5", @@ -64992,7 +64989,7 @@ "requires": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.0.0", - "@types/react-dom": "<18.0.0" + "@types/react-dom": "^17.0.25" } }, "@testing-library/react-hooks": { diff --git a/packages/compass-context-menu/package.json b/packages/compass-context-menu/package.json index b810c1a2aff..67099115f92 100644 --- a/packages/compass-context-menu/package.json +++ b/packages/compass-context-menu/package.json @@ -57,7 +57,6 @@ "@mongodb-js/prettier-config-compass": "^1.2.8", "@mongodb-js/testing-library-compass": "^1.3.1", "@mongodb-js/tsconfig-compass": "^1.2.8", - "@testing-library/react": "^12.1.5", "@types/chai": "^4.2.21", "@types/mocha": "^9.0.0", "@types/react": "^17.0.5", diff --git a/packages/compass-context-menu/src/context-menu-provider.spec.tsx b/packages/compass-context-menu/src/context-menu-provider.spec.tsx index 3173b88f7f6..53284272555 100644 --- a/packages/compass-context-menu/src/context-menu-provider.spec.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.spec.tsx @@ -1,9 +1,12 @@ import React from 'react'; -import { render } from './render'; +import { testingLibrary } from '@mongodb-js/testing-library-compass'; import { expect } from 'chai'; import { ContextMenuProvider } from './context-menu-provider'; import type { ContextMenuWrapperProps } from './types'; +// We need to import from testing-library-compass directly to avoid the extra wrapping. +const { render } = testingLibrary; + describe('ContextMenuProvider', function () { const TestMenu: React.FC = () => (
Test Menu
diff --git a/packages/compass-context-menu/src/render.ts b/packages/compass-context-menu/src/render.ts deleted file mode 100644 index 35e6c857ea9..00000000000 --- a/packages/compass-context-menu/src/render.ts +++ /dev/null @@ -1,5 +0,0 @@ -// We need to import from testing-library/react directly because the wrapping done -// by testing-library-compass already sets up the context menu provider which is not -// useful for our tests. -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -export { render } from '@testing-library/react'; 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 fadaffd73a6..9459f924b05 100644 --- a/packages/compass-context-menu/src/use-context-menu.spec.tsx +++ b/packages/compass-context-menu/src/use-context-menu.spec.tsx @@ -1,12 +1,18 @@ import React from 'react'; -import { render } from './render'; -import { screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { + screen, + userEvent, + testingLibrary, +} from '@mongodb-js/testing-library-compass'; import { expect } from 'chai'; import sinon from 'sinon'; import { useContextMenu } from './use-context-menu'; import { ContextMenuProvider } from './context-menu-provider'; import type { ContextMenuItem, ContextMenuWrapperProps } from './types'; +// We need to import from testing-library-compass directly to avoid the extra wrapping. +const { render } = testingLibrary; + describe('useContextMenu', function () { const TestMenu: React.FC = ({ menu }) => (
From 8f306e41cb8d22e29164bcd379cf62e41069e646 Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 19 Jun 2025 18:27:56 +0200 Subject: [PATCH 56/62] feat: memoize items for context menu --- configs/eslint-config-compass/index.js | 2 +- .../src/components/context-menu.spec.tsx | 2 +- .../compass-components/src/components/context-menu.tsx | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/configs/eslint-config-compass/index.js b/configs/eslint-config-compass/index.js index a7f2479d9b6..21572f894e9 100644 --- a/configs/eslint-config-compass/index.js +++ b/configs/eslint-config-compass/index.js @@ -46,7 +46,7 @@ const tsxRules = { 'react-hooks/exhaustive-deps': [ 'warn', { - additionalHooks: 'useTrackOnChange', + additionalHooks: '(useTrackOnChange|useContextMenuItems)', }, ], }; diff --git a/packages/compass-components/src/components/context-menu.spec.tsx b/packages/compass-components/src/components/context-menu.spec.tsx index aa4aee700df..51ff62d6b6d 100644 --- a/packages/compass-components/src/components/context-menu.spec.tsx +++ b/packages/compass-components/src/components/context-menu.spec.tsx @@ -18,7 +18,7 @@ describe('useContextMenuItems', function () { children?: React.ReactNode; 'data-testid'?: string; }) => { - const ref = useContextMenuItems(items); + const ref = useContextMenuItems(() => items, [items]); return (
diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index b15715e128c..99abb6472c2 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { Menu, MenuItem, MenuSeparator } from './leafygreen'; import type { ContextMenuItem } from '@mongodb-js/compass-context-menu'; import { useContextMenu } from '@mongodb-js/compass-context-menu'; @@ -88,8 +88,10 @@ export function ContextMenu({ menu }: ContextMenuWrapperProps) { } export function useContextMenuItems( - items: ContextMenuItem[] + getItems: () => ContextMenuItem[], + dependencies: React.DependencyList | undefined ): React.RefCallback { + const memoizedItems = useMemo(getItems, dependencies); const contextMenu = useContextMenu(); - return contextMenu.registerItems(items); + return contextMenu.registerItems(memoizedItems); } From aa52fb7f0153bd61a847d47b2883d2bb7a8c66ce Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 19 Jun 2025 18:42:02 +0200 Subject: [PATCH 57/62] fix: adjust dependencies --- package-lock.json | 2 ++ packages/compass-components/package.json | 1 + packages/compass-components/src/components/context-menu.tsx | 1 + 3 files changed, 4 insertions(+) diff --git a/package-lock.json b/package-lock.json index 3bafa1def9b..286d3a3f9e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43583,6 +43583,7 @@ "@leafygreen-ui/tokens": "^2.11.3", "@leafygreen-ui/tooltip": "^13.0.2", "@leafygreen-ui/typography": "^20.0.2", + "@mongodb-js/compass-context-menu": "^0.0.1", "@react-aria/interactions": "^3.9.1", "@react-aria/utils": "^3.13.1", "@react-aria/visually-hidden": "^3.3.1", @@ -56777,6 +56778,7 @@ "@leafygreen-ui/tokens": "^2.11.3", "@leafygreen-ui/tooltip": "^13.0.2", "@leafygreen-ui/typography": "^20.0.2", + "@mongodb-js/compass-context-menu": "^0.0.1", "@mongodb-js/eslint-config-compass": "^1.3.10", "@mongodb-js/mocha-config-compass": "^1.6.8", "@mongodb-js/prettier-config-compass": "^1.2.8", diff --git a/packages/compass-components/package.json b/packages/compass-components/package.json index 14af3a7b98b..e5f44908ba0 100644 --- a/packages/compass-components/package.json +++ b/packages/compass-components/package.json @@ -75,6 +75,7 @@ "@leafygreen-ui/tokens": "^2.11.3", "@leafygreen-ui/tooltip": "^13.0.2", "@leafygreen-ui/typography": "^20.0.2", + "@mongodb-js/compass-context-menu": "^0.0.1", "@react-aria/interactions": "^3.9.1", "@react-aria/utils": "^3.13.1", "@react-aria/visually-hidden": "^3.3.1", diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index 99abb6472c2..0346589e318 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -91,6 +91,7 @@ export function useContextMenuItems( getItems: () => ContextMenuItem[], dependencies: React.DependencyList | undefined ): React.RefCallback { + // eslint-disable-next-line react-hooks/exhaustive-deps const memoizedItems = useMemo(getItems, dependencies); const contextMenu = useContextMenu(); return contextMenu.registerItems(memoizedItems); From fb9c5996eb87cf7d539363d08a0a941faad57f3f Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 19 Jun 2025 19:01:40 +0200 Subject: [PATCH 58/62] fix: support nesting --- .../src/components/context-menu.spec.tsx | 17 +++++++---- .../src/context-menu-provider.spec.tsx | 29 ++++++++++--------- .../src/context-menu-provider.tsx | 17 ++++++----- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/packages/compass-components/src/components/context-menu.spec.tsx b/packages/compass-components/src/components/context-menu.spec.tsx index 51ff62d6b6d..13fd851ec0c 100644 --- a/packages/compass-components/src/components/context-menu.spec.tsx +++ b/packages/compass-components/src/components/context-menu.spec.tsx @@ -28,7 +28,7 @@ describe('useContextMenuItems', function () { ); }; - it('errors if the component is double wrapped', function () { + it('works with nested providers, using the parent provider', function () { const items = [ { label: 'Test Item', @@ -36,15 +36,20 @@ describe('useContextMenuItems', function () { }, ]; - expect(() => { - render( + const { container } = render( + - ); - }).to.throw( - 'Duplicated ContextMenuProvider found. Please remove the nested provider.' + ); + + // Should only find one context menu (from the parent provider) + expect( + container.querySelectorAll('[data-testid="context-menu"]') + ).to.have.length(1); + // Should still render the trigger + expect(screen.getByTestId(menuTestTriggerId)).to.exist; }); it('renders without error', function () { diff --git a/packages/compass-context-menu/src/context-menu-provider.spec.tsx b/packages/compass-context-menu/src/context-menu-provider.spec.tsx index 53284272555..88d85c1fbcf 100644 --- a/packages/compass-context-menu/src/context-menu-provider.spec.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.spec.tsx @@ -17,20 +17,23 @@ describe('ContextMenuProvider', function () { ); describe('when nested', function () { - it('throws an error when providers are nested', function () { - expect(() => { - render( - -
- - - -
-
- ); - }).to.throw( - 'Duplicated ContextMenuProvider found. Please remove the nested provider.' + it('uses parent provider and does not render duplicate menu wrapper', function () { + const { container } = render( + +
+ + + +
+
); + + // Should only find one test-menu element (from the parent provider) + expect( + container.querySelectorAll('[data-testid="test-menu"]') + ).to.have.length(1); + // Should still render the content + expect(container.querySelector('[data-testid="test-content"]')).to.exist; }); }); diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index aac3cd92494..0c2134f7ec4 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -26,13 +26,6 @@ export function ContextMenuProvider({ // Check if there's already a parent context menu provider const parentContext = useContext(ContextMenuContext); - // Prevent accidental nested providers - if (parentContext) { - throw new Error( - 'Duplicated ContextMenuProvider found. Please remove the nested provider.' - ); - } - const [menu, setMenu] = useState({ isOpen: false, itemGroups: [], @@ -50,6 +43,9 @@ export function ContextMenuProvider({ ); useEffect(() => { + // Don't set up event listeners if we have a parent context + if (parentContext) return; + function handleContextMenu(event: MouseEvent) { event.preventDefault(); @@ -77,7 +73,7 @@ export function ContextMenuProvider({ document.removeEventListener('contextmenu', handleContextMenu); window.removeEventListener('resize', handleClosingEvent); }; - }, [handleClosingEvent]); + }, [handleClosingEvent, parentContext]); const value = useMemo( () => ({ @@ -86,6 +82,11 @@ export function ContextMenuProvider({ [close] ); + // If we have a parent context, just render children without the wrapper + if (parentContext) { + return <>{children}; + } + const Wrapper = menuWrapper ?? React.Fragment; return ( From 38853663dc77f6ffd0c7400bd2c9117d5f204af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 3 Jul 2025 10:51:09 +0200 Subject: [PATCH 59/62] Squash merge of crud related changes from gagik/collection-tab --- package-lock.json | 169 +++++++++++- .../components/document-list/element.spec.tsx | 121 +++++++++ .../src/components/document-list/element.tsx | 40 +++ packages/compass-crud/package.json | 2 +- .../document-json-view-item.spec.tsx | 107 ++++++++ .../components/document-json-view-item.tsx | 72 +++++ .../document-list-view-item.spec.tsx | 104 ++++++++ .../components/document-list-view-item.tsx | 63 +++++ .../components/document-list-view.spec.tsx | 4 +- .../src/components/editable-document.spec.tsx | 4 +- .../use-document-item-context-menu.spec.tsx | 249 ++++++++++++++++++ .../use-document-item-context-menu.tsx | 75 ++++++ .../virtualized-document-json-view.tsx | 44 ++-- .../virtualized-document-list-view.tsx | 37 +-- 14 files changed, 1045 insertions(+), 46 deletions(-) create mode 100644 packages/compass-components/src/components/document-list/element.spec.tsx create mode 100644 packages/compass-crud/src/components/document-json-view-item.spec.tsx create mode 100644 packages/compass-crud/src/components/document-json-view-item.tsx create mode 100644 packages/compass-crud/src/components/document-list-view-item.spec.tsx create mode 100644 packages/compass-crud/src/components/document-list-view-item.tsx create mode 100644 packages/compass-crud/src/components/use-document-item-context-menu.spec.tsx create mode 100644 packages/compass-crud/src/components/use-document-item-context-menu.tsx diff --git a/package-lock.json b/package-lock.json index f34d90f6d94..225c89833a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44190,7 +44190,7 @@ "mongodb-instance-model": "^12.35.0", "nyc": "^15.1.0", "react-dom": "^17.0.2", - "sinon": "^8.1.1", + "sinon": "^17.0.1", "typescript": "^5.0.4" } }, @@ -44206,6 +44206,55 @@ "bson": "^4.6.3 || ^5 || ^6" } }, + "packages/compass-crud/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "packages/compass-crud/node_modules/@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "packages/compass-crud/node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "packages/compass-crud/node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "packages/compass-crud/node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, "packages/compass-crud/node_modules/mongodb-query-parser": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/mongodb-query-parser/-/mongodb-query-parser-4.3.0.tgz", @@ -44221,6 +44270,20 @@ "bson": "^4.6.3 || ^5 || ^6" } }, + "packages/compass-crud/node_modules/nise": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, "packages/compass-crud/node_modules/numeral": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", @@ -44229,6 +44292,32 @@ "node": "*" } }, + "packages/compass-crud/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "packages/compass-crud/node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "packages/compass-data-modeling": { "name": "@mongodb-js/compass-data-modeling", "version": "1.14.0", @@ -56751,7 +56840,7 @@ "react-dom": "^17.0.2", "reflux": "^0.4.1", "semver": "^7.6.2", - "sinon": "^8.1.1", + "sinon": "^17.0.1", "typescript": "^5.0.4" }, "dependencies": { @@ -56763,6 +56852,49 @@ "acorn": "^8.1.0" } }, + "@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1" + } + }, + "@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + }, + "dependencies": { + "type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true + } + } + }, + "just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, "mongodb-query-parser": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/mongodb-query-parser/-/mongodb-query-parser-4.3.0.tgz", @@ -56774,10 +56906,43 @@ "lodash": "^4.17.21" } }, + "nise": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, "numeral": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==" + }, + "path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true + }, + "sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + } } } }, diff --git a/packages/compass-components/src/components/document-list/element.spec.tsx b/packages/compass-components/src/components/document-list/element.spec.tsx new file mode 100644 index 00000000000..88fa527f257 --- /dev/null +++ b/packages/compass-components/src/components/document-list/element.spec.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import HadronDocument from 'hadron-document'; +import { HadronElement } from './element'; +import type { Element } from 'hadron-document'; + +describe('HadronElement', function () { + describe('context menu', function () { + let doc: HadronDocument; + let element: Element; + let windowOpenStub: sinon.SinonStub; + let clipboardWriteTextStub: sinon.SinonStub; + + beforeEach(function () { + doc = new HadronDocument({ field: 'value' }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + element = doc.elements.at(0)!; + windowOpenStub = sinon.stub(window, 'open'); + clipboardWriteTextStub = sinon.stub(navigator.clipboard, 'writeText'); + }); + + afterEach(function () { + windowOpenStub.restore(); + clipboardWriteTextStub.restore(); + }); + + it('copies field and value when "Copy field & value" is clicked', function () { + render( + {}} + /> + ); + + // Open context menu and click the copy option + const elementNode = screen.getByTestId('hadron-document-element'); + userEvent.click(elementNode, { button: 2 }); + userEvent.click(screen.getByText('Copy field & value'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(clipboardWriteTextStub).to.have.been.calledWith('field: "value"'); + }); + + it('shows "Open URL in browser" for URL string values', function () { + const urlDoc = new HadronDocument({ link: 'https://mongodb.com' }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const urlElement = urlDoc.elements.at(0)!; + + render( + {}} + /> + ); + + // Open context menu + const elementNode = screen.getByTestId('hadron-document-element'); + userEvent.click(elementNode, { button: 2 }); + + // Check if the menu item exists + expect(screen.getByText('Open URL in browser')).to.exist; + }); + + it('opens URL in new tab when "Open URL in browser" is clicked', function () { + const urlDoc = new HadronDocument({ link: 'https://mongodb.com' }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const urlElement = urlDoc.elements.at(0)!; + + render( + {}} + /> + ); + + // Open context menu and click the open URL option + const elementNode = screen.getByTestId('hadron-document-element'); + userEvent.click(elementNode, { button: 2 }); + userEvent.click(screen.getByText('Open URL in browser'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(windowOpenStub).to.have.been.calledWith( + 'https://mongodb.com', + '_blank', + 'noopener' + ); + }); + + it('does not show "Open URL in browser" for non-URL string values', function () { + render( + {}} + /> + ); + + // Open context menu + const elementNode = screen.getByTestId('hadron-document-element'); + userEvent.click(elementNode, { button: 2 }); + + // Check that the menu item doesn't exist + expect(screen.queryByText('Open URL in browser')).to.not.exist; + }); + }); +}); diff --git a/packages/compass-components/src/components/document-list/element.tsx b/packages/compass-components/src/components/document-list/element.tsx index b79c96f4015..e1bfc32c0ef 100644 --- a/packages/compass-components/src/components/document-list/element.tsx +++ b/packages/compass-components/src/components/document-list/element.tsx @@ -15,6 +15,7 @@ import { ElementEvents, ElementEditor, DEFAULT_VISIBLE_ELEMENTS, + objectToIdiomaticEJSON, } from 'hadron-document'; import BSONValue from '../bson-value'; import { spacing } from '@leafygreen-ui/tokens'; @@ -28,6 +29,7 @@ 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'; function getEditorByType(type: HadronElementType['type']) { switch (type) { @@ -409,6 +411,16 @@ export const calculateShowMoreToggleOffset = ({ return spacerWidth + editableOffset + expandIconSize; }; +// Helper function to check if a string is a URL +const isValidUrl = (str: string): boolean => { + try { + const url = new URL(str); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + export const HadronElement: React.FunctionComponent<{ value: HadronElementType; editable: boolean; @@ -447,6 +459,32 @@ export const HadronElement: React.FunctionComponent<{ collapse, } = useHadronElement(element); + // Add context menu hook for the field + const fieldContextMenuRef = useContextMenuItems( + () => [ + { + label: 'Copy field & value', + onAction: () => { + const fieldStr = `${key.value}: ${objectToIdiomaticEJSON( + value.originalValue + )}`; + void navigator.clipboard.writeText(fieldStr); + }, + }, + ...(type.value === 'String' && isValidUrl(value.value) + ? [ + { + label: 'Open URL in browser', + onAction: () => { + window.open(value.value, '_blank', 'noopener'); + }, + }, + ] + : []), + ], + [key.value, value.originalValue, value.value, type.value] + ); + const toggleExpanded = () => { if (expanded) { collapse(); @@ -493,6 +531,7 @@ export const HadronElement: React.FunctionComponent<{ : elementInvalidLightMode; const elementProps = { + ref: fieldContextMenuRef, className: cx( hadronElement, darkMode ? hadronElementDarkMode : hadronElementLightMode, @@ -531,6 +570,7 @@ export const HadronElement: React.FunctionComponent<{ data-field={key.value} data-id={element.uuid} {...elementProps} + ref={fieldContextMenuRef} > {editable && (
diff --git a/packages/compass-crud/package.json b/packages/compass-crud/package.json index bb50e822157..016ab15188e 100644 --- a/packages/compass-crud/package.json +++ b/packages/compass-crud/package.json @@ -66,7 +66,7 @@ "mongodb-instance-model": "^12.35.0", "nyc": "^15.1.0", "react-dom": "^17.0.2", - "sinon": "^8.1.1", + "sinon": "^17.0.1", "typescript": "^5.0.4" }, "dependencies": { diff --git a/packages/compass-crud/src/components/document-json-view-item.spec.tsx b/packages/compass-crud/src/components/document-json-view-item.spec.tsx new file mode 100644 index 00000000000..767ab08350b --- /dev/null +++ b/packages/compass-crud/src/components/document-json-view-item.spec.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import HadronDocument from 'hadron-document'; +import { DocumentJsonViewItem } from './document-json-view-item'; + +describe('DocumentJsonViewItem', function () { + let doc: HadronDocument; + let copyToClipboardStub: sinon.SinonStub; + let openInsertDocumentDialogStub: sinon.SinonStub; + + beforeEach(function () { + doc = new HadronDocument({ + _id: 1, + name: 'test', + url: 'https://mongodb.com', + nested: { field: 'value' }, + }); + + copyToClipboardStub = sinon.stub(); + openInsertDocumentDialogStub = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('renders the JSON editor component', function () { + render( + + ); + + // Should render without error + expect(document.querySelector('[data-testid="editable-json"]')).to.exist; + }); + + it('renders context menu when right-clicked', function () { + const { container } = render( + + ); + + const element = container.firstChild as HTMLElement; + + // Right-click to open context menu + userEvent.click(element, { button: 2 }); + + // Should show context menu with expected items + expect(screen.getByText('Copy document')).to.exist; + expect(screen.getByText('Clone document...')).to.exist; + expect(screen.getByText('Delete document')).to.exist; + }); + + it('renders scroll trigger when docIndex is 0', function () { + const scrollTriggerRef = React.createRef(); + + render( + + ); + + expect(scrollTriggerRef.current).to.exist; + }); + + it('does not render scroll trigger when docIndex is not 0', function () { + const scrollTriggerRef = React.createRef(); + + render( + + ); + + expect(scrollTriggerRef.current).to.be.null; + }); +}); diff --git a/packages/compass-crud/src/components/document-json-view-item.tsx b/packages/compass-crud/src/components/document-json-view-item.tsx new file mode 100644 index 00000000000..003f82ab73a --- /dev/null +++ b/packages/compass-crud/src/components/document-json-view-item.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import type HadronDocument from 'hadron-document'; +import { css, KeylineCard } from '@mongodb-js/compass-components'; + +import JSONEditor, { type JSONEditorProps } from './json-editor'; +import { useDocumentItemContextMenu } from './use-document-item-context-menu'; + +const keylineCardStyles = css({ + overflow: 'hidden', + position: 'relative', +}); + +export type DocumentJsonViewItemProps = { + doc: HadronDocument; + docRef: React.Ref; + docIndex: number; + namespace: string; + isEditable: boolean; + isTimeSeries?: boolean; + scrollTriggerRef?: React.Ref; +} & Pick< + JSONEditorProps, + | 'copyToClipboard' + | 'removeDocument' + | 'replaceDocument' + | 'updateDocument' + | 'openInsertDocumentDialog' +>; + +const DocumentJsonViewItem: React.FC = ({ + doc, + docRef, + docIndex, + namespace, + isEditable, + isTimeSeries, + scrollTriggerRef, + copyToClipboard, + removeDocument, + replaceDocument, + updateDocument, + openInsertDocumentDialog, +}) => { + const ref = useDocumentItemContextMenu({ + doc, + isEditable, + copyToClipboard, + openInsertDocumentDialog, + }); + + return ( +
+ + {scrollTriggerRef && docIndex === 0 &&
} + + +
+ ); +}; + +export { DocumentJsonViewItem }; diff --git a/packages/compass-crud/src/components/document-list-view-item.spec.tsx b/packages/compass-crud/src/components/document-list-view-item.spec.tsx new file mode 100644 index 00000000000..5ad9ffa8798 --- /dev/null +++ b/packages/compass-crud/src/components/document-list-view-item.spec.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import HadronDocument from 'hadron-document'; +import { DocumentListViewItem } from './document-list-view-item'; + +describe('DocumentListViewItem', function () { + let doc: HadronDocument; + let copyToClipboardStub: sinon.SinonStub; + let openInsertDocumentDialogStub: sinon.SinonStub; + + beforeEach(function () { + doc = new HadronDocument({ + _id: 1, + name: 'test', + url: 'https://mongodb.com', + nested: { field: 'value' }, + }); + + copyToClipboardStub = sinon.stub(); + openInsertDocumentDialogStub = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('renders the document component', function () { + render( + + ); + + // Should render without error + expect(document.querySelector('[data-testid="editable-document"]')).to + .exist; + }); + + it('renders context menu when right-clicked', function () { + const { container } = render( + + ); + + const element = container.firstChild as HTMLElement; + + // Right-click to open context menu + userEvent.click(element, { button: 2 }); + + // Should show context menu with expected items + expect(screen.getByText('Copy document')).to.exist; + expect(screen.getByText('Clone document...')).to.exist; + expect(screen.getByText('Delete document')).to.exist; + }); + + it('renders scroll trigger when docIndex is 0', function () { + const scrollTriggerRef = React.createRef(); + + render( + + ); + + expect(scrollTriggerRef.current).to.exist; + }); + + it('does not render scroll trigger when docIndex is not 0', function () { + const scrollTriggerRef = React.createRef(); + + render( + + ); + + expect(scrollTriggerRef.current).to.be.null; + }); +}); diff --git a/packages/compass-crud/src/components/document-list-view-item.tsx b/packages/compass-crud/src/components/document-list-view-item.tsx new file mode 100644 index 00000000000..288ce2b8621 --- /dev/null +++ b/packages/compass-crud/src/components/document-list-view-item.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import type HadronDocument from 'hadron-document'; +import { KeylineCard } from '@mongodb-js/compass-components'; +import Document, { type DocumentProps } from './document'; +import { useDocumentItemContextMenu } from './use-document-item-context-menu'; + +export type DocumentListViewItemProps = { + doc: HadronDocument; + docRef: React.Ref; + docIndex: number; + isEditable: boolean; + isTimeSeries?: boolean; + scrollTriggerRef?: React.Ref; +} & Pick< + DocumentProps, + | 'copyToClipboard' + | 'removeDocument' + | 'replaceDocument' + | 'updateDocument' + | 'openInsertDocumentDialog' +>; + +const DocumentListViewItem: React.FC = ({ + doc, + docRef, + docIndex, + isEditable, + isTimeSeries, + scrollTriggerRef, + copyToClipboard, + removeDocument, + replaceDocument, + updateDocument, + openInsertDocumentDialog, +}) => { + const ref = useDocumentItemContextMenu({ + doc, + isEditable, + copyToClipboard, + openInsertDocumentDialog, + }); + + return ( +
+ + {scrollTriggerRef && docIndex === 0 &&
} + + +
+ ); +}; + +export { DocumentListViewItem }; diff --git a/packages/compass-crud/src/components/document-list-view.spec.tsx b/packages/compass-crud/src/components/document-list-view.spec.tsx index ed257306567..5938ac2ab06 100644 --- a/packages/compass-crud/src/components/document-list-view.spec.tsx +++ b/packages/compass-crud/src/components/document-list-view.spec.tsx @@ -5,7 +5,7 @@ import HadronDocument from 'hadron-document'; import { expect } from 'chai'; import DocumentListView from './document-list-view'; -import { ContextMenuProvider } from '@mongodb-js/compass-components'; +import { CompassComponentsProvider } from '@mongodb-js/compass-components'; describe('', function () { describe('#render', function () { @@ -20,7 +20,7 @@ describe('', function () { isEditable={false} isTimeSeries={false} />, - { wrappingComponent: ContextMenuProvider } + { wrappingComponent: CompassComponentsProvider } ); }); diff --git a/packages/compass-crud/src/components/editable-document.spec.tsx b/packages/compass-crud/src/components/editable-document.spec.tsx index 19667f66d2e..25643935ce4 100644 --- a/packages/compass-crud/src/components/editable-document.spec.tsx +++ b/packages/compass-crud/src/components/editable-document.spec.tsx @@ -6,6 +6,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import EditableDocument from './editable-document'; +import { ContextMenuProvider } from '@mongodb-js/compass-context-menu'; describe('', function () { describe('#render', function () { @@ -22,7 +23,8 @@ describe('', function () { updateDocument={sinon.spy(action)} copyToClipboard={sinon.spy(action)} openInsertDocumentDialog={sinon.spy(action)} - /> + />, + { wrappingComponent: ContextMenuProvider } ); }); diff --git a/packages/compass-crud/src/components/use-document-item-context-menu.spec.tsx b/packages/compass-crud/src/components/use-document-item-context-menu.spec.tsx new file mode 100644 index 00000000000..5bbd3671a9c --- /dev/null +++ b/packages/compass-crud/src/components/use-document-item-context-menu.spec.tsx @@ -0,0 +1,249 @@ +import React from 'react'; +import { render, screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import HadronDocument from 'hadron-document'; +import { useDocumentItemContextMenu } from './use-document-item-context-menu'; + +// Test component that uses the hook +const TestComponent: React.FC< + Parameters[0] +> = ({ doc, isEditable, copyToClipboard, openInsertDocumentDialog }) => { + const ref = useDocumentItemContextMenu({ + doc, + isEditable, + copyToClipboard, + openInsertDocumentDialog, + }); + + return ( +
+ Test Content +
+ ); +}; + +describe('useDocumentItemContextMenu', function () { + let doc: HadronDocument; + let copyToClipboardStub: sinon.SinonStub; + let openInsertDocumentDialogStub: sinon.SinonStub; + let collapseStub: sinon.SinonStub; + let expandStub: sinon.SinonStub; + let startEditingStub: sinon.SinonStub; + let markForDeletionStub: sinon.SinonStub; + let generateObjectStub: sinon.SinonStub; + + beforeEach(function () { + doc = new HadronDocument({ + _id: 1, + name: 'test', + nested: { field: 'value' }, + }); + + copyToClipboardStub = sinon.stub(); + openInsertDocumentDialogStub = sinon.stub(); + + // Set up document methods as stubs + collapseStub = sinon.stub(doc, 'collapse'); + expandStub = sinon.stub(doc, 'expand'); + startEditingStub = sinon.stub(doc, 'startEditing'); + markForDeletionStub = sinon.stub(doc, 'markForDeletion'); + generateObjectStub = sinon.stub(doc, 'generateObject').returns({ + _id: 1, + name: 'test', + nested: { field: 'value' }, + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('when editable', function () { + it('shows all menu items when document is editable and not editing', function () { + doc.expanded = false; + doc.editing = false; + + render( + + ); + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Should show all operations + expect(screen.getByText('Expand all fields')).to.exist; + expect(screen.getByText('Edit document')).to.exist; + expect(screen.getByText('Copy document')).to.exist; + expect(screen.getByText('Clone document...')).to.exist; + expect(screen.getByText('Delete document')).to.exist; + }); + + it('hides edit document when document is editing', function () { + doc.expanded = false; + doc.editing = true; + + render( + + ); + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Should hide edit document when editing + expect(screen.queryByText('Edit document')).to.not.exist; + // But show other operations + expect(screen.getByText('Expand all fields')).to.exist; + expect(screen.getByText('Copy document')).to.exist; + expect(screen.getByText('Clone document...')).to.exist; + expect(screen.getByText('Delete document')).to.exist; + }); + }); + + describe('when read-only', function () { + it('shows only non-mutating operations when not editable', function () { + doc.expanded = false; + doc.editing = false; + + render( + + ); + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Should show non-mutating operations + expect(screen.getByText('Expand all fields')).to.exist; + expect(screen.getByText('Copy document')).to.exist; + + // Should hide mutating operations + expect(screen.queryByText('Edit document')).to.not.exist; + expect(screen.queryByText('Clone document...')).to.not.exist; + expect(screen.queryByText('Delete document')).to.not.exist; + }); + + it('collapses document when collapse is clicked', function () { + doc.expanded = true; + + // Render with expanded document + render( + + ); + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click collapse + userEvent.click(screen.getByText('Collapse all fields'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(collapseStub).to.have.been.calledOnce; + }); + }); + + describe('functionality', function () { + beforeEach(function () { + render( + + ); + }); + + it('toggles expand/collapse correctly', function () { + doc.expanded = false; + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click expand + userEvent.click(screen.getByText('Expand all fields')); + + expect(expandStub).to.have.been.calledOnce; + }); + + it('starts editing when edit is clicked', function () { + doc.editing = false; + + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click edit + userEvent.click(screen.getByText('Edit document'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(startEditingStub).to.have.been.calledOnce; + }); + + it('calls copyToClipboard when copy is clicked', function () { + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click copy + userEvent.click(screen.getByText('Copy document'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(copyToClipboardStub).to.have.been.calledWith(doc); + }); + + it('calls openInsertDocumentDialog with cloned document when clone is clicked', function () { + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click clone + userEvent.click(screen.getByText('Clone document...'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(generateObjectStub).to.have.been.calledWith({ + excludeInternalFields: true, + }); + expect(openInsertDocumentDialogStub).to.have.been.calledWith( + { + _id: 1, + name: 'test', + nested: { field: 'value' }, + }, + true + ); + }); + + it('marks document for deletion when delete is clicked', function () { + // Right-click to open context menu + userEvent.click(screen.getByTestId('test-container'), { button: 2 }); + + // Click delete + userEvent.click(screen.getByText('Delete document'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(markForDeletionStub).to.have.been.calledOnce; + }); + }); +}); 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 new file mode 100644 index 00000000000..51d254b056a --- /dev/null +++ b/packages/compass-crud/src/components/use-document-item-context-menu.tsx @@ -0,0 +1,75 @@ +import type HadronDocument from 'hadron-document'; +import { useContextMenuItems } from '@mongodb-js/compass-components'; + +import type { DocumentProps } from './document'; + +export type UseDocumentItemContextMenuProps = { + doc: HadronDocument; + isEditable: boolean; +} & Pick; + +export function useDocumentItemContextMenu({ + doc, + isEditable, + copyToClipboard, + openInsertDocumentDialog, +}: UseDocumentItemContextMenuProps) { + const { expanded: isExpanded, editing: isEditing } = doc; + return useContextMenuItems( + () => [ + { + label: isExpanded ? 'Collapse all fields' : 'Expand all fields', + onAction: () => { + if (isExpanded) { + doc.collapse(); + } else { + doc.expand(); + } + }, + }, + ...(isEditable && !isEditing + ? [ + { + label: 'Edit document', + onAction: () => { + doc.startEditing(); + }, + }, + ] + : []), + { + label: 'Copy document', + onAction: () => { + copyToClipboard?.(doc); + }, + }, + ...(isEditable + ? [ + { + label: 'Clone document...', + onAction: () => { + const clonedDoc = doc.generateObject({ + excludeInternalFields: true, + }); + void openInsertDocumentDialog?.(clonedDoc, true); + }, + }, + { + label: 'Delete document', + onAction: () => { + doc.markForDeletion(); + }, + }, + ] + : []), + ], + [ + doc, + isExpanded, + isEditing, + isEditable, + copyToClipboard, + openInsertDocumentDialog, + ] + ); +} diff --git a/packages/compass-crud/src/components/virtualized-document-json-view.tsx b/packages/compass-crud/src/components/virtualized-document-json-view.tsx index 8cffcf8f26f..609bb657836 100644 --- a/packages/compass-crud/src/components/virtualized-document-json-view.tsx +++ b/packages/compass-crud/src/components/virtualized-document-json-view.tsx @@ -2,19 +2,14 @@ import React, { useCallback } from 'react'; import type HadronDocument from 'hadron-document'; import { css, - KeylineCard, spacing, VirtualList, type VirtualListRef, type VirtualListItemRenderer, } from '@mongodb-js/compass-components'; -import JSONEditor, { type JSONEditorProps } from './json-editor'; - -const keylineCardStyles = css({ - overflow: 'hidden', - position: 'relative', -}); +import type { JSONEditorProps } from './json-editor'; +import { DocumentJsonViewItem } from './document-json-view-item'; const spacingStyles = css({ padding: spacing[400], @@ -75,23 +70,26 @@ const VirtualizedDocumentJsonView: React.FC< listRef, }) => { const renderItem: VirtualListItemRenderer = useCallback( - (doc, docRef, docIndex) => { + ( + doc: HadronDocument, + docRef: React.Ref, + docIndex: number + ) => { return ( - - {scrollTriggerRef && docIndex === 0 &&
} - - + ); }, [ diff --git a/packages/compass-crud/src/components/virtualized-document-list-view.tsx b/packages/compass-crud/src/components/virtualized-document-list-view.tsx index 5ddfa237fb4..6cd5567a948 100644 --- a/packages/compass-crud/src/components/virtualized-document-list-view.tsx +++ b/packages/compass-crud/src/components/virtualized-document-list-view.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useMemo } from 'react'; import HadronDocument from 'hadron-document'; import { css, - KeylineCard, spacing, VirtualList, type VirtualListItemRenderer, @@ -10,7 +9,8 @@ import { } from '@mongodb-js/compass-components'; import { type BSONObject } from '../stores/crud-store'; -import Document, { type DocumentProps } from './document'; +import type { DocumentProps } from './document'; +import { DocumentListViewItem } from './document-list-view-item'; const spacingStyles = css({ padding: spacing[400], @@ -90,22 +90,25 @@ const VirtualizedDocumentListView: React.FC< }, [_docs]); const renderItem: VirtualListItemRenderer = useCallback( - (doc, docRef, docIndex) => { + ( + doc: HadronDocument, + docRef: React.Ref, + docIndex: number + ) => { return ( - - {scrollTriggerRef && docIndex === 0 &&
} - - + ); }, [ From 02d7a2ead212905e71e1cb1b22002e37ee1bdb28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 3 Jul 2025 11:15:59 +0200 Subject: [PATCH 60/62] Fix copy field & value --- .../src/components/document-list/element.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/compass-components/src/components/document-list/element.tsx b/packages/compass-components/src/components/document-list/element.tsx index e1bfc32c0ef..137adb21835 100644 --- a/packages/compass-components/src/components/document-list/element.tsx +++ b/packages/compass-components/src/components/document-list/element.tsx @@ -465,9 +465,8 @@ export const HadronElement: React.FunctionComponent<{ { label: 'Copy field & value', onAction: () => { - const fieldStr = `${key.value}: ${objectToIdiomaticEJSON( - value.originalValue - )}`; + const generated = element.generateObject(); + const fieldStr = `${key.value}: ${objectToIdiomaticEJSON(generated)}`; void navigator.clipboard.writeText(fieldStr); }, }, From ac8572dc2d855138612e53a58778b6c67d4e8d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 3 Jul 2025 11:47:58 +0200 Subject: [PATCH 61/62] Fix enzyme tests --- packages/compass-context-menu/src/context-menu-provider.tsx | 4 +--- .../compass-crud/src/components/editable-document.spec.tsx | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 802ffa2f2f8..96ddf8f4c91 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -20,7 +20,7 @@ export const ContextMenuContext = createContext( export function ContextMenuProvider({ disabled = false, children, - menuWrapper, + menuWrapper: Wrapper, }: { disabled?: boolean; children: React.ReactNode; @@ -95,8 +95,6 @@ export function ContextMenuProvider({ return <>{children}; } - const Wrapper = menuWrapper ?? React.Fragment; - return ( {children} diff --git a/packages/compass-crud/src/components/editable-document.spec.tsx b/packages/compass-crud/src/components/editable-document.spec.tsx index 25643935ce4..b59114276ae 100644 --- a/packages/compass-crud/src/components/editable-document.spec.tsx +++ b/packages/compass-crud/src/components/editable-document.spec.tsx @@ -6,7 +6,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import EditableDocument from './editable-document'; -import { ContextMenuProvider } from '@mongodb-js/compass-context-menu'; +import { CompassComponentsProvider } from '@mongodb-js/compass-components'; describe('', function () { describe('#render', function () { @@ -24,7 +24,7 @@ describe('', function () { copyToClipboard={sinon.spy(action)} openInsertDocumentDialog={sinon.spy(action)} />, - { wrappingComponent: ContextMenuProvider } + { wrappingComponent: CompassComponentsProvider } ); }); From 197d0edfe9ee2a009b0887b6a010c8d8d17718aa Mon Sep 17 00:00:00 2001 From: gagik Date: Thu, 3 Jul 2025 11:52:54 +0200 Subject: [PATCH 62/62] fix: remove duplicated ref passing --- .../compass-components/src/components/document-list/element.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/compass-components/src/components/document-list/element.tsx b/packages/compass-components/src/components/document-list/element.tsx index 137adb21835..88781235301 100644 --- a/packages/compass-components/src/components/document-list/element.tsx +++ b/packages/compass-components/src/components/document-list/element.tsx @@ -569,7 +569,6 @@ export const HadronElement: React.FunctionComponent<{ data-field={key.value} data-id={element.uuid} {...elementProps} - ref={fieldContextMenuRef} > {editable && (