+
+ );
+}
+
+export {
+ SpinLoaderWithLabel,
+ SpinLoader,
+ CancelLoader,
+ ProgressLoaderWithCancel,
+};
diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts
index cc6484d7b86..d9c36b124e3 100644
--- a/packages/compass-components/src/index.ts
+++ b/packages/compass-components/src/index.ts
@@ -40,6 +40,7 @@ export {
SpinLoader,
SpinLoaderWithLabel,
CancelLoader,
+ ProgressLoaderWithCancel,
} from './components/loader';
import { ResizeHandle, ResizeDirection } from './components/resize-handle';
import { Accordion } from './components/accordion';
diff --git a/packages/compass-data-modeling/src/components/analysis-progress-status.spec.tsx b/packages/compass-data-modeling/src/components/analysis-progress-status.spec.tsx
new file mode 100644
index 00000000000..b7776d2413f
--- /dev/null
+++ b/packages/compass-data-modeling/src/components/analysis-progress-status.spec.tsx
@@ -0,0 +1,139 @@
+import React from 'react';
+import { expect } from 'chai';
+import { screen, waitFor } from '@mongodb-js/testing-library-compass';
+import AnalysisProgressStatus from './analysis-progress-status';
+import { renderWithStore, testConnections } from '../../test/setup-store';
+import {
+ AnalysisProcessActionTypes,
+ startAnalysis,
+} from '../store/analysis-process';
+import { createSandboxFromDefaultPreferences } from 'compass-preferences-model';
+
+describe('AnalysisProgressStatus', () => {
+ async function renderAnalysisProgressStatus({
+ automaticallyInferRelations = false,
+ } = {}) {
+ const preferences = await createSandboxFromDefaultPreferences();
+ const { store } = renderWithStore(
, {
+ services: {
+ preferences,
+ },
+ });
+ void store.dispatch(
+ startAnalysis(
+ 'My Diagram',
+ testConnections[0].id,
+ 'testDB',
+ ['coll1', 'coll2', 'coll3'],
+ { automaticallyInferRelations }
+ )
+ );
+ return store;
+ }
+
+ it('Allows cancellation', async () => {
+ const store = await renderAnalysisProgressStatus();
+ expect(screen.getByText('Sampling collections…')).to.be.visible;
+ expect(screen.getByText('Cancel')).to.be.visible;
+ screen.getByText('Cancel').click();
+ await waitFor(() => {
+ expect(store.getState().analysisProgress.step).to.equal('IDLE');
+ expect(store.getState().diagram).to.be.null;
+ });
+ });
+
+ describe('Keeps showing progress along the way', () => {
+ it('Without relationship inferring', async () => {
+ const store = await renderAnalysisProgressStatus({
+ automaticallyInferRelations: false,
+ });
+ expect(screen.getByText('Sampling collections…')).to.be.visible;
+ expect(screen.getByText('0/3')).to.be.visible;
+
+ // 2 out of 3 samples fetched, 1 analyzed
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SAMPLE_FETCHED,
+ });
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SAMPLE_FETCHED,
+ });
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SCHEMA_ANALYZED,
+ });
+
+ expect(screen.getByText('Sampling collections…')).to.be.visible;
+ expect(screen.getByText('2/3')).to.be.visible;
+
+ // Last sample fetched
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SAMPLE_FETCHED,
+ });
+
+ expect(screen.getByText('Analyzing collection schemas…')).to.be.visible;
+ expect(screen.getByText('1/3')).to.be.visible;
+
+ // Finish analyzing
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SCHEMA_ANALYZED,
+ });
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SCHEMA_ANALYZED,
+ });
+
+ expect(screen.queryByText('Inferring relationships between collections…'))
+ .not.to.exist;
+ expect(screen.getByText('Preparing diagram…')).to.be.visible;
+ });
+
+ it('With relationship inferring', async () => {
+ const store = await renderAnalysisProgressStatus({
+ automaticallyInferRelations: true,
+ });
+ expect(screen.getByText('Sampling collections…')).to.be.visible;
+ expect(screen.getByText('0/3')).to.be.visible;
+
+ // Fetch and analyze all samples
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SAMPLE_FETCHED,
+ });
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SAMPLE_FETCHED,
+ });
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SAMPLE_FETCHED,
+ });
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SCHEMA_ANALYZED,
+ });
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SCHEMA_ANALYZED,
+ });
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_SCHEMA_ANALYZED,
+ });
+
+ expect(screen.getByText('Inferring relationships between collections…'))
+ .to.be.visible;
+ expect(screen.queryByText('0/3')).not.to.exist;
+
+ // Infer some relationships
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_RELATIONS_INFERRED,
+ });
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_RELATIONS_INFERRED,
+ });
+
+ expect(screen.getByText('Inferring relationships between collections…'))
+ .to.be.visible;
+ expect(screen.queryByText('2/3')).not.to.exist;
+
+ // Finish inferring
+ store.dispatch({
+ type: AnalysisProcessActionTypes.NAMESPACE_RELATIONS_INFERRED,
+ });
+
+ expect(screen.getByText('Preparing diagram…')).to.be.visible;
+ });
+ });
+});
diff --git a/packages/compass-data-modeling/src/components/analysis-progress-status.tsx b/packages/compass-data-modeling/src/components/analysis-progress-status.tsx
new file mode 100644
index 00000000000..2b2e30cc967
--- /dev/null
+++ b/packages/compass-data-modeling/src/components/analysis-progress-status.tsx
@@ -0,0 +1,118 @@
+import {
+ ProgressLoaderWithCancel,
+ useDarkMode,
+} from '@mongodb-js/compass-components';
+import React from 'react';
+import { connect } from 'react-redux';
+import type { DataModelingState } from '../store/reducer';
+import { cancelAnalysis, type AnalysisStep } from '../store/analysis-process';
+
+function getProgressPropsFromStatus({
+ step,
+ sampledCollections,
+ analyzedCollections,
+ collectionRelationsInferred,
+ totalCollections,
+}: {
+ step: AnalysisStep;
+ sampledCollections: number;
+ analyzedCollections: number;
+ collectionRelationsInferred: number;
+ totalCollections: number;
+}): {
+ label: string;
+} & (
+ | {
+ isIndeterminate: false;
+ maxValue: number;
+ value: number;
+ formatValue?: 'fraction';
+ }
+ | {
+ isIndeterminate: true;
+ }
+) {
+ if (step === 'SAMPLING') {
+ return {
+ isIndeterminate: false,
+ label: 'Sampling collections…',
+ maxValue: totalCollections,
+ value: sampledCollections,
+ formatValue: 'fraction',
+ };
+ }
+ if (step === 'ANALYZING_SCHEMA') {
+ return {
+ isIndeterminate: false,
+ label: 'Analyzing collection schemas…',
+ maxValue: totalCollections,
+ value: analyzedCollections,
+ formatValue: 'fraction',
+ };
+ }
+ if (step === 'INFERRING_RELATIONSHIPS') {
+ return {
+ isIndeterminate: false,
+ label: 'Inferring relationships between collections…',
+ maxValue: totalCollections,
+ value: collectionRelationsInferred,
+ formatValue: undefined,
+ };
+ }
+ return {
+ isIndeterminate: true,
+ label: 'Preparing diagram…',
+ };
+}
+
+export type AnalysisProgressStatusProps = {
+ step: AnalysisStep;
+ sampledCollections: number;
+ analyzedCollections: number;
+ collectionRelationsInferred: number;
+ totalCollections: number;
+ onCancelClick: () => void;
+};
+
+export const AnalysisProgressStatus: React.FC
= ({
+ step,
+ sampledCollections,
+ analyzedCollections,
+ collectionRelationsInferred,
+ totalCollections,
+ onCancelClick,
+}) => {
+ const darkMode = useDarkMode();
+ return (
+
+ );
+};
+
+export default connect(
+ (state: DataModelingState) => {
+ const analysisProgress = state.analysisProgress;
+ return {
+ step: analysisProgress.step,
+ sampledCollections: analysisProgress.samplesFetched,
+ analyzedCollections: analysisProgress.schemasAnalyzed,
+ collectionRelationsInferred: analysisProgress.collectionRelationsInferred,
+ totalCollections:
+ analysisProgress.currentAnalysisOptions?.collections.length ?? 0,
+ };
+ },
+ {
+ onCancelClick: cancelAnalysis,
+ }
+)(AnalysisProgressStatus);
diff --git a/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx b/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx
index 31750755507..2d3df8d6cdf 100644
--- a/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx
+++ b/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx
@@ -215,7 +215,7 @@ export default connect(
(state: DataModelingState) => {
const { diagram, step } = state;
return {
- step: step,
+ step,
hasUndo: (diagram?.edits.prev.length ?? 0) > 0,
hasRedo: (diagram?.edits.next.length ?? 0) > 0,
diagramName: diagram?.name,
diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx
index ef08e257deb..dec3a101b7b 100644
--- a/packages/compass-data-modeling/src/components/diagram-editor.tsx
+++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx
@@ -34,7 +34,6 @@ import type {
} from '@mongodb-js/compass-components';
import {
Banner,
- CancelLoader,
WorkspaceContainer,
css,
spacing,
@@ -50,7 +49,7 @@ import {
useFocusStateIncludingUnfocused,
FocusStates,
} from '@mongodb-js/compass-components';
-import { cancelAnalysis, retryAnalysis } from '../store/analysis-process';
+import { retryAnalysis } from '../store/analysis-process';
import type { FieldPath, StaticModel } from '../services/data-model-storage';
import DiagramEditorToolbar from './diagram-editor-toolbar';
import ExportDiagramModal from './export-diagram-modal';
@@ -64,15 +63,7 @@ import toNS from 'mongodb-ns';
import { FIELD_TYPES } from '../utils/field-types';
import { getNamespaceRelationships } from '../utils/utils';
import { usePreference } from 'compass-preferences-model/provider';
-
-const loadingContainerStyles = css({
- width: '100%',
- paddingTop: spacing[1800] * 3,
-});
-
-const loaderStyles = css({
- margin: '0 auto',
-});
+import AnalysisProgressStatus from './analysis-progress-status';
const errorBannerStyles = css({
margin: spacing[200],
@@ -585,14 +576,12 @@ const DiagramEditor: React.FunctionComponent<{
step: DataModelingState['step'];
diagramId?: string;
onRetryClick: () => void;
- onCancelClick: () => void;
onAddCollectionClick: () => void;
DiagramComponent?: typeof Diagram;
}> = ({
step,
diagramId,
onRetryClick,
- onCancelClick,
onAddCollectionClick,
DiagramComponent = Diagram,
}) => {
@@ -622,16 +611,7 @@ const DiagramEditor: React.FunctionComponent<{
}
if (step === 'ANALYZING') {
- content = (
-
-
-
- );
+ content = ;
}
if (step === 'ANALYSIS_FAILED') {
@@ -690,7 +670,6 @@ export default connect(
},
{
onRetryClick: retryAnalysis,
- onCancelClick: cancelAnalysis,
onAddCollectionClick: addCollection,
}
)(DiagramEditor);
diff --git a/packages/compass-data-modeling/src/components/new-diagram-form.spec.tsx b/packages/compass-data-modeling/src/components/new-diagram-form.spec.tsx
index 72794bd1ed3..7e5f7f9c9b9 100644
--- a/packages/compass-data-modeling/src/components/new-diagram-form.spec.tsx
+++ b/packages/compass-data-modeling/src/components/new-diagram-form.spec.tsx
@@ -10,6 +10,7 @@ import NewDiagramForm from './new-diagram-form';
import { changeName, createNewDiagram } from '../store/generate-diagram-wizard';
import { renderWithStore } from '../../test/setup-store';
import type { DataModelingStore } from '../../test/setup-store';
+import { createSandboxFromDefaultPreferences } from 'compass-preferences-model';
function getComboboxByTestId(testId: string) {
return within(screen.getByTestId(testId)).getByRole('combobox');
@@ -282,7 +283,12 @@ describe('NewDiagramForm', function () {
context('select-collections step', function () {
it('shows list of collections', async function () {
- const { store } = renderWithStore();
+ const preferences = await createSandboxFromDefaultPreferences();
+ const { store } = renderWithStore(, {
+ services: {
+ preferences,
+ },
+ });
{
// Navigate to connections step
diff --git a/packages/compass-data-modeling/src/components/new-diagram-form.tsx b/packages/compass-data-modeling/src/components/new-diagram-form.tsx
index b99516c1e34..4e73f830c26 100644
--- a/packages/compass-data-modeling/src/components/new-diagram-form.tsx
+++ b/packages/compass-data-modeling/src/components/new-diagram-form.tsx
@@ -39,6 +39,7 @@ import {
Checkbox,
} from '@mongodb-js/compass-components';
import { usePreference } from 'compass-preferences-model/provider';
+import { selectIsAnalysisInProgress } from '../store/analysis-process';
const footerStyles = css({
flexDirection: 'row',
@@ -559,8 +560,7 @@ export default connect(
collections: databaseCollections ?? [],
selectedCollections: selectedCollections ?? [],
error,
- analysisInProgress:
- state.analysisProgress.analysisProcessStatus === 'in-progress',
+ analysisInProgress: selectIsAnalysisInProgress(state),
automaticallyInferRelationships:
state.generateDiagramWizard.automaticallyInferRelations,
};
diff --git a/packages/compass-data-modeling/src/store/analysis-process.ts b/packages/compass-data-modeling/src/store/analysis-process.ts
index 1c1ab5e81e1..21700e1918c 100644
--- a/packages/compass-data-modeling/src/store/analysis-process.ts
+++ b/packages/compass-data-modeling/src/store/analysis-process.ts
@@ -16,6 +16,12 @@ import {
import { inferForeignToLocalRelationshipsForCollection } from './relationships';
import { mongoLogId } from '@mongodb-js/compass-logging/provider';
+export type AnalysisStep =
+ | 'IDLE'
+ | 'SAMPLING'
+ | 'ANALYZING_SCHEMA'
+ | 'INFERRING_RELATIONSHIPS';
+
export type AnalysisProcessState = {
currentAnalysisOptions:
| ({
@@ -25,10 +31,11 @@ export type AnalysisProcessState = {
collections: string[];
} & AnalysisOptions)
| null;
- analysisProcessStatus: 'idle' | 'in-progress';
+ step: AnalysisStep;
samplesFetched: number;
schemasAnalyzed: number;
- relationsInferred: number;
+ willInferRelations: boolean;
+ collectionRelationsInferred: number;
};
export const AnalysisProcessActionTypes = {
@@ -38,8 +45,8 @@ export const AnalysisProcessActionTypes = {
'data-modeling/analysis-stats/NAMESPACE_SAMPLE_FETCHED',
NAMESPACE_SCHEMA_ANALYZED:
'data-modeling/analysis-stats/NAMESPACE_SCHEMA_ANALYZED',
- NAMESPACES_RELATIONS_INFERRED:
- 'data-modeling/analysis-stats/NAMESPACES_RELATIONS_INFERRED',
+ NAMESPACE_RELATIONS_INFERRED:
+ 'data-modeling/analysis-stats/NAMESPACE_RELATIONS_INFERRED',
ANALYSIS_FINISHED: 'data-modeling/analysis-stats/ANALYSIS_FINISHED',
ANALYSIS_FAILED: 'data-modeling/analysis-stats/ANALYSIS_FAILED',
ANALYSIS_CANCELED: 'data-modeling/analysis-stats/ANALYSIS_CANCELED',
@@ -56,22 +63,19 @@ export type AnalyzingCollectionsStartAction = {
database: string;
collections: string[];
options: AnalysisOptions;
+ willInferRelations: boolean;
};
export type NamespaceSampleFetchedAction = {
type: typeof AnalysisProcessActionTypes.NAMESPACE_SAMPLE_FETCHED;
- namespace: string;
};
export type NamespaceSchemaAnalyzedAction = {
type: typeof AnalysisProcessActionTypes.NAMESPACE_SCHEMA_ANALYZED;
- namespace: string;
};
export type NamespacesRelationsInferredAction = {
- type: typeof AnalysisProcessActionTypes.NAMESPACES_RELATIONS_INFERRED;
- namespace: string;
- count: number;
+ type: typeof AnalysisProcessActionTypes.NAMESPACE_RELATIONS_INFERRED;
};
export type AnalysisFinishedAction = {
@@ -106,24 +110,26 @@ export type AnalysisProgressActions =
| AnalysisFailedAction
| AnalysisCanceledAction;
-const INITIAL_STATE = {
+const INITIAL_STATE: AnalysisProcessState = {
currentAnalysisOptions: null,
- analysisProcessStatus: 'idle' as const,
+ step: 'IDLE',
samplesFetched: 0,
schemasAnalyzed: 0,
- relationsInferred: 0,
+ willInferRelations: false,
+ collectionRelationsInferred: 0,
};
export const analysisProcessReducer: Reducer = (
state = INITIAL_STATE,
action
) => {
+ const totalCollections =
+ state.currentAnalysisOptions?.collections.length ?? 0;
if (
isAction(action, AnalysisProcessActionTypes.ANALYZING_COLLECTIONS_START)
) {
return {
...INITIAL_STATE,
- analysisProcessStatus: 'in-progress',
currentAnalysisOptions: {
name: action.name,
connectionId: action.connectionId,
@@ -131,18 +137,42 @@ export const analysisProcessReducer: Reducer = (
collections: action.collections,
automaticallyInferRelations: action.options.automaticallyInferRelations,
},
+ step: 'SAMPLING',
+ willInferRelations: action.willInferRelations,
};
}
if (isAction(action, AnalysisProcessActionTypes.NAMESPACE_SAMPLE_FETCHED)) {
+ const samplesFetched = state.samplesFetched + 1;
+ const nextStep = 'ANALYZING_SCHEMA';
return {
...state,
- samplesFetched: state.samplesFetched + 1,
+ samplesFetched,
+ step: samplesFetched === totalCollections ? nextStep : state.step,
};
}
if (isAction(action, AnalysisProcessActionTypes.NAMESPACE_SCHEMA_ANALYZED)) {
+ const schemasAnalyzed = state.schemasAnalyzed + 1;
+ const nextStep = state.willInferRelations
+ ? 'INFERRING_RELATIONSHIPS'
+ : 'IDLE';
return {
...state,
- schemasAnalyzed: state.schemasAnalyzed + 1,
+ schemasAnalyzed,
+ step: schemasAnalyzed === totalCollections ? nextStep : state.step,
+ };
+ }
+ if (
+ isAction(action, AnalysisProcessActionTypes.NAMESPACE_RELATIONS_INFERRED)
+ ) {
+ const collectionRelationsInferred = state.collectionRelationsInferred + 1;
+ const nextStep = 'IDLE';
+ return {
+ ...state,
+ collectionRelationsInferred,
+ step:
+ collectionRelationsInferred === totalCollections
+ ? nextStep
+ : state.step,
};
}
if (
@@ -152,7 +182,7 @@ export const analysisProcessReducer: Reducer = (
) {
return {
...state,
- analysisProcessStatus: 'idle',
+ step: 'IDLE',
};
}
return state;
@@ -218,6 +248,9 @@ export function startAnalysis(
});
const cancelController = (cancelAnalysisControllerRef.current =
new AbortController());
+ const willInferRelations =
+ preferences.getPreferences().enableAutomaticRelationshipInference &&
+ options.automaticallyInferRelations;
dispatch({
type: AnalysisProcessActionTypes.ANALYZING_COLLECTIONS_START,
name,
@@ -225,6 +258,7 @@ export function startAnalysis(
database,
collections,
options,
+ willInferRelations,
});
try {
let relations: Relationship[] = [];
@@ -242,31 +276,27 @@ export function startAnalysis(
}
);
- const accessor = await analyzeDocuments(sample, {
- signal: cancelController.signal,
- });
-
- // TODO(COMPASS-9314): Update how we show analysis progress.
dispatch({
type: AnalysisProcessActionTypes.NAMESPACE_SAMPLE_FETCHED,
- namespace: ns,
+ });
+
+ const accessor = await analyzeDocuments(sample, {
+ signal: cancelController.signal,
});
const schema = await accessor.getMongoDBJsonSchema({
signal: cancelController.signal,
});
+
dispatch({
type: AnalysisProcessActionTypes.NAMESPACE_SCHEMA_ANALYZED,
- namespace: ns,
});
+
return { ns, schema, sample, isExpanded: DEFAULT_IS_EXPANDED };
})
);
- const attemptRelationshipInference =
- preferences.getPreferences().enableAutomaticRelationshipInference &&
- options.automaticallyInferRelations;
- if (attemptRelationshipInference) {
+ if (willInferRelations) {
relations = (
await Promise.all(
collections.map(
@@ -293,9 +323,7 @@ export function startAnalysis(
}
);
dispatch({
- type: AnalysisProcessActionTypes.NAMESPACES_RELATIONS_INFERRED,
- namespace: ns,
- count: relationships.length,
+ type: AnalysisProcessActionTypes.NAMESPACE_RELATIONS_INFERRED,
});
return relationships;
}
@@ -338,7 +366,7 @@ export function startAnalysis(
track('Data Modeling Diagram Created', {
num_collections: collections.length,
- num_relations_inferred: attemptRelationshipInference
+ num_relations_inferred: willInferRelations
? relations.length
: undefined,
});
@@ -392,3 +420,7 @@ export function cancelAnalysis(): DataModelingThunkAction {
cancelAnalysisControllerRef.current = null;
};
}
+
+export const selectIsAnalysisInProgress = (state: {
+ analysisProgress: AnalysisProcessState;
+}): boolean => state.analysisProgress.step !== 'IDLE';
diff --git a/packages/compass-data-modeling/test/setup-store.tsx b/packages/compass-data-modeling/test/setup-store.tsx
index 1b0d89ea509..5a7ba38bdd9 100644
--- a/packages/compass-data-modeling/test/setup-store.tsx
+++ b/packages/compass-data-modeling/test/setup-store.tsx
@@ -28,7 +28,7 @@ type ConnectionInfoWithMockData = ConnectionInfo & {
};
export type DataModelingStore = Awaited>;
-const testConnections = [
+export const testConnections = [
{
id: 'one',
savedConnectionType: 'favorite' as const,
@@ -174,6 +174,19 @@ export const setupStore = (
conn.databases.find((x) => x._id === database)?.collections
);
},
+ sample: () => {
+ return new Promise((resolve) =>
+ setTimeout(
+ () =>
+ resolve([
+ { _id: 'sample1' },
+ { _id: 'sample2' },
+ { _id: 'sample3' },
+ ]),
+ 1
+ )
+ );
+ },
};
},
} as any,