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..88781235301 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,31 @@ export const HadronElement: React.FunctionComponent<{ collapse, } = useHadronElement(element); + // Add context menu hook for the field + const fieldContextMenuRef = useContextMenuItems( + () => [ + { + label: 'Copy field & value', + onAction: () => { + const generated = element.generateObject(); + const fieldStr = `${key.value}: ${objectToIdiomaticEJSON(generated)}`; + 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 +530,7 @@ export const HadronElement: React.FunctionComponent<{ : elementInvalidLightMode; const elementProps = { + ref: fieldContextMenuRef, className: cx( hadronElement, darkMode ? hadronElementDarkMode : hadronElementLightMode, 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-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/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..b59114276ae 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 { CompassComponentsProvider } from '@mongodb-js/compass-components'; describe('', function () { describe('#render', function () { @@ -22,7 +23,8 @@ describe('', function () { updateDocument={sinon.spy(action)} copyToClipboard={sinon.spy(action)} openInsertDocumentDialog={sinon.spy(action)} - /> + />, + { wrappingComponent: CompassComponentsProvider } ); }); 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..0ae6e755e7d 100644 --- a/packages/compass-crud/src/components/table-view/cell-renderer.tsx +++ b/packages/compass-crud/src/components/table-view/cell-renderer.tsx @@ -1,16 +1,21 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { + useMemo, + useCallback, + useEffect, + useReducer, + useState, +} from 'react'; import { BSONValue, css, Icon, IconButton, - LeafyGreenProvider, spacing, withDarkMode, + useFieldContextMenu, + LeafyGreenProvider, } from '@mongodb-js/compass-components'; -import { Element } from 'hadron-document'; -import type { ICellRendererReactComp } from 'ag-grid-react'; +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'; @@ -61,6 +66,11 @@ const UNEDITABLE = 'is-uneditable'; */ const INVALID = 'is-invalid'; +/** + * The valid constant. + */ +const VALID = 'valid'; + /** * The deleted constant. */ @@ -81,182 +91,116 @@ const decrypdedIconStyles = css({ display: 'flex', }); -export type CellRendererProps = Omit & { - context: GridContext; - parentType: TableHeaderType; - elementAdded: GridActions['elementAdded']; - elementRemoved: GridActions['elementRemoved']; - elementTypeChanged: GridActions['elementTypeChanged']; - drillDown: CrudActions['drillDown']; - tz: string; - darkMode?: boolean; -}; - -/** - * 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; +interface CellContentProps { + element: Element | undefined | null; + fieldName: string; + 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; +} - /* 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; - } 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; - } - } +const CellContent: React.FC = ({ + element, + cellState, + onUndo, + onExpand, + fieldName, +}) => { + const [, forceUpdate] = useReducer((x: number) => x + 1, 0); + const isEmpty = element === undefined || element === null; + const handleElementEvent = useCallback(() => { + forceUpdate(); + }, []); + + // Context menu functionality + const cellContextMenuRef = useFieldContextMenu({ + element, + fieldName, + }); + + // 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]); - componentDidMount() { - if (!this.isEmpty) { - this.subscribeElementEvents(); + const elementLength = useMemo((): number | undefined => { + if (!element) { + return undefined; } - } - componentWillUnmount() { - if (!this.isEmpty) { - this.unsubscribeElementEvents(); + if (element.currentType === 'Object') { + return Object.keys(element.generateObject() as object).length; } - } - - 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 - ); + if (element.currentType === 'Array' && element.elements) { + return element.elements.size; } - this.element.revert(); - }; + }, [element]); - 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(), - }); + const renderContent = useMemo(() => { + if (cellState === EMPTY || !element) { + return 'No field'; } - } - refresh() { - return true; - } - - renderInvalidCell() { - let valueClass = `${VALUE_CLASS}-is-${this.element.currentType.toLowerCase()}`; - valueClass = `${valueClass} ${INVALID_VALUE}`; + if (cellState === UNEDITABLE) { + return ''; + } - /* Return internal div because invalid cells should only hightlight text? */ + if (cellState === DELETED) { + return 'Deleted field'; + } - return
{this.element.currentValue}
; - } + if (cellState === INVALID) { + let valueClass = `${VALUE_CLASS}-is-${element.currentType.toLowerCase()}`; + valueClass = `${valueClass} ${INVALID_VALUE}`; - getLength(): number | undefined { - if (this.element.currentType === 'Object') { - return Object.keys(this.element.generateObject() as object).length; - } - if (this.element.currentType === 'Array') { - return this.element.elements!.size; + return
{element.currentValue}
; } - } - renderValidCell() { let className = VALUE_BASE; - let element: string | JSX.Element = ''; - if (this.element.isAdded()) { - className = `${className} ${VALUE_BASE}-${ADDED}`; - } else if (this.element.isEdited()) { - className = `${className} ${VALUE_BASE}-${EDITED}`; + let elementContent: string | JSX.Element = ''; + if (cellState === ADDED || cellState === EDITED) { + className = `${className} ${VALUE_BASE}-${cellState}`; } - if (this.element.currentType === 'Object') { - element = `{} ${this.getLength() as number} fields`; - } else if (this.element.currentType === 'Array') { - element = `[] ${this.getLength() as number} elements`; + 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 { - 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, elementLength, cellState, cellContextMenuRef]); + + 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} + + ); +}; - if (!canUndo) { - return null; - } - return ( - - - - ); - } +export type CellRendererProps = Omit & { + context: GridContext; + parentType: TableHeaderType; + elementAdded: GridActions['elementAdded']; + elementRemoved: GridActions['elementRemoved']; + elementTypeChanged: GridActions['elementTypeChanged']; + drillDown: CrudActions['drillDown']; + tz: string; + darkMode?: boolean; +}; - renderExpand(canExpand: boolean) { - if (!canExpand) { - return null; +/** + * The custom cell renderer that renders a cell in the table view. + */ +const CellRenderer: React.FC = ({ + value, + context, + column, + node, + parentType, + elementAdded, + elementRemoved, + elementTypeChanged, + drillDown, + api, + darkMode, +}) => { + const element = value as Element | undefined | null; + + const [isDeleted, setIsDeleted] = useState(false); + + 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') { + 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 (+column.getColId() > maxKey) { + editable = false; + } + } } - return ( - - - - - - ); + 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 (!element || 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; } - 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}`; - canUndo = true; - } else if (this.element.isRemoved()) { - element = 'Deleted field'; - className = `${className}-${DELETED}`; - 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; + const handleUndo = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (!element) { + return; } - canExpand = - this.element.currentType === 'Object' || - this.element.currentType === 'Array'; + const oid: string = node.data.hadronDocument.getStringId(); + if (cellState === ADDED) { + setIsDeleted(true); + const isArray = + !element.parent?.isRoot() && element.parent?.currentType === 'Array'; + elementRemoved(String(element.currentKey), oid, isArray); + } else if (cellState === DELETED) { + elementAdded(String(element.currentKey), element.currentType, oid); + } else { + elementTypeChanged(String(element.currentKey), element.type, oid); + } + element.revert(); + }, + [ + element, + node.data.hadronDocument, + cellState, + elementRemoved, + elementAdded, + elementTypeChanged, + ] + ); + + const handleDrillDown = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (!element) { + return; + } + drillDown(node.data.hadronDocument as Document, element); + }, + [drillDown, node.data.hadronDocument, element] + ); + + const handleClicked = useCallback(() => { + if (node.data.state === 'editing') { + api.startEditingCell({ + rowIndex: node.rowIndex, + colKey: column.getColId(), + }); } + }, [node, api, column]); - 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} +
- - ); +
+
+ ); +}; + +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`; } - 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'; + return ( + + + + ); +}; + +interface CellExpandButtonProps { + onClick: (event: React.MouseEvent) => void; } -export default withDarkMode(CellRenderer); +const CellExpandButton: React.FC = ({ onClick }) => { + return ( + + + + ); +}; diff --git a/packages/compass-crud/src/components/table-view/full-width-cell-renderer.tsx b/packages/compass-crud/src/components/table-view/full-width-cell-renderer.tsx index 5aecf2391f7..2c98c513772 100644 --- a/packages/compass-crud/src/components/table-view/full-width-cell-renderer.tsx +++ b/packages/compass-crud/src/components/table-view/full-width-cell-renderer.tsx @@ -9,6 +9,7 @@ import type { CellEditorProps } from './cell-editor'; import type { GridActions } from '../../stores/grid-store'; import type { Element } from 'hadron-document'; import type { BSONObject, CrudActions } from '../../stores/crud-store'; +import { CompassComponentsProvider } from '@mongodb-js/compass-components'; export type FullWidthCellRendererProps = Pick< CellEditorProps, @@ -127,30 +128,32 @@ class FullWidthCellRenderer extends React.Component< // this is needed cause ag-grid renders this component outside // of the context chain - { - 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(); + } + }} + /> + ); } 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 &&
} - - + ); }, [