diff --git a/packages/compass-aggregations/src/components/focus-mode/focus-mode-modal-header.spec.tsx b/packages/compass-aggregations/src/components/focus-mode/focus-mode-modal-header.spec.tsx index 5e1deda2b63..5e78a7c328b 100644 --- a/packages/compass-aggregations/src/components/focus-mode/focus-mode-modal-header.spec.tsx +++ b/packages/compass-aggregations/src/components/focus-mode/focus-mode-modal-header.spec.tsx @@ -26,6 +26,10 @@ describe('FocusModeModalHeader', function () { return render( void; onStageSelect: (index: number) => void; onStageDisabledToggleClick: (index: number, newVal: boolean) => void; onAddStageClick: (index: number) => void; @@ -93,8 +97,11 @@ export const FocusModeModalHeader: React.FunctionComponent< > = ({ stageIndex, isEnabled, - insight, stages, + env, + isSearchIndexesSupported, + stage, + onCreateSearchIndex, onAddStageClick, onStageSelect, onStageDisabledToggleClick, @@ -102,6 +109,17 @@ export const FocusModeModalHeader: React.FunctionComponent< const [menuOpen, setMenuOpen] = useState(false); const showInsights = usePreference('showInsights', React); + const insight = useMemo(() => { + if (stage) { + return getInsightForStage( + stage, + env, + isSearchIndexesSupported, + onCreateSearchIndex + ); + } + }, [stage, env, isSearchIndexesSupported, onCreateSearchIndex]); + const isFirst = stages[0].idxInStore === stageIndex; const isLast = stages[stages.length - 1].idxInStore === stageIndex; @@ -322,13 +340,16 @@ export default connect( pipelineBuilder: { stageEditor: { stages }, }, + searchIndexes: { isSearchIndexesSupported }, } = state; const stage = stages[stageIndex] as StoreStage; return { stageIndex, isEnabled: !stage?.disabled, - insight: stage ? getInsightForStage(stage, env) : undefined, + stage, + env, + isSearchIndexesSupported, stages: stages.reduce((accumulator, stage, idxInStore) => { if (stage.type === 'stage') { accumulator.push({ @@ -344,5 +365,6 @@ export default connect( onStageSelect: selectFocusModeStage, onStageDisabledToggleClick: changeStageDisabled, onAddStageClick: addStageInFocusMode, + onCreateSearchIndex: createSearchIndex, } )(FocusModeModalHeader); diff --git a/packages/compass-aggregations/src/components/focus-mode/focus-mode.spec.tsx b/packages/compass-aggregations/src/components/focus-mode/focus-mode.spec.tsx index 23052452283..1babad27e52 100644 --- a/packages/compass-aggregations/src/components/focus-mode/focus-mode.spec.tsx +++ b/packages/compass-aggregations/src/components/focus-mode/focus-mode.spec.tsx @@ -1,51 +1,46 @@ import React from 'react'; -import type { ComponentProps } from 'react'; import { render, screen } from '@testing-library/react'; import { expect } from 'chai'; import { Provider } from 'react-redux'; -import sinon from 'sinon'; import configureStore from '../../../test/configure-store'; -import { FocusMode } from './focus-mode'; +import FocusMode from './focus-mode'; +import { disableFocusMode, enableFocusMode } from '../../modules/focus-mode'; -const renderFocusMode = ( - props: Partial> = {} -) => { +const renderFocusMode = () => { + const store = configureStore({ + pipeline: [{ $match: { _id: 1 } }, { $limit: 10 }, { $out: 'out' }], + }); render( - - {}} - {...props} - /> + + ); + return store; }; describe('FocusMode', function () { it('does not show modal when closed', function () { - renderFocusMode({ isModalOpen: false }); + const store = renderFocusMode(); + store.dispatch(disableFocusMode()); expect(() => { screen.getByTestId('focus-mode-modal'); }).to.throw; }); it('shows modal when open', function () { - renderFocusMode({ isModalOpen: true }); + const store = renderFocusMode(); + store.dispatch(enableFocusMode(0)); expect(screen.getByTestId('focus-mode-modal')).to.exist; }); - it('calls onCloseModal when close button is clicked', function () { - const onCloseModal = sinon.spy(); - renderFocusMode({ onCloseModal, isModalOpen: true }); - - expect(onCloseModal).to.not.have.been.called; + it('hides modal when close button is clicked', function () { + const store = renderFocusMode(); + store.dispatch(enableFocusMode(0)); screen.getByLabelText(/close modal/i).click(); - expect(onCloseModal).to.have.been.calledOnce; + + expect(() => { + screen.getByTestId('focus-mode-modal'); + }).to.throw; }); }); diff --git a/packages/compass-aggregations/src/components/stage-toolbar/index.spec.tsx b/packages/compass-aggregations/src/components/stage-toolbar/index.spec.tsx index 72fc0e322b5..9f19ca12532 100644 --- a/packages/compass-aggregations/src/components/stage-toolbar/index.spec.tsx +++ b/packages/compass-aggregations/src/components/stage-toolbar/index.spec.tsx @@ -1,34 +1,25 @@ import React from 'react'; -import type { ComponentProps } from 'react'; import { render, screen } from '@testing-library/react'; import { expect } from 'chai'; import { Provider } from 'react-redux'; import configureStore from '../../../test/configure-store'; -import { StageToolbar } from './'; +import StageToolbar from './'; +import { + changeStageCollapsed, + changeStageDisabled, +} from '../../modules/pipeline-builder/stage-editor'; -const renderStageToolbar = ( - props: Partial> = {} -) => { +const renderStageToolbar = () => { + const store = configureStore({ + pipeline: [{ $match: { _id: 1 } }, { $limit: 10 }, { $out: 'out' }], + }); render( - - {}} - {...props} - /> + + ); + return store; }; describe('StageToolbar', function () { @@ -50,13 +41,15 @@ describe('StageToolbar', function () { }); context('renders stage text', function () { it('when stage is disabled', function () { - renderStageToolbar({ isDisabled: true }); + const store = renderStageToolbar(); + store.dispatch(changeStageDisabled(0, true)); expect( screen.getByText('Stage disabled. Results not passed in the pipeline.') ).to.exist; }); it('when stage is collapsed', function () { - renderStageToolbar({ isCollapsed: true }); + const store = renderStageToolbar(); + store.dispatch(changeStageCollapsed(0, true)); expect( screen.getByText( 'A sample of the aggregated results from this stage will be shown below.' diff --git a/packages/compass-aggregations/src/components/stage-toolbar/index.tsx b/packages/compass-aggregations/src/components/stage-toolbar/index.tsx index c1b8b6a3fb3..f7a4939b564 100644 --- a/packages/compass-aggregations/src/components/stage-toolbar/index.tsx +++ b/packages/compass-aggregations/src/components/stage-toolbar/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { connect } from 'react-redux'; import { Icon, @@ -11,7 +11,6 @@ import { IconButton, SignalPopover, } from '@mongodb-js/compass-components'; -import type { Signal } from '@mongodb-js/compass-components'; import type { RootState } from '../../modules'; import ToggleStage from './toggle-stage'; import StageCollapser from './stage-collapser'; @@ -22,6 +21,8 @@ import OptionMenu from './option-menu'; import type { StoreStage } from '../../modules/pipeline-builder/stage-editor'; import { getInsightForStage } from '../../utils/insights'; import { usePreference } from 'compass-preferences-model'; +import type { ServerEnvironment } from '../../modules/env'; +import { createSearchIndex } from '../../modules/search-indexes'; const toolbarStyles = css({ width: '100%', @@ -92,13 +93,12 @@ const rightStyles = css({ type StageToolbarProps = { index: number; - idxInPipeline: number; - isAutoPreviewing?: boolean; - hasSyntaxError?: boolean; - hasServerError?: boolean; - isCollapsed?: boolean; - isDisabled?: boolean; - insight?: Signal; + + stage: StoreStage; + env: ServerEnvironment; + isSearchIndexesSupported: boolean; + onCreateSearchIndex: () => void; + onOpenFocusMode: (index: number) => void; onStageOperatorChange?: ( index: number, @@ -113,40 +113,53 @@ const COLLAPSED_TEXT = export function StageToolbar({ index, - idxInPipeline, - hasSyntaxError, - hasServerError, - isCollapsed, - isDisabled, - insight, + stage, + env, + isSearchIndexesSupported, + onCreateSearchIndex, onOpenFocusMode, onStageOperatorChange, }: StageToolbarProps) { const showInsights = usePreference('showInsights', React); const darkMode = useDarkMode(); + const insight = useMemo( + () => + getInsightForStage( + stage, + env, + isSearchIndexesSupported, + onCreateSearchIndex + ), + [stage, env, isSearchIndexesSupported, onCreateSearchIndex] + ); + return (
- Stage {idxInPipeline + 1} + Stage {stage.idxInPipeline + 1}
{showInsights && insight && }
- {isDisabled ? DISABLED_TEXT : isCollapsed ? COLLAPSED_TEXT : null} + {stage.disabled + ? DISABLED_TEXT + : stage.collapsed + ? COLLAPSED_TEXT + : null}
; diff --git a/packages/compass-aggregations/src/modules/search-indexes.ts b/packages/compass-aggregations/src/modules/search-indexes.ts new file mode 100644 index 00000000000..7e4d59f92fe --- /dev/null +++ b/packages/compass-aggregations/src/modules/search-indexes.ts @@ -0,0 +1,23 @@ +import type { Reducer } from 'redux'; +import type { PipelineBuilderThunkAction } from '.'; +import { localAppRegistryEmit } from '@mongodb-js/mongodb-redux-common/app-registry'; + +type State = { + isSearchIndexesSupported: boolean; +}; + +export const INITIAL_STATE: State = { + isSearchIndexesSupported: false, +}; + +const reducer: Reducer = (state = INITIAL_STATE) => { + return state; +}; + +export const createSearchIndex = (): PipelineBuilderThunkAction => { + return (dispatch) => { + dispatch(localAppRegistryEmit('open-create-search-index-modal')); + }; +}; + +export default reducer; diff --git a/packages/compass-aggregations/src/stores/store.spec.ts b/packages/compass-aggregations/src/stores/store.spec.ts index 08440ff4194..992dabed4b9 100644 --- a/packages/compass-aggregations/src/stores/store.spec.ts +++ b/packages/compass-aggregations/src/stores/store.spec.ts @@ -137,6 +137,7 @@ describe('Aggregation Store', function () { focusMode: INITIAL_STATE.focusMode, sidePanel: INITIAL_STATE.sidePanel, collectionsFields: INITIAL_STATE.collectionsFields, + searchIndexes: INITIAL_STATE.searchIndexes, }); }); }); diff --git a/packages/compass-aggregations/src/stores/store.ts b/packages/compass-aggregations/src/stores/store.ts index 1d52f7db58d..14dd961732e 100644 --- a/packages/compass-aggregations/src/stores/store.ts +++ b/packages/compass-aggregations/src/stores/store.ts @@ -133,6 +133,10 @@ export type ConfigureStoreOptions = { * Service for interacting with Atlas-only features */ atlasService: AtlasService; + /** + * Whether or not search indexes are supported in the current environment + */ + isSearchIndexesSupported: boolean; }>; const configureStore = (options: ConfigureStoreOptions) => { @@ -228,6 +232,9 @@ const configureStore = (options: ConfigureStoreOptions) => { }, sourceName: options.sourceName, editViewName: options.editViewName, + searchIndexes: { + isSearchIndexesSupported: Boolean(options.isSearchIndexesSupported), + }, }, applyMiddleware( thunk.withExtraArgument({ diff --git a/packages/compass-aggregations/src/utils/insights.ts b/packages/compass-aggregations/src/utils/insights.ts index 5f983cc5e9a..5683c8dd89b 100644 --- a/packages/compass-aggregations/src/utils/insights.ts +++ b/packages/compass-aggregations/src/utils/insights.ts @@ -4,16 +4,29 @@ import { type Signal, } from '@mongodb-js/compass-components'; import type { StoreStage } from '../modules/pipeline-builder/stage-editor'; +import type { ServerEnvironment } from '../modules/env'; export const getInsightForStage = ( { stageOperator, value }: StoreStage, - env: string + env: ServerEnvironment, + isSearchIndexesSupported: boolean, + onCreateSearchIndex: () => void ): Signal | undefined => { const isAtlas = [ATLAS, ADL].includes(env); if (stageOperator === '$match' && /\$(text|regex)\b/.test(value ?? '')) { - return isAtlas - ? PerformanceSignals.get('atlas-text-regex-usage-in-stage') - : PerformanceSignals.get('non-atlas-text-regex-usage-in-stage'); + if (isAtlas) { + return isSearchIndexesSupported + ? { + ...PerformanceSignals.get( + 'atlas-with-search-text-regex-usage-in-stage' + ), + onPrimaryActionButtonClick: onCreateSearchIndex, + } + : PerformanceSignals.get( + 'atlas-without-search-text-regex-usage-in-stage' + ); + } + return PerformanceSignals.get('non-atlas-text-regex-usage-in-stage'); } if (stageOperator === '$lookup') { return PerformanceSignals.get('lookup-in-stage'); diff --git a/packages/compass-collection/src/stores/collection-tab.ts b/packages/compass-collection/src/stores/collection-tab.ts index 42eb3b17570..12f7ea4e1c3 100644 --- a/packages/compass-collection/src/stores/collection-tab.ts +++ b/packages/compass-collection/src/stores/collection-tab.ts @@ -110,6 +110,10 @@ export function configureStore(options: CollectionTabOptions) { store.dispatch(selectTab('Indexes')); }); + localAppRegistry.on('open-create-search-index-modal', () => { + store.dispatch(selectTab('Indexes')); + }); + localAppRegistry.on('generate-aggregation-from-query', () => { store.dispatch(selectTab('Aggregations')); }); diff --git a/packages/compass-components/src/components/signals.tsx b/packages/compass-components/src/components/signals.tsx index f173c73cb4b..15879f0007f 100644 --- a/packages/compass-components/src/components/signals.tsx +++ b/packages/compass-components/src/components/signals.tsx @@ -46,7 +46,16 @@ const SIGNALS = [ 'https://www.mongodb.com/docs/atlas/schema-suggestions/reduce-lookup-operations/#std-label-anti-pattern-denormalization', }, { - id: 'atlas-text-regex-usage-in-stage', + id: 'atlas-with-search-text-regex-usage-in-stage', + title: 'Alternate text search options available', + description: + "In many cases, Atlas Search is MongoDB's most efficient full text search option. Convert your query to $search for a wider range of functionality.", + learnMoreLink: + 'https://www.mongodb.com/docs/atlas/atlas-search/best-practices/', + primaryActionButtonLabel: 'Create Search Index', + }, + { + id: 'atlas-without-search-text-regex-usage-in-stage', title: 'Alternate text search options available', description: "In many cases, Atlas Search is MongoDB's most efficient full text search option. Convert your query to $search for a wider range of functionality.", @@ -88,6 +97,14 @@ const SIGNALS = [ primaryActionButtonLabel: 'Create index', primaryActionButtonIcon: 'Plus', }, + { + id: 'atlas-text-regex-usage-in-query', + title: 'Alternate text search options available', + description: + "In many cases, Atlas Search is MongoDB's most efficient full text search option. Convert your query to $search for a wider range of functionality.", + learnMoreLink: 'https://www.mongodb.com/cloud/atlas/lp/search-1', + primaryActionButtonLabel: 'Create Search index', + }, { id: 'bloated-document', title: 'Possibly bloated document', diff --git a/packages/compass-crud/src/components/crud-toolbar.tsx b/packages/compass-crud/src/components/crud-toolbar.tsx index 9a9982bb64d..30cfc4314c3 100644 --- a/packages/compass-crud/src/components/crud-toolbar.tsx +++ b/packages/compass-crud/src/components/crud-toolbar.tsx @@ -11,9 +11,8 @@ import { spacing, WarningSummary, ErrorSummary, - PerformanceSignals, } from '@mongodb-js/compass-components'; -import type { MenuAction } from '@mongodb-js/compass-components'; +import type { MenuAction, Signal } from '@mongodb-js/compass-components'; import { ViewSwitcher } from './view-switcher'; import type { DocumentView } from '../stores/crud-store'; import { AddDataMenu } from './add-data-menu'; @@ -108,8 +107,7 @@ export type CrudToolbarProps = { resultId: string; start: number; viewSwitchHandler: (view: DocumentView) => void; - isCollectionScan?: boolean; - onCollectionScanInsightActionButtonClick?: () => void; + insights?: Signal; }; const CrudToolbar: React.FunctionComponent = ({ @@ -134,8 +132,7 @@ const CrudToolbar: React.FunctionComponent = ({ resultId, start, viewSwitchHandler, - isCollectionScan, - onCollectionScanInsightActionButtonClick, + insights, }) => { const queryBarRole = localAppRegistry.getRole('Query.QueryBar')![0]; @@ -184,15 +181,7 @@ const CrudToolbar: React.FunctionComponent = ({ onApply={onApplyClicked} onReset={onResetClicked} showExplainButton={enableExplainPlan} - insights={ - isCollectionScan - ? { - ...PerformanceSignals.get('query-executed-without-index'), - onPrimaryActionButtonClick: - onCollectionScanInsightActionButtonClick, - } - : undefined - } + insights={insights} /> )}
diff --git a/packages/compass-crud/src/components/document-list.tsx b/packages/compass-crud/src/components/document-list.tsx index 38aadf5cba1..554ab554897 100644 --- a/packages/compass-crud/src/components/document-list.tsx +++ b/packages/compass-crud/src/components/document-list.tsx @@ -31,8 +31,14 @@ import { } from '../constants/documents-statuses'; import './index.less'; -import type { CrudStore, BSONObject, DocumentView } from '../stores/crud-store'; +import type { + CrudStore, + BSONObject, + DocumentView, + QueryState, +} from '../stores/crud-store'; import type Document from 'hadron-document'; +import { getToolbarSignal } from '../utils/toolbar-signal'; const listAndJsonStyles = css({ padding: spacing[3], @@ -70,6 +76,9 @@ export type DocumentListProps = { debouncingLoad?: boolean; viewChanged: CrudToolbarProps['viewSwitchHandler']; darkMode?: boolean; + isCollectionScan?: boolean; + isSearchIndexesSupported: boolean; + query: QueryState; } & Omit & Omit & Omit & @@ -104,8 +113,6 @@ export type DocumentListProps = { | 'instanceDescription' | 'refreshDocuments' | 'resultId' - | 'isCollectionScan' - | 'onCollectionScanInsightActionButtonClick' >; /** @@ -316,9 +323,14 @@ class DocumentList extends React.Component { instanceDescription={this.props.instanceDescription} refreshDocuments={this.props.refreshDocuments} resultId={this.props.resultId} - isCollectionScan={this.props.isCollectionScan} - onCollectionScanInsightActionButtonClick={this.props.store.openCreateIndexModal.bind( - this.props.store + insights={getToolbarSignal( + JSON.stringify(this.props.query.filter), + Boolean(this.props.isCollectionScan), + this.props.isSearchIndexesSupported, + this.props.store.openCreateIndexModal.bind(this.props.store), + this.props.store.openCreateSearchIndexModal.bind( + this.props.store + ) )} /> } @@ -427,6 +439,9 @@ DocumentList.propTypes = { isWritable: PropTypes.bool, instanceDescription: PropTypes.string, darkMode: PropTypes.bool, + isCollectionScan: PropTypes.bool, + isSearchIndexesSupported: PropTypes.bool, + query: PropTypes.object, }; DocumentList.defaultProps = { diff --git a/packages/compass-crud/src/stores/crud-store.spec.ts b/packages/compass-crud/src/stores/crud-store.spec.ts index 62845ca9493..e9f75d56d7f 100644 --- a/packages/compass-crud/src/stores/crud-store.spec.ts +++ b/packages/compass-crud/src/stores/crud-store.spec.ts @@ -175,6 +175,7 @@ describe('store', function () { localAppRegistry: localAppRegistry, globalAppRegistry: globalAppRegistry, actions: actions, + isSearchIndexesSupported: true, }); }); @@ -205,6 +206,7 @@ describe('store', function () { isDataLake: false, isEditable: true, isReadonly: false, + isSearchIndexesSupported: true, isTimeSeries: false, isWritable: false, ns: '', diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index d5539d3c214..a953ff55caf 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -322,6 +322,7 @@ type CrudStoreOptions = { isTimeSeries: boolean; dataProvider: { error?: Error; dataProvider?: DataService }; noRefreshOnConfigure?: boolean; + isSearchIndexesSupported: boolean; }; export type InsertCSFLEState = { @@ -355,7 +356,7 @@ export type TableState = { }; }; -type QueryState = { +export type QueryState = { filter: BSONObject; sort: null | BSONObject; limit: number; @@ -394,6 +395,7 @@ type CrudState = { instanceDescription: string; fields: string[]; isCollectionScan?: boolean; + isSearchIndexesSupported: boolean; }; class CrudStoreImpl @@ -452,6 +454,7 @@ class CrudStoreImpl instanceDescription: '', fields: [], isCollectionScan: false, + isSearchIndexesSupported: false, }; } @@ -523,6 +526,13 @@ class CrudStoreImpl this.setState({ isReadonly }); } + /** + * Set if the connection supports search index management. + */ + setIsSearchIndexesSupported(isSearchIndexesSupported: boolean) { + this.setState({ isSearchIndexesSupported }); + } + /** * Set if the collection is readonly. * @@ -1570,6 +1580,10 @@ class CrudStoreImpl openCreateIndexModal() { this.localAppRegistry.emit('open-create-index-modal'); } + + openCreateSearchIndexModal() { + this.localAppRegistry.emit('open-create-search-index-modal'); + } } export type CrudStore = Store & CrudStoreImpl & { gridStore: GridStore }; @@ -1660,6 +1674,8 @@ const configureStore = (options: CrudStoreOptions & GridStoreOptions) => { } } + store.setIsSearchIndexesSupported(options.isSearchIndexesSupported); + const gridStore = configureGridStore(options); store.gridStore = gridStore; diff --git a/packages/compass-crud/src/utils/toolbar-signal.ts b/packages/compass-crud/src/utils/toolbar-signal.ts new file mode 100644 index 00000000000..2edf88725c8 --- /dev/null +++ b/packages/compass-crud/src/utils/toolbar-signal.ts @@ -0,0 +1,26 @@ +import { + PerformanceSignals, + type Signal, +} from '@mongodb-js/compass-components'; + +export const getToolbarSignal = ( + query: string, + isCollectionScan: boolean, + isSearchIndexesSupported: boolean, + onCreateIndex: () => void, + onCreateSearchIndex: () => void +): Signal | undefined => { + if (!isCollectionScan) { + return undefined; + } + if (/\$(text|regex)\b/.test(query) && isSearchIndexesSupported) { + return { + ...PerformanceSignals.get('atlas-text-regex-usage-in-query'), + onPrimaryActionButtonClick: onCreateSearchIndex, + }; + } + return { + ...PerformanceSignals.get('query-executed-without-index'), + onPrimaryActionButtonClick: onCreateIndex, + }; +}; diff --git a/packages/compass-indexes/src/stores/store.ts b/packages/compass-indexes/src/stores/store.ts index 3480a3e9309..d787268c74e 100644 --- a/packages/compass-indexes/src/stores/store.ts +++ b/packages/compass-indexes/src/stores/store.ts @@ -19,6 +19,7 @@ import { INITIAL_STATE as SEARCH_INDEXES_INITIAL_STATE, refreshSearchIndexes, SearchIndexesStatuses, + showCreateModal, } from '../modules/search-indexes'; import type { DataService } from 'mongodb-data-service'; import type AppRegistry from 'hadron-app-registry'; @@ -111,6 +112,10 @@ const configureStore = (options: ConfigureStoreOptions) => { localAppRegistry.on('fields-changed', (fields) => { store.dispatch(setFields(fields.autocompleteFields)); }); + + localAppRegistry.on('open-create-search-index-modal', () => { + store.dispatch(showCreateModal()); + }); } if (options.globalAppRegistry) {