From 550d2b8dd3890808ce8cd1c1ae7b8b2615d8501f Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Wed, 15 Jul 2026 14:18:01 +0200 Subject: [PATCH 1/9] Add copy value and copy as json for document --- .../components/document-list/element.spec.tsx | 21 ++++++++++ .../src/components/document-list/element.tsx | 6 +++ .../document-json-view-item.spec.tsx | 2 +- .../document-list-view-item.spec.tsx | 2 +- .../use-document-item-context-menu.spec.tsx | 8 ++-- .../use-document-item-context-menu.tsx | 40 ++++++++++--------- 6 files changed, 54 insertions(+), 25 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 60eadef0777..4e58c2b4f76 100644 --- a/packages/compass-components/src/components/document-list/element.spec.tsx +++ b/packages/compass-components/src/components/document-list/element.spec.tsx @@ -97,6 +97,27 @@ describe('HadronElement', function () { ); }); + it('copies value when "Copy 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 value'), undefined, { + skipPointerEventsCheck: true, + }); + + expect(clipboardWriteTextStub).to.have.been.calledWith('"value"'); + }); + it('copies field and value when "Copy field & value" is clicked', function () { render( { + void navigator.clipboard.writeText(element.toEJSON()); + }, + }, { label: 'Copy field & value', onAction: () => { 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 index 767ab08350b..1b46d5e8183 100644 --- a/packages/compass-crud/src/components/document-json-view-item.spec.tsx +++ b/packages/compass-crud/src/components/document-json-view-item.spec.tsx @@ -62,7 +62,7 @@ describe('DocumentJsonViewItem', function () { userEvent.click(element, { button: 2 }); // Should show context menu with expected items - expect(screen.getByText('Copy document')).to.exist; + expect(screen.getByText('Copy document as EJSON')).to.exist; expect(screen.getByText('Clone document...')).to.exist; expect(screen.getByText('Delete document')).to.exist; }); 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 e7005e47b73..10fd62cd68a 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 @@ -67,7 +67,7 @@ describe('DocumentListViewItem', function () { userEvent.click(element, { button: 2 }); // Should show context menu with expected items - expect(screen.getByText('Copy document')).to.exist; + expect(screen.getByText('Copy document as EJSON')).to.exist; expect(screen.getByText('Clone document...')).to.exist; expect(screen.getByText('Delete document')).to.exist; }); 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 index cc4bb0587d3..59dbca5a015 100644 --- 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 @@ -81,7 +81,7 @@ describe('useDocumentItemContextMenu', function () { // 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('Copy document as EJSON')).to.exist; expect(screen.getByText('Clone document...')).to.exist; expect(screen.getByText('Delete document')).to.exist; }); @@ -109,7 +109,7 @@ describe('useDocumentItemContextMenu', function () { expect(screen.queryByText('Delete 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('Copy document as EJSON')).to.exist; expect(screen.getByText('Clone document...')).to.exist; }); }); @@ -133,7 +133,7 @@ describe('useDocumentItemContextMenu', function () { // Should show non-mutating operations expect(screen.getByText('Expand all fields')).to.exist; - expect(screen.getByText('Copy document')).to.exist; + expect(screen.getByText('Copy document as EJSON')).to.exist; // Should hide mutating operations expect(screen.queryByText('Edit document')).to.not.exist; @@ -249,7 +249,7 @@ describe('useDocumentItemContextMenu', function () { userEvent.click(screen.getByTestId('test-container'), { button: 2 }); // Click copy - userEvent.click(screen.getByText('Copy document'), undefined, { + userEvent.click(screen.getByText('Copy document as EJSON'), undefined, { skipPointerEventsCheck: true, }); diff --git a/packages/compass-crud/src/components/use-document-item-context-menu.tsx b/packages/compass-crud/src/components/use-document-item-context-menu.tsx index 778c90426a4..92416b87450 100644 --- a/packages/compass-crud/src/components/use-document-item-context-menu.tsx +++ b/packages/compass-crud/src/components/use-document-item-context-menu.tsx @@ -18,25 +18,8 @@ export function useDocumentItemContextMenu({ return useContextMenuGroups( () => [ - isEditable - ? { - telemetryLabel: 'Document Item Edit', - items: [ - { - label: isEditing ? 'Cancel editing' : 'Edit document', - onAction: () => { - if (isEditing) { - doc.finishEditing(); - } else { - doc.startEditing(); - } - }, - }, - ], - } - : undefined, { - telemetryLabel: 'Document Item', + telemetryLabel: 'Document Expand Collapse', items: [ { label: isExpanded ? 'Collapse all fields' : 'Expand all fields', @@ -48,8 +31,27 @@ export function useDocumentItemContextMenu({ } }, }, + ], + }, + { + telemetryLabel: 'Document Item', + items: [ + ...(isEditable + ? [ + { + label: isEditing ? 'Cancel editing' : 'Edit document', + onAction: () => { + if (isEditing) { + doc.finishEditing(); + } else { + doc.startEditing(); + } + }, + }, + ] + : []), { - label: 'Copy document', + label: 'Copy document as EJSON', onAction: () => { copyToClipboard?.(doc); }, From 4f40b4ceb41a8cb55398e7a5dafaf41464b01648 Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Thu, 16 Jul 2026 11:42:06 +0200 Subject: [PATCH 2/9] handle copy document as shell syntax --- .../src/components/editable-document.tsx | 4 ++-- .../src/components/json-editor.tsx | 2 +- .../use-document-item-context-menu.tsx | 8 +++++++- .../src/stores/crud-store.spec.ts | 16 ++++++++++++++-- .../compass-crud/src/stores/crud-store.ts | 19 ++++++------------- .../compass-telemetry/src/telemetry-events.ts | 4 ++++ packages/hadron-document/package.json | 3 ++- packages/hadron-document/src/document.ts | 16 ++++++++++++++++ 8 files changed, 52 insertions(+), 20 deletions(-) diff --git a/packages/compass-crud/src/components/editable-document.tsx b/packages/compass-crud/src/components/editable-document.tsx index 914fd365098..769ec3744a9 100644 --- a/packages/compass-crud/src/components/editable-document.tsx +++ b/packages/compass-crud/src/components/editable-document.tsx @@ -6,7 +6,7 @@ import { withPreferences } from 'compass-preferences-model/provider'; import { documentStyles, documentContentStyles } from './readonly-document'; import { getInsightsForDocument } from '../utils'; -import type { CrudActions } from '../stores/crud-store'; +import type { CopyDocumentFormat, CrudActions } from '../stores/crud-store'; const documentElementsContainerStyles = css({ position: 'relative', @@ -126,7 +126,7 @@ class EditableDocument extends React.Component< * Handle copying JSON to clipboard of the document. */ handleCopy() { - this.props.copyToClipboard?.(this.props.doc); + this.props.copyToClipboard?.(this.props.doc, 'ejson'); } /** diff --git a/packages/compass-crud/src/components/json-editor.tsx b/packages/compass-crud/src/components/json-editor.tsx index 3ec53603b82..6d552b3b4d7 100644 --- a/packages/compass-crud/src/components/json-editor.tsx +++ b/packages/compass-crud/src/components/json-editor.tsx @@ -99,7 +99,7 @@ const JSONEditor: React.FunctionComponent = ({ }, [value, editing, setModifiedEJSONStringRef]); const handleCopy = useCallback(() => { - copyToClipboard?.(doc); + copyToClipboard?.(doc, 'ejson'); }, [copyToClipboard, doc]); const handleClone = useCallback(() => { diff --git a/packages/compass-crud/src/components/use-document-item-context-menu.tsx b/packages/compass-crud/src/components/use-document-item-context-menu.tsx index 92416b87450..bcd9c56846c 100644 --- a/packages/compass-crud/src/components/use-document-item-context-menu.tsx +++ b/packages/compass-crud/src/components/use-document-item-context-menu.tsx @@ -50,10 +50,16 @@ export function useDocumentItemContextMenu({ }, ] : []), + { + label: 'Copy document as Shell Syntax', + onAction: () => { + copyToClipboard?.(doc, 'shell-syntax'); + }, + }, { label: 'Copy document as EJSON', onAction: () => { - copyToClipboard?.(doc); + copyToClipboard?.(doc, 'ejson'); }, }, isEditable diff --git a/packages/compass-crud/src/stores/crud-store.spec.ts b/packages/compass-crud/src/stores/crud-store.spec.ts index 9a4175d8473..d58adc5850d 100644 --- a/packages/compass-crud/src/stores/crud-store.spec.ts +++ b/packages/compass-crud/src/stores/crud-store.spec.ts @@ -403,17 +403,29 @@ describe('store', function () { } }); - it('copies the document to the clipboard', function () { + it('copies the document to the clipboard in ejson format', function () { expect(mockCopyToClipboard.called).to.equal(false); const doc = { _id: 'testing', name: 'heart 5' }; const hadronDoc = new HadronDocument(doc); - store.copyToClipboard(hadronDoc); + store.copyToClipboard(hadronDoc, 'ejson'); expect(mockCopyToClipboard).to.have.been.calledOnceWithExactly( '{\n "_id": "testing",\n "name": "heart 5"\n}' ); }); + + it('copies the document to the clipboard in shell syntax', function () { + expect(mockCopyToClipboard.called).to.equal(false); + + const doc = { _id: 'testing', count: 2 }; + const hadronDoc = new HadronDocument(doc); + + store.copyToClipboard(hadronDoc, 'shell-syntax'); + expect(mockCopyToClipboard).to.have.been.calledOnceWithExactly( + '{\n "_id": "testing",\n "count": "Int32(5)"\n}' + ); + }); }); describe('#toggleInsertDocument', function () { diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index 0c45eb6956b..ce88fb65ed5 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -73,6 +73,7 @@ import type { CollationOptions, MongoServerError } from 'mongodb'; export type BSONObject = TypeCastMap['Object']; export type BSONArray = TypeCastMap['Array']; type Mutable = { -readonly [P in keyof T]: T[P] }; +export type CopyDocumentFormat = 'ejson' | 'shell-syntax'; export type EmittedAppRegistryEvents = | 'open-import' @@ -95,7 +96,7 @@ export type CrudActions = { removeDocument(doc: Document): Promise; replaceDocument(doc: Document): Promise; openInsertDocumentDialog(doc: BSONObject, cloned: boolean): Promise; - copyToClipboard(doc: Document): void; //XXX + copyToClipboard(doc: Document, format: CopyDocumentFormat): void; //XXX openBulkDeleteDialog(): void; runBulkUpdate(): Promise; closeBulkDeleteDialog(): void; @@ -541,22 +542,14 @@ class CrudStoreImpl return this.state.view.toLowerCase() as Lowercase; } - /** - * Copy the document to the clipboard. - * - * @param {HadronDocument} doc - The document. - * - * @returns {Boolean} If the copy succeeded. - */ - copyToClipboard(doc: Document) { + copyToClipboard(doc: Document, format: CopyDocumentFormat = 'ejson') { this.track( 'Document Copied', - { mode: this.modeForTelemetry() }, + { mode: this.modeForTelemetry(), format }, this.connectionInfoRef.current ); - const documentEJSON = doc.toEJSON(); - // eslint-disable-next-line no-undef - void navigator.clipboard.writeText(documentEJSON); + const str = format === 'ejson' ? doc.toEJSON() : doc.toShellSyntax(); + void navigator.clipboard.writeText(str); } getWriteError(error: Error): WriteError { diff --git a/packages/compass-telemetry/src/telemetry-events.ts b/packages/compass-telemetry/src/telemetry-events.ts index 44ac3801fb6..cee822ad35f 100644 --- a/packages/compass-telemetry/src/telemetry-events.ts +++ b/packages/compass-telemetry/src/telemetry-events.ts @@ -900,6 +900,10 @@ type DocumentCopiedEvent = ConnectionScopedEvent<{ * The view used to copy the document. */ mode: 'list' | 'json' | 'table'; + /** + * The format used to copy the document. + */ + format: 'ejson' | 'shell-syntax'; }; }>; diff --git a/packages/hadron-document/package.json b/packages/hadron-document/package.json index 21ab8b613ce..f31e6735a46 100644 --- a/packages/hadron-document/package.json +++ b/packages/hadron-document/package.json @@ -54,7 +54,8 @@ "eventemitter3": "^4.0.0", "hadron-type-checker": "^7.5.7", "lodash": "^4.18.1", - "mongodb": "^7.1.1" + "mongodb": "^7.1.1", + "mongodb-query-parser": "^4.7.14" }, "devDependencies": { "@mongodb-js/eslint-config-compass": "^1.4.24", diff --git a/packages/hadron-document/src/document.ts b/packages/hadron-document/src/document.ts index da1256164fd..efe3f662f99 100644 --- a/packages/hadron-document/src/document.ts +++ b/packages/hadron-document/src/document.ts @@ -3,6 +3,7 @@ import type { Element, ElementEventsType } from './element'; import { ElementList } from './element'; import EventEmitter from 'eventemitter3'; import { EJSON, UUID } from 'bson'; +import { toJSString } from 'mongodb-query-parser'; import type { KeyInclusionOptions, ObjectGeneratorOptions, @@ -436,6 +437,21 @@ export class Document extends EventEmitter< return objectToIdiomaticEJSON(obj, options); } + toShellSyntax( + source: 'original' | 'current' = 'current', + options: { + indent?: number; + } = { + indent: 2, + } + ): string { + const obj = + source === 'original' + ? this.generateOriginalObject() + : this.generateObject(); + return toJSString(obj, options.indent) ?? '{}'; + } + /** * Expands a document by expanding all of its fields */ From 8edc9adf2baac1cae69a29bb0549a23344806f20 Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Thu, 16 Jul 2026 11:42:13 +0200 Subject: [PATCH 3/9] fix menu width --- packages/compass-components/src/components/context-menu.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index d6b31bbe9ff..ade846bdf7c 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -23,6 +23,9 @@ export type { const menuStyles = css({ paddingTop: spacing[150], paddingBottom: spacing[150], + width: 'max-content', + // LG default width for menu + minWidth: 210, }); const itemStyles = css({ From 5a41e38e8c7f579cb602329e5e4db8042105fc01 Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Thu, 16 Jul 2026 12:41:50 +0200 Subject: [PATCH 4/9] shell syntax for element values --- .../components/document-list/element.spec.tsx | 4 +- .../src/components/document-list/element.tsx | 4 +- packages/hadron-document/src/document.ts | 17 ++--- packages/hadron-document/src/element.ts | 15 +++-- packages/hadron-document/src/utils.ts | 4 ++ .../hadron-document/test/document.test.ts | 36 ++++++++++ packages/hadron-document/test/element.test.ts | 67 ++++--------------- 7 files changed, 73 insertions(+), 74 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 4e58c2b4f76..da159d4b191 100644 --- a/packages/compass-components/src/components/document-list/element.spec.tsx +++ b/packages/compass-components/src/components/document-list/element.spec.tsx @@ -115,7 +115,7 @@ describe('HadronElement', function () { skipPointerEventsCheck: true, }); - expect(clipboardWriteTextStub).to.have.been.calledWith('"value"'); + expect(clipboardWriteTextStub).to.have.been.calledWith("'value'"); }); it('copies field and value when "Copy field & value" is clicked', function () { @@ -136,7 +136,7 @@ describe('HadronElement', function () { skipPointerEventsCheck: true, }); - expect(clipboardWriteTextStub).to.have.been.calledWith('field: "value"'); + expect(clipboardWriteTextStub).to.have.been.calledWith("field: 'value'"); }); it('shows "Open URL in browser" for URL string values', function () { diff --git a/packages/compass-components/src/components/document-list/element.tsx b/packages/compass-components/src/components/document-list/element.tsx index 4881de60c28..7e3988ccc09 100644 --- a/packages/compass-components/src/components/document-list/element.tsx +++ b/packages/compass-components/src/components/document-list/element.tsx @@ -515,14 +515,14 @@ export const HadronElement: React.FunctionComponent<{ { label: 'Copy value', onAction: () => { - void navigator.clipboard.writeText(element.toEJSON()); + void navigator.clipboard.writeText(element.toShellSyntax()); }, }, { label: 'Copy field & value', onAction: () => { void navigator.clipboard.writeText( - `${key.value}: ${element.toEJSON()}` + `${key.value}: ${element.toShellSyntax()}` ); }, }, diff --git a/packages/hadron-document/src/document.ts b/packages/hadron-document/src/document.ts index efe3f662f99..bc872cfccdc 100644 --- a/packages/hadron-document/src/document.ts +++ b/packages/hadron-document/src/document.ts @@ -9,7 +9,12 @@ import type { ObjectGeneratorOptions, } from './object-generator'; import ObjectGenerator from './object-generator'; -import type { BSONArray, BSONObject, BSONValue } from './utils'; +import type { + BSONArray, + BSONObject, + BSONValue, + HadronShellSyntaxOptions, +} from './utils'; import { objectToIdiomaticEJSON } from './utils'; import type { HadronEJSONOptions } from './utils'; import type { Binary, MongoServerError } from 'mongodb'; @@ -216,7 +221,7 @@ export class Document extends EventEmitter< if (!path) { return undefined; } - let element = this.elements.get(path[0] as string); + let element = this.elements.get(path[0]); let i = 1; while (i < path.length) { if (element === undefined) { @@ -225,7 +230,7 @@ export class Document extends EventEmitter< element = element.currentType === 'Array' ? element.at(path[i] as number) - : element.get(path[i] as string); + : element.get(path[i]); i++; } return element; @@ -439,11 +444,7 @@ export class Document extends EventEmitter< toShellSyntax( source: 'original' | 'current' = 'current', - options: { - indent?: number; - } = { - indent: 2, - } + options: HadronShellSyntaxOptions = {} ): string { const obj = source === 'original' diff --git a/packages/hadron-document/src/element.ts b/packages/hadron-document/src/element.ts index 9739481860d..ac3a9b638a7 100644 --- a/packages/hadron-document/src/element.ts +++ b/packages/hadron-document/src/element.ts @@ -10,6 +10,7 @@ import TypeChecker, { isUUIDType, } from 'hadron-type-checker'; import { Binary, UUID } from 'bson'; +import { toJSString } from 'mongodb-query-parser'; import DateEditor from './editor/date'; import { ElementEvents, type ElementEventsType } from './element-events'; import type { Document } from './document'; @@ -19,9 +20,9 @@ import type { BSONArray, BSONObject, BSONValue, - HadronEJSONOptions, + HadronShellSyntaxOptions, } from './utils'; -import { getDefaultValueForType, objectToIdiomaticEJSON } from './utils'; +import { getDefaultValueForType } from './utils'; import { DocumentEvents, type DocumentEventsType } from './document-events'; export const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSS'; @@ -416,19 +417,19 @@ export class Element extends EventEmitter { } /** - * Generate the Extended JSON string representation of this element. + * Generate the Shell Syntax string representation of this element. * - * @returns The Extended JSON string. + * @returns The Shell Syntax string. */ - toEJSON( + toShellSyntax( source: 'original' | 'current' = 'current', - options: HadronEJSONOptions = {} + options: HadronShellSyntaxOptions = {} ): string { const generated = source === 'original' ? this.generateOriginalObject() : this.generateObject(); - return objectToIdiomaticEJSON(generated, options); + return toJSString(generated, options.indent) ?? '{}'; } /** diff --git a/packages/hadron-document/src/utils.ts b/packages/hadron-document/src/utils.ts index 3bd2110b6d4..1a326015008 100644 --- a/packages/hadron-document/src/utils.ts +++ b/packages/hadron-document/src/utils.ts @@ -46,6 +46,10 @@ export interface HadronEJSONOptions { indent?: number | string; } +export interface HadronShellSyntaxOptions { + indent?: number | string; +} + /** * Turn a BSON value into what we consider idiomatic extended JSON. * diff --git a/packages/hadron-document/test/document.test.ts b/packages/hadron-document/test/document.test.ts index 43c00a171c5..60841c0413f 100644 --- a/packages/hadron-document/test/document.test.ts +++ b/packages/hadron-document/test/document.test.ts @@ -2535,6 +2535,42 @@ describe('Document', function () { }); }); + describe('#toShellSyntax', function () { + it('serializes document', function () { + const doc = new Document({ + a: 1, + b: { foo: 2 }, + null_val: null, + }); + expect(doc.toShellSyntax('current', { indent: 0 })).to.equal( + "{a:NumberInt('1'),b:{foo:NumberInt('2')},null_val:null}" + ); + }); + + it('optionally serializes the current or the original document', function () { + const doc = new Document({ + a: 1, + b: 1.5, + c: Long.fromNumber(2), + }); + doc.get('a')?.edit(2 as unknown as BSONValue); + doc.get('a')?.edit(new Int32(2)); + expect(doc.toShellSyntax('current', { indent: 0 })).to.equal( + "{a:NumberInt('2'),b:Double('1.5'),c:NumberLong('2')}" + ); + expect(doc.toShellSyntax('original', { indent: 0 })).to.equal( + "{a:NumberInt('1'),b:Double('1.5'),c:NumberLong('2')}" + ); + }); + + it('allows specifying JSON indent', function () { + const doc = new Document({ a: 1 }); + expect(doc.toShellSyntax('current', { indent: '>' })).to.equal( + "{\n>a: NumberInt('1')\n}" + ); + }); + }); + context('when a document is expanded/collapsed', function () { it('expands/collapse all of the child elements and sub-elements and notifies about the expansion/collapse', function () { const doc = { diff --git a/packages/hadron-document/test/element.test.ts b/packages/hadron-document/test/element.test.ts index 15ac1c71d91..b765c7b2f55 100644 --- a/packages/hadron-document/test/element.test.ts +++ b/packages/hadron-document/test/element.test.ts @@ -3065,80 +3065,37 @@ describe('Element', function () { } ); - describe('#toEJSON', function () { - it('handles null values', function () { + describe('#toShellSyntax', function () { + it('serializes element value', function () { const element = new Element('test', { a: 1, b: { foo: 2 }, null_val: null, }); - expect(element.toEJSON('current', { indent: undefined })).to.equal( - '{"a":1,"b":{"foo":2},"null_val":null}' + expect(element.toShellSyntax('current', { indent: 0 })).to.equal( + "{a:NumberInt('1'),b:{foo:NumberInt('2')},null_val:null}" ); }); - it('serializes Int32/Double as relaxed but not Int64', function () { - const element = new Element('test', { - a: 1, - b: 1.5, - c: Long.fromNumber(2), - }); - expect(element.toEJSON('current', { indent: undefined })).to.equal( - '{"a":1,"b":1.5,"c":{"$numberLong":"2"}}' - ); - }); - - it('serializes Date as relaxed, but not dates before 1970 and after 9999', function () { - const element = new Element('test', { - epoch: new Date(0), - negative: new Date(-1), - y10k: new Date(253402300800000), - }); - expect(element.toEJSON('current', { indent: undefined })).to.equal( - '{"epoch":{"$date":"1970-01-01T00:00:00.000Z"},"negative":{"$date":{"$numberLong":"-1"}},"y10k":{"$date":{"$numberLong":"253402300800000"}}}' - ); - }); - - it('optionally serializes the current or the original element', function () { + it('serializes current and original values', function () { const element = new Element('test', { a: 1, b: 1.5, c: Long.fromNumber(2), }); element.get('a')?.edit(new Int32(2)); - expect(element.toEJSON('current', { indent: undefined })).to.equal( - '{"a":2,"b":1.5,"c":{"$numberLong":"2"}}' + expect(element.toShellSyntax('current', { indent: 0 })).to.equal( + "{a:NumberInt('2'),b:Double('1.5'),c:NumberLong('2')}" ); - expect(element.toEJSON('original', { indent: undefined })).to.equal( - '{"a":1,"b":1.5,"c":{"$numberLong":"2"}}' + expect(element.toShellSyntax('original', { indent: 0 })).to.equal( + "{a:NumberInt('1'),b:Double('1.5'),c:NumberLong('2')}" ); }); - it('allows specifying JSON indent', function () { + it('allows specifying indent', function () { const element = new Element('test', { a: 1 }); - expect(element.toEJSON('current', { indent: '>' })).to.equal( - '{\n>"a": 1\n}' - ); - }); - - it('handles oddball floating point values', function () { - const element = new Element('test', { - negzero: new Double(-0), - int: new Int32(1), - inf: Infinity, - ninf: -Infinity, - nan: NaN, - }); - expect(element.toEJSON('current', { indent: undefined })).to.equal( - '{' + - [ - '"negzero":{"$numberDouble":"-0.0"},', - '"int":1,', - '"inf":{"$numberDouble":"Infinity"},', - '"ninf":{"$numberDouble":"-Infinity"},', - '"nan":{"$numberDouble":"NaN"}', - ].join('') + - '}' + expect(element.toShellSyntax('current', { indent: '>' })).to.equal( + "{\n>a: NumberInt('1')\n}" ); }); }); From cfaef1898ee55c6ed2dc333b627e646530cb7f53 Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Thu, 16 Jul 2026 12:53:03 +0200 Subject: [PATCH 5/9] fix tests --- .../document-json-view-item.spec.tsx | 1 + .../document-list-view-item.spec.tsx | 1 + .../use-document-item-context-menu.spec.tsx | 23 +++++++++++++++++-- .../src/stores/crud-store.spec.ts | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) 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 index 1b46d5e8183..3344ce43606 100644 --- a/packages/compass-crud/src/components/document-json-view-item.spec.tsx +++ b/packages/compass-crud/src/components/document-json-view-item.spec.tsx @@ -62,6 +62,7 @@ describe('DocumentJsonViewItem', function () { userEvent.click(element, { button: 2 }); // Should show context menu with expected items + expect(screen.getByText('Copy document as Shell Syntax')).to.exist; expect(screen.getByText('Copy document as EJSON')).to.exist; expect(screen.getByText('Clone document...')).to.exist; expect(screen.getByText('Delete document')).to.exist; 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 10fd62cd68a..92821db87cc 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 @@ -67,6 +67,7 @@ describe('DocumentListViewItem', function () { userEvent.click(element, { button: 2 }); // Should show context menu with expected items + expect(screen.getByText('Copy document as Shell Syntax')).to.exist; expect(screen.getByText('Copy document as EJSON')).to.exist; expect(screen.getByText('Clone document...')).to.exist; expect(screen.getByText('Delete document')).to.exist; 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 index 59dbca5a015..1f50377ae50 100644 --- 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 @@ -81,6 +81,7 @@ describe('useDocumentItemContextMenu', function () { // Should show all operations expect(screen.getByText('Expand all fields')).to.exist; expect(screen.getByText('Edit document')).to.exist; + expect(screen.getByText('Copy document as Shell Syntax')).to.exist; expect(screen.getByText('Copy document as EJSON')).to.exist; expect(screen.getByText('Clone document...')).to.exist; expect(screen.getByText('Delete document')).to.exist; @@ -109,6 +110,7 @@ describe('useDocumentItemContextMenu', function () { expect(screen.queryByText('Delete document')).to.not.exist; // But show other operations expect(screen.getByText('Expand all fields')).to.exist; + expect(screen.getByText('Copy document as Shell Syntax')).to.exist; expect(screen.getByText('Copy document as EJSON')).to.exist; expect(screen.getByText('Clone document...')).to.exist; }); @@ -133,6 +135,7 @@ describe('useDocumentItemContextMenu', function () { // Should show non-mutating operations expect(screen.getByText('Expand all fields')).to.exist; + expect(screen.getByText('Copy document as Shell Syntax')).to.exist; expect(screen.getByText('Copy document as EJSON')).to.exist; // Should hide mutating operations @@ -244,7 +247,7 @@ describe('useDocumentItemContextMenu', function () { expect(expandStub).to.have.been.calledOnce; }); - it('calls copyToClipboard when copy is clicked', function () { + it('calls copyToClipboard when copy as EJSON is clicked', function () { // Right-click to open context menu userEvent.click(screen.getByTestId('test-container'), { button: 2 }); @@ -253,7 +256,23 @@ describe('useDocumentItemContextMenu', function () { skipPointerEventsCheck: true, }); - expect(copyToClipboardStub).to.have.been.calledWith(doc); + expect(copyToClipboardStub).to.have.been.calledWith(doc, 'ejson'); + }); + + it('calls copyToClipboard when copy as Shell Syntax 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 as Shell Syntax'), + undefined, + { + skipPointerEventsCheck: true, + } + ); + + expect(copyToClipboardStub).to.have.been.calledWith(doc, 'shell-syntax'); }); it('calls openInsertDocumentDialog with cloned document when clone is clicked', function () { diff --git a/packages/compass-crud/src/stores/crud-store.spec.ts b/packages/compass-crud/src/stores/crud-store.spec.ts index d58adc5850d..d54ace6de71 100644 --- a/packages/compass-crud/src/stores/crud-store.spec.ts +++ b/packages/compass-crud/src/stores/crud-store.spec.ts @@ -423,7 +423,7 @@ describe('store', function () { store.copyToClipboard(hadronDoc, 'shell-syntax'); expect(mockCopyToClipboard).to.have.been.calledOnceWithExactly( - '{\n "_id": "testing",\n "count": "Int32(5)"\n}' + "{\n _id: 'testing',\n count: NumberInt('2')\n}" ); }); }); From 78db8a9a9b6ddb9bbf4db601bf222330e420bf60 Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Thu, 16 Jul 2026 12:55:26 +0200 Subject: [PATCH 6/9] remove unused type --- packages/compass-crud/src/components/editable-document.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compass-crud/src/components/editable-document.tsx b/packages/compass-crud/src/components/editable-document.tsx index 769ec3744a9..4570db73488 100644 --- a/packages/compass-crud/src/components/editable-document.tsx +++ b/packages/compass-crud/src/components/editable-document.tsx @@ -6,7 +6,7 @@ import { withPreferences } from 'compass-preferences-model/provider'; import { documentStyles, documentContentStyles } from './readonly-document'; import { getInsightsForDocument } from '../utils'; -import type { CopyDocumentFormat, CrudActions } from '../stores/crud-store'; +import type { CrudActions } from '../stores/crud-store'; const documentElementsContainerStyles = css({ position: 'relative', From eccda856c9a775f69aa291c78abc2b3f9ba48072 Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Thu, 16 Jul 2026 13:25:20 +0200 Subject: [PATCH 7/9] copilot comments --- packages/compass-crud/src/stores/crud-store.ts | 2 +- packages/hadron-document/src/document.ts | 2 +- packages/hadron-document/src/element.ts | 2 +- packages/hadron-document/test/document.test.ts | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index ce88fb65ed5..c0fff2627c5 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -96,7 +96,7 @@ export type CrudActions = { removeDocument(doc: Document): Promise; replaceDocument(doc: Document): Promise; openInsertDocumentDialog(doc: BSONObject, cloned: boolean): Promise; - copyToClipboard(doc: Document, format: CopyDocumentFormat): void; //XXX + copyToClipboard(doc: Document, format?: CopyDocumentFormat): void; //XXX openBulkDeleteDialog(): void; runBulkUpdate(): Promise; closeBulkDeleteDialog(): void; diff --git a/packages/hadron-document/src/document.ts b/packages/hadron-document/src/document.ts index bc872cfccdc..1548829d1a9 100644 --- a/packages/hadron-document/src/document.ts +++ b/packages/hadron-document/src/document.ts @@ -450,7 +450,7 @@ export class Document extends EventEmitter< source === 'original' ? this.generateOriginalObject() : this.generateObject(); - return toJSString(obj, options.indent) ?? '{}'; + return toJSString(obj, options.indent) || '{}'; } /** diff --git a/packages/hadron-document/src/element.ts b/packages/hadron-document/src/element.ts index ac3a9b638a7..c43663b691f 100644 --- a/packages/hadron-document/src/element.ts +++ b/packages/hadron-document/src/element.ts @@ -429,7 +429,7 @@ export class Element extends EventEmitter { source === 'original' ? this.generateOriginalObject() : this.generateObject(); - return toJSString(generated, options.indent) ?? '{}'; + return toJSString(generated, options.indent) || '{}'; } /** diff --git a/packages/hadron-document/test/document.test.ts b/packages/hadron-document/test/document.test.ts index 60841c0413f..1c9678caf2a 100644 --- a/packages/hadron-document/test/document.test.ts +++ b/packages/hadron-document/test/document.test.ts @@ -2553,7 +2553,6 @@ describe('Document', function () { b: 1.5, c: Long.fromNumber(2), }); - doc.get('a')?.edit(2 as unknown as BSONValue); doc.get('a')?.edit(new Int32(2)); expect(doc.toShellSyntax('current', { indent: 0 })).to.equal( "{a:NumberInt('2'),b:Double('1.5'),c:NumberLong('2')}" From 18fe539d50f229e9b389e17af06a5d27c5439eab Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Thu, 16 Jul 2026 13:46:16 +0200 Subject: [PATCH 8/9] copy document as shell syntax --- packages/compass-crud/src/components/editable-document.tsx | 2 +- packages/compass-crud/src/components/json-editor.tsx | 2 +- packages/compass-crud/src/components/readonly-document.tsx | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/compass-crud/src/components/editable-document.tsx b/packages/compass-crud/src/components/editable-document.tsx index 4570db73488..a73fbbf7794 100644 --- a/packages/compass-crud/src/components/editable-document.tsx +++ b/packages/compass-crud/src/components/editable-document.tsx @@ -126,7 +126,7 @@ class EditableDocument extends React.Component< * Handle copying JSON to clipboard of the document. */ handleCopy() { - this.props.copyToClipboard?.(this.props.doc, 'ejson'); + this.props.copyToClipboard?.(this.props.doc, 'shell-syntax'); } /** diff --git a/packages/compass-crud/src/components/json-editor.tsx b/packages/compass-crud/src/components/json-editor.tsx index 6d552b3b4d7..5f73da4b8c8 100644 --- a/packages/compass-crud/src/components/json-editor.tsx +++ b/packages/compass-crud/src/components/json-editor.tsx @@ -99,7 +99,7 @@ const JSONEditor: React.FunctionComponent = ({ }, [value, editing, setModifiedEJSONStringRef]); const handleCopy = useCallback(() => { - copyToClipboard?.(doc, 'ejson'); + copyToClipboard?.(doc, 'shell-syntax'); }, [copyToClipboard, doc]); const handleClone = useCallback(() => { diff --git a/packages/compass-crud/src/components/readonly-document.tsx b/packages/compass-crud/src/components/readonly-document.tsx index 76052a4240d..240b58cb586 100644 --- a/packages/compass-crud/src/components/readonly-document.tsx +++ b/packages/compass-crud/src/components/readonly-document.tsx @@ -5,6 +5,7 @@ import type { TypeCastMap } from 'hadron-type-checker'; import { withPreferences } from 'compass-preferences-model/provider'; import { getInsightsForDocument } from '../utils'; import { DocumentEvents } from 'hadron-document'; +import type { CopyDocumentFormat } from '../stores/crud-store'; type BSONObject = TypeCastMap['Object']; export const documentStyles = css({ @@ -24,7 +25,7 @@ export const documentContentStyles = css({ }); export type ReadonlyDocumentProps = { - copyToClipboard?: (doc: Document) => void; + copyToClipboard?: (doc: Document, format: CopyDocumentFormat) => void; openInsertDocumentDialog?: (doc: BSONObject, cloned: boolean) => void; doc: Document; showInsights?: boolean; @@ -110,7 +111,7 @@ class ReadonlyDocument extends React.Component< * Handle copying JSON to clipboard of the document. */ handleCopy = () => { - this.props.copyToClipboard?.(this.props.doc); + this.props.copyToClipboard?.(this.props.doc, 'shell-syntax'); }; /** From e8489a8b9b1ed46baed98757f4509b11e3e0574b Mon Sep 17 00:00:00 2001 From: Basit Chonka Date: Thu, 16 Jul 2026 14:54:19 +0200 Subject: [PATCH 9/9] fix test --- .../compass-e2e-tests/tests/collection-documents-tab.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compass-e2e-tests/tests/collection-documents-tab.test.ts b/packages/compass-e2e-tests/tests/collection-documents-tab.test.ts index 0465379d541..7d43e568e70 100644 --- a/packages/compass-e2e-tests/tests/collection-documents-tab.test.ts +++ b/packages/compass-e2e-tests/tests/collection-documents-tab.test.ts @@ -644,7 +644,7 @@ FindIterable result = collection.find(filter);`); await browser.waitUntil( async () => { - return !!/^\{ "_id": \{ "\$oid": "[a-f0-9]{24}" \}, "i": 34, "j": 0 \}$/.exec( + return !!/^\{ _id: ObjectId\('[a-f0-9]{24}'\), i: NumberInt\('34'\), j: NumberInt\('0'\) \}$/.exec( (await clipboard.read()).replace(/\s+/g, ' ').replace(/\n/g, '') ); },