From 20ae0184c2e604973e2eba08c6868827cac91e3e Mon Sep 17 00:00:00 2001 From: Basit <1305718+mabaasit@users.noreply.github.com> Date: Thu, 19 Oct 2023 14:43:57 +0200 Subject: [PATCH] feat(stage-wizard): implement text search wizard COMPASS-6669 (#5001) --- .../stage-wizard-use-cases/index.ts | 11 +- .../search/text-search.spec.tsx | 186 ++++++++++++ .../search/text-search.tsx | 264 ++++++++++++++++++ .../src/components/stage-wizard/index.tsx | 25 +- .../src/modules/search-indexes.spec.ts | 192 +++++++++++++ .../src/modules/search-indexes.ts | 100 ++++++- .../compass-aggregations/src/stores/store.ts | 2 + 7 files changed, 769 insertions(+), 11 deletions(-) create mode 100644 packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/search/text-search.spec.tsx create mode 100644 packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/search/text-search.tsx create mode 100644 packages/compass-aggregations/src/modules/search-indexes.spec.ts diff --git a/packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/index.ts b/packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/index.ts index 83a69bcd3f1..1af9d9555b0 100644 --- a/packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/index.ts +++ b/packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/index.ts @@ -6,6 +6,7 @@ import BasicGroupUseCase from './group/basic-group'; import GroupWithStatistics from './group/group-with-statistics'; import MatchUseCase from './match/match'; import GroupWithSubset from './group/group-with-subset'; +import TextSearch from './search/text-search'; import type { FieldSchema } from '../../../utils/get-schema'; export type StageWizardFields = FieldSchema[]; @@ -21,6 +22,7 @@ export type StageWizardUseCase = { stageOperator: string; wizardComponent: React.FunctionComponent; serverVersion?: string; + isAtlasOnly?: boolean; }; export const STAGE_WIZARD_USE_CASES: StageWizardUseCase[] = [ @@ -63,10 +65,17 @@ export const STAGE_WIZARD_USE_CASES: StageWizardUseCase[] = [ }, { id: 'group-with-subset', - title: 'Return a subset of values based on their order or rank', + title: 'Return a subset of values based on their order or rank', stageOperator: '$group', wizardComponent: GroupWithSubset, }, + { + id: 'text-search', + title: 'Search for a text field across all documents in a collection', + stageOperator: '$search', + wizardComponent: TextSearch, + isAtlasOnly: true, + }, ]; export { UseCaseCard }; diff --git a/packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/search/text-search.spec.tsx b/packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/search/text-search.spec.tsx new file mode 100644 index 00000000000..1ddd9f857e2 --- /dev/null +++ b/packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/search/text-search.spec.tsx @@ -0,0 +1,186 @@ +import React, { type ComponentProps } from 'react'; +import { TextSearch } from './text-search'; +import { screen, render } from '@testing-library/react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { + setComboboxValue, + setInputElementValue, + setMultiSelectComboboxValues, + setSelectValue, +} from '../../../../../test/form-helper'; +import { MULTI_SELECT_LABEL } from '../field-combobox'; + +const FIELDS = [ + { name: 'a', type: 'string' }, + { name: 'b', type: 'string' }, + { name: 'c', type: 'string' }, +] as any; + +const SEARCH_INDEXES = [{ name: 'index1' }, { name: 'index2' }] as any; + +const renderTextSearch = ( + props: Partial> = {} +) => { + render( + {}} + onChange={() => {}} + {...props} + /> + ); +}; + +describe('TextSearch', function () { + const onChangeSpy = sinon.spy(); + + beforeEach(function () { + renderTextSearch({ + onChange: onChangeSpy, + fields: FIELDS, + indexes: SEARCH_INDEXES, + indexesStatus: 'READY', + }); + }); + + afterEach(function () { + onChangeSpy.resetHistory(); + }); + + it('should render the component', () => { + expect(screen.getByText('Perform a')).to.exist; + expect(screen.getByText('with maxEdits')).to.exist; + expect(screen.getByText('for all documents where')).to.exist; + expect(screen.getByText('contains')).to.exist; + expect(screen.getByText('using')).to.exist; + }); + + context('calls onChange', function () { + it('for text search with fields', () => { + setSelectValue(/select search type/i, 'text-search'); + setSelectValue(/select search path/i, 'field names'); + setMultiSelectComboboxValues(new RegExp(MULTI_SELECT_LABEL, 'i'), [ + 'a', + 'c', + ]); + setInputElementValue(/text/i, 'abc'); + setComboboxValue(/select or type a search index/i, 'index1'); + + expect(onChangeSpy.lastCall.firstArg).to.equal( + JSON.stringify({ + index: 'index1', + text: { + query: 'abc', + path: ['a', 'c'], + }, + }) + ); + expect(onChangeSpy.lastCall.lastArg).to.be.null; + }); + + it('for text search with wildcard', () => { + setSelectValue(/select search type/i, 'text-search'); + setSelectValue(/select search path/i, 'wildcard'); + setInputElementValue(/Wildcard/i, 'path.*'); + + setInputElementValue(/text/i, 'abc'); + setComboboxValue(/select or type a search index/i, 'index1'); + + expect(onChangeSpy.lastCall.firstArg).to.equal( + JSON.stringify({ + index: 'index1', + text: { + query: 'abc', + path: { + wildcard: 'path.*', + }, + }, + }) + ); + expect(onChangeSpy.lastCall.lastArg).to.be.null; + }); + + it('for fuzzy search with fields', () => { + setSelectValue(/select search type/i, 'fuzzy-search'); + setInputElementValue(/maxEdits/i, '1'); + + setSelectValue(/select search path/i, 'field names'); + setMultiSelectComboboxValues(new RegExp(MULTI_SELECT_LABEL, 'i'), [ + 'a', + 'b', + ]); + + setInputElementValue(/text/i, 'def'); + setComboboxValue(/select or type a search index/i, 'index2'); + + expect(onChangeSpy.lastCall.firstArg).to.equal( + JSON.stringify({ + index: 'index2', + text: { + query: 'def', + path: ['a', 'b'], + fuzzy: { + maxEdits: 1, + }, + }, + }) + ); + expect(onChangeSpy.lastCall.lastArg).to.be.null; + }); + + it('for fuzzy search with wildcard', () => { + setSelectValue(/select search type/i, 'fuzzy-search'); + setInputElementValue(/maxEdits/i, '2'); + + setSelectValue(/select search path/i, 'wildcard'); + setInputElementValue(/wildcard/i, 'path.*'); + + setInputElementValue(/text/i, 'xyz'); + setComboboxValue(/select or type a search index/i, 'index2'); + + expect(onChangeSpy.lastCall.firstArg).to.equal( + JSON.stringify({ + index: 'index2', + text: { + query: 'xyz', + path: { + wildcard: 'path.*', + }, + fuzzy: { + maxEdits: 2, + }, + }, + }) + ); + expect(onChangeSpy.lastCall.lastArg).to.be.null; + }); + }); + + context('validation', function () { + it('should validate maxEdits', function () { + setSelectValue(/select search type/i, 'fuzzy-search'); + { + setInputElementValue(/maxEdits/i, '0'); + expect(onChangeSpy.lastCall.lastArg).to.be.an.instanceOf(Error); + } + { + setInputElementValue(/maxEdits/i, '3'); + expect(onChangeSpy.lastCall.lastArg).to.be.an.instanceOf(Error); + } + }); + + it('should validate fields', function () { + setSelectValue(/select search path/i, 'field names'); + expect(onChangeSpy.lastCall.lastArg).to.be.an.instanceOf(Error); + }); + + it('should validate search term', function () { + setInputElementValue(/text/i, 'xyz'); + setInputElementValue(/text/i, ''); + expect(onChangeSpy.lastCall.lastArg).to.be.an.instanceOf(Error); + }); + }); +}); diff --git a/packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/search/text-search.tsx b/packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/search/text-search.tsx new file mode 100644 index 00000000000..3573c87a840 --- /dev/null +++ b/packages/compass-aggregations/src/components/aggregation-side-panel/stage-wizard-use-cases/search/text-search.tsx @@ -0,0 +1,264 @@ +import { + Select, + Option, + Body, + spacing, + css, + TextInput, + ComboboxWithCustomOption, + ComboboxOption, +} from '@mongodb-js/compass-components'; +import React, { useState, useEffect } from 'react'; +import { connect } from 'react-redux'; +import { type Document } from 'mongodb'; +import type { SearchIndex } from 'mongodb-data-service'; + +import type { RootState } from '../../../../modules'; +import type { WizardComponentProps } from '..'; +import { FieldCombobox } from '../field-combobox'; +import { + type SearchIndexesStatus, + fetchIndexes, +} from '../../../../modules/search-indexes'; + +type SearchType = 'text' | 'fuzzy'; +type SearchPath = 'fields' | 'wildcard'; + +type TextSearchState = { + type: SearchType; + path: SearchPath; + maxEdits?: number; + fields?: string[]; + wildcard?: string; + text: string; + indexName: string; +}; + +const containerStyles = css({ + gap: spacing[2], + width: '100%', + maxWidth: '800px', + display: 'grid', + gridTemplateColumns: '150px 1fr 1fr', + alignItems: 'center', +}); + +const rowStyles = css({ + display: 'contents', +}); + +const inputWithLabelStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[2], +}); + +const labelStyles = css({ + textAlign: 'right', +}); + +const inputStyles = css({ flex: 1 }); + +const mapTextSearchDataToStageValue = (formData: TextSearchState): Document => { + return { + index: formData.indexName || 'default', + text: { + query: formData.text, + path: + formData.path === 'wildcard' + ? { wildcard: formData.wildcard || '*' } + : formData.fields, + ...(formData.type === 'fuzzy' + ? { fuzzy: { maxEdits: formData.maxEdits } } + : {}), + }, + }; +}; + +const getFormValidationError = (formData: TextSearchState): Error | null => { + if (formData.type === 'fuzzy') { + if (formData.maxEdits === undefined) { + return new Error('No max edits provided.'); + } + if (formData.maxEdits < 1 || formData.maxEdits > 2) { + return new Error('Max edits must be either 1 or 2.'); + } + } + + if (formData.path === 'fields' && !formData.fields?.length) { + return new Error('No fields provided.'); + } + + if (!formData.text) { + return new Error('No search text provided'); + } + + return null; +}; + +export const TextSearch = ({ + fields, + onChange, + indexes, + indexesStatus, + onFetchIndexes, +}: WizardComponentProps & { + indexes: SearchIndex[]; + indexesStatus: SearchIndexesStatus; + onFetchIndexes: () => void; +}) => { + const [formData, setFormData] = useState({ + type: 'text', + path: 'fields', + maxEdits: 2, + wildcard: '*', + text: '', + indexName: '', + }); + + useEffect(() => { + onFetchIndexes(); + }, []); + + const onSetFormData = (data: TextSearchState) => { + const stageValue = mapTextSearchDataToStageValue(data); + onChange(JSON.stringify(stageValue), getFormValidationError(data)); + setFormData(data); + }; + + const onChangeProperty = ( + property: T, + value: TextSearchState[T] + ) => { + const newFormData = { + ...formData, + [property]: value, + }; + onSetFormData(newFormData); + }; + + return ( +
+
+ Perform a + {/* @ts-expect-error leafygreen unresonably expects a labelledby here */} + +
+ with maxEdits + + onChangeProperty('maxEdits', Number(e.target.value)) + } + /> +
+
+
+ for all documents where + {/* @ts-expect-error leafygreen unresonably expects a labelledby here */} + + {formData.path === 'fields' && ( + onChangeProperty('fields', val)} + fields={fields} + multiselect={true} + /> + )} + {formData.path === 'wildcard' && ( + onChangeProperty('wildcard', e.target.value)} + /> + )} +
+
+ contains + onChangeProperty('text', e.target.value)} + /> +
+ using + + onChangeProperty('indexName', value ?? '') + } + searchState={(() => { + if (indexesStatus === 'LOADING') { + return 'loading'; + } + if (indexesStatus === 'ERROR') { + return 'error'; + } + return 'unset'; + })()} + searchLoadingMessage="Fetching search indexes ..." + searchErrorMessage={ + 'Failed to fetch the search indexes. Type the index name manually.' + } + options={indexes.map((x) => ({ value: x.name }))} + renderOption={(option, index, isCustom) => { + return ( + + ); + }} + /> +
+
+
+ ); +}; + +export default connect( + (state: RootState) => ({ + indexes: state.searchIndexes.indexes, + indexesStatus: state.searchIndexes.status, + }), + { + onFetchIndexes: fetchIndexes, + } +)(TextSearch); diff --git a/packages/compass-aggregations/src/components/stage-wizard/index.tsx b/packages/compass-aggregations/src/components/stage-wizard/index.tsx index f5d0d273d03..d24e8feb382 100644 --- a/packages/compass-aggregations/src/components/stage-wizard/index.tsx +++ b/packages/compass-aggregations/src/components/stage-wizard/index.tsx @@ -6,6 +6,7 @@ import { KeylineCard, Link, spacing, + Disclaimer, WarningSummary, } from '@mongodb-js/compass-components'; import type { @@ -38,12 +39,17 @@ const containerStyles = css({ const headerStyles = css({ display: 'flex', - alignItems: 'center', + justifyContent: 'space-between', gap: spacing[2], padding: spacing[3], cursor: 'grab', }); +const headingStyles = css({ + display: 'flex', + gap: spacing[2], +}); + const wizardContentStyles = css({ padding: spacing[3], display: 'flex', @@ -124,13 +130,16 @@ export const StageWizard = ({ >
- {useCase.title} - - {useCase.stageOperator} - +
+ {useCase.title} + + {useCase.stageOperator} + +
+ {useCase.isAtlasOnly && Atlas-only}
diff --git a/packages/compass-aggregations/src/modules/search-indexes.spec.ts b/packages/compass-aggregations/src/modules/search-indexes.spec.ts new file mode 100644 index 00000000000..4961706b13d --- /dev/null +++ b/packages/compass-aggregations/src/modules/search-indexes.spec.ts @@ -0,0 +1,192 @@ +import { expect } from 'chai'; +import reducer, { fetchIndexes, ActionTypes } from './search-indexes'; +import configureStore from '../../test/configure-store'; +import { DATA_SERVICE_CONNECTED } from './data-service'; +import sinon from 'sinon'; + +describe('search-indexes module', function () { + describe('#reducer', function () { + it('returns default state', function () { + expect( + reducer(undefined, { + type: 'test', + }) + ).to.deep.equal({ + isSearchIndexesSupported: false, + indexes: [], + status: 'INITIAL', + }); + }); + it('returns state when fetching starts', function () { + expect( + reducer(undefined, { + type: ActionTypes.FetchIndexesStarted, + }) + ).to.deep.equal({ + isSearchIndexesSupported: false, + indexes: [], + status: 'LOADING', + }); + }); + it('returns state when fetching succeeds', function () { + expect( + reducer(undefined, { + type: ActionTypes.FetchIndexesFinished, + indexes: [{ name: 'default' }, { name: 'vector_index' }], + }) + ).to.deep.equal({ + isSearchIndexesSupported: false, + indexes: [{ name: 'default' }, { name: 'vector_index' }], + status: 'READY', + }); + }); + it('returns state when fetching fails', function () { + expect( + reducer(undefined, { + type: ActionTypes.FetchIndexesFailed, + }) + ).to.deep.equal({ + isSearchIndexesSupported: false, + indexes: [], + status: 'ERROR', + }); + }); + }); + describe('#actions', function () { + let store: ReturnType; + beforeEach(function () { + store = configureStore({ + pipeline: [], + isSearchIndexesSupported: true, + namespace: 'test.listings', + }); + }); + context('fetchIndexes', function () { + it('fetches search indexes and sets status to READY', async function () { + const spy = sinon.spy((ns: string) => { + expect(ns).to.equal('test.listings'); + return Promise.resolve([ + { name: 'default' }, + { name: 'vector_index' }, + ]); + }); + + store.dispatch({ + type: DATA_SERVICE_CONNECTED, + dataService: { + getSearchIndexes: spy, + }, + }); + + await store.dispatch(fetchIndexes()); + + expect(store.getState().searchIndexes).to.deep.equal({ + isSearchIndexesSupported: true, + indexes: [{ name: 'default' }, { name: 'vector_index' }], + status: 'READY', + }); + }); + + it('does not fetch indexes when status is LOADING', async function () { + // Set the status to LOADING + store.dispatch({ + type: ActionTypes.FetchIndexesStarted, + }); + + const spy = sinon.spy(); + store.dispatch({ + type: DATA_SERVICE_CONNECTED, + dataService: { + getSearchIndexes: spy, + }, + }); + + await store.dispatch(fetchIndexes()); + await store.dispatch(fetchIndexes()); + await store.dispatch(fetchIndexes()); + + expect(spy.callCount).to.equal(0); + }); + + it('does not fetch indexes when status is READY', async function () { + // Set the status to LOADING + store.dispatch({ + type: ActionTypes.FetchIndexesFinished, + indexes: [{ name: 'default' }, { name: 'vector_index' }], + }); + + const spy = sinon.spy(); + store.dispatch({ + type: DATA_SERVICE_CONNECTED, + dataService: { + getSearchIndexes: spy, + }, + }); + + await store.dispatch(fetchIndexes()); + await store.dispatch(fetchIndexes()); + await store.dispatch(fetchIndexes()); + + expect(spy.callCount).to.equal(0); + + expect(store.getState().searchIndexes).to.deep.equal({ + isSearchIndexesSupported: true, + indexes: [{ name: 'default' }, { name: 'vector_index' }], + status: 'READY', + }); + }); + + it('sets ERROR status when fetching indexes fails', async function () { + const spy = sinon.spy((ns: string) => { + expect(ns).to.equal('test.listings'); + return Promise.reject(new Error('Failed to fetch indexes')); + }); + + store.dispatch({ + type: DATA_SERVICE_CONNECTED, + dataService: { + getSearchIndexes: spy, + }, + }); + + await store.dispatch(fetchIndexes()); + + expect(store.getState().searchIndexes).to.deep.equal({ + isSearchIndexesSupported: true, + indexes: [], + status: 'ERROR', + }); + }); + + it('fetchs indexes in error state', async function () { + // Set the status to ERROR + store.dispatch({ + type: ActionTypes.FetchIndexesFailed, + }); + + const spy = sinon.spy((ns: string) => { + expect(ns).to.equal('test.listings'); + return Promise.resolve([ + { name: 'default' }, + { name: 'vector_index' }, + ]); + }); + + store.dispatch({ + type: DATA_SERVICE_CONNECTED, + dataService: { + getSearchIndexes: spy, + }, + }); + + await store.dispatch(fetchIndexes()); + + expect(store.getState().searchIndexes).to.deep.equal({ + isSearchIndexesSupported: true, + indexes: [{ name: 'default' }, { name: 'vector_index' }], + status: 'READY', + }); + }); + }); + }); +}); diff --git a/packages/compass-aggregations/src/modules/search-indexes.ts b/packages/compass-aggregations/src/modules/search-indexes.ts index 7e4d59f92fe..411e0a588f5 100644 --- a/packages/compass-aggregations/src/modules/search-indexes.ts +++ b/packages/compass-aggregations/src/modules/search-indexes.ts @@ -1,19 +1,115 @@ -import type { Reducer } from 'redux'; +import type { AnyAction, Reducer } from 'redux'; import type { PipelineBuilderThunkAction } from '.'; import { localAppRegistryEmit } from '@mongodb-js/mongodb-redux-common/app-registry'; +import type { SearchIndex } from 'mongodb-data-service'; +import { isAction } from '../utils/is-action'; + +enum SearchIndexesStatuses { + INITIAL = 'INITIAL', + LOADING = 'LOADING', + READY = 'READY', + ERROR = 'ERROR', +} + +export type SearchIndexesStatus = keyof typeof SearchIndexesStatuses; + +export enum ActionTypes { + FetchIndexesStarted = 'compass-aggregations/search-indexes/FetchIndexesStarted', + FetchIndexesFinished = 'compass-aggregations/search-indexes/FetchIndexesFinished', + FetchIndexesFailed = 'compass-aggregations/search-indexes/FetchIndexesFailed', +} + +type FetchIndexesStartedAction = { + type: ActionTypes.FetchIndexesStarted; +}; + +type FetchIndexesFinishedAction = { + type: ActionTypes.FetchIndexesFinished; + indexes: SearchIndex[]; +}; + +type FetchIndexesFailedAction = { + type: ActionTypes.FetchIndexesFailed; +}; type State = { isSearchIndexesSupported: boolean; + indexes: SearchIndex[]; + status: SearchIndexesStatus; }; export const INITIAL_STATE: State = { isSearchIndexesSupported: false, + indexes: [], + status: SearchIndexesStatuses.INITIAL, }; -const reducer: Reducer = (state = INITIAL_STATE) => { +const reducer: Reducer = (state = INITIAL_STATE, action: AnyAction) => { + if ( + isAction(action, ActionTypes.FetchIndexesStarted) + ) { + return { + ...state, + status: SearchIndexesStatuses.LOADING, + }; + } + if ( + isAction( + action, + ActionTypes.FetchIndexesFinished + ) + ) { + return { + ...state, + indexes: action.indexes, + status: SearchIndexesStatuses.READY, + }; + } + if ( + isAction(action, ActionTypes.FetchIndexesFailed) + ) { + return { + ...state, + status: SearchIndexesStatuses.ERROR, + }; + } return state; }; +export const fetchIndexes = (): PipelineBuilderThunkAction> => { + return async (dispatch, getState) => { + const { + namespace, + dataService: { dataService }, + searchIndexes: { status }, + } = getState(); + + if ( + !dataService || + status === SearchIndexesStatuses.LOADING || + status === SearchIndexesStatuses.READY + ) { + return; + } + + dispatch({ + type: ActionTypes.FetchIndexesStarted, + }); + + try { + const indexes = await dataService.getSearchIndexes(namespace); + dispatch({ + type: ActionTypes.FetchIndexesFinished, + indexes, + }); + } catch (e) { + dispatch({ + type: ActionTypes.FetchIndexesFailed, + }); + } + }; +}; + export const createSearchIndex = (): PipelineBuilderThunkAction => { return (dispatch) => { dispatch(localAppRegistryEmit('open-create-search-index-modal')); diff --git a/packages/compass-aggregations/src/stores/store.ts b/packages/compass-aggregations/src/stores/store.ts index 14dd961732e..cb59538b327 100644 --- a/packages/compass-aggregations/src/stores/store.ts +++ b/packages/compass-aggregations/src/stores/store.ts @@ -26,6 +26,7 @@ import { } from '../modules/collections-fields'; import type { CollectionInfo } from '../modules/collections-fields'; import { disableAIFeature } from '../modules/pipeline-builder/pipeline-ai'; +import { INITIAL_STATE as SEARCH_INDEXES_INITIAL_STATE } from '../modules/search-indexes'; export type ConfigureStoreOptions = { /** @@ -233,6 +234,7 @@ const configureStore = (options: ConfigureStoreOptions) => { sourceName: options.sourceName, editViewName: options.editViewName, searchIndexes: { + ...SEARCH_INDEXES_INITIAL_STATE, isSearchIndexesSupported: Boolean(options.isSearchIndexesSupported), }, },