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());
}