diff --git a/package-lock.json b/package-lock.json index 61581dd338e..eaf36c550b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3650,13 +3650,13 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" }, "node_modules/@codemirror/autocomplete": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.4.0.tgz", - "integrity": "sha512-HLF2PnZAm1s4kGs30EiqKMgD7XsYaQ0XJnMR0rofEWQ5t5D60SfqpDIkIh1ze5tiEbyUWm8+VJ6W1/erVvBMIA==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.17.0.tgz", + "integrity": "sha512-fdfj6e6ZxZf8yrkMHUSJJir7OJkHkZKaOZGzLWIYp2PZ3jd+d+UjG8zVPqJF6d3bKxkhvXTPan/UZ1t7Bqm0gA==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.6.0", + "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" }, "peerDependencies": { @@ -3724,17 +3724,17 @@ } }, "node_modules/@codemirror/state": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.1.4.tgz", - "integrity": "sha512-g+3OJuRylV5qsXuuhrc6Cvs1NQluNioepYMM2fhnpYkNk7NgX+j0AFuevKSVKzTDmDyt9+Puju+zPdHNECzCNQ==" + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", + "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" }, "node_modules/@codemirror/view": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.7.1.tgz", - "integrity": "sha512-kYtS+uqYw/q/0ytYxpkqE1JVuK5NsbmBklWYhwLFTKO9gVuTdh/kDEeZPKorbqHcJ+P+ucrhcsS1czVweOpT2g==", + "version": "6.28.4", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.28.4.tgz", + "integrity": "sha512-QScv95fiviSQ/CaVGflxAvvvDy/9wi0RFyDl4LkHHWiMr/UPebyuTspmYSeN5Nx6eujcPYwsQzA6ZIZucKZVHQ==", "dependencies": { - "@codemirror/state": "^6.1.4", - "style-mod": "^4.0.0", + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, @@ -40697,9 +40697,9 @@ } }, "node_modules/style-mod": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz", - "integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw==" + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" }, "node_modules/stylis": { "version": "4.2.0", @@ -45117,7 +45117,7 @@ "version": "0.27.0", "license": "SSPL", "dependencies": { - "@codemirror/autocomplete": "^6.4.0", + "@codemirror/autocomplete": "^6.17.0", "@codemirror/commands": "^6.1.2", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/lang-json": "^6.0.1", @@ -52571,13 +52571,13 @@ } }, "@codemirror/autocomplete": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.4.0.tgz", - "integrity": "sha512-HLF2PnZAm1s4kGs30EiqKMgD7XsYaQ0XJnMR0rofEWQ5t5D60SfqpDIkIh1ze5tiEbyUWm8+VJ6W1/erVvBMIA==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.17.0.tgz", + "integrity": "sha512-fdfj6e6ZxZf8yrkMHUSJJir7OJkHkZKaOZGzLWIYp2PZ3jd+d+UjG8zVPqJF6d3bKxkhvXTPan/UZ1t7Bqm0gA==", "requires": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.6.0", + "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, @@ -52639,17 +52639,17 @@ } }, "@codemirror/state": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.1.4.tgz", - "integrity": "sha512-g+3OJuRylV5qsXuuhrc6Cvs1NQluNioepYMM2fhnpYkNk7NgX+j0AFuevKSVKzTDmDyt9+Puju+zPdHNECzCNQ==" + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", + "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" }, "@codemirror/view": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.7.1.tgz", - "integrity": "sha512-kYtS+uqYw/q/0ytYxpkqE1JVuK5NsbmBklWYhwLFTKO9gVuTdh/kDEeZPKorbqHcJ+P+ucrhcsS1czVweOpT2g==", + "version": "6.28.4", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.28.4.tgz", + "integrity": "sha512-QScv95fiviSQ/CaVGflxAvvvDy/9wi0RFyDl4LkHHWiMr/UPebyuTspmYSeN5Nx6eujcPYwsQzA6ZIZucKZVHQ==", "requires": { - "@codemirror/state": "^6.1.4", - "style-mod": "^4.0.0", + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, @@ -56271,7 +56271,7 @@ "@mongodb-js/compass-editor": { "version": "file:packages/compass-editor", "requires": { - "@codemirror/autocomplete": "^6.4.0", + "@codemirror/autocomplete": "^6.17.0", "@codemirror/commands": "^6.1.2", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/lang-json": "^6.0.1", @@ -86858,9 +86858,9 @@ } }, "style-mod": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz", - "integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw==" + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" }, "stylis": { "version": "4.2.0", diff --git a/packages/compass-editor/package.json b/packages/compass-editor/package.json index 6353bd93d4f..5942777357c 100644 --- a/packages/compass-editor/package.json +++ b/packages/compass-editor/package.json @@ -63,7 +63,7 @@ "typescript": "^5.0.4" }, "dependencies": { - "@codemirror/autocomplete": "^6.4.0", + "@codemirror/autocomplete": "^6.17.0", "@codemirror/commands": "^6.1.2", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/lang-json": "^6.0.1", diff --git a/packages/compass-editor/src/codemirror/aggregation-autocompleter.test.ts b/packages/compass-editor/src/codemirror/aggregation-autocompleter.test.ts index bc4e4311297..0b28ba66aa0 100644 --- a/packages/compass-editor/src/codemirror/aggregation-autocompleter.test.ts +++ b/packages/compass-editor/src/codemirror/aggregation-autocompleter.test.ts @@ -12,30 +12,30 @@ describe('query autocompleter', function () { context('when autocompleting outside of stage', function () { context('with empty pipeline', function () { - it('should return stages', function () { + it('should return stages', async function () { const completions = getCompletions('[{ $'); expect( - completions.map((completion) => completion.label).sort() + (await completions).map((completion) => completion.label).sort() ).to.deep.eq([...STAGE_OPERATOR_NAMES].sort()); }); }); context('with other stages in the pipeline', function () { - it('should return stages', function () { + it('should return stages', async function () { const completions = getCompletions('[{$match:{foo: 1}},{$'); expect( - completions.map((completion) => completion.label).sort() + (await completions).map((completion) => completion.label).sort() ).to.deep.eq([...STAGE_OPERATOR_NAMES].sort()); }); }); context('inside block', function () { - it('should not suggest blocks in snippets', function () { + it('should not suggest blocks in snippets', async function () { const completions = getCompletions(`[{ /** comment */ $`); - completions.forEach((completion) => { + (await completions).forEach((completion) => { const snippet = applySnippet(completion); expect(snippet).to.match( /^[^{]/, @@ -46,10 +46,10 @@ describe('query autocompleter', function () { }); context('outside block', function () { - it('should have blocks in snippets', function () { + it('should have blocks in snippets', async function () { const completions = getCompletions(`[{ $match: {foo: 1} }, $`); - completions.forEach((completion) => { + (await completions).forEach((completion) => { const snippet = applySnippet(completion); expect(snippet).to.match( /^{/, @@ -61,15 +61,14 @@ describe('query autocompleter', function () { }); context('when autocompleting inside the stage', function () { - it('should return stage completer results', function () { + it('should return stage completer results', async function () { const completions = getCompletions('[{$bucket: { _id: "$', { fields: [{ name: 'foo' }, { name: 'bar' }], }); - expect(completions.map((completion) => completion.label)).to.deep.eq([ - '$foo', - '$bar', - ]); + expect( + (await completions).map((completion) => completion.label) + ).to.deep.eq(['$foo', '$bar']); }); }); }); 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 index 47b2d0dda73..cf66809397a 100644 --- a/packages/compass-editor/src/codemirror/query-autocompleter-with-history.test.ts +++ b/packages/compass-editor/src/codemirror/query-autocompleter-with-history.test.ts @@ -2,6 +2,7 @@ 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'; +import { createQuery } from './query-history-autocompleter'; describe('query history autocompleter', function () { const { getCompletions, cleanup } = setupCodemirrorCompleter( @@ -65,26 +66,31 @@ describe('query history autocompleter', function () { const mockOnApply: (query: SavedQuery['queryProperties']) => any = () => {}; - it('returns all saved queries as completions on click', function () { + it('returns all saved queries as completions on click', async function () { expect( - getCompletions('{}', savedQueries, undefined, mockOnApply) + await getCompletions('{}', savedQueries, undefined, mockOnApply) ).to.have.lengthOf(5); }); - it('returns normal autocompletion when user starts typing', function () { + it('returns combined completions when user starts typing', async function () { expect( - getCompletions('foo', savedQueries, undefined, mockOnApply) - ).to.have.lengthOf(45); + await getCompletions('foo', savedQueries, undefined, mockOnApply) + ).to.have.lengthOf(50); }); - it('completes "any text" when inside a string', function () { + it('completes "any text" when inside a string', async function () { + const prettifiedSavedQueries = savedQueries.map((query) => + createQuery(query) + ); expect( - getCompletions( - '{ bar: 1, buz: 2, foo: "b', - savedQueries, - undefined, - mockOnApply + ( + await getCompletions( + '{ bar: 1, buz: 2, foo: "b', + savedQueries, + undefined, + mockOnApply + ) ).map((completion) => completion.label) - ).to.deep.eq(['bar', '1', 'buz', '2', 'foo']); + ).to.deep.eq(['bar', '1', 'buz', '2', 'foo', ...prettifiedSavedQueries]); }); }); diff --git a/packages/compass-editor/src/codemirror/query-autocompleter-with-history.ts b/packages/compass-editor/src/codemirror/query-autocompleter-with-history.ts index cdfa2009e77..53e18788802 100644 --- a/packages/compass-editor/src/codemirror/query-autocompleter-with-history.ts +++ b/packages/compass-editor/src/codemirror/query-autocompleter-with-history.ts @@ -3,12 +3,14 @@ import { createQueryHistoryAutocompleter, } from './query-history-autocompleter'; import { createQueryAutocompleter } from './query-autocompleter'; - import type { CompletionSource, CompletionContext, + CompletionSection, + Completion, } from '@codemirror/autocomplete'; import type { CompletionOptions } from '../autocompleter'; +import { css } from '@mongodb-js/compass-components'; export const createQueryWithHistoryAutocompleter = ( recentQueries: SavedQuery[], @@ -21,10 +23,55 @@ export const createQueryWithHistoryAutocompleter = ( ); const originalQueryAutocompleter = createQueryAutocompleter(options); + const historySection: CompletionSection = { + name: 'Query History', + header: renderDottedLine, + }; + + return async function fullCompletions(context: CompletionContext) { + if (context.state.doc.toString() === '{}') { + return queryHistoryAutocompleter(context); + } + + const combinedOptions: Completion[] = []; + // completions assigned to a section appear below ones that are not assigned + const baseCompletions = await originalQueryAutocompleter(context); + const historyCompletions = await queryHistoryAutocompleter(context); - return function fullCompletions(context: CompletionContext) { - if (context.state.doc.toString() !== '{}') - return originalQueryAutocompleter(context); - return queryHistoryAutocompleter(context); + if (baseCompletions) { + combinedOptions.push( + ...baseCompletions.options.map((option) => ({ + ...option, + })) + ); + } + if (historyCompletions) { + combinedOptions.push( + ...historyCompletions.options.map((option) => ({ + ...option, + section: historySection, + })) + ); + } + + return { + from: Math.min( + historyCompletions?.from ?? context.pos, + baseCompletions?.from ?? context.pos + ), + options: combinedOptions, + }; }; }; + +const sectionHeaderStyles = css({ + display: 'list-item', + borderBottom: '1px dashed #ccc', + margin: `5px 0`, +}); + +function renderDottedLine(): HTMLElement { + const header = document.createElement('div'); + header.className = sectionHeaderStyles; + return header; +} diff --git a/packages/compass-editor/src/codemirror/query-autocompleter.test.ts b/packages/compass-editor/src/codemirror/query-autocompleter.test.ts index 12c214593b5..963906c1475 100644 --- a/packages/compass-editor/src/codemirror/query-autocompleter.test.ts +++ b/packages/compass-editor/src/codemirror/query-autocompleter.test.ts @@ -9,31 +9,33 @@ describe('query autocompleter', function () { after(cleanup); - it('returns all completions when current token is vaguely matches identifier', function () { - expect(getCompletions('foo')).to.have.lengthOf(45); + it('returns all completions when current token is vaguely matches identifier', async function () { + expect(await getCompletions('foo')).to.have.lengthOf(45); }); - it("doesn't return anything when not matching identifier", function () { - expect(getCompletions('[')).to.have.lengthOf(0); + it("doesn't return anything when not matching identifier", async function () { + expect(await getCompletions('[')).to.have.lengthOf(0); }); - it('completes "any text" when inside a string', function () { + it('completes "any text" when inside a string', async function () { expect( - getCompletions('{ bar: 1, buz: 2, foo: "b').map( + (await getCompletions('{ bar: 1, buz: 2, foo: "b')).map( (completion) => completion.label ) ).to.deep.eq(['bar', '1', 'buz', '2', 'foo']); }); - it('escapes field names that are not valid identifiers', function () { + it('escapes field names that are not valid identifiers', async function () { expect( - getCompletions('{ $m', { - fields: [ - 'field name with spaces', - 'dots.and+what@not', - 'quotes"in"quotes', - ], - }) + ( + await getCompletions('{ $m', { + fields: [ + 'field name with spaces', + 'dots.and+what@not', + 'quotes"in"quotes', + ], + } as any) + ) .filter((completion) => completion.detail?.startsWith('field')) .map((completion) => completion.apply) ).to.deep.eq([ diff --git a/packages/compass-editor/src/codemirror/query-history-autocompleter.test.ts b/packages/compass-editor/src/codemirror/query-history-autocompleter.test.ts new file mode 100644 index 00000000000..13cc25a80d5 --- /dev/null +++ b/packages/compass-editor/src/codemirror/query-history-autocompleter.test.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai'; +import { scaleBetween } from './query-history-autocompleter'; + +describe('scaleBetween', function () { + // args: unscaledNum, newScaleMin, newScaleMax, originalScaleMin, originalScaleMax + it('should scale a number the same if given same range', function () { + const result = scaleBetween(5, 0, 10, 0, 10); + expect(result).to.equal(5); + }); + + it('should scale a number halfway', function () { + const result = scaleBetween(5, -99, 99, 0, 10); + expect(result).to.equal(0); + }); + + it('should scale the minimum value to the minimum allowed', function () { + const result = scaleBetween(-1000, -99, 99, -1000, 1000); + expect(result).to.equal(-99); + }); + + it('should return midpoint when min equals max', function () { + const result = scaleBetween(10, 20, 30, 10, 10); + expect(result).to.equal(25); + }); +}); diff --git a/packages/compass-editor/src/codemirror/query-history-autocompleter.ts b/packages/compass-editor/src/codemirror/query-history-autocompleter.ts index 3a7e5dee48c..7e80bfcd323 100644 --- a/packages/compass-editor/src/codemirror/query-history-autocompleter.ts +++ b/packages/compass-editor/src/codemirror/query-history-autocompleter.ts @@ -22,6 +22,10 @@ export const createQueryHistoryAutocompleter = ( return null; } + const maxTime = + savedQueries[savedQueries.length - 1].lastExecuted.getTime(); + const minTime = savedQueries[0].lastExecuted.getTime(); + const options = savedQueries.map((query) => ({ label: createQuery(query), type: 'text', @@ -30,7 +34,14 @@ export const createQueryHistoryAutocompleter = ( apply: () => { onApply(query.queryProperties); }, - boost: query.lastExecuted.getTime(), + // CodeMirror expects boost values to be between -99 and 99 + boost: scaleBetween( + query.lastExecuted.getTime(), + -99, + 99, + minTime, + maxTime + ), })); return { @@ -50,7 +61,11 @@ const queryCodeStyles = css({ maxHeight: '30vh', }); -function createQuery(query: SavedQuery): string { +const completionInfoStyles = css({ + overflow: 'auto', +}); + +export function createQuery(query: SavedQuery): string { let res = ''; Object.entries(query.queryProperties).forEach(([key, value]) => { const formattedQuery = toJSString(value); @@ -66,6 +81,7 @@ function createInfo(query: SavedQuery): { destroy?: () => void; } { const container = document.createElement('div'); + container.className = completionInfoStyles; Object.entries(query.queryProperties).forEach(([key, value]) => { const formattedQuery = toJSString(value); const codeDiv = document.createElement('div'); @@ -92,3 +108,21 @@ function createInfo(query: SavedQuery): { }, }; } + +// scales a number unscaledNum between [newScaleMin, newScaleMax] +export function scaleBetween( + unscaledNum: number, + newScaleMin: number, + newScaleMax: number, + originalScaleMin: number, + originalScaleMax: number +): number { + // returns midpoint of new range if original range is of size 0 + if (originalScaleMax === originalScaleMin) + return newScaleMin + (newScaleMax - newScaleMin) / 2; + return ( + ((newScaleMax - newScaleMin) * (unscaledNum - originalScaleMin)) / + (originalScaleMax - originalScaleMin) + + newScaleMin + ); +} diff --git a/packages/compass-editor/src/codemirror/search-index-autocompleter.test.ts b/packages/compass-editor/src/codemirror/search-index-autocompleter.test.ts index 21f48cc0539..fc69246a2da 100644 --- a/packages/compass-editor/src/codemirror/search-index-autocompleter.test.ts +++ b/packages/compass-editor/src/codemirror/search-index-autocompleter.test.ts @@ -9,22 +9,22 @@ describe('search-index autocompleter', function () { after(cleanup); - it('returns words in context when its not completing fields', function () { + it('returns words in context when its not completing fields', async function () { const completions = getCompletions('{ dynamic: true, type: "dy', { fields: ['_id', 'name', 'age'], }); - expect(completions.map((x) => x.label)).to.deep.equal([ + expect((await completions).map((x) => x.label)).to.deep.equal([ 'dynamic', 'true', 'type', ]); }); - it('returns field names when autocompleting fields', function () { + it('returns field names when autocompleting fields', async function () { const completions = getCompletions('{ fields: { "a', { fields: ['_id', 'name', 'age'], }); - expect(completions.map((x) => x.label)).to.deep.equal([ + expect((await completions).map((x) => x.label)).to.deep.equal([ '_id', 'name', 'age', diff --git a/packages/compass-editor/src/codemirror/stage-autocompleter.test.ts b/packages/compass-editor/src/codemirror/stage-autocompleter.test.ts index c75a607f771..5d778c7de70 100644 --- a/packages/compass-editor/src/codemirror/stage-autocompleter.test.ts +++ b/packages/compass-editor/src/codemirror/stage-autocompleter.test.ts @@ -26,33 +26,37 @@ describe('createStageAutocompleter', function () { after(cleanup); - it('returns nothing for empty input', function () { + it('returns nothing for empty input', async function () { const completions = getCompletions('', { fields }); - expect(completions).to.have.lengthOf(0); + expect(await completions).to.have.lengthOf(0); }); - it('retuns nothing when inside a comment', function () { + it('retuns nothing when inside a comment', async function () { const completions = getCompletions('// a', { fields }); - expect(completions).to.have.lengthOf(0); + expect(await completions).to.have.lengthOf(0); }); - it('returns any word when inside a string', function () { + it('returns any word when inside a string', async function () { const completions = getCompletions('{ bar: 1, foo: "b', { fields }); - expect(completions.map((c) => c.label)).to.deep.eq(['bar', '1', 'foo']); + expect((await completions).map((c) => c.label)).to.deep.eq([ + 'bar', + '1', + 'foo', + ]); }); - it('returns unescaped field references when inside string and starts with $', function () { + it('returns unescaped field references when inside string and starts with $', async function () { const completions = getCompletions('{ foo: "$', { fields }); - expect(completions.map((c) => c.apply)).to.deep.eq([ + expect((await completions).map((c) => c.apply)).to.deep.eq([ '$name', '$with.dots', '$with spaces', ]); }); - it('returns expected types of completions for identifier-like completion', function () { + it('returns expected types of completions for identifier-like completion', async function () { const completions = getCompletions('{ a', { fields }); - expect(meta(completions)).to.deep.eq([ + expect(meta(await completions)).to.deep.eq([ 'bson', 'conv', 'expr:arith', @@ -75,24 +79,24 @@ describe('createStageAutocompleter', function () { ]); }); - it('returns query completions for $match stage', function () { + it('returns query completions for $match stage', async function () { const completions = getCompletions('{ a', { fields, stageOperator: '$match', }); - expect(meta(completions)).to.deep.eq(['bson', 'query', 'field']); + expect(meta(await completions)).to.deep.eq(['bson', 'query', 'field']); }); ['$project', '$group'].forEach((stageOperator) => { - it(`returns accumulators when stage is ${stageOperator}`, function () { + it(`returns accumulators when stage is ${stageOperator}`, async function () { const completions = getCompletions('{ a', { fields, stageOperator, }); - expect(meta(completions)).to.include('accumulator'); - expect(meta(completions)).to.include('accumulator:bottom-n'); - expect(meta(completions)).to.include('accumulator:top-n'); - expect(meta(completions)).to.include('accumulator:window'); + expect(meta(await completions)).to.include('accumulator'); + expect(meta(await completions)).to.include('accumulator:bottom-n'); + expect(meta(await completions)).to.include('accumulator:top-n'); + expect(meta(await completions)).to.include('accumulator:window'); }); }); }); diff --git a/packages/compass-editor/src/codemirror/validation-autocompleter.test.ts b/packages/compass-editor/src/codemirror/validation-autocompleter.test.ts index 4ed60b419d6..7914cb054e4 100644 --- a/packages/compass-editor/src/codemirror/validation-autocompleter.test.ts +++ b/packages/compass-editor/src/codemirror/validation-autocompleter.test.ts @@ -9,42 +9,44 @@ describe('validation autocompleter', function () { after(cleanup); - it('returns $jsonSchema by default', function () { + it('returns $jsonSchema by default', async function () { const completions = getCompletions(''); - expect(completions.map((x) => x.label)).to.deep.equal(['$jsonSchema']); + expect((await completions).map((x) => x.label)).to.deep.equal([ + '$jsonSchema', + ]); }); - it('returns query operators when completing at root', function () { + it('returns query operators when completing at root', async function () { const completions = getCompletions('{ $'); - expect(completions).to.have.lengthOf(33); + expect(await completions).to.have.lengthOf(33); }); - it('returns field names when autocompleting required', function () { + it('returns field names when autocompleting required', async function () { const completions = getCompletions('{ $jsonSchema: { required: ["i', { fields: ['_id', 'name', 'age'], }); - expect(completions.map((x) => x.label)).to.deep.equal([ + expect((await completions).map((x) => x.label)).to.deep.equal([ '_id', 'name', 'age', ]); }); - it('returns bson type when autocompleting bsonType as a string', function () { + it('returns bson type when autocompleting bsonType as a string', async function () { const completions = getCompletions('{ $jsonSchema: { bsonType: "a'); - expect(completions).to.have.lengthOf(19); + expect(await completions).to.have.lengthOf(19); }); - it('returns bson type when autocompleting bsonType as a array', function () { + it('returns bson type when autocompleting bsonType as a array', async function () { const completions = getCompletions('{ $jsonSchema: { bsonType: ["a'); - expect(completions).to.have.lengthOf(19); + expect(await completions).to.have.lengthOf(19); }); - it('returns field names when autocompleting properties', function () { + it('returns field names when autocompleting properties', async function () { const completions = getCompletions('{ $jsonSchema: { properties: { "a', { fields: ['_id', 'name', 'age'], }); - expect(completions.map((x) => x.label)).to.deep.equal([ + expect((await completions).map((x) => x.label)).to.deep.equal([ '_id', 'name', 'age', diff --git a/packages/compass-editor/test/completer.ts b/packages/compass-editor/test/completer.ts index bc0fa88aef7..0642c8d406e 100644 --- a/packages/compass-editor/test/completer.ts +++ b/packages/compass-editor/test/completer.ts @@ -1,9 +1,6 @@ import { forceParsing } from '@codemirror/language'; import { EditorView } from '@codemirror/view'; -import type { - CompletionSource, - CompletionResult, -} from '@codemirror/autocomplete'; +import type { CompletionSource } from '@codemirror/autocomplete'; import { CompletionContext } from '@codemirror/autocomplete'; import { languages } from '../src/editor'; @@ -23,7 +20,7 @@ export const setupCodemirrorCompleter = < parent: el, }); }); - const getCompletions = (text = '', ...args: Parameters) => { + const getCompletions = async (text = '', ...args: Parameters) => { editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: text }, selection: { anchor: text.length }, @@ -32,9 +29,9 @@ export const setupCodemirrorCompleter = < forceParsing(editor, editor.state.doc.length, 10_000); return ( ( - completer(...args)( + await completer(...args)( new CompletionContext(editor.state, text.length, false) - ) as CompletionResult + ) )?.options ?? [] ); }; diff --git a/packages/compass-query-bar/src/components/option-editor.tsx b/packages/compass-query-bar/src/components/option-editor.tsx index 307595d1f92..b77662085b1 100644 --- a/packages/compass-query-bar/src/components/option-editor.tsx +++ b/packages/compass-query-bar/src/components/option-editor.tsx @@ -159,7 +159,10 @@ export const OptionEditor: React.FunctionComponent = ({ .map((query) => ({ lastExecuted: query._lastExecuted, queryProperties: getQueryAttributes(query), - })), + })) + .sort( + (a, b) => a.lastExecuted.getTime() - b.lastExecuted.getTime() + ), { fields: schemaFields, serverVersion, @@ -181,11 +184,14 @@ export const OptionEditor: React.FunctionComponent = ({ const onFocus = () => { if (insertEmptyDocOnFocus) { rafraf(() => { - if (editorRef.current?.editorContents === '') { + if ( + editorRef.current?.editorContents === '' || + editorRef.current?.editorContents === '{}' + ) { editorRef.current?.applySnippet('\\{${}}'); + if (isQueryHistoryAutocompleteEnabled && editorRef.current?.editor) + editorRef.current?.startCompletion(); } - if (isQueryHistoryAutocompleteEnabled && editorRef.current?.editor) - editorRef.current?.startCompletion(); }); } }; diff --git a/packages/compass-query-bar/src/stores/query-bar-reducer.spec.ts b/packages/compass-query-bar/src/stores/query-bar-reducer.spec.ts index bf192c59a6f..c84cdf6af9a 100644 --- a/packages/compass-query-bar/src/stores/query-bar-reducer.spec.ts +++ b/packages/compass-query-bar/src/stores/query-bar-reducer.spec.ts @@ -11,6 +11,7 @@ import { changeField, explainQuery, resetQuery, + saveRecentAsFavorite, setQuery, } from './query-bar-reducer'; import { configureStore } from './query-bar-store'; @@ -22,6 +23,7 @@ import type { PreferencesAccess } from 'compass-preferences-model'; import { createSandboxFromDefaultPreferences } from 'compass-preferences-model'; import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider'; +import { waitFor } from '@testing-library/react'; function createStore( opts: Partial = {}, @@ -43,6 +45,10 @@ describe('queryBarReducer', function () { } as QueryBarExtraArgs); }); + afterEach(function () { + Sinon.restore(); + }); + describe('changeField', function () { const specs: [QueryProperty, string, unknown, boolean][] = [ ['filter', '{ foo: true }', { foo: true }, true], @@ -163,6 +169,56 @@ describe('queryBarReducer', function () { 'lastAppliedQuery.query.test' ); }); + + it('should not re-save favorite query in recents', async function () { + const updateAttributesStub = Sinon.stub(); + const saveQueriesStub = Sinon.stub().resolves(); + const loadAllStub = Sinon.stub().resolves([ + { filter: { _id: '123' }, limit: 10 }, + ]); + const favoriteQueriesStorage = { + updateAttributes: updateAttributesStub, + loadAll: loadAllStub, + }; + const recentQueriesStorage = { + saveQuery: saveQueriesStub, + }; + preferences = await createSandboxFromDefaultPreferences(); + const store = createStore({}, { + preferences, + logger: createNoopLogger(), + track: createNoopTrack(), + favoriteQueryStorage: favoriteQueriesStorage, + recentQueryStorage: recentQueriesStorage, + } as any); + + const queryAction = setQuery({ filter: { _id: '123' }, limit: 10 }); + store.dispatch(queryAction); + store.dispatch(applyQuery('test')); + await waitFor(() => { + expect(saveQueriesStub.calledOnce).to.be.true; + }); + await store.dispatch( + saveRecentAsFavorite(saveQueriesStub.firstCall.firstArg, 'favorite') + ); + await waitFor(() => { + expect(loadAllStub.called).to.be.true; + }); + const appliedQuery = store.dispatch(applyQuery('test')); + + expect(appliedQuery).to.deep.eq({ + ...DEFAULT_QUERY_VALUES, + filter: { _id: '123' }, + limit: 10, + }); + expect(store.getState().queryBar) + .to.have.nested.property('lastAppliedQuery.query.test') + .deep.eq(appliedQuery); + + // updateAttributes is called in saveRecentAsFavorite and updateFavoriteQuery + expect(updateAttributesStub).to.have.been.calledTwice; + expect(saveQueriesStub).not.to.have.been.calledTwice; + }); }); describe('resetQuery', function () { 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 0996a5c9036..87c050a9829 100644 --- a/packages/compass-query-bar/src/stores/query-bar-reducer.ts +++ b/packages/compass-query-bar/src/stores/query-bar-reducer.ts @@ -128,15 +128,22 @@ export const applyQuery = ( ): QueryBarThunkAction => { return (dispatch, getState, { preferences }) => { const { - queryBar: { fields }, + queryBar: { fields, favoriteQueries }, } = getState(); if (!isQueryFieldsValid(fields, preferences.getPreferences())) { return false; } const query = mapFormFieldsToQuery(fields); dispatch({ type: QueryBarActions.ApplyQuery, query, source }); - - void dispatch(saveRecentQuery(query)); + const queryAttributes = getQueryAttributes(query); + const existingFavoriteQuery = favoriteQueries.find((favoriteQuery) => { + return isQueryEqual(getQueryAttributes(favoriteQuery), queryAttributes); + }); + if (existingFavoriteQuery) { + void dispatch(updateFavoriteQuery(existingFavoriteQuery)); + } else { + void dispatch(saveRecentQuery(query)); + } return query; }; }; @@ -440,6 +447,38 @@ const saveRecentQuery = ( }; }; +const updateFavoriteQuery = ( + query: FavoriteQuery +): QueryBarThunkAction> => { + return async ( + dispatch, + getState, + { favoriteQueryStorage, logger: { debug } } + ) => { + try { + const { + queryBar: { host }, + } = getState(); + + const queryAttributes = getQueryAttributes(query); + // Ignore empty or default queries + if (isEmpty(queryAttributes)) { + return; + } + + const updateAttributes = { + _host: query._host ?? host, + _lastExecuted: new Date(), + }; + await favoriteQueryStorage?.updateAttributes(query._id, updateAttributes); + // update favorites + void dispatch(fetchFavorites()); + } catch (e) { + debug('Failed to update favorite query', e); + } + }; +}; + export const queryBarReducer: Reducer = ( state = INITIAL_STATE, action