diff --git a/package-lock.json b/package-lock.json index a3ee0bc831e..351678c6e92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45958,7 +45958,6 @@ "@mongodb-js/compass-query-bar": "^8.50.5", "@mongodb-js/compass-telemetry": "^1.3.5", "@mongodb-js/connection-storage": "^0.25.5", - "@mongodb-js/reflux-state-mixin": "^1.1.5", "bson": "^6.10.1", "compass-preferences-model": "^2.32.5", "d3": "^3.5.17", @@ -45976,7 +45975,9 @@ "react": "^17.0.2", "react-leaflet": "^2.4.0", "react-leaflet-draw": "^0.19.0", - "reflux": "^0.4.1" + "react-redux": "^8.1.3", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2" }, "devDependencies": { "@mongodb-js/eslint-config-compass": "^1.2.5", @@ -56909,7 +56910,6 @@ "@mongodb-js/mocha-config-compass": "^1.5.5", "@mongodb-js/my-queries-storage": "^0.21.5", "@mongodb-js/prettier-config-compass": "^1.1.5", - "@mongodb-js/reflux-state-mixin": "^1.1.5", "@mongodb-js/testing-library-compass": "^1.1.5", "@mongodb-js/tsconfig-compass": "^1.1.5", "@types/chai": "^4.2.21", @@ -56942,7 +56942,9 @@ "react-dom": "^17.0.2", "react-leaflet": "^2.4.0", "react-leaflet-draw": "^0.19.0", - "reflux": "^0.4.1", + "react-redux": "^8.1.3", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", "sinon": "^9.2.3", "typescript": "^5.0.4", "xvfb-maybe": "^0.2.1" diff --git a/packages/compass-schema/package.json b/packages/compass-schema/package.json index 728734d9b25..5bd2bd03510 100644 --- a/packages/compass-schema/package.json +++ b/packages/compass-schema/package.json @@ -97,8 +97,9 @@ "react": "^17.0.2", "react-leaflet": "^2.4.0", "react-leaflet-draw": "^0.19.0", - "reflux": "^0.4.1", - "@mongodb-js/reflux-state-mixin": "^1.1.5" + "react-redux": "^8.1.3", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2" }, "is_compass_plugin": true } diff --git a/packages/compass-schema/src/actions/index.spec.ts b/packages/compass-schema/src/actions/index.spec.ts deleted file mode 100644 index bfaf8dab6a2..00000000000 --- a/packages/compass-schema/src/actions/index.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { expect } from 'chai'; - -import { configureActions } from '.'; - -describe('#configureActions', function () { - it('returns a new instance of the reflux actions', function () { - expect(configureActions().startAnalysis).to.not.equal(undefined); - }); -}); diff --git a/packages/compass-schema/src/actions/index.ts b/packages/compass-schema/src/actions/index.ts deleted file mode 100644 index 41c90013e98..00000000000 --- a/packages/compass-schema/src/actions/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import Reflux from 'reflux'; - -/** - * Need to create an instance of actions for each store. - * - * @returns {Actions} - The actions. - */ -export const configureActions = () => { - return Reflux.createActions({ - /** - * starts schema analysis with the current query - */ - startAnalysis: { sync: false }, - /** - * stops schema analysis - */ - stopAnalysis: { sync: true }, - /** - * Reset store - */ - reset: { sync: false }, - /** - * set new maxTimeMS value - */ - setMaxTimeMS: { sync: true }, - /** - * reset maxTimeMS value to default - */ - resetMaxTimeMS: { sync: true }, - /** - * Resize the minicharts. - */ - resizeMiniCharts: { sync: true }, - geoLayerAdded: { sync: true }, - geoLayersEdited: { sync: true }, - geoLayersDeleted: { sync: true }, - }); -}; diff --git a/packages/compass-schema/src/components/compass-schema.tsx b/packages/compass-schema/src/components/compass-schema.tsx index 3f2cee3b180..01f9abd716d 100644 --- a/packages/compass-schema/src/components/compass-schema.tsx +++ b/packages/compass-schema/src/components/compass-schema.tsx @@ -1,5 +1,6 @@ import React, { useCallback } from 'react'; - +import type { Schema as MongodbSchema } from 'mongodb-schema'; +import { connect } from 'react-redux'; import type { AnalysisState } from '../constants/analysis-states'; import { ANALYSIS_STATE_INITIAL, @@ -29,12 +30,13 @@ import { Badge, Icon, } from '@mongodb-js/compass-components'; -import type { configureActions } from '../actions'; import { usePreference } from 'compass-preferences-model/provider'; import { useConnectionInfo } from '@mongodb-js/compass-connections/provider'; import { getAtlasPerformanceAdvisorLink } from '../utils'; import { useIsLastAppliedQueryOutdated } from '@mongodb-js/compass-query-bar'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; +import type { RootState } from '../stores/store'; +import { startAnalysis, stopAnalysis } from '../stores/reducer'; const rootStyles = css({ width: '100%', @@ -296,10 +298,9 @@ const AnalyzingScreen: React.FunctionComponent<{ }; const FieldList: React.FunctionComponent<{ - schema: any; + schema: MongodbSchema | null; analysisState: AnalysisState; - actions: Record; -}> = ({ schema, analysisState, actions }) => { +}> = ({ schema, analysisState }) => { const darkMode = useDarkMode(); if (analysisState !== ANALYSIS_STATE_COMPLETE) { @@ -327,7 +328,7 @@ const FieldList: React.FunctionComponent<{ >
{fields.map((field: any) => ( - + ))}
@@ -366,25 +367,25 @@ const PerformanceAdvisorBanner = () => { }; const Schema: React.FunctionComponent<{ - actions: ReturnType; analysisState: AnalysisState; errorMessage?: string; maxTimeMS?: number; - schema?: any; + schema: MongodbSchema | null; count?: number; resultId?: string; -}> = ({ actions, analysisState, errorMessage, schema, resultId }) => { + onStartAnalysis: () => Promise; + onStopAnalysis: () => void; +}> = ({ + analysisState, + errorMessage, + schema, + resultId, + onStartAnalysis, + onStopAnalysis, +}) => { const onApplyClicked = useCallback(() => { - actions.startAnalysis(); - }, [actions]); - - const onCancelClicked = useCallback(() => { - actions.stopAnalysis(); - }, [actions]); - - const onResetClicked = useCallback(() => { - actions.startAnalysis(); - }, [actions]); + void onStartAnalysis(); + }, [onStartAnalysis]); const outdated = useIsLastAppliedQueryOutdated('schema'); @@ -398,7 +399,7 @@ const Schema: React.FunctionComponent<{ toolbar={ )} {analysisState === ANALYSIS_STATE_ANALYZING && ( - + )} {analysisState === ANALYSIS_STATE_COMPLETE && ( - + )} @@ -428,4 +425,15 @@ const Schema: React.FunctionComponent<{ ); }; -export default Schema; +export default connect( + (state: RootState) => ({ + analysisState: state.analysisState, + errorMessage: state.errorMessage, + schema: state.schema, + resultId: state.resultId, + }), + { + onStartAnalysis: startAnalysis, + onStopAnalysis: stopAnalysis, + } +)(Schema); diff --git a/packages/compass-schema/src/components/coordinates-minichart/coordinates-minichart.jsx b/packages/compass-schema/src/components/coordinates-minichart/coordinates-minichart.jsx index 77378b443cf..79f70b24aeb 100644 --- a/packages/compass-schema/src/components/coordinates-minichart/coordinates-minichart.jsx +++ b/packages/compass-schema/src/components/coordinates-minichart/coordinates-minichart.jsx @@ -1,5 +1,6 @@ import React, { PureComponent, useCallback } from 'react'; import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; import L from 'leaflet'; @@ -17,6 +18,11 @@ import GeoscatterMapItem from './marker'; import { LIGHTMODE_TILE_URL, DARKMODE_TILE_URL } from './constants'; import { getHereAttributionMessage } from './utils'; import { debounce } from 'lodash'; +import { + geoLayerAdded, + geoLayersDeleted, + geoLayersEdited, +} from '../../stores/reducer'; // TODO: Disable boxZoom handler for circle lasso. // @@ -124,10 +130,12 @@ class UnthemedCoordinatesMinichart extends PureComponent { unique: PropTypes.number, values: PropTypes.array, }), - actions: PropTypes.object.isRequired, fieldName: PropTypes.string.isRequired, darkMode: PropTypes.bool, onGeoQueryChanged: PropTypes.func.isRequired, + geoLayerAdded: PropTypes.func.isRequired, + geoLayersEdited: PropTypes.func.isRequired, + geoLayersDeleted: PropTypes.func.isRequired, }; state = { @@ -239,7 +247,7 @@ class UnthemedCoordinatesMinichart extends PureComponent { } onCreated = (evt) => { - this.props.actions.geoLayerAdded( + this.props.geoLayerAdded( this.props.fieldName, evt.layer, this.props.onGeoQueryChanged @@ -247,7 +255,7 @@ class UnthemedCoordinatesMinichart extends PureComponent { }; onEdited = (evt) => { - this.props.actions.geoLayersEdited( + this.props.geoLayersEdited( this.props.fieldName, evt.layers, this.props.onGeoQueryChanged @@ -255,10 +263,7 @@ class UnthemedCoordinatesMinichart extends PureComponent { }; onDeleted = (evt) => { - this.props.actions.geoLayersDeleted( - evt.layers, - this.props.onGeoQueryChanged - ); + this.props.geoLayersDeleted(evt.layers, this.props.onGeoQueryChanged); }; /** @@ -328,5 +333,10 @@ CoordinatesMinichart.propTypes = { onQueryChanged: PropTypes.func, }; -export default CoordinatesMinichart; -export { CoordinatesMinichart }; +const ConnectedCoordinatesMinichart = connect(undefined, { + geoLayerAdded, + geoLayersEdited, + geoLayersDeleted, +})(CoordinatesMinichart); + +export default ConnectedCoordinatesMinichart; diff --git a/packages/compass-schema/src/components/field.spec.tsx b/packages/compass-schema/src/components/field.spec.tsx index 68c2ca09196..e891c711634 100644 --- a/packages/compass-schema/src/components/field.spec.tsx +++ b/packages/compass-schema/src/components/field.spec.tsx @@ -13,7 +13,6 @@ import { type SchemaType, } from 'mongodb-schema'; import { BSON, Decimal128 } from 'bson'; -import { configureActions } from '../actions'; import Field, { shouldShowUnboundArrayInsight } from './field'; import QueryBarPlugin from '@mongodb-js/compass-query-bar'; import { @@ -44,7 +43,6 @@ function renderField( ; name: string; path: string[]; types: SchemaType[]; @@ -196,7 +194,7 @@ export function shouldShowUnboundArrayInsight( ); } -function Field({ actions, name, path, types, enableMaps }: FieldProps) { +function Field({ name, path, types, enableMaps }: FieldProps) { const query = useQueryBarQuery(); const changeQuery = useChangeQueryBarQuery(); const [isExpanded, setIsExpanded] = useState(false); @@ -308,7 +306,6 @@ function Field({ actions, name, path, types, enableMaps }: FieldProps) { fieldValue={query.filter?.[fieldName]} type={activeType} nestedDocType={nestedDocType} - actions={actions} onQueryChanged={debouncedChangeQuery} /> @@ -326,7 +323,7 @@ function Field({ actions, name, path, types, enableMaps }: FieldProps) { {(getNestedDocType(types)?.fields || []).map( (field: SchemaField) => (
- +
) )} diff --git a/packages/compass-schema/src/components/minichart/minichart.jsx b/packages/compass-schema/src/components/minichart/minichart.jsx index e782db7e699..1d020158927 100644 --- a/packages/compass-schema/src/components/minichart/minichart.jsx +++ b/packages/compass-schema/src/components/minichart/minichart.jsx @@ -34,13 +34,10 @@ class MiniChart extends PureComponent { // but it is not noticable to the user. this.resizeListener(); window.addEventListener('resize', this.resizeListener); - this.unsubscribeMiniChartResize = - this.props.actions.resizeMiniCharts.listen(this.resizeListener); } componentWillUnmount() { window.removeEventListener('resize', this.resizeListener); - this.unsubscribeMiniChartResize(); } /** diff --git a/packages/compass-schema/src/stores/reducer.ts b/packages/compass-schema/src/stores/reducer.ts new file mode 100644 index 00000000000..1824a86be00 --- /dev/null +++ b/packages/compass-schema/src/stores/reducer.ts @@ -0,0 +1,309 @@ +import type { Schema } from 'mongodb-schema'; +import type { Action, AnyAction, Reducer } from 'redux'; +import { type AnalysisState } from '../constants/analysis-states'; +import { + ANALYSIS_STATE_ANALYZING, + ANALYSIS_STATE_COMPLETE, + ANALYSIS_STATE_ERROR, + ANALYSIS_STATE_INITIAL, + ANALYSIS_STATE_TIMEOUT, +} from '../constants/analysis-states'; +import { addLayer, generateGeoQuery } from '../modules/geo'; +import { + analyzeSchema, + calculateSchemaDepth, + schemaContainsGeoData, +} from '../modules/schema-analysis'; +import { capMaxTimeMSAtPreferenceLimit } from 'compass-preferences-model/provider'; +import { openToast } from '@mongodb-js/compass-components'; +import type { Circle, Layer, LayerGroup, Polygon } from 'leaflet'; +import { mongoLogId } from '@mongodb-js/compass-logging/provider'; +import type { SchemaThunkAction } from './store'; +import { UUID } from 'bson'; + +const DEFAULT_SAMPLE_SIZE = 1000; + +const ERROR_CODE_MAX_TIME_MS_EXPIRED = 50; + +export function isAction( + action: AnyAction, + type: A['type'] +): action is A { + return action.type === type; +} + +export type SchemaState = { + analysisState: AnalysisState; + errorMessage: string; + schema: Schema | null; + resultId: string; +}; + +export const enum SchemaActions { + analysisStarted = 'schema-service/schema/analysisStarted', + analysisFinished = 'schema-service/schema/analysisFinished', + analysisFailed = 'schema-service/schema/analysisFailed', +} + +export type AnalysisStartedAction = { + type: SchemaActions.analysisStarted; +}; + +export type AnalysisFinishedAction = { + type: SchemaActions.analysisFinished; + schema: Schema | null; +}; + +export type AnalysisFailedAction = { + type: SchemaActions.analysisFailed; + error: Error; +}; + +const reducer: Reducer = ( + state = getInitialState(), + action +) => { + if (isAction(action, SchemaActions.analysisStarted)) { + return { + ...state, + analysisState: ANALYSIS_STATE_ANALYZING, + errorMessage: '', + schema: null, + }; + } + + if ( + isAction(action, SchemaActions.analysisFinished) + ) { + return { + ...state, + analysisState: action.schema + ? ANALYSIS_STATE_COMPLETE + : ANALYSIS_STATE_INITIAL, + schema: action.schema, + resultId: resultId(), + }; + } + + if (isAction(action, SchemaActions.analysisFailed)) { + return { + ...state, + ...getErrorState(action.error), + resultId: resultId(), + }; + } + + return state; +}; + +function getErrorState(err: Error & { code?: number }) { + const errorMessage = (err && err.message) || 'Unknown error'; + const errorCode = err && err.code; + + let analysisState: AnalysisState; + + if (errorCode === ERROR_CODE_MAX_TIME_MS_EXPIRED) { + analysisState = ANALYSIS_STATE_TIMEOUT; + } else { + analysisState = ANALYSIS_STATE_ERROR; + } + + return { analysisState, errorMessage }; +} + +function resultId(): string { + return new UUID().toString(); +} + +export const handleSchemaShare = (): SchemaThunkAction => { + return (dispatch, getState, { namespace }) => { + const { schema } = getState(); + const hasSchema = schema !== null; + if (hasSchema) { + void navigator.clipboard.writeText(JSON.stringify(schema, null, ' ')); + } + dispatch(_trackSchemaShared(hasSchema)); + openToast( + 'share-schema', + hasSchema + ? { + variant: 'success', + title: 'Schema Copied', + description: `The schema definition of ${namespace} has been copied to your clipboard in JSON format.`, + timeout: 5_000, + } + : { + variant: 'warning', + title: 'Analyze Schema First', + description: 'Please Analyze the Schema First from the Schema Tab.', + timeout: 5_000, + } + ); + }; +}; + +export const _trackSchemaShared = ( + hasSchema: boolean +): SchemaThunkAction => { + return (dispatch, getState, { track, connectionInfoRef }) => { + const { schema } = getState(); + // Use a function here to a) ensure that the calculations here + // are only made when telemetry is enabled and b) that errors from + // those calculations are caught and logged rather than displayed to + // users as errors from the core schema sharing logic. + const trackEvent = () => ({ + has_schema: hasSchema, + schema_width: schema?.fields?.length ?? 0, + schema_depth: schema ? calculateSchemaDepth(schema) : 0, + geo_data: schema ? schemaContainsGeoData(schema) : false, + }); + track('Schema Exported', trackEvent, connectionInfoRef.current); + }; +}; + +const getInitialState = (): SchemaState => ({ + analysisState: ANALYSIS_STATE_INITIAL, + errorMessage: '', + schema: null, + resultId: resultId(), +}); + +export const geoLayerAdded = ( + field: string, + layer: Layer +): SchemaThunkAction> => { + return (dispatch, getState, { geoLayersRef }) => { + geoLayersRef.current = addLayer( + field, + layer as Circle | Polygon, + geoLayersRef.current + ); + return generateGeoQuery(geoLayersRef.current); + }; +}; + +export const geoLayersEdited = ( + field: string, + layers: LayerGroup +): SchemaThunkAction> => { + return (dispatch, getState, { geoLayersRef }) => { + layers.eachLayer((layer) => { + dispatch(geoLayerAdded(field, layer)); + }); + return generateGeoQuery(geoLayersRef.current); + }; +}; + +export const geoLayersDeleted = ( + layers: LayerGroup +): SchemaThunkAction> => { + return (dispatch, getState, { geoLayersRef }) => { + layers.eachLayer((layer) => { + delete geoLayersRef.current[(layer as any)._leaflet_id]; + }); + return generateGeoQuery(geoLayersRef.current); + }; +}; + +export const stopAnalysis = (): SchemaThunkAction => { + return (dispatch, getState, { abortControllerRef }) => { + if (!abortControllerRef.current) return; + abortControllerRef.current?.abort(); + }; +}; + +export const startAnalysis = (): SchemaThunkAction< + Promise, + AnalysisStartedAction | AnalysisFinishedAction | AnalysisFailedAction +> => { + return async ( + dispatch, + getState, + { + queryBar, + preferences, + logger: { debug, log }, + dataService, + logger, + fieldStoreService, + abortControllerRef, + namespace, + geoLayersRef, + connectionInfoRef, + track, + } + ) => { + const { analysisState } = getState(); + if (analysisState === ANALYSIS_STATE_ANALYZING) { + debug('analysis already in progress. ignoring subsequent start'); + return; + } + const query = queryBar.getLastAppliedQuery('schema'); + + const sampleSize = query.limit + ? Math.min(DEFAULT_SAMPLE_SIZE, query.limit) + : DEFAULT_SAMPLE_SIZE; + + const samplingOptions = { + query: query.filter ?? {}, + size: sampleSize, + fields: query.project ?? undefined, + }; + + const driverOptions = { + maxTimeMS: capMaxTimeMSAtPreferenceLimit(preferences, query.maxTimeMS), + }; + + try { + debug('analysis started'); + + abortControllerRef.current = new AbortController(); + const abortSignal = abortControllerRef.current.signal; + + dispatch({ type: SchemaActions.analysisStarted }); + + const analysisStartTime = Date.now(); + const schema = await analyzeSchema( + dataService, + abortSignal, + namespace, + samplingOptions, + driverOptions, + logger + ); + const analysisTime = Date.now() - analysisStartTime; + + if (schema !== null) { + fieldStoreService.updateFieldsFromSchema(namespace, schema); + } + + dispatch({ type: SchemaActions.analysisFinished, schema }); + + // track schema analyzed + const trackEvent = () => ({ + with_filter: Object.entries(query.filter ?? {}).length > 0, + schema_width: schema?.fields?.length ?? 0, + schema_depth: schema ? calculateSchemaDepth(schema) : 0, + geo_data: schema ? schemaContainsGeoData(schema) : false, + analysis_time_ms: analysisTime, + }); + track('Schema Analyzed', trackEvent, connectionInfoRef.current); + + geoLayersRef.current = {}; + } catch (err: any) { + log.error( + mongoLogId(1_001_000_188), + 'Schema analysis', + 'Error sampling schema', + { + error: err.stack, + } + ); + dispatch({ type: SchemaActions.analysisFailed, error: err as Error }); + } finally { + abortControllerRef.current = undefined; + } + }; +}; + +export default reducer; diff --git a/packages/compass-schema/src/stores/store.spec.ts b/packages/compass-schema/src/stores/store.spec.ts index e6cf05f7ba5..85d04f0d9ca 100644 --- a/packages/compass-schema/src/stores/store.spec.ts +++ b/packages/compass-schema/src/stores/store.spec.ts @@ -1,5 +1,4 @@ -import type { SchemaStore } from './store'; -import { activateSchemaPlugin } from './store'; +import { activateSchemaPlugin, type SchemaStore } from './store'; import AppRegistry, { createActivateHelpers } from 'hadron-app-registry'; import { expect } from 'chai'; @@ -9,6 +8,8 @@ import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; import type { FieldStoreService } from '@mongodb-js/compass-field-store'; import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider'; import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; +import { startAnalysis, stopAnalysis } from './reducer'; +import Sinon from 'sinon'; const dummyLogger = createNoopLogger('TEST'); const dummyTrack = createNoopTrack(); @@ -28,15 +29,24 @@ describe('Schema Store', function () { describe('#configureStore', function () { let store: SchemaStore; let deactivate: () => void; + let sandbox: Sinon.SinonSandbox; const localAppRegistry = new AppRegistry(); const globalAppRegistry = new AppRegistry(); - const dataService = 'test'; const namespace = 'db.coll'; const connectionInfoRef = { current: {}, } as ConnectionInfoRef; + let sampleStub: Sinon.SinonStub; + let isCancelErrorStub: Sinon.SinonStub; beforeEach(async function () { + sandbox = Sinon.createSandbox(); + sampleStub = sandbox.stub(); + isCancelErrorStub = sandbox.stub(); + const dataService = { + sample: sampleStub, + isCancelError: isCancelErrorStub, + }; const plugin = activateSchemaPlugin( { namespace: namespace, @@ -60,34 +70,42 @@ describe('Schema Store', function () { afterEach(function () { deactivate(); + sandbox.reset(); }); - it('sets the local app registry', function () { - expect(store.localAppRegistry).to.equal(localAppRegistry); - }); - - it('sets the global app registry', function () { - expect(store.globalAppRegistry).to.equal(globalAppRegistry); - }); - - it('sets the data provider', function () { - expect(store.dataService).to.equal(dataService); + it('defaults analysis state to initial', function () { + expect(store.getState().analysisState).to.equal(ANALYSIS_STATE_INITIAL); }); - it('sets the namespace', function () { - expect(store.ns).to.equal(namespace); + it('defaults the error to empty', function () { + expect(store.getState().errorMessage).to.equal(''); }); - it('defaults analysis state to initial', function () { - expect(store.state.analysisState).to.equal(ANALYSIS_STATE_INITIAL); + it('defaults the schema to null', function () { + expect(store.getState().schema).to.equal(null); }); - it('defaults the error to empty', function () { - expect(store.state.errorMessage).to.equal(''); + it('runs analysis', async function () { + const oldResultId = store.getState().resultId; + sampleStub.resolves([{ name: 'Hans' }, { name: 'Greta' }]); + await store.dispatch(startAnalysis()); + expect(sampleStub).to.have.been.called; + const { analysisState, errorMessage, schema, resultId } = + store.getState(); + expect(analysisState).to.equal('complete'); + expect(!!errorMessage).to.be.false; + expect(schema).not.to.be.null; + expect(resultId).not.to.equal(oldResultId); }); - it('defaults the schema to null', function () { - expect(store.state.schema).to.equal(null); + it('analysis can be aborted', async function () { + const analysisPromise = store.dispatch(startAnalysis()); + expect(store.getState().analysisState).to.equal('analyzing'); + sampleStub.rejects(new Error('abort')); + store.dispatch(stopAnalysis()); + isCancelErrorStub.returns(true); + await analysisPromise; + expect(store.getState().analysisState).to.equal('initial'); }); }); }); diff --git a/packages/compass-schema/src/stores/store.ts b/packages/compass-schema/src/stores/store.ts index 9377d80a032..243a8117b06 100644 --- a/packages/compass-schema/src/stores/store.ts +++ b/packages/compass-schema/src/stores/store.ts @@ -1,24 +1,6 @@ -import Reflux from 'reflux'; -import type { StoreWithStateMixin } from '@mongodb-js/reflux-state-mixin'; -import StateMixin from '@mongodb-js/reflux-state-mixin'; import type { Logger } from '@mongodb-js/compass-logging'; -import type { InternalLayer } from '../modules/geo'; -import { addLayer, generateGeoQuery } from '../modules/geo'; -import { - analyzeSchema, - calculateSchemaDepth, - schemaContainsGeoData, -} from '../modules/schema-analysis'; -import type { AnalysisState } from '../constants/analysis-states'; -import { - ANALYSIS_STATE_ANALYZING, - ANALYSIS_STATE_COMPLETE, - ANALYSIS_STATE_ERROR, - ANALYSIS_STATE_INITIAL, - ANALYSIS_STATE_TIMEOUT, -} from '../constants/analysis-states'; -import { capMaxTimeMSAtPreferenceLimit } from 'compass-preferences-model/provider'; -import { openToast } from '@mongodb-js/compass-components'; +import { createStore, applyMiddleware, type AnyAction } from 'redux'; +import thunk, { type ThunkDispatch, type ThunkAction } from 'redux-thunk'; import type { CollectionTabPluginMetadata } from '@mongodb-js/compass-collection'; import type { ConnectionInfoRef, @@ -26,36 +8,12 @@ import type { } from '@mongodb-js/compass-connections/provider'; import type { ActivateHelpers } from 'hadron-app-registry'; import type AppRegistry from 'hadron-app-registry'; -import { configureActions } from '../actions'; -import type { Circle, Layer, LayerGroup, Polygon } from 'leaflet'; -import type { Schema } from 'mongodb-schema'; import type { PreferencesAccess } from 'compass-preferences-model/provider'; import type { FieldStoreService } from '@mongodb-js/compass-field-store'; -import type { Query, QueryBarService } from '@mongodb-js/compass-query-bar'; +import type { QueryBarService } from '@mongodb-js/compass-query-bar'; import type { TrackFunction } from '@mongodb-js/compass-telemetry'; - -const DEFAULT_SAMPLE_SIZE = 1000; - -const ERROR_CODE_MAX_TIME_MS_EXPIRED = 50; - -function getErrorState(err: Error & { code?: number }) { - const errorMessage = (err && err.message) || 'Unknown error'; - const errorCode = err && err.code; - - let analysisState; - - if (errorCode === ERROR_CODE_MAX_TIME_MS_EXPIRED) { - analysisState = ANALYSIS_STATE_TIMEOUT; - } else { - analysisState = ANALYSIS_STATE_ERROR; - } - - return { analysisState, errorMessage }; -} - -function resultId(): number { - return Math.floor(Math.random() * 2 ** 53); -} +import reducer, { handleSchemaShare, stopAnalysis } from './reducer'; +import type { InternalLayer } from '../modules/geo'; export type DataService = Pick; export type SchemaPluginServices = { @@ -70,311 +28,74 @@ export type SchemaPluginServices = { queryBar: QueryBarService; }; -type SchemaState = { - analysisState: AnalysisState; - errorMessage: string; - schema: Schema | null; - resultId: number; - abortController: undefined | AbortController; -}; - -export type SchemaStore = StoreWithStateMixin & { - localAppRegistry: SchemaPluginServices['localAppRegistry']; - globalAppRegistry: SchemaPluginServices['globalAppRegistry']; - fieldStoreService: SchemaPluginServices['fieldStoreService']; - ns: string; - geoLayers: Record; - dataService: DataService; - - handleSchemaShare(): void; - _trackSchemaShared(hasSchema: boolean): void; - - onSchemaSampled(): void; - geoLayerAdded( - field: string, - layer: Layer, - // NB: reflux doesn't return values from actions so we have to pass - // component onChage as a callback - onAdded: (geoQuery: ReturnType) => void - ): void; - geoLayersEdited( - field: string, - layers: LayerGroup, - // NB: reflux doesn't return values from actions so we have to pass - // component onChage as a callback - onEdited: (geoQuery: ReturnType) => void - ): void; - geoLayersDeleted( - layers: LayerGroup, - // NB: reflux doesn't return values from actions so we have to pass - // component onChage as a callback - onDeleted: (geoQuery: ReturnType) => void - ): void; - stopAnalysis(): void; - _trackSchemaAnalyzed(analysisTimeMS: number, query: any): void; - startAnalysis(): void; +export type RootState = ReturnType; +export type SchemaExtraArgs = SchemaPluginServices & { + abortControllerRef: { current?: AbortController }; + geoLayersRef: { current: Record }; + namespace: string; }; +export type SchemaThunkAction = ThunkAction< + R, + RootState, + SchemaExtraArgs, + A +>; +export type SchemaThunkDispatch = + ThunkDispatch; /** * Configure a store with the provided options. * * @param {Object} options - The options. * - * @returns {Store} The reflux store. + * @returns {Store} The redux store. */ export function activateSchemaPlugin( - options: Pick, - { - dataService, - localAppRegistry, - globalAppRegistry, - logger, - track, - preferences, - fieldStoreService, - queryBar, - connectionInfoRef, - }: SchemaPluginServices, - { on, cleanup }: ActivateHelpers + { namespace }: Pick, + services: SchemaPluginServices, + { on, cleanup, addCleanup }: ActivateHelpers ) { - const { debug, log, mongoLogId } = logger; - const actions = configureActions(); - - /** - * The reflux store for the schema. - */ - const store: SchemaStore = Reflux.createStore({ - mixins: [StateMixin.store()], - listenables: actions, - - /** - * Initialize the document list store. - */ - init: function (this: SchemaStore) { - this.ns = options.namespace; - this.geoLayers = {}; - this.dataService = dataService; - this.localAppRegistry = localAppRegistry; - this.globalAppRegistry = globalAppRegistry; - this.fieldStoreService = fieldStoreService; - }, - - handleSchemaShare(this: SchemaStore) { - void navigator.clipboard.writeText( - JSON.stringify(this.state.schema, null, ' ') - ); - const hasSchema = this.state.schema !== null; - this._trackSchemaShared(hasSchema); - openToast( - 'share-schema', - hasSchema - ? { - variant: 'success', - title: 'Schema Copied', - description: `The schema definition of ${this.ns} has been copied to your clipboard in JSON format.`, - timeout: 5_000, - } - : { - variant: 'warning', - title: 'Analyze Schema First', - description: - 'Please Analyze the Schema First from the Schema Tab.', - timeout: 5_000, - } - ); - }, - - _trackSchemaShared(this: SchemaStore, hasSchema: boolean) { - const { schema } = this.state; - // Use a function here to a) ensure that the calculations here - // are only made when telemetry is enabled and b) that errors from - // those calculations are caught and logged rather than displayed to - // users as errors from the core schema sharing logic. - const trackEvent = () => ({ - has_schema: hasSchema, - schema_width: schema?.fields?.length ?? 0, - schema_depth: schema ? calculateSchemaDepth(schema) : 0, - geo_data: schema ? schemaContainsGeoData(schema) : false, - }); - track('Schema Exported', trackEvent, connectionInfoRef.current); - }, - - /** - * Initialize the schema store. - * - * @return {Object} initial schema state. - */ - getInitialState(this: SchemaStore): SchemaState { - return { - analysisState: ANALYSIS_STATE_INITIAL, - errorMessage: '', - schema: null, - resultId: resultId(), - abortController: undefined, - }; - }, - - onSchemaSampled(this: SchemaStore) { - this.geoLayers = {}; - }, - - geoLayerAdded( - this: SchemaStore, - field: string, - layer: Layer, - // NB: reflux doesn't return values from actions so we have to pass - // component onChage as a callback - onAdded: (geoQuery: ReturnType) => void - ) { - this.geoLayers = addLayer( - field, - layer as Circle | Polygon, - this.geoLayers - ); - onAdded(generateGeoQuery(this.geoLayers)); - }, - - geoLayersEdited( - this: SchemaStore, - field: string, - layers: LayerGroup, - // NB: reflux doesn't return values from actions so we have to pass - // component onChage as a callback - onEdited: (geoQuery: ReturnType) => void - ) { - layers.eachLayer((layer) => { - this.geoLayerAdded(field, layer, () => { - // noop, we will call `onEdited` when we're done with updates - }); - }); - onEdited(generateGeoQuery(this.geoLayers)); - }, - - geoLayersDeleted( - this: SchemaStore, - layers: LayerGroup, - // NB: reflux doesn't return values from actions so we have to pass - // component onChage as a callback - onDeleted: (geoQuery: ReturnType) => void - ) { - layers.eachLayer((layer) => { - delete this.geoLayers[(layer as any)._leaflet_id]; - }); - onDeleted(generateGeoQuery(this.geoLayers)); - }, - - stopAnalysis(this: SchemaStore) { - this.state.abortController?.abort(); - }, - - _trackSchemaAnalyzed( - this: SchemaStore, - analysisTimeMS: number, - query: Query - ) { - const { schema } = this.state; - // Use a function here to a) ensure that the calculations here - // are only made when telemetry is enabled and b) that errors from - // those calculations are caught and logged rather than displayed to - // users as errors from the core schema analysis logic. - const trackEvent = () => ({ - with_filter: Object.entries(query.filter ?? {}).length > 0, - schema_width: schema?.fields?.length ?? 0, - schema_depth: schema ? calculateSchemaDepth(schema) : 0, - geo_data: schema ? schemaContainsGeoData(schema) : false, - analysis_time_ms: analysisTimeMS, - }); - track('Schema Analyzed', trackEvent, connectionInfoRef.current); - }, - - startAnalysis: async function (this: SchemaStore) { - const query = queryBar.getLastAppliedQuery('schema'); - - const sampleSize = query.limit - ? Math.min(DEFAULT_SAMPLE_SIZE, query.limit) - : DEFAULT_SAMPLE_SIZE; - - const samplingOptions = { - query: query.filter ?? {}, - size: sampleSize, - fields: query.project ?? undefined, - }; - - const driverOptions = { - maxTimeMS: capMaxTimeMSAtPreferenceLimit(preferences, query.maxTimeMS), - }; - - try { - debug('analysis started'); - - const abortController = new AbortController(); - const abortSignal = abortController.signal; - - this.setState({ - analysisState: ANALYSIS_STATE_ANALYZING, - errorMessage: '', - schema: null, - abortController, - }); - - const analysisStartTime = Date.now(); - const schema = await analyzeSchema( - this.dataService, - abortSignal, - this.ns, - samplingOptions, - driverOptions, - logger - ); - const analysisTime = Date.now() - analysisStartTime; - - if (schema !== null) { - this.fieldStoreService.updateFieldsFromSchema(this.ns, schema); - } - - this.setState({ - analysisState: schema - ? ANALYSIS_STATE_COMPLETE - : ANALYSIS_STATE_INITIAL, - schema: schema, - resultId: resultId(), - }); - - this._trackSchemaAnalyzed(analysisTime, query); - - this.onSchemaSampled(); - } catch (err: any) { - log.error( - mongoLogId(1_001_000_188), - 'Schema analysis', - 'Error sampling schema', - { - error: err.stack, - } - ); - this.setState({ ...getErrorState(err), resultId: resultId() }); - } finally { - this.setState({ abortController: undefined }); - } - }, - - storeDidUpdate(this: SchemaStore, prevState: SchemaState) { - debug('schema store changed from', prevState, 'to', this.state); - }, - }) as SchemaStore; - + const store = configureStore(services, namespace); /** * When `Share Schema as JSON` clicked in menu show a dialog message. */ - on(localAppRegistry, 'menu-share-schema-json', () => - store.handleSchemaShare() + + on(services.localAppRegistry, 'menu-share-schema-json', () => + store.dispatch(handleSchemaShare()) ); + addCleanup(() => store.dispatch(stopAnalysis())); + return { store, - actions, deactivate() { cleanup(); }, }; } + +export function configureStore( + services: SchemaPluginServices, + namespace: string +) { + const abortControllerRef = { + current: undefined, + }; + const geoLayersRef: { current: Record } = { + current: {}, + }; + const store = createStore( + reducer, + applyMiddleware( + thunk.withExtraArgument({ + ...services, + abortControllerRef, + geoLayersRef, + namespace, + }) + ) + ); + return store; +} + +export type SchemaStore = ReturnType;