diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index 3dee965b3b7..34b61bfa6ba 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -1834,7 +1834,7 @@ class CrudStoreImpl openCreateIndexModal() { this.localAppRegistry.emit('open-create-index-modal', { - query: EJSON.serialize(this.queryBar.getLastAppliedQuery('crud')), + query: EJSON.serialize(this.queryBar.getLastAppliedQuery('crud')?.filter), }); } diff --git a/packages/compass-indexes/src/components/create-index-actions/create-index-actions.tsx b/packages/compass-indexes/src/components/create-index-actions/create-index-actions.tsx index 6ce3c05f8eb..20c20501d4c 100644 --- a/packages/compass-indexes/src/components/create-index-actions/create-index-actions.tsx +++ b/packages/compass-indexes/src/components/create-index-actions/create-index-actions.tsx @@ -1,5 +1,9 @@ import React from 'react'; import { css, Banner, spacing, Button } from '@mongodb-js/compass-components'; +import { connect } from 'react-redux'; +import { areAllFieldsFilledIn } from '../../utils/create-index-modal-validation'; +import type { Field, Tab } from '../../modules/create-index'; +import type { RootState } from '../../modules'; const containerStyles = css({ display: 'flex', @@ -27,12 +31,37 @@ function CreateIndexActions({ onErrorBannerCloseClick, onCreateIndexClick, onCancelCreateIndexClick, + fields, + currentTab, + showIndexesGuidanceVariant, + indexSuggestions, }: { error: string | null; onErrorBannerCloseClick: () => void; onCreateIndexClick: () => void; onCancelCreateIndexClick: () => void; + fields: Field[]; + currentTab: Tab; + showIndexesGuidanceVariant: boolean; + indexSuggestions: Record | null; }) { + let isCreateIndexButtonDisabled = false; + + if (showIndexesGuidanceVariant) { + // Disable create index button if the user is in Query Flow and has no suggestions + if (currentTab === 'QueryFlow') { + if (indexSuggestions === null) { + isCreateIndexButtonDisabled = true; + } + } + // Or if they are in the Index Flow but have not completed the fields + else { + if (!areAllFieldsFilledIn(fields)) { + isCreateIndexButtonDisabled = true; + } + } + } + return (
{error && ( @@ -61,6 +90,7 @@ function CreateIndexActions({ onClick={onCreateIndexClick} variant="primary" className={createIndexButtonStyles} + disabled={isCreateIndexButtonDisabled} > Create Index @@ -68,4 +98,13 @@ function CreateIndexActions({ ); } -export default CreateIndexActions; +const mapState = ({ createIndex }: RootState) => { + const { fields, currentTab, indexSuggestions } = createIndex; + return { + fields, + currentTab, + indexSuggestions, + }; +}; + +export default connect(mapState)(CreateIndexActions); diff --git a/packages/compass-indexes/src/components/create-index-form/create-index-form.tsx b/packages/compass-indexes/src/components/create-index-form/create-index-form.tsx index c0cb3edc257..373361d30f8 100644 --- a/packages/compass-indexes/src/components/create-index-form/create-index-form.tsx +++ b/packages/compass-indexes/src/components/create-index-form/create-index-form.tsx @@ -21,7 +21,7 @@ import { usePreference } from 'compass-preferences-model/provider'; import IndexFlowSection from './index-flow-section'; import QueryFlowSection from './query-flow-section'; import toNS from 'mongodb-ns'; -import type { Document } from 'bson'; +import type { Document as BsonDocument } from 'bson'; const createIndexModalFieldsStyles = css({ margin: `${spacing[600]}px 0 ${spacing[800]}px 0`, @@ -50,7 +50,7 @@ export type CreateIndexFormProps = { onRemoveFieldClick: (idx: number) => void; // Minus icon. onTabClick: (tab: Tab) => void; showIndexesGuidanceVariant?: boolean; - query: Document | null; + query: BsonDocument | null; }; function CreateIndexForm({ diff --git a/packages/compass-indexes/src/components/create-index-form/index-flow-section.tsx b/packages/compass-indexes/src/components/create-index-form/index-flow-section.tsx index e367e3629d3..93c9bb957ce 100644 --- a/packages/compass-indexes/src/components/create-index-form/index-flow-section.tsx +++ b/packages/compass-indexes/src/components/create-index-form/index-flow-section.tsx @@ -12,9 +12,17 @@ import { InfoSprinkle, Tooltip, } from '@mongodb-js/compass-components'; -import React, { useState, useCallback } from 'react'; -import type { Field } from '../../modules/create-index'; +import React, { useState, useCallback, useEffect } from 'react'; +import { + errorCleared, + errorEncountered, + type Field, +} from '../../modules/create-index'; import MDBCodeViewer from './mdb-code-viewer'; +import { areAllFieldsFilledIn } from '../../utils/create-index-modal-validation'; +import { connect } from 'react-redux'; +import type { TrackFunction } from '@mongodb-js/compass-telemetry/provider'; +import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; const flexContainerStyles = css({ display: 'flex', @@ -77,10 +85,13 @@ export type IndexFlowSectionProps = { createIndexFieldsComponent: JSX.Element | null; dbName: string; collectionName: string; + onErrorEncountered: (error: string) => void; + onErrorCleared: () => void; }; const generateCoveredQueries = ( - coveredQueriesArr: Array> + coveredQueriesArr: Array>, + track: TrackFunction ) => { const rows = []; for (let i = 0; i < coveredQueriesArr.length; i++) { @@ -92,6 +103,15 @@ const generateCoveredQueries = ( ); } + if (rows.length === 0) { + // TODO: remove this in CLOUDP-320224 + track('Error generating covered queries', { + context: 'Create Index Modal', + }); + throw new Error( + 'Error generating covered query examples. Please try again later.' + ); + } return <>{rows}; }; @@ -148,20 +168,22 @@ const IndexFlowSection = ({ fields, dbName, collectionName, + onErrorEncountered, + onErrorCleared, }: IndexFlowSectionProps) => { const [isCodeEquivalentToggleChecked, setIsCodeEquivalentToggleChecked] = useState(false); - - const areAllFieldsFilledIn = fields.every((field) => { - return field.name && field.type; - }); + const [hasFieldChanges, setHasFieldChanges] = useState(false); const hasUnsupportedQueryTypes = fields.some((field) => { return field.type === '2dsphere' || field.type === 'text'; }); + const track = useTelemetry(); const isCoveredQueriesButtonDisabled = - !areAllFieldsFilledIn || hasUnsupportedQueryTypes; + !areAllFieldsFilledIn(fields) || + hasUnsupportedQueryTypes || + !hasFieldChanges; const indexNameTypeMap = fields.reduce>( (accumulator, currentValue) => { @@ -188,12 +210,23 @@ const IndexFlowSection = ({ return { [field.name]: index + 1 }; }); - setCoveredQueriesObj({ - coveredQueries: generateCoveredQueries(coveredQueriesArr), - optimalQueries: generateOptimalQueries(coveredQueriesArr), - showCoveredQueries: true, - }); - }, [fields]); + try { + setCoveredQueriesObj({ + coveredQueries: generateCoveredQueries(coveredQueriesArr, track), + optimalQueries: generateOptimalQueries(coveredQueriesArr), + showCoveredQueries: true, + }); + } catch (e) { + onErrorEncountered(e instanceof Error ? e.message : String(e)); + } + + setHasFieldChanges(false); + }, [fields, onErrorEncountered, track]); + + useEffect(() => { + setHasFieldChanges(true); + onErrorCleared(); + }, [fields, onErrorCleared]); const { coveredQueries, optimalQueries, showCoveredQueries } = coveredQueriesObj; @@ -315,4 +348,13 @@ const IndexFlowSection = ({ ); }; -export default IndexFlowSection; +const mapState = () => { + return {}; +}; + +const mapDispatch = { + onErrorEncountered: errorEncountered, + onErrorCleared: errorCleared, +}; + +export default connect(mapState, mapDispatch)(IndexFlowSection); diff --git a/packages/compass-indexes/src/components/create-index-form/query-flow-section.spec.tsx b/packages/compass-indexes/src/components/create-index-form/query-flow-section.spec.tsx index acd4a9d1973..658f08ddbd1 100644 --- a/packages/compass-indexes/src/components/create-index-form/query-flow-section.spec.tsx +++ b/packages/compass-indexes/src/components/create-index-form/query-flow-section.spec.tsx @@ -67,7 +67,7 @@ describe('QueryFlowSection', () => { type: ActionTypes.SuggestedIndexesFetched, sampleDocs: [], indexSuggestions: { a: 1, b: 2 }, - fetchingSuggestionsError: null, + error: null, indexSuggestionsState: 'success', }); }); diff --git a/packages/compass-indexes/src/components/create-index-form/query-flow-section.tsx b/packages/compass-indexes/src/components/create-index-form/query-flow-section.tsx index 8e9e73e3845..b79a1f7fc22 100644 --- a/packages/compass-indexes/src/components/create-index-form/query-flow-section.tsx +++ b/packages/compass-indexes/src/components/create-index-form/query-flow-section.tsx @@ -7,6 +7,7 @@ import { useFocusRing, ParagraphSkeleton, } from '@mongodb-js/compass-components'; +import type { Document as BsonDocument } from 'bson'; import React, { useMemo, useCallback } from 'react'; import { css, spacing } from '@mongodb-js/compass-components'; import { @@ -21,7 +22,7 @@ import type { SuggestedIndexFetchedProps, } from '../../modules/create-index'; import { connect } from 'react-redux'; -import type { Document } from 'bson'; +import { parseFilter } from 'mongodb-query-parser'; const inputQueryContainerStyles = css({ display: 'flex', @@ -81,6 +82,8 @@ const insightStyles = css({ display: 'flex', alignItems: 'center', gap: spacing[100], + marginBottom: spacing[200], + height: spacing[500], }); const QueryFlowSection = ({ @@ -104,11 +107,17 @@ const QueryFlowSection = ({ }: SuggestedIndexFetchedProps) => Promise; indexSuggestions: Record | null; fetchingSuggestionsState: IndexSuggestionState; - initialQuery: Document | null; + initialQuery: BsonDocument | null; }) => { const [inputQuery, setInputQuery] = React.useState( - JSON.stringify(initialQuery?.filter ?? {}, null, 2) + JSON.stringify(initialQuery ?? '', null, 2) + ); + const [hasNewChanges, setHasNewChanges] = React.useState( + initialQuery !== null ); + const [isShowSuggestionsButtonDisabled, setIsShowSuggestionsButtonDisabled] = + React.useState(true); + const completer = useMemo( () => createQueryAutocompleter({ @@ -133,10 +142,33 @@ const QueryFlowSection = ({ collectionName, inputQuery: sanitizedInputQuery, }); + + setHasNewChanges(false); }, [inputQuery, dbName, collectionName, onSuggestedIndexButtonClick]); + const handleQueryInputChange = useCallback((text: string) => { + setInputQuery(text); + setHasNewChanges(true); + }, []); + const isFetchingIndexSuggestions = fetchingSuggestionsState === 'fetching'; + // Validate query upon typing + useMemo(() => { + let _isShowSuggestionsButtonDisabled = !hasNewChanges; + try { + parseFilter(inputQuery); + + if (!inputQuery.startsWith('{') || !inputQuery.endsWith('}')) { + _isShowSuggestionsButtonDisabled = true; + } + } catch (e) { + _isShowSuggestionsButtonDisabled = true; + } finally { + setIsShowSuggestionsButtonDisabled(_isShowSuggestionsButtonDisabled); + } + }, [hasNewChanges, inputQuery]); + return ( <> {initialQuery && ( @@ -164,7 +196,7 @@ const QueryFlowSection = ({ copyable={false} formattable={false} text={inputQuery} - onChangeText={(text) => setInputQuery(text)} + onChangeText={(text) => handleQueryInputChange(text)} placeholder="Type a query: { field: 'value' }" completer={completer} className={codeEditorStyles} @@ -176,6 +208,7 @@ const QueryFlowSection = ({ onClick={handleSuggestedIndexButtonClick} className={suggestedIndexButtonStyles} size="small" + disabled={isShowSuggestionsButtonDisabled} > Show suggested index diff --git a/packages/compass-indexes/src/components/create-index-modal/create-index-modal.tsx b/packages/compass-indexes/src/components/create-index-modal/create-index-modal.tsx index 976d5f6205f..bd5ba5a0ea6 100644 --- a/packages/compass-indexes/src/components/create-index-modal/create-index-modal.tsx +++ b/packages/compass-indexes/src/components/create-index-modal/create-index-modal.tsx @@ -29,14 +29,14 @@ import { import { useConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; import { usePreference } from 'compass-preferences-model/provider'; import CreateIndexModalHeader from './create-index-modal-header'; -import type { Document } from 'bson'; +import type { Document as BsonDocument } from 'bson'; type CreateIndexModalProps = React.ComponentProps & { isVisible: boolean; namespace: string; error: string | null; currentTab: Tab; - query: Document | null; + query: BsonDocument | null; onErrorBannerCloseClick: () => void; onCreateIndexClick: () => void; onCancelCreateIndexClick: () => void; @@ -121,6 +121,7 @@ function CreateIndexModal({ onErrorBannerCloseClick={onErrorBannerCloseClick} onCreateIndexClick={onCreateIndexClick} onCancelCreateIndexClick={onCancelCreateIndexClick} + showIndexesGuidanceVariant={showIndexesGuidanceVariant} /> diff --git a/packages/compass-indexes/src/modules/create-index.tsx b/packages/compass-indexes/src/modules/create-index.tsx index c903ea780a5..5b4ed1203a2 100644 --- a/packages/compass-indexes/src/modules/create-index.tsx +++ b/packages/compass-indexes/src/modules/create-index.tsx @@ -307,9 +307,6 @@ export type State = { // state of the index suggestions fetchingSuggestionsState: IndexSuggestionState; - // error specific to fetching index suggestions - fetchingSuggestionsError: string | null; - // index suggestions in a format such as {fieldName: 1} indexSuggestions: Record | null; @@ -328,7 +325,6 @@ export const INITIAL_STATE: State = { options: INITIAL_OPTIONS_STATE, currentTab: 'IndexFlow', fetchingSuggestionsState: 'initial', - fetchingSuggestionsError: null, indexSuggestions: null, sampleDocs: null, query: null, @@ -343,7 +339,7 @@ function getInitialState(): State { //------- -export const createIndexOpened = (query?: BsonDocument) => ({ +export const createIndexOpened = (query?: Document) => ({ type: ActionTypes.CreateIndexOpened, query, }); @@ -352,7 +348,7 @@ export const createIndexClosed = () => ({ type: ActionTypes.CreateIndexClosed, }); -const errorEncountered = (error: string): ErrorEncounteredAction => ({ +export const errorEncountered = (error: string): ErrorEncounteredAction => ({ type: ActionTypes.ErrorEncountered, error, }); @@ -373,7 +369,7 @@ export type SuggestedIndexFetchedAction = { type: ActionTypes.SuggestedIndexesFetched; sampleDocs: Array; indexSuggestions: { [key: string]: number } | null; - fetchingSuggestionsError: string | null; + error: string | null; indexSuggestionsState: IndexSuggestionState; }; @@ -395,7 +391,7 @@ export const fetchIndexSuggestions = ({ Promise, SuggestedIndexFetchedAction | SuggestedIndexesRequestedAction > => { - return async (dispatch, getState, { dataService }) => { + return async (dispatch, getState, { dataService, track }) => { dispatch({ type: ActionTypes.SuggestedIndexesRequested, }); @@ -416,6 +412,19 @@ export const fetchIndexSuggestions = ({ } } + const throwError = (e?: unknown) => { + dispatch({ + type: ActionTypes.SuggestedIndexesFetched, + sampleDocs: sampleDocuments || [], + indexSuggestions: null, + error: + e instanceof Error + ? 'Error parsing query. Please follow query structure. ' + e.message + : 'Error parsing query. Please follow query structure.', + indexSuggestionsState: 'error', + }); + }; + // Analyze namespace and fetch suggestions try { const analyzedNamespace = mql.analyzeNamespace( @@ -428,18 +437,13 @@ export const fetchIndexSuggestions = ({ analyzedNamespace ); const results = await mql.suggestIndex([query]); - const indexSuggestions = results?.index || null; - - // TODO in CLOUDP-311787: add info banner and update the current error banner to take in fetchingSuggestionsError as well - if (!indexSuggestions) { - dispatch({ - type: ActionTypes.SuggestedIndexesFetched, - sampleDocs: sampleDocuments, - indexSuggestions, - fetchingSuggestionsError: - 'No suggested index found. Please choose "Start with an Index" at the top to continue.', - indexSuggestionsState: 'error', - }); + const indexSuggestions = results?.index; + + if ( + !indexSuggestions || + Object.keys(indexSuggestions as Record).length === 0 + ) { + throwError(); return; } @@ -447,20 +451,13 @@ export const fetchIndexSuggestions = ({ type: ActionTypes.SuggestedIndexesFetched, sampleDocs: sampleDocuments, indexSuggestions, - fetchingSuggestionsError: null, + error: null, indexSuggestionsState: 'success', }); } catch (e: unknown) { - dispatch({ - type: ActionTypes.SuggestedIndexesFetched, - sampleDocs: sampleDocuments, - indexSuggestions: null, - fetchingSuggestionsError: - e instanceof Error - ? 'Error parsing query. Please follow query structure. ' + e.message - : 'Error parsing query. Please follow query structure.', - indexSuggestionsState: 'error', - }); + // TODO: remove this in CLOUDP-320224 + track('Error parsing query', { context: 'Create Index Modal' }); + throwError(e); } }; }; @@ -496,6 +493,8 @@ export const createIndexFormSubmitted = (): IndexesThunkAction< return (dispatch, getState, { track, preferences }) => { // @experiment Early Journey Indexes Guidance & Awareness | Jira Epic: CLOUDP-239367 const currentTab = getState().createIndex.currentTab; + const isQueryFlow = currentTab === 'QueryFlow'; + const indexSuggestions = getState().createIndex.indexSuggestions; const { enableIndexesGuidanceExp, showIndexesGuidanceVariant } = preferences.getPreferences(); @@ -511,6 +510,7 @@ export const createIndexFormSubmitted = (): IndexesThunkAction< // Check for field errors. if ( + !isQueryFlow && getState().createIndex.fields.some( (field: Field) => field.name === '' || field.type === '' ) @@ -521,14 +521,22 @@ export const createIndexFormSubmitted = (): IndexesThunkAction< const formIndexOptions = getState().createIndex.options; - let spec: Record; + let spec: Record = {}; try { - spec = Object.fromEntries( - getState().createIndex.fields.map((field) => { - return [field.name, fieldTypeToIndexDirection(field.type)]; - }) - ); + if (isQueryFlow) { + // Gather from suggested index + if (indexSuggestions) { + spec = indexSuggestions; + } + } else { + // Gather from the index input fields + spec = Object.fromEntries( + getState().createIndex.fields.map((field) => { + return [field.name, fieldTypeToIndexDirection(field.type)]; + }) + ); + } } catch (e) { dispatch(errorEncountered((e as any).message)); return; @@ -768,7 +776,7 @@ const reducer: Reducer = (state = INITIAL_STATE, action) => { return { ...state, fetchingSuggestionsState: 'fetching', - fetchingSuggestionsError: null, + error: null, indexSuggestions: null, }; } @@ -782,7 +790,7 @@ const reducer: Reducer = (state = INITIAL_STATE, action) => { return { ...state, fetchingSuggestionsState: action.indexSuggestionsState, - fetchingSuggestionsError: action.fetchingSuggestionsError, + error: action.error, indexSuggestions: action.indexSuggestions, sampleDocs: action.sampleDocs, }; diff --git a/packages/compass-indexes/src/utils/create-index-modal-validation.ts b/packages/compass-indexes/src/utils/create-index-modal-validation.ts new file mode 100644 index 00000000000..f90b614c859 --- /dev/null +++ b/packages/compass-indexes/src/utils/create-index-modal-validation.ts @@ -0,0 +1,5 @@ +import type { Field } from '../modules/create-index'; + +export const areAllFieldsFilledIn = (fields: Field[]) => { + return fields.every((field) => field.name && field.type); +}; diff --git a/packages/compass-telemetry/src/telemetry-events.ts b/packages/compass-telemetry/src/telemetry-events.ts index 3a5182ac913..f11fab76d77 100644 --- a/packages/compass-telemetry/src/telemetry-events.ts +++ b/packages/compass-telemetry/src/telemetry-events.ts @@ -2694,6 +2694,20 @@ type CreateIndexButtonClickedEvent = CommonEvent<{ }; }>; +type CreateIndexErrorParsingQueryEvent = CommonEvent<{ + name: 'Error parsing query'; + payload: { + context: CreateIndexModalContext; + }; +}>; + +type CreateIndexErrorGettingCoveredQueriesEvent = CommonEvent<{ + name: 'Error generating covered queries'; + payload: { + context: CreateIndexModalContext; + }; +}>; + export type TelemetryEvent = | AggregationCanceledEvent | AggregationCopiedEvent @@ -2816,4 +2830,6 @@ export type TelemetryEvent = | CumulativeLayoutShiftEvent | TimeToFirstByteEvent | ExperimentViewedEvent - | CreateIndexButtonClickedEvent; + | CreateIndexButtonClickedEvent + | CreateIndexErrorParsingQueryEvent + | CreateIndexErrorGettingCoveredQueriesEvent;