diff --git a/package-lock.json b/package-lock.json index 0529ef25df3..e2be69b72fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45120,6 +45120,7 @@ "@lezer/highlight": "^1.1.3", "@mongodb-js/compass-components": "^1.27.0", "@mongodb-js/mongodb-constants": "^0.10.0", + "mongodb-query-parser": "^4.1.2", "polished": "^4.2.2", "prettier": "^2.7.1", "react": "^17.0.2" @@ -56287,6 +56288,7 @@ "depcheck": "^1.4.1", "eslint": "^7.25.0", "mocha": "^10.2.0", + "mongodb-query-parser": "^4.1.2", "nyc": "^15.1.0", "polished": "^4.2.2", "prettier": "^2.7.1", diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 171fd1fd92c..7887f26c50e 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -199,3 +199,4 @@ export { useRequiredURLSearchParams, } from './components/links/link'; export { ChevronCollapse } from './components/chevron-collapse-icon'; +export { formatDate } from './utils/format-date'; diff --git a/packages/compass-editor/package.json b/packages/compass-editor/package.json index 6b29a2525cf..19d4bc56685 100644 --- a/packages/compass-editor/package.json +++ b/packages/compass-editor/package.json @@ -74,6 +74,7 @@ "@lezer/highlight": "^1.1.3", "@mongodb-js/compass-components": "^1.27.0", "@mongodb-js/mongodb-constants": "^0.10.0", + "mongodb-query-parser": "^4.1.2", "polished": "^4.2.2", "prettier": "^2.7.1", "react": "^17.0.2" diff --git a/packages/compass-editor/src/codemirror/query-autocompleter-with-history.test.ts b/packages/compass-editor/src/codemirror/query-autocompleter-with-history.test.ts new file mode 100644 index 00000000000..47b2d0dda73 --- /dev/null +++ b/packages/compass-editor/src/codemirror/query-autocompleter-with-history.test.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai'; +import { createQueryWithHistoryAutocompleter } from './query-autocompleter-with-history'; +import { setupCodemirrorCompleter } from '../../test/completer'; +import type { SavedQuery } from '../../dist/codemirror/query-history-autocompleter'; + +describe('query history autocompleter', function () { + const { getCompletions, cleanup } = setupCodemirrorCompleter( + createQueryWithHistoryAutocompleter + ); + + const savedQueries: SavedQuery[] = [ + { + lastExecuted: new Date('2023-06-01T12:00:00Z'), + queryProperties: { + filter: { status: 'active' }, + }, + }, + { + lastExecuted: new Date('2023-06-02T14:00:00Z'), + queryProperties: { + filter: { age: { $gt: 30 } }, + project: { name: 1, age: 1, address: 1 }, + collation: { locale: 'en' }, + sort: { age: 1 }, + skip: 5, + limit: 100, + maxTimeMS: 3000, + }, + }, + { + lastExecuted: new Date('2023-06-03T16:00:00Z'), + queryProperties: { + filter: { score: { $gte: 85 } }, + project: { studentName: 1, score: 1 }, + sort: { score: -1 }, + hint: { indexName: 'score_1' }, + limit: 20, + maxTimeMS: 1000, + }, + }, + { + lastExecuted: new Date('2023-06-04T18:00:00Z'), + queryProperties: { + filter: { isActive: true }, + project: { userId: 1, isActive: 1 }, + collation: { locale: 'simple' }, + sort: { userId: 1 }, + limit: 10, + maxTimeMS: 500, + }, + }, + { + lastExecuted: new Date('2023-06-05T20:00:00Z'), + queryProperties: { + filter: { category: 'electronics' }, + project: { productId: 1, category: 1, price: 1 }, + sort: { price: -1 }, + limit: 30, + maxTimeMS: 1500, + }, + }, + ]; + + after(cleanup); + + const mockOnApply: (query: SavedQuery['queryProperties']) => any = () => {}; + + it('returns all saved queries as completions on click', function () { + expect( + getCompletions('{}', savedQueries, undefined, mockOnApply) + ).to.have.lengthOf(5); + }); + + it('returns normal autocompletion when user starts typing', function () { + expect( + getCompletions('foo', savedQueries, undefined, mockOnApply) + ).to.have.lengthOf(45); + }); + + it('completes "any text" when inside a string', function () { + expect( + getCompletions( + '{ bar: 1, buz: 2, foo: "b', + savedQueries, + undefined, + mockOnApply + ).map((completion) => completion.label) + ).to.deep.eq(['bar', '1', 'buz', '2', 'foo']); + }); +}); diff --git a/packages/compass-editor/src/codemirror/query-autocompleter-with-history.ts b/packages/compass-editor/src/codemirror/query-autocompleter-with-history.ts new file mode 100644 index 00000000000..cdfa2009e77 --- /dev/null +++ b/packages/compass-editor/src/codemirror/query-autocompleter-with-history.ts @@ -0,0 +1,30 @@ +import { + type SavedQuery, + createQueryHistoryAutocompleter, +} from './query-history-autocompleter'; +import { createQueryAutocompleter } from './query-autocompleter'; + +import type { + CompletionSource, + CompletionContext, +} from '@codemirror/autocomplete'; +import type { CompletionOptions } from '../autocompleter'; + +export const createQueryWithHistoryAutocompleter = ( + recentQueries: SavedQuery[], + options: Pick = {}, + onApply: (query: SavedQuery['queryProperties']) => void +): CompletionSource => { + const queryHistoryAutocompleter = createQueryHistoryAutocompleter( + recentQueries, + onApply + ); + + const originalQueryAutocompleter = createQueryAutocompleter(options); + + return function fullCompletions(context: CompletionContext) { + if (context.state.doc.toString() !== '{}') + return originalQueryAutocompleter(context); + return queryHistoryAutocompleter(context); + }; +}; diff --git a/packages/compass-editor/src/codemirror/query-history-autocompleter.ts b/packages/compass-editor/src/codemirror/query-history-autocompleter.ts new file mode 100644 index 00000000000..3a7e5dee48c --- /dev/null +++ b/packages/compass-editor/src/codemirror/query-history-autocompleter.ts @@ -0,0 +1,94 @@ +import type { + CompletionContext, + CompletionSource, +} from '@codemirror/autocomplete'; +import { formatDate, spacing } from '@mongodb-js/compass-components'; +import { toJSString } from 'mongodb-query-parser'; +import { css } from '@mongodb-js/compass-components'; + +export type SavedQuery = { + lastExecuted: Date; + queryProperties: { + [properyName: string]: any; + }; +}; + +export const createQueryHistoryAutocompleter = ( + savedQueries: SavedQuery[], + onApply: (query: SavedQuery['queryProperties']) => void +): CompletionSource => { + return function queryCompletions(context: CompletionContext) { + if (savedQueries.length === 0) { + return null; + } + + const options = savedQueries.map((query) => ({ + label: createQuery(query), + type: 'text', + detail: formatDate(query.lastExecuted.getTime()), + info: () => createInfo(query).dom, + apply: () => { + onApply(query.queryProperties); + }, + boost: query.lastExecuted.getTime(), + })); + + return { + from: context.pos, + options: options, + }; + }; +}; + +const queryLabelStyles = css({ + textTransform: 'capitalize', + fontWeight: 'bold', + margin: `${spacing[2]}px 0`, +}); + +const queryCodeStyles = css({ + maxHeight: '30vh', +}); + +function createQuery(query: SavedQuery): string { + let res = ''; + Object.entries(query.queryProperties).forEach(([key, value]) => { + const formattedQuery = toJSString(value); + const noFilterKey = key === 'filter' ? '' : `${key}: `; + res += formattedQuery ? `, ${noFilterKey}${formattedQuery}` : ''; + }); + const len = res.length; + return len <= 100 ? res.slice(2, res.length) : res.slice(2, 100); +} + +function createInfo(query: SavedQuery): { + dom: Node; + destroy?: () => void; +} { + const container = document.createElement('div'); + Object.entries(query.queryProperties).forEach(([key, value]) => { + const formattedQuery = toJSString(value); + const codeDiv = document.createElement('div'); + + const label = document.createElement('label'); + label.className = queryLabelStyles; + label.textContent = key; + + const code = document.createElement('pre'); + code.className = queryCodeStyles; + if (formattedQuery) code.textContent = formattedQuery; + + codeDiv.append(label); + codeDiv.appendChild(code); + container.appendChild(codeDiv); + }); + + return { + dom: container, + destroy: () => { + while (container.firstChild) { + container.removeChild(container.firstChild); + } + }, + }; +} diff --git a/packages/compass-editor/src/editor.tsx b/packages/compass-editor/src/editor.tsx index 7946456e132..9bc368effd6 100644 --- a/packages/compass-editor/src/editor.tsx +++ b/packages/compass-editor/src/editor.tsx @@ -47,6 +47,7 @@ import { closeBrackets, closeBracketsKeymap, snippetCompletion, + startCompletion, } from '@codemirror/autocomplete'; import type { IconGlyph } from '@mongodb-js/compass-components'; import { @@ -344,6 +345,7 @@ function getStylesForTheme(theme: CodemirrorThemeType) { }, '& .cm-tooltip .completion-info p': { margin: 0, + marginRight: `${spacing[2]}px`, marginTop: `${spacing[2]}px`, marginBottom: `${spacing[2]}px`, }, @@ -604,6 +606,7 @@ export type EditorRef = { prettify: () => boolean; applySnippet: (template: string) => boolean; focus: () => boolean; + startCompletion: () => boolean; readonly editorContents: string | null; readonly editor: EditorView | null; }; @@ -713,6 +716,12 @@ const BaseEditor = React.forwardRef(function BaseEditor( editorViewRef.current.focus(); return true; }, + startCompletion() { + if (!editorViewRef.current) { + return false; + } + return startCompletion(editorViewRef.current); + }, get editorContents() { if (!editorViewRef.current) { return null; @@ -1353,6 +1362,9 @@ const MultilineEditor = React.forwardRef( applySnippet(template: string) { return editorRef.current?.applySnippet(template) ?? false; }, + startCompletion() { + return editorRef.current?.startCompletion() ?? false; + }, get editorContents() { return editorRef.current?.editorContents ?? null; }, diff --git a/packages/compass-editor/src/index.ts b/packages/compass-editor/src/index.ts index e06ec89ab65..70024a73d80 100644 --- a/packages/compass-editor/src/index.ts +++ b/packages/compass-editor/src/index.ts @@ -21,3 +21,5 @@ export { createQueryAutocompleter } from './codemirror/query-autocompleter'; export { createStageAutocompleter } from './codemirror/stage-autocompleter'; export { createAggregationAutocompleter } from './codemirror/aggregation-autocompleter'; export { createSearchIndexAutocompleter } from './codemirror/search-index-autocompleter'; +export { createQueryHistoryAutocompleter } from './codemirror/query-history-autocompleter'; +export { createQueryWithHistoryAutocompleter } from './codemirror/query-autocompleter-with-history'; diff --git a/packages/compass-query-bar/src/components/option-editor.spec.tsx b/packages/compass-query-bar/src/components/option-editor.spec.tsx index f59d8230eab..57035c0b4d1 100644 --- a/packages/compass-query-bar/src/components/option-editor.spec.tsx +++ b/packages/compass-query-bar/src/components/option-editor.spec.tsx @@ -3,6 +3,9 @@ import { expect } from 'chai'; import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { OptionEditor } from './option-editor'; +import type { SinonSpy } from 'sinon'; +import { applyFromHistory } from '../stores/query-bar-reducer'; +import sinon from 'sinon'; class MockPasteEvent extends window.Event { constructor(private text: string) { @@ -35,6 +38,8 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="" + savedQueries={[]} + onApplyQuery={applyFromHistory} > ); @@ -54,6 +59,8 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="{ foo: 1 }" + savedQueries={[]} + onApplyQuery={applyFromHistory} > ); @@ -73,6 +80,8 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="" + savedQueries={[]} + onApplyQuery={applyFromHistory} > ); @@ -98,6 +107,8 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="" + savedQueries={[]} + onApplyQuery={applyFromHistory} > ); @@ -125,6 +136,8 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="" + savedQueries={[]} + onApplyQuery={applyFromHistory} > ); @@ -140,4 +153,56 @@ describe('OptionEditor', function () { }); }); }); + + describe('createQueryWithHistoryAutocompleter', function () { + let onApplySpy: SinonSpy; + + afterEach(function () { + cleanup(); + }); + + it('filter applied correctly when autocomplete option is clicked', async function () { + onApplySpy = sinon.spy(); + render( + {}} + value="" + savedQueries={[ + { + _id: '1', + _ns: '1', + filter: { a: 1 }, + _lastExecuted: new Date(), + }, + { + _id: '1', + _ns: '1', + filter: { a: 2 }, + sort: { a: -1 }, + _lastExecuted: new Date(), + }, + ]} + onApplyQuery={onApplySpy} + > + ); + + userEvent.click(screen.getByRole('textbox')); + await waitFor(() => { + expect(screen.getAllByText('{ a: 1 }')[0]).to.be.visible; + expect(screen.getAllByText('{ a: 1 }')[1]).to.be.visible; + expect(screen.getByText('{ a: 2 }, sort: { a: -1 }')).to.be.visible; + }); + + // Simulate selecting the autocomplete option + userEvent.click(screen.getByText('{ a: 2 }, sort: { a: -1 }')); + await waitFor(() => { + expect(onApplySpy.lastCall).to.be.calledWithExactly({ + filter: { a: 2 }, + sort: { a: -1 }, + }); + }); + }); + }); }); diff --git a/packages/compass-query-bar/src/components/option-editor.tsx b/packages/compass-query-bar/src/components/option-editor.tsx index 2512b99f513..b362ada98c5 100644 --- a/packages/compass-query-bar/src/components/option-editor.tsx +++ b/packages/compass-query-bar/src/components/option-editor.tsx @@ -12,13 +12,17 @@ import { import type { Command, EditorRef } from '@mongodb-js/compass-editor'; import { CodemirrorInlineEditor as InlineEditor, - createQueryAutocompleter, + createQueryWithHistoryAutocompleter, } from '@mongodb-js/compass-editor'; import { connect } from '../stores/context'; import { usePreference } from 'compass-preferences-model/provider'; import { lenientlyFixQuery } from '../query/leniently-fix-query'; import type { RootState } from '../stores/query-bar-store'; import { useAutocompleteFields } from '@mongodb-js/compass-field-store'; +import type { RecentQuery } from '@mongodb-js/my-queries-storage'; +import { applyFromHistory } from '../stores/query-bar-reducer'; +import { getQueryAttributes } from '../utils'; +import type { BaseQuery } from '../constants/query-properties'; const editorContainerStyles = css({ position: 'relative', @@ -94,6 +98,8 @@ type OptionEditorProps = { ['data-testid']?: string; insights?: Signal | Signal[]; disabled?: boolean; + savedQueries: RecentQuery[]; + onApplyQuery: (query: BaseQuery) => void; }; export const OptionEditor: React.FunctionComponent = ({ @@ -110,6 +116,8 @@ export const OptionEditor: React.FunctionComponent = ({ ['data-testid']: dataTestId, insights, disabled = false, + savedQueries, + onApplyQuery, }) => { const showInsights = usePreference('showInsights'); const editorContainerRef = useRef(null); @@ -140,11 +148,20 @@ export const OptionEditor: React.FunctionComponent = ({ const schemaFields = useAutocompleteFields(namespace); const completer = useMemo(() => { - return createQueryAutocompleter({ - fields: schemaFields, - serverVersion, - }); - }, [schemaFields, serverVersion]); + return createQueryWithHistoryAutocompleter( + savedQueries + .filter((query) => !('update' in query)) + .map((query) => ({ + lastExecuted: query._lastExecuted, + queryProperties: getQueryAttributes(query), + })), + { + fields: schemaFields, + serverVersion, + }, + onApplyQuery + ); + }, [savedQueries, schemaFields, serverVersion, onApplyQuery]); const onFocus = () => { if (insertEmptyDocOnFocus) { @@ -152,6 +169,7 @@ export const OptionEditor: React.FunctionComponent = ({ if (editorRef.current?.editorContents === '') { editorRef.current?.applySnippet('\\{${}}'); } + if (editorRef.current?.editor) editorRef.current?.startCompletion(); }); } }; @@ -215,11 +233,17 @@ export const OptionEditor: React.FunctionComponent = ({ ); }; -const ConnectedOptionEditor = connect((state: RootState) => { - return { - namespace: state.queryBar.namespace, - serverVersion: state.queryBar.serverVersion, - }; -})(OptionEditor); +const ConnectedOptionEditor = (state: RootState) => ({ + namespace: state.queryBar.namespace, + serverVersion: state.queryBar.serverVersion, + savedQueries: [ + ...state.queryBar.recentQueries, + ...state.queryBar.favoriteQueries, + ], +}); + +const mapDispatchToProps = { + onApplyQuery: applyFromHistory, +}; -export default ConnectedOptionEditor; +export default connect(ConnectedOptionEditor, mapDispatchToProps)(OptionEditor); diff --git a/packages/compass-query-bar/src/components/query-history-button-popover.tsx b/packages/compass-query-bar/src/components/query-history-button-popover.tsx index 7fe44f426a7..5cd780239eb 100644 --- a/packages/compass-query-bar/src/components/query-history-button-popover.tsx +++ b/packages/compass-query-bar/src/components/query-history-button-popover.tsx @@ -9,11 +9,11 @@ import { } from '@mongodb-js/compass-components'; import QueryHistory from './query-history'; -import { fetchSavedQueries } from '../stores/query-bar-reducer'; import { useTrackOnChange, type TrackFunction, } from '@mongodb-js/compass-telemetry/provider'; +import { fetchSavedQueries } from '../stores/query-bar-reducer'; const openQueryHistoryButtonStyles = css( { diff --git a/packages/compass-query-bar/src/stores/query-bar-reducer.ts b/packages/compass-query-bar/src/stores/query-bar-reducer.ts index 71da0837305..0996a5c9036 100644 --- a/packages/compass-query-bar/src/stores/query-bar-reducer.ts +++ b/packages/compass-query-bar/src/stores/query-bar-reducer.ts @@ -394,7 +394,7 @@ const saveRecentQuery = ( query: Omit ): QueryBarThunkAction> => { return async ( - _dispatch, + dispatch, getState, { recentQueryStorage, logger: { debug } } ) => { @@ -424,6 +424,7 @@ const saveRecentQuery = ( existingQuery._id, updateAttributes ); + dispatch(fetchSavedQueries()); return; } @@ -432,6 +433,7 @@ const saveRecentQuery = ( _ns: namespace, _host: host ?? '', }); + dispatch(fetchSavedQueries()); } catch (e) { debug('Failed to save recent query', e); } diff --git a/packages/compass-query-bar/src/stores/query-bar-store.spec.ts b/packages/compass-query-bar/src/stores/query-bar-store.spec.ts new file mode 100644 index 00000000000..370507d03ec --- /dev/null +++ b/packages/compass-query-bar/src/stores/query-bar-store.spec.ts @@ -0,0 +1,74 @@ +import sinon from 'sinon'; +import { activatePlugin } from './query-bar-store'; +import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; +import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider'; +import { AppRegistry } from 'hadron-app-registry'; +import type { PreferencesAccess } from 'compass-preferences-model'; +import { createSandboxFromDefaultPreferences } from 'compass-preferences-model'; +import { expect } from 'chai'; +import { waitFor } from '@testing-library/react'; +describe('createQueryWithHistoryAutocompleter', function () { + let preferences: PreferencesAccess; + let loadAllStub: sinon.SinonStub; + beforeEach(async function () { + loadAllStub = sinon.stub(); + preferences = await createSandboxFromDefaultPreferences(); + }); + afterEach(function () { + sinon.restore(); + }); + it('calls fetchSavedQueries when activatePlugin is called', async function () { + const mockService = { + getQueryFromUserInput: sinon + .stub() + .resolves({ content: { query: { filter: '{_id: 1}' } } }), + }; + const mockDataService = { + sample: sinon.stub().resolves([{ _id: 42 }]), + getConnectionString: sinon.stub().returns({ + hosts: ['localhost:27017'], + }), + }; + activatePlugin( + { + namespace: 'test.coll', + isReadonly: true, + serverVersion: '6.0.0', + isSearchIndexesSupported: true, + isTimeSeries: false, + isClustered: false, + isFLE: false, + isDataLake: false, + isAtlas: false, + }, + { + localAppRegistry: new AppRegistry(), + globalAppRegistry: new AppRegistry(), + dataService: mockDataService, + recentQueryStorageAccess: { + getStorage: () => ({ + loadAll: loadAllStub, + }), + }, + favoriteQueryStorageAccess: { + getStorage: () => ({ + loadAll: loadAllStub, + }), + }, + atlasAuthService: { on: sinon.stub() }, + atlasAiService: mockService, + preferences, + logger: createNoopLogger(), + track: createNoopTrack(), + instance: { isWritable: true, on: sinon.stub() }, + } as any, + { + on: () => {}, + cleanup: () => {}, + } as any + ); + await waitFor(() => { + expect(loadAllStub).to.have.been.calledTwice; + }); + }); +}); diff --git a/packages/compass-query-bar/src/stores/query-bar-store.ts b/packages/compass-query-bar/src/stores/query-bar-store.ts index a3fd82c2f96..b6b1d6fecff 100644 --- a/packages/compass-query-bar/src/stores/query-bar-store.ts +++ b/packages/compass-query-bar/src/stores/query-bar-store.ts @@ -14,6 +14,7 @@ import { queryBarReducer, INITIAL_STATE as INITIAL_QUERY_BAR_STATE, QueryBarActions, + fetchSavedQueries, } from './query-bar-reducer'; import { aiQueryReducer } from './ai-query-reducer'; import { getQueryAttributes } from '../utils'; @@ -164,5 +165,7 @@ export function activatePlugin( }); }); + store.dispatch(fetchSavedQueries()); + return { store, deactivate: cleanup, context: QueryBarStoreContext }; }