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 d8e15fa5961..891532f2f84 100644 --- a/packages/compass-query-bar/src/components/option-editor.spec.tsx +++ b/packages/compass-query-bar/src/components/option-editor.spec.tsx @@ -7,7 +7,7 @@ import { waitFor, userEvent, } from '@mongodb-js/testing-library-compass'; -import { OptionEditor } from './option-editor'; +import { OptionEditor, getOptionBasedQueries } from './option-editor'; import type { SinonSpy } from 'sinon'; import { applyFromHistory } from '../stores/query-bar-reducer'; import sinon from 'sinon'; @@ -47,7 +47,8 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="" - savedQueries={[]} + recentQueries={[]} + favoriteQueries={[]} onApplyQuery={applyFromHistory} > ); @@ -69,7 +70,8 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="{ foo: 1 }" - savedQueries={[]} + recentQueries={[]} + favoriteQueries={[]} onApplyQuery={applyFromHistory} > ); @@ -91,7 +93,8 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="" - savedQueries={[]} + favoriteQueries={[]} + recentQueries={[]} onApplyQuery={applyFromHistory} > ); @@ -119,7 +122,8 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="" - savedQueries={[]} + favoriteQueries={[]} + recentQueries={[]} onApplyQuery={applyFromHistory} > ); @@ -149,7 +153,8 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="" - savedQueries={[]} + favoriteQueries={[]} + recentQueries={[]} onApplyQuery={applyFromHistory} > ); @@ -167,7 +172,7 @@ describe('OptionEditor', function () { }); }); - describe('when render filter bar with the query history autocompleter', function () { + describe('when rendering filter option', function () { let onApplySpy: SinonSpy; let preferencesAccess: PreferencesAccess; @@ -182,64 +187,54 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="" - savedQueries={[ + recentQueries={[ { - type: 'recent', - lastExecuted: new Date(), - queryProperties: { - filter: { a: 1 }, - }, - }, - { - type: 'favorite', - lastExecuted: new Date(), - queryProperties: { - filter: { a: 2 }, - sort: { a: -1 }, - }, + _lastExecuted: new Date(), + filter: { a: 1 }, }, + ]} + favoriteQueries={[ { - type: 'recent', - lastExecuted: new Date(), - queryProperties: { - filter: { a: 2 }, - sort: { a: -1 }, - update: { a: 10 }, - }, + _lastExecuted: new Date(), + filter: { a: 2 }, + sort: { a: -1 }, }, ]} onApplyQuery={onApplySpy} /> ); + userEvent.click(screen.getByRole('textbox')); + await waitFor(() => { + screen.getByLabelText('Completions'); + }); }); afterEach(function () { cleanup(); }); - it('filter applied correctly when autocomplete option is clicked', async function () { - userEvent.click(screen.getByRole('textbox')); - await waitFor(() => { - expect(screen.getAllByText('{ a: 1 }')[0]).to.be.visible; - expect(screen.getByText('{ a: 2 }, sort: { a: -1 }')).to.be.visible; - expect( - screen.queryByText('{ a: 2 }, sort: { a: -1 }, update: { a: 10 }') - ).to.be.null; - }); + it('renders autocomplete options', function () { + expect(screen.getAllByText('{ a: 1 }')[0]).to.be.visible; + expect(screen.getByText('{ a: 2 }, sort: { a: -1 }')).to.be.visible; + }); + it('calls onApply with correct params', async function () { // 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 }, - }); + expect(onApplySpy.lastCall).to.be.calledWithExactly( + { + filter: { a: 2 }, + sort: { a: -1 }, + }, + [] + ); }); }); }); - describe('when render project bar with the query history autocompleter', function () { + describe('when rendering project option', function () { let onApplySpy: SinonSpy; let preferencesAccess: PreferencesAccess; @@ -254,62 +249,124 @@ describe('OptionEditor', function () { insertEmptyDocOnFocus onChange={() => {}} value="" - savedQueries={[ - { - type: 'favorite', - lastExecuted: new Date(), - queryProperties: { - project: { a: 1 }, - }, - }, + favoriteQueries={[ { - type: 'favorite', - lastExecuted: new Date(), - queryProperties: { - filter: { a: 2 }, - sort: { a: -1 }, - }, + _lastExecuted: new Date(), + project: { a: 1 }, }, + ]} + recentQueries={[ { - type: 'recent', - lastExecuted: new Date(), - queryProperties: { - filter: { a: 2 }, - sort: { a: -1 }, - project: { a: 0 }, - }, + _lastExecuted: new Date(), + project: { a: 0 }, }, ]} onApplyQuery={onApplySpy} /> ); + userEvent.click(screen.getByRole('textbox')); + await waitFor(() => { + screen.getByLabelText('Completions'); + }); }); afterEach(function () { cleanup(); }); - it('only queries with project property are shown in project editor', async function () { - userEvent.click(screen.getByRole('textbox')); - await waitFor(() => { - expect(screen.getAllByText('project: { a: 1 }')[0]).to.be.visible; - expect(screen.queryByText('{ a: 2 }, sort: { a: -1 }')).to.be.null; - expect(screen.getByText('{ a: 2 }, sort: { a: -1 }, project: { a: 0 }')) - .to.be.visible; - }); + it('renders autocomplete options', function () { + expect(screen.getAllByText('project: { a: 1 }')[0]).to.be.visible; + expect(screen.getAllByText('project: { a: 0 }')[0]).to.be.visible; + }); + it('calls onApply with correct params', async function () { // Simulate selecting the autocomplete option - userEvent.click( - screen.getByText('{ a: 2 }, sort: { a: -1 }, project: { a: 0 }') - ); + userEvent.click(screen.getByText('project: { a: 0 }')); await waitFor(() => { - expect(onApplySpy.lastCall).to.be.calledWithExactly({ - filter: { a: 2 }, - sort: { a: -1 }, - project: { a: 0 }, - }); + expect(onApplySpy).to.have.been.calledOnceWithExactly( + { + project: { a: 0 }, + }, + ['filter', 'collation', 'sort', 'hint', 'skip', 'limit', 'maxTimeMS'] + ); }); }); }); + + describe('getOptionBasedQueries', function () { + const savedQueries = [ + { + _lastExecuted: new Date(), + filter: { a: 1 }, + project: { b: 1 }, + sort: { c: 1 }, + collation: { locale: 'en' }, + hint: { a: 1 }, + skip: 1, + limit: 1, + }, + ]; + + it('filters out update queries', function () { + const queries = getOptionBasedQueries('filter', 'recent', [ + ...savedQueries, + { _lastExecuted: new Date(), update: { a: 1 }, filter: { a: 2 } }, + ]); + expect(queries.length).to.equal(1); + }); + + it('filters out empty queries', function () { + const queries = getOptionBasedQueries('filter', 'recent', [ + ...savedQueries, + { _lastExecuted: new Date() }, + ]); + expect(queries.length).to.equal(1); + }); + + it('filters out duplicate queries', function () { + const queries = getOptionBasedQueries('filter', 'recent', [ + ...savedQueries, + ...savedQueries, + ...savedQueries, + { _lastExecuted: new Date() }, + { _lastExecuted: new Date() }, + ]); + expect(queries.length).to.equal(1); + }); + + const optionNames = [ + 'filter', + 'project', + 'sort', + 'collation', + 'hint', + ] as const; + for (const name of optionNames) { + it(`maps query for ${name}`, function () { + const queries = getOptionBasedQueries(name, 'recent', savedQueries); + + // For filter, we include all the query properties and for the rest + // we only include that specific option. + const queryProperties = + name === 'filter' + ? Object.fromEntries( + Object.entries(savedQueries[0]).filter( + ([key]) => key !== '_lastExecuted' + ) + ) + : { + [name]: savedQueries[0][name], + }; + + expect(queries).to.deep.equal([ + { + lastExecuted: savedQueries[0]._lastExecuted, + queryProperties, + type: 'recent', + }, + ]); + }); + } + }); }); diff --git a/packages/compass-query-bar/src/components/option-editor.tsx b/packages/compass-query-bar/src/components/option-editor.tsx index db74d34b930..a008c9d3ce7 100644 --- a/packages/compass-query-bar/src/components/option-editor.tsx +++ b/packages/compass-query-bar/src/components/option-editor.tsx @@ -27,9 +27,24 @@ import type { RootState } from '../stores/query-bar-store'; import { useAutocompleteFields } from '@mongodb-js/compass-field-store'; import { applyFromHistory } from '../stores/query-bar-reducer'; import { getQueryAttributes } from '../utils'; -import type { BaseQuery, QueryFormFields } from '../constants/query-properties'; +import type { + BaseQuery, + QueryFormFields, + QueryProperty, +} from '../constants/query-properties'; +import { QUERY_PROPERTIES } from '../constants/query-properties'; import { mapQueryToFormFields } from '../utils/query'; import { DEFAULT_FIELD_VALUES } from '../constants/query-bar-store'; +import type { + FavoriteQuery, + RecentQuery, +} from '@mongodb-js/my-queries-storage'; +import type { QueryOptionOfTypeDocument } from '../constants/query-option-definition'; + +type AutoCompleteQuery = Partial & + Pick; +type AutoCompleteRecentQuery = AutoCompleteQuery; +type AutoCompleteFavoriteQuery = AutoCompleteQuery; const editorContainerStyles = css({ position: 'relative', @@ -88,7 +103,7 @@ const insightsBadgeStyles = css({ }); type OptionEditorProps = { - optionName: string; + optionName: QueryOptionOfTypeDocument; namespace: string; id?: string; hasError?: boolean; @@ -106,8 +121,9 @@ type OptionEditorProps = { ['data-testid']?: string; insights?: Signal | Signal[]; disabled?: boolean; - savedQueries: SavedQuery[]; - onApplyQuery: (query: BaseQuery) => void; + recentQueries: AutoCompleteRecentQuery[]; + favoriteQueries: AutoCompleteFavoriteQuery[]; + onApplyQuery: (query: BaseQuery, fieldsToPreserve: QueryProperty[]) => void; }; export const OptionEditor: React.FunctionComponent = ({ @@ -125,7 +141,8 @@ export const OptionEditor: React.FunctionComponent = ({ ['data-testid']: dataTestId, insights, disabled = false, - savedQueries, + recentQueries, + favoriteQueries, onApplyQuery, }) => { const showInsights = usePreference('showInsights'); @@ -162,31 +179,30 @@ export const OptionEditor: React.FunctionComponent = ({ const schemaFields = useAutocompleteFields(namespace); const maxTimeMSPreference = usePreference('maxTimeMS'); + const savedQueries = useMemo(() => { + return [ + ...getOptionBasedQueries(optionName, 'recent', recentQueries), + ...getOptionBasedQueries(optionName, 'favorite', favoriteQueries), + ]; + }, [optionName, recentQueries, favoriteQueries]); + const completer = useMemo(() => { return isQueryHistoryAutocompleteEnabled ? createQueryWithHistoryAutocompleter({ queryProperty: optionName, - savedQueries: savedQueries - .filter((query) => { - const isOptionNameInQuery = - optionName === 'filter' || optionName in query.queryProperties; - const isUpdateNotInQuery = !('update' in query.queryProperties); - return isOptionNameInQuery && isUpdateNotInQuery; - }) - .map((query) => ({ - type: query.type, - lastExecuted: query.lastExecuted, - queryProperties: query.queryProperties, - })) - .sort( - (a, b) => a.lastExecuted.getTime() - b.lastExecuted.getTime() - ), + savedQueries, options: { fields: schemaFields, serverVersion, }, onApply: (query: SavedQuery['queryProperties']) => { - onApplyQuery(query); + // When we are applying a query from `filter` field, we want to apply the whole query, + // otherwise we want to preserve the other fields that are already in the current query. + const fieldsToPreserve = + optionName === 'filter' + ? [] + : QUERY_PROPERTIES.filter((x) => x !== optionName); + onApplyQuery(query, fieldsToPreserve); if (!query[optionName]) { return; } @@ -295,25 +311,58 @@ export const OptionEditor: React.FunctionComponent = ({ ); }; -const ConnectedOptionEditor = (state: RootState) => ({ - namespace: state.queryBar.namespace, - serverVersion: state.queryBar.serverVersion, - savedQueries: [ - ...state.queryBar.recentQueries.map((query) => ({ - type: 'recent', - lastExecuted: query._lastExecuted, - queryProperties: getQueryAttributes(query), - })), - ...state.queryBar.favoriteQueries.map((query) => ({ - type: 'favorite', - lastExecuted: query._lastExecuted, - queryProperties: getQueryAttributes(query), - })), - ], +export function getOptionBasedQueries( + optionName: QueryOptionOfTypeDocument, + type: 'recent' | 'favorite', + queries: (AutoCompleteRecentQuery | AutoCompleteFavoriteQuery)[] +) { + return ( + queries + .map((query) => ({ + type, + lastExecuted: query._lastExecuted, + // For query that's being autocompeted from the main `filter`, we want to + // show whole query to the user, so that when its applied, it will replace + // the whole query (filter, project, sort etc). + // For other options, we only want to show the query for that specific option. + queryProperties: getQueryAttributes( + optionName !== 'filter' ? { [optionName]: query[optionName] } : query + ), + })) + // Filter the query if: + // - its empty + // - its an `update` query + // - its a duplicate + .filter((query, i, arr) => { + const queryIsUpdate = 'update' in query.queryProperties; + const queryIsEmpty = Object.keys(query.queryProperties).length === 0; + if (queryIsEmpty || queryIsUpdate) { + return false; + } + return ( + i === + arr.findIndex( + (t) => + JSON.stringify(t.queryProperties) === + JSON.stringify(query.queryProperties) + ) + ); + }) + .sort((a, b) => a.lastExecuted.getTime() - b.lastExecuted.getTime()) + ); +} + +const mapStateToProps = ({ + queryBar: { namespace, serverVersion, recentQueries, favoriteQueries }, +}: RootState) => ({ + namespace, + serverVersion, + recentQueries, + favoriteQueries, }); const mapDispatchToProps = { onApplyQuery: applyFromHistory, }; -export default connect(ConnectedOptionEditor, mapDispatchToProps)(OptionEditor); +export default connect(mapStateToProps, mapDispatchToProps)(OptionEditor); diff --git a/packages/compass-query-bar/src/components/query-option.tsx b/packages/compass-query-bar/src/components/query-option.tsx index 429aeedf004..01a2208fd4c 100644 --- a/packages/compass-query-bar/src/components/query-option.tsx +++ b/packages/compass-query-bar/src/components/query-option.tsx @@ -12,7 +12,10 @@ import { import { connect } from '../stores/context'; import OptionEditor from './option-editor'; import { OPTION_DEFINITION } from '../constants/query-option-definition'; -import type { QueryOption as QueryOptionType } from '../constants/query-option-definition'; +import type { + QueryOptionOfTypeDocument, + QueryOption as QueryOptionType, +} from '../constants/query-option-definition'; import { changeField } from '../stores/query-bar-reducer'; import type { QueryProperty } from '../constants/query-properties'; import type { RootState } from '../stores/query-bar-store'; @@ -187,7 +190,7 @@ const QueryOption: React.FunctionComponent = ({
{isDocumentEditor ? ( ; +export type QueryOptionOfTypeDocument = Exclude< + QueryProperty, + 'maxTimeMS' | 'limit' | 'skip' +>; export const OPTION_DEFINITION: { [optionName in QueryOption]: { 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 d57ddde9463..0069a473753 100644 --- a/packages/compass-query-bar/src/stores/query-bar-reducer.ts +++ b/packages/compass-query-bar/src/stores/query-bar-reducer.ts @@ -224,14 +224,26 @@ type ApplyFromHistoryAction = { }; export const applyFromHistory = ( - query: BaseQuery & { update?: Document } + query: BaseQuery & { update?: Document }, + currentQueryFieldsToRetain: QueryProperty[] = [] ): QueryBarThunkAction => { return (dispatch, getState, { localAppRegistry, preferences }) => { + const currentFields = getState().queryBar.fields; + const currentQuery = currentQueryFieldsToRetain.reduce< + Record + >((acc, key) => { + const { value } = currentFields[key]; + if (value) { + acc[key] = value; + } + return acc; + }, {}); const fields = mapQueryToFormFields( { maxTimeMS: preferences.getPreferences().maxTimeMS }, { ...DEFAULT_FIELD_VALUES, ...query, + ...currentQuery, } ); dispatch({