diff --git a/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.spec.tsx b/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.spec.tsx index 763e81b165b..f279e9097f6 100644 --- a/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.spec.tsx +++ b/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.spec.tsx @@ -58,8 +58,9 @@ describe('CollectionHeaderActions [Component]', function () { onOpenMockDataModal={sinon.stub()} hasSchemaAnalysisData={true} analyzedSchemaDepth={2} - schemaAnalysisStatus="complete" schemaAnalysisError={null} + isCollectionEmpty={false} + hasUnsupportedStateError={false} {...props} /> @@ -354,7 +355,9 @@ describe('CollectionHeaderActions [Component]', function () { isReadonly: false, hasSchemaAnalysisData: true, analyzedSchemaDepth: MAX_COLLECTION_NESTING_DEPTH + 1, - schemaAnalysisStatus: 'complete', + schemaAnalysisError: null, + isCollectionEmpty: false, + hasUnsupportedStateError: false, onOpenMockDataModal: sinon.stub(), }, {}, @@ -374,11 +377,37 @@ describe('CollectionHeaderActions [Component]', function () { namespace: 'test.collection', isReadonly: false, hasSchemaAnalysisData: false, - schemaAnalysisStatus: 'error', schemaAnalysisError: { errorType: 'unsupportedState', errorMessage: 'Unsupported state', }, + isCollectionEmpty: false, + hasUnsupportedStateError: true, + onOpenMockDataModal: sinon.stub(), + }, + {}, + atlasConnectionInfo + ); + + const button = screen.getByTestId( + 'collection-header-generate-mock-data-button' + ); + expect(button).to.exist; + expect(button).to.have.attribute('aria-disabled', 'true'); + }); + + it('should disable button when collection is empty', async function () { + await renderCollectionHeaderActions( + { + namespace: 'test.collection', + isReadonly: false, + hasSchemaAnalysisData: false, + schemaAnalysisError: { + errorType: 'empty', + errorMessage: 'No documents found in the collection to analyze.', + }, + isCollectionEmpty: true, + hasUnsupportedStateError: false, onOpenMockDataModal: sinon.stub(), }, {}, diff --git a/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.tsx b/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.tsx index a1ef5f33167..2253ddc03a7 100644 --- a/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.tsx +++ b/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.tsx @@ -24,11 +24,7 @@ import { useTrackOnChange, type TrackFunction, } from '@mongodb-js/compass-telemetry/provider'; -import { - SCHEMA_ANALYSIS_STATE_ANALYZING, - type SchemaAnalysisStatus, - type SchemaAnalysisError, -} from '../../schema-analysis-types'; +import { type SchemaAnalysisError } from '../../schema-analysis-types'; import { MAX_COLLECTION_NESTING_DEPTH } from '../mock-data-generator-modal/utils'; import { buildChartsUrl, @@ -61,7 +57,8 @@ type CollectionHeaderActionsProps = { hasSchemaAnalysisData: boolean; schemaAnalysisError: SchemaAnalysisError | null; analyzedSchemaDepth: number; - schemaAnalysisStatus: SchemaAnalysisStatus | null; + isCollectionEmpty: boolean; + hasUnsupportedStateError: boolean; }; const CollectionHeaderActions: React.FunctionComponent< @@ -76,8 +73,9 @@ const CollectionHeaderActions: React.FunctionComponent< onOpenMockDataModal, hasSchemaAnalysisData, analyzedSchemaDepth, - schemaAnalysisStatus, schemaAnalysisError, + isCollectionEmpty, + hasUnsupportedStateError, }: CollectionHeaderActionsProps) => { const connectionInfo = useConnectionInfo(); const { id: connectionId, atlasMetadata } = connectionInfo; @@ -117,21 +115,11 @@ const CollectionHeaderActions: React.FunctionComponent< const exceedsMaxNestingDepth = analyzedSchemaDepth > MAX_COLLECTION_NESTING_DEPTH; - const isCollectionEmpty = - !hasSchemaAnalysisData && - schemaAnalysisStatus !== SCHEMA_ANALYSIS_STATE_ANALYZING; - - const hasSchemaAnalysisUnsupportedStateError = Boolean( - schemaAnalysisError && schemaAnalysisError.errorType === 'unsupportedState' - ); - const isView = isReadonly && sourceName && !editViewName; const showViewEdit = isView && !preferencesReadWrite; const shouldDisableMockDataButton = - !hasSchemaAnalysisData || - exceedsMaxNestingDepth || - hasSchemaAnalysisUnsupportedStateError; + !hasSchemaAnalysisData || exceedsMaxNestingDepth; const onMockDataGeneratorCtaButtonClicked = useCallback(() => { track('Mock Data Generator Opened', { @@ -189,7 +177,7 @@ const CollectionHeaderActions: React.FunctionComponent< enabled={ exceedsMaxNestingDepth || isCollectionEmpty || - hasSchemaAnalysisUnsupportedStateError + hasUnsupportedStateError } trigger={
@@ -206,7 +194,7 @@ const CollectionHeaderActions: React.FunctionComponent< } > <> - {hasSchemaAnalysisUnsupportedStateError ? ( + {hasUnsupportedStateError ? ( {schemaAnalysisError?.errorMessage} diff --git a/packages/compass-collection/src/components/collection-header/collection-header.tsx b/packages/compass-collection/src/components/collection-header/collection-header.tsx index bbca8d22b48..0321189da2b 100644 --- a/packages/compass-collection/src/components/collection-header/collection-header.tsx +++ b/packages/compass-collection/src/components/collection-header/collection-header.tsx @@ -23,11 +23,16 @@ import { connect } from 'react-redux'; import { openMockDataGeneratorModal } from '../../modules/collection-tab'; import type { CollectionState } from '../../modules/collection-tab'; import { - SCHEMA_ANALYSIS_STATE_COMPLETE, SCHEMA_ANALYSIS_STATE_ERROR, + SCHEMA_ANALYSIS_STATE_COMPLETE, type SchemaAnalysisStatus, type SchemaAnalysisError, } from '../../schema-analysis-types'; +import { + selectHasSchemaAnalysisData, + selectIsCollectionEmpty, + selectHasUnsupportedStateError, +} from '../../stores/collection-tab'; const collectionHeaderStyles = css({ padding: spacing[400], @@ -73,6 +78,8 @@ type CollectionHeaderProps = { analyzedSchemaDepth: number; schemaAnalysisStatus: SchemaAnalysisStatus | null; schemaAnalysisError: SchemaAnalysisError | null; + isCollectionEmpty: boolean; + hasUnsupportedStateError: boolean; }; const getInsightsForPipeline = (pipeline: any[], isAtlas: boolean) => { @@ -110,8 +117,9 @@ const CollectionHeader: React.FunctionComponent = ({ onOpenMockDataModal, hasSchemaAnalysisData, analyzedSchemaDepth, - schemaAnalysisStatus, schemaAnalysisError, + isCollectionEmpty, + hasUnsupportedStateError, }) => { const darkMode = useDarkMode(); const showInsights = usePreference('showInsights'); @@ -192,8 +200,9 @@ const CollectionHeader: React.FunctionComponent = ({ onOpenMockDataModal={onOpenMockDataModal} hasSchemaAnalysisData={hasSchemaAnalysisData} analyzedSchemaDepth={analyzedSchemaDepth} - schemaAnalysisStatus={schemaAnalysisStatus} schemaAnalysisError={schemaAnalysisError} + isCollectionEmpty={isCollectionEmpty} + hasUnsupportedStateError={hasUnsupportedStateError} />
@@ -209,15 +218,14 @@ const mapStateToProps = (state: CollectionState) => { schemaAnalysis && schemaAnalysis.status === SCHEMA_ANALYSIS_STATE_ERROR ? schemaAnalysis.error : null, - hasSchemaAnalysisData: - schemaAnalysis && - schemaAnalysis.status === SCHEMA_ANALYSIS_STATE_COMPLETE && - Object.keys(schemaAnalysis.processedSchema).length > 0, + hasSchemaAnalysisData: selectHasSchemaAnalysisData(state), analyzedSchemaDepth: schemaAnalysis && schemaAnalysis.status === SCHEMA_ANALYSIS_STATE_COMPLETE ? schemaAnalysis.schemaMetadata.maxNestingDepth : 0, schemaAnalysisStatus: schemaAnalysis?.status || null, + isCollectionEmpty: selectIsCollectionEmpty(state), + hasUnsupportedStateError: selectHasUnsupportedStateError(state), }; }; diff --git a/packages/compass-collection/src/modules/collection-tab.ts b/packages/compass-collection/src/modules/collection-tab.ts index 09c243f18d1..741bed1e674 100644 --- a/packages/compass-collection/src/modules/collection-tab.ts +++ b/packages/compass-collection/src/modules/collection-tab.ts @@ -50,6 +50,13 @@ const DEFAULT_SAMPLE_SIZE = 100; const NO_DOCUMENTS_ERROR = 'No documents found in the collection to analyze.'; +export class EmptyCollectionError extends Error { + constructor() { + super(NO_DOCUMENTS_ERROR); + this.name = 'EmptyCollectionError'; + } +} + function isAction( action: AnyAction, type: A['type'] @@ -68,6 +75,13 @@ function getErrorDetails(error: Error): SchemaAnalysisError { }; } + if (error instanceof EmptyCollectionError) { + return { + errorType: 'empty', + errorMessage: error.message, + }; + } + const errorCode = (error as MongoError).code; const errorMessage = error.message || 'Unknown error'; let errorType: SchemaAnalysisError['errorType'] = 'general'; @@ -756,7 +770,7 @@ export const analyzeCollectionSchema = (): CollectionThunkAction< logger.debug(NO_DOCUMENTS_ERROR); dispatch({ type: CollectionActions.SchemaAnalysisFailed, - error: new Error(NO_DOCUMENTS_ERROR), + error: new EmptyCollectionError(), }); return; } diff --git a/packages/compass-collection/src/schema-analysis-types.ts b/packages/compass-collection/src/schema-analysis-types.ts index b1705b41cc6..da98c0fbc3a 100644 --- a/packages/compass-collection/src/schema-analysis-types.ts +++ b/packages/compass-collection/src/schema-analysis-types.ts @@ -22,7 +22,12 @@ export type SchemaAnalysisStartedState = { export type SchemaAnalysisError = { errorMessage: string; - errorType: 'timeout' | 'highComplexity' | 'general' | 'unsupportedState'; + errorType: + | 'timeout' + | 'highComplexity' + | 'general' + | 'unsupportedState' + | 'empty'; }; export type SchemaAnalysisErrorState = { diff --git a/packages/compass-collection/src/stores/collection-tab.spec.ts b/packages/compass-collection/src/stores/collection-tab.spec.ts index b773949675a..398bbac6809 100644 --- a/packages/compass-collection/src/stores/collection-tab.spec.ts +++ b/packages/compass-collection/src/stores/collection-tab.spec.ts @@ -1,10 +1,13 @@ import type { CollectionTabOptions } from './collection-tab'; import { activatePlugin } from './collection-tab'; -import { selectTab } from '../modules/collection-tab'; +import { selectTab, EmptyCollectionError } from '../modules/collection-tab'; import * as collectionTabModule from '../modules/collection-tab'; import { waitFor } from '@mongodb-js/testing-library-compass'; import Sinon from 'sinon'; -import AppRegistry from '@mongodb-js/compass-app-registry'; +import type { ActivateHelpers } from '@mongodb-js/compass-app-registry'; +import AppRegistry, { + createActivateHelpers, +} from '@mongodb-js/compass-app-registry'; import { expect } from 'chai'; import type { workspacesServiceLocator } from '@mongodb-js/compass-workspaces/provider'; import type { ExperimentationServices } from '@mongodb-js/compass-telemetry/provider'; @@ -18,6 +21,9 @@ import { import { type CollectionMetadata } from 'mongodb-collection-model'; import type { types } from '@mongodb-js/mdb-experiment-js'; +// Wait time in ms for async operations to complete +const WAIT_TIME = 50; + // Helper function to create proper mock assignment objects for testing const createMockAssignment = ( variant: ExperimentTestGroup @@ -86,10 +92,13 @@ describe('Collection Tab Content store', function () { const sandbox = Sinon.createSandbox(); const localAppRegistry = sandbox.spy(new AppRegistry()); + const globalAppRegistry = sandbox.spy(new AppRegistry()); const analyzeCollectionSchemaStub = sandbox .stub(collectionTabModule, 'analyzeCollectionSchema') .returns(async () => {}); + let mockActivateHelpers: ActivateHelpers; + const dataService = {} as any; const atlasAiService = {} as any; let store: ReturnType['store']; @@ -101,7 +110,7 @@ describe('Collection Tab Content store', function () { experimentationServices: Partial = {}, connectionInfoRef: Partial< ReturnType - > = {}, + > = mockAtlasConnectionInfo, logger = createNoopLogger('COMPASS-COLLECTION-TEST'), preferences = new ReadOnlyPreferenceAccess({ enableGenAIFeatures: true, @@ -128,6 +137,7 @@ describe('Collection Tab Content store', function () { dataService, atlasAiService, localAppRegistry, + globalAppRegistry, collection: mockCollection as any, workspaces: workspaces as any, experimentationServices: experimentationServices as any, @@ -135,7 +145,7 @@ describe('Collection Tab Content store', function () { logger, preferences, }, - { on() {}, cleanup() {}, addCleanup() {} } as any + mockActivateHelpers )); await waitFor(() => { expect(store.getState()) @@ -145,7 +155,12 @@ describe('Collection Tab Content store', function () { return store; }; + beforeEach(function () { + mockActivateHelpers = createActivateHelpers(); + }); + afterEach(function () { + mockActivateHelpers.cleanup(); sandbox.resetHistory(); deactivate(); }); @@ -153,9 +168,13 @@ describe('Collection Tab Content store', function () { describe('selectTab', function () { it('should set active tab', async function () { const openCollectionWorkspaceSubtab = sandbox.spy(); - const store = await configureStore(undefined, { - openCollectionWorkspaceSubtab, - }); + const assignExperiment = sandbox.spy(() => Promise.resolve(null)); + + const store = await configureStore( + undefined, + { openCollectionWorkspaceSubtab }, + { assignExperiment } + ); store.dispatch(selectTab('Documents') as any); expect(openCollectionWorkspaceSubtab).to.have.been.calledWith( 'workspace-tab-id', @@ -206,7 +225,7 @@ describe('Collection Tab Content store', function () { ); // Wait a bit to ensure assignment would have happened if it was going to - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); expect(assignExperiment).to.not.have.been.called; }); @@ -229,7 +248,7 @@ describe('Collection Tab Content store', function () { ); // Wait a bit to ensure assignment would have happened if it was going to - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); expect(assignExperiment).to.not.have.been.called; // Store should still be functional @@ -278,7 +297,7 @@ describe('Collection Tab Content store', function () { ); // Wait a bit to ensure assignment would have happened if it was going to - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); expect(assignExperiment).to.not.have.been.called; }); @@ -296,7 +315,7 @@ describe('Collection Tab Content store', function () { ); // Wait a bit to ensure assignment would have happened if it was going to - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); expect(assignExperiment).to.not.have.been.called; }); }); @@ -377,7 +396,7 @@ describe('Collection Tab Content store', function () { }); // Wait a bit to ensure schema analysis would not have been called - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); expect(analyzeCollectionSchemaStub).to.not.have.been.called; }); @@ -428,7 +447,7 @@ describe('Collection Tab Content store', function () { }); // Wait a bit to ensure schema analysis would not have been called - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); expect(analyzeCollectionSchemaStub).to.not.have.been.called; }); @@ -450,7 +469,7 @@ describe('Collection Tab Content store', function () { }); // Wait a bit to ensure schema analysis would not have been called - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); expect(analyzeCollectionSchemaStub).to.not.have.been.called; }); }); @@ -473,9 +492,380 @@ describe('Collection Tab Content store', function () { store.dispatch(collectionTabModule.cancelSchemaAnalysis() as any); // Verify the state is reset to initial - expect((store.getState() as any).schemaAnalysis.status).to.equal( - 'initial' + expect( + (store.getState() as { schemaAnalysis: { status: string } }) + .schemaAnalysis.status + ).to.equal('initial'); + }); + }); + + describe('document-inserted event listener', function () { + it('should re-trigger schema analysis when document is inserted into current collection', async function () { + const getAssignment = sandbox.spy(() => + Promise.resolve( + createMockAssignment(ExperimentTestGroup.mockDataGeneratorVariant) + ) + ); + const assignExperiment = sandbox.spy(() => Promise.resolve(null)); + + const store = await configureStore( + undefined, + undefined, + { getAssignment, assignExperiment }, + mockAtlasConnectionInfo, + undefined, + undefined, + { ...defaultMetadata, isReadonly: false, isTimeSeries: false } + ); + + // Wait for initial schema analysis to complete + await waitFor(() => { + expect(analyzeCollectionSchemaStub).to.have.been.calledOnce; + }); + + // Reset the stub to track new calls + analyzeCollectionSchemaStub.resetHistory(); + + // Simulate the empty collection + store.dispatch({ + type: 'compass-collection/SchemaAnalysisFailed', + error: new EmptyCollectionError(), + } as any); + + // Trigger the document-inserted event + globalAppRegistry.emit( + 'document-inserted', + { + ns: defaultMetadata.namespace, + view: 'default', + mode: 'default', + multiple: false, + docs: [{ _id: 'test-doc-id', name: 'test' }], + }, + { connectionId: mockAtlasConnectionInfo.current.id } + ); + + // Wait for schema analysis to be re-triggered + await waitFor(() => { + expect(analyzeCollectionSchemaStub).to.have.been.calledOnce; + }); + }); + + it('should not re-trigger schema analysis for different collection', async function () { + const getAssignment = sandbox.spy(() => + Promise.resolve( + createMockAssignment(ExperimentTestGroup.mockDataGeneratorVariant) + ) + ); + const assignExperiment = sandbox.spy(() => Promise.resolve(null)); + + await configureStore( + undefined, + undefined, + { getAssignment, assignExperiment }, + mockAtlasConnectionInfo + ); + + // Wait for initial schema analysis to complete + await waitFor(() => { + expect(analyzeCollectionSchemaStub).to.have.been.calledOnce; + }); + + // Reset the stub to track new calls + analyzeCollectionSchemaStub.resetHistory(); + + // Trigger the document-inserted event with different collection + globalAppRegistry.emit( + 'document-inserted', + { + ns: 'different.collection', + view: 'default', + mode: 'default', + multiple: false, + docs: [{ _id: 'test-doc-id', name: 'test' }], + }, + { connectionId: mockAtlasConnectionInfo.current.id } + ); + + // Wait a bit to ensure schema analysis is not called + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); + expect(analyzeCollectionSchemaStub).to.not.have.been.called; + }); + + it('should not re-trigger schema analysis for different connection', async function () { + const getAssignment = sandbox.spy(() => + Promise.resolve( + createMockAssignment(ExperimentTestGroup.mockDataGeneratorVariant) + ) + ); + const assignExperiment = sandbox.spy(() => Promise.resolve(null)); + + await configureStore( + undefined, + undefined, + { getAssignment, assignExperiment }, + mockAtlasConnectionInfo + ); + + // Wait for initial schema analysis to complete + await waitFor(() => { + expect(analyzeCollectionSchemaStub).to.have.been.calledOnce; + }); + + // Reset the stub to track new calls + analyzeCollectionSchemaStub.resetHistory(); + + // Trigger the document-inserted event with different connection + globalAppRegistry.emit( + 'document-inserted', + { + ns: defaultMetadata.namespace, + view: 'default', + mode: 'default', + multiple: false, + docs: [{ _id: 'test-doc-id', name: 'test' }], + }, + { connectionId: 'different-connection-id' } + ); + + // Wait a bit to ensure schema analysis is not called + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); + expect(analyzeCollectionSchemaStub).to.not.have.been.called; + }); + + it('should not re-trigger schema analysis when user is not in experiment variant', async function () { + const getAssignment = sandbox.spy(() => + Promise.resolve( + createMockAssignment(ExperimentTestGroup.mockDataGeneratorControl) + ) + ); + const assignExperiment = sandbox.spy(() => Promise.resolve(null)); + + await configureStore( + undefined, + undefined, + { getAssignment, assignExperiment }, + mockAtlasConnectionInfo + ); + + // Wait for initial assignment check + await waitFor(() => { + expect(getAssignment).to.have.been.calledOnce; + }); + + // Schema analysis should not have been called initially + expect(analyzeCollectionSchemaStub).to.not.have.been.called; + + // Verify the schema analysis state is INITIAL (as expected for control variant) + const initialState = store.getState() as { + schemaAnalysis: { status: string }; + }; + expect(initialState.schemaAnalysis.status).to.equal('initial'); + + // Trigger the document-inserted event + globalAppRegistry.emit( + 'document-inserted', + { + ns: defaultMetadata.namespace, + view: 'default', + mode: 'default', + multiple: false, + docs: [{ _id: 'test-doc-id', name: 'test' }], + }, + { connectionId: mockAtlasConnectionInfo.current.id } + ); + + // Wait a bit to ensure schema analysis is not called + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); + expect(analyzeCollectionSchemaStub).to.not.have.been.called; + }); + }); + + describe('import-finished event listener', function () { + it('should re-trigger schema analysis when import is completed for current collection', async function () { + const getAssignment = sandbox.spy(() => + Promise.resolve( + createMockAssignment(ExperimentTestGroup.mockDataGeneratorVariant) + ) + ); + const assignExperiment = sandbox.spy(() => Promise.resolve(null)); + + const store = await configureStore( + undefined, + undefined, + { getAssignment, assignExperiment }, + mockAtlasConnectionInfo, + undefined, + undefined, + { ...defaultMetadata, isReadonly: false, isTimeSeries: false } + ); + + // Wait for initial schema analysis to complete + await waitFor(() => { + expect(analyzeCollectionSchemaStub).to.have.been.calledOnce; + }); + + // Reset the stub to track new calls + analyzeCollectionSchemaStub.resetHistory(); + + // Simulate the empty collection + store.dispatch({ + type: 'compass-collection/SchemaAnalysisFailed', + error: new EmptyCollectionError(), + }); + + // Emit import-finished event + globalAppRegistry.emit( + 'import-finished', + { + ns: defaultMetadata.namespace, + connectionId: mockAtlasConnectionInfo.current.id, + }, + { connectionId: mockAtlasConnectionInfo.current.id } + ); + + // Wait for schema analysis to be re-triggered + await waitFor(() => { + expect(analyzeCollectionSchemaStub).to.have.been.calledOnce; + }); + }); + + it('should not re-trigger schema analysis for different collection', async function () { + const getAssignment = sandbox.spy(() => + Promise.resolve( + createMockAssignment(ExperimentTestGroup.mockDataGeneratorVariant) + ) ); + const assignExperiment = sandbox.spy(() => Promise.resolve(null)); + + const store = await configureStore( + undefined, + undefined, + { getAssignment, assignExperiment }, + mockAtlasConnectionInfo, + undefined, + undefined, + { ...defaultMetadata, isReadonly: false, isTimeSeries: false } + ); + + // Wait for initial schema analysis to complete + await waitFor(() => { + expect(analyzeCollectionSchemaStub).to.have.been.calledOnce; + }); + + // Reset the stub to track new calls + analyzeCollectionSchemaStub.resetHistory(); + + // Simulate the empty collection + store.dispatch({ + type: 'compass-collection/SchemaAnalysisFailed', + error: new EmptyCollectionError(), + }); + + // Emit import-finished event for different collection + globalAppRegistry.emit( + 'import-finished', + { + ns: 'different.collection', + connectionId: mockAtlasConnectionInfo.current.id, + }, + { connectionId: mockAtlasConnectionInfo.current.id } + ); + + // Wait a bit to ensure no action is taken + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); + expect(analyzeCollectionSchemaStub).to.not.have.been.called; + }); + + it('should not re-trigger schema analysis for different connection', async function () { + const getAssignment = sandbox.spy(() => + Promise.resolve( + createMockAssignment(ExperimentTestGroup.mockDataGeneratorVariant) + ) + ); + const assignExperiment = sandbox.spy(() => Promise.resolve(null)); + + const store = await configureStore( + undefined, + undefined, + { getAssignment, assignExperiment }, + mockAtlasConnectionInfo, + undefined, + undefined, + { ...defaultMetadata, isReadonly: false, isTimeSeries: false } + ); + + // Wait for initial schema analysis to complete + await waitFor(() => { + expect(analyzeCollectionSchemaStub).to.have.been.calledOnce; + }); + + // Reset the stub to track new calls + analyzeCollectionSchemaStub.resetHistory(); + + // Simulate the empty collection + store.dispatch({ + type: 'compass-collection/SchemaAnalysisFailed', + error: new EmptyCollectionError(), + }); + + // Emit import-finished event for different connection + globalAppRegistry.emit( + 'import-finished', + { + ns: defaultMetadata.namespace, + connectionId: 'different-connection-id', + }, + { connectionId: 'different-connection-id' } + ); + + // Wait a bit to ensure no action is taken + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); + expect(analyzeCollectionSchemaStub).to.not.have.been.called; + }); + + it('should not re-trigger schema analysis when user is not in experiment variant', async function () { + const getAssignment = sandbox.spy(() => + Promise.resolve( + createMockAssignment(ExperimentTestGroup.mockDataGeneratorControl) + ) + ); + const assignExperiment = sandbox.spy(() => Promise.resolve(null)); + + await configureStore( + undefined, + undefined, + { getAssignment, assignExperiment }, + mockAtlasConnectionInfo + ); + + // Wait for initial assignment check + await waitFor(() => { + expect(getAssignment).to.have.been.calledOnce; + }); + + // Schema analysis should not have been called initially + expect(analyzeCollectionSchemaStub).to.not.have.been.called; + + // Verify the schema analysis state is INITIAL (as expected for control variant) + const initialState = store.getState() as { + schemaAnalysis: { status: string }; + }; + expect(initialState.schemaAnalysis.status).to.equal('initial'); + + // Emit import-finished event + globalAppRegistry.emit( + 'import-finished', + { + ns: defaultMetadata.namespace, + connectionId: mockAtlasConnectionInfo.current.id, + }, + { connectionId: mockAtlasConnectionInfo.current.id } + ); + + // Wait a bit to ensure schema analysis is not called + await new Promise((resolve) => setTimeout(resolve, WAIT_TIME)); + expect(analyzeCollectionSchemaStub).to.not.have.been.called; }); }); }); diff --git a/packages/compass-collection/src/stores/collection-tab.ts b/packages/compass-collection/src/stores/collection-tab.ts index 10f60a834e2..7b558c41d73 100644 --- a/packages/compass-collection/src/stores/collection-tab.ts +++ b/packages/compass-collection/src/stores/collection-tab.ts @@ -27,7 +27,95 @@ import { ExperimentTestName, ExperimentTestGroup, } from '@mongodb-js/compass-telemetry/provider'; -import { SCHEMA_ANALYSIS_STATE_INITIAL } from '../schema-analysis-types'; +import { + SCHEMA_ANALYSIS_STATE_INITIAL, + SCHEMA_ANALYSIS_STATE_ERROR, + SCHEMA_ANALYSIS_STATE_COMPLETE, + SCHEMA_ANALYSIS_STATE_ANALYZING, +} from '../schema-analysis-types'; +import type { CollectionState } from '../modules/collection-tab'; + +/** + * Determines if collection has valid schema analysis data. + * Returns true when analysis is complete and has processed schema data. + */ +export function selectHasSchemaAnalysisData(state: CollectionState): boolean { + return !!( + state.schemaAnalysis && + state.schemaAnalysis.status === SCHEMA_ANALYSIS_STATE_COMPLETE && + Object.keys(state.schemaAnalysis.processedSchema).length > 0 + ); +} + +/** + * Determines if schema analysis error is of 'unsupportedState' type. + * Used for showing specific error messages and disabling certain features. + */ +export function selectHasUnsupportedStateError( + state: CollectionState +): boolean { + return ( + state.schemaAnalysis?.status === SCHEMA_ANALYSIS_STATE_ERROR && + state.schemaAnalysis?.error?.errorType === 'unsupportedState' + ); +} + +/** + * Determines if collection appears empty (no schema data and not analyzing). + * Used for UI states and button enabling/disabling. + */ +export function selectIsCollectionEmpty(state: CollectionState): boolean { + return ( + state.schemaAnalysis?.status === SCHEMA_ANALYSIS_STATE_ERROR && + state.schemaAnalysis?.error?.errorType === 'empty' + ); +} + +/** + * Determines if schema analysis should be re-triggered after document insertion. + * Re-triggers when collection has no valid schema analysis data (error states, + * initial state, and completed analysis with empty schema). + */ +export function selectShouldRetriggerSchemaAnalysis( + state: CollectionState +): boolean { + // Don't retrigger if already analyzing + if (state.schemaAnalysis?.status === SCHEMA_ANALYSIS_STATE_ANALYZING) { + return false; + } + + // Re-trigger if no valid schema data + return !selectHasSchemaAnalysisData(state); +} + +/** + * Checks if user is in Mock Data Generator experiment variant. + * Returns false on error to default to not running schema analysis. + */ +async function shouldRunSchemaAnalysis( + experimentationServices: ExperimentationServices, + logger: Logger, + namespace: string +): Promise { + try { + const assignment = await experimentationServices.getAssignment( + ExperimentTestName.mockDataGenerator, + false // Don't track "Experiment Viewed" event here + ); + return ( + assignment?.assignmentData?.variant === + ExperimentTestGroup.mockDataGeneratorVariant + ); + } catch (error) { + // On error, default to not running schema analysis + logger.debug('Failed to get Mock Data Generator experiment assignment', { + experiment: ExperimentTestName.mockDataGenerator, + namespace: namespace, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} export type CollectionTabOptions = { /** @@ -59,7 +147,7 @@ export type CollectionTabServices = { export function activatePlugin( { namespace, editViewName, tabId }: CollectionTabOptions, - services: CollectionTabServices, + services: CollectionTabServices & { globalAppRegistry: AppRegistry }, { on, cleanup, addCleanup }: ActivateHelpers ): { store: ReturnType; @@ -69,6 +157,7 @@ export function activatePlugin( dataService, collection: collectionModel, localAppRegistry, + globalAppRegistry, atlasAiService, workspaces, experimentationServices, @@ -141,6 +230,73 @@ export function activatePlugin( store.dispatch(selectTab('Schema')); }); + const handleSchemaAnalysisRetrigger = (eventType: string) => { + const currentState = store.getState(); + if (selectShouldRetriggerSchemaAnalysis(currentState)) { + // Check if user is in Mock Data Generator experiment variant before re-triggering + shouldRunSchemaAnalysis(experimentationServices, logger, namespace) + .then((shouldRun) => { + if (shouldRun) { + logger.debug(`Re-triggering schema analysis after ${eventType}`, { + namespace, + }); + void store.dispatch(analyzeCollectionSchema()); + } + }) + .catch((error) => { + logger.debug('Error checking schema analysis experiment', { + namespace: namespace, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + }; + + // Listen for document insertions to re-trigger schema analysis for previously empty collections + on( + globalAppRegistry, + 'document-inserted', + ( + payload: { + ns: string; + view?: string; + mode: string; + multiple: boolean; + docs: unknown[]; + }, + { connectionId }: { connectionId?: string } = {} + ) => { + // Ensure event is for the current connection and namespace + if ( + connectionId === connectionInfoRef.current.id && + payload.ns === namespace + ) { + handleSchemaAnalysisRetrigger('document insertion'); + } + } + ); + + // Listen for import completion to re-trigger schema analysis for previously empty collections + on( + globalAppRegistry, + 'import-finished', + ( + payload: { + ns: string; + connectionId?: string; + }, + { connectionId }: { connectionId?: string } = {} + ) => { + // Ensure event is for the current connection and namespace + if ( + connectionId === connectionInfoRef.current.id && + payload.ns === namespace + ) { + handleSchemaAnalysisRetrigger('import finished'); + } + } + ); + void collectionModel.fetchMetadata({ dataService }).then((metadata) => { store.dispatch(collectionMetadataFetched(metadata)); @@ -169,31 +325,11 @@ export function activatePlugin( if (!metadata.isReadonly && !metadata.isTimeSeries) { // Check experiment variant before running schema analysis // Only run schema analysis if user is in treatment variant - const shouldRunSchemaAnalysis = async () => { - try { - const assignment = await experimentationServices.getAssignment( - ExperimentTestName.mockDataGenerator, - false // Don't track "Experiment Viewed" event here - ); - return ( - assignment?.assignmentData?.variant === - ExperimentTestGroup.mockDataGeneratorVariant - ); - } catch (error) { - // On error, default to not running schema analysis - logger.debug( - 'Failed to get Mock Data Generator experiment assignment', - { - experiment: ExperimentTestName.mockDataGenerator, - namespace: namespace, - error: error instanceof Error ? error.message : String(error), - } - ); - return false; - } - }; - - void shouldRunSchemaAnalysis().then((shouldRun) => { + void shouldRunSchemaAnalysis( + experimentationServices, + logger, + namespace + ).then((shouldRun) => { if (shouldRun) { void store.dispatch(analyzeCollectionSchema()); }