From a31253f4ecb1eeb0002086ffa7439d7c46bb393a Mon Sep 17 00:00:00 2001 From: Sergey Petushkov Date: Wed, 20 Sep 2023 13:28:53 +0200 Subject: [PATCH] chore(compass-collection): separate collection tab content from collection tabs management logic (#4858) * chore(compass-collection): separate collection tab content from collection tabs management logic * chore(compass-collection): subscribe to the collection model directly; pick stats props from modal before storing in state * chore(compass-collection): simplify feature flag check Co-authored-by: Anna Henningsen * chore(compass-collection): add tests for tabs store * chore(compass-collection): add tests for collection tab store * chore(compass-collection): re-enable workspace component tests * chore(saved-queries, app-stores): do not emit open-namespace directly from my-queries plugin; add descriptions to collection metadata * chore(compass-collection): use instance.env instead of atlas (prepare for #4875 to be merged) --------- Co-authored-by: Anna Henningsen --- package-lock.json | 6 +- packages/collection-model/index.d.ts | 64 +- packages/compass-aggregations/src/plugin.jsx | 33 +- .../compass-aggregations/src/plugin.spec.tsx | 4 +- .../src/stores/instance-store.js | 105 +- packages/compass-collection/package.json | 2 +- .../components/collection-header/badges.tsx | 64 + .../collection-header/clustered-badge.tsx | 18 - .../collection-header.spec.tsx | 178 +-- .../collection-header/collection-header.tsx | 77 +- .../collection-header/fle-badge.tsx | 20 - .../collection-header/read-only-badge.tsx | 18 - .../collection-header/time-series-badge.tsx | 19 - .../collection-header/view-badge.tsx | 19 - .../collection-stats-item.spec.tsx | 16 +- .../collection-stats-item.tsx | 54 +- .../collection-stats.spec.tsx | 35 +- .../collection-stats/collection-stats.tsx | 83 +- .../src/components/collection-tab.tsx | 143 ++ .../components/collection/collection.spec.tsx | 96 -- .../src/components/collection/collection.tsx | 203 --- .../src/components/collection/index.ts | 2 - .../document-stats-item.spec.tsx | 29 - .../document-stats-item.tsx | 41 - .../components/document-stats-item/index.ts | 4 - .../index-stats-item.spec.tsx | 29 - .../index-stats-item/index-stats-item.tsx | 41 - .../src/components/index-stats-item/index.ts | 4 - .../src/components/workspace/index.ts | 2 - .../components/workspace/workspace.spec.tsx | 123 +- .../src/components/workspace/workspace.tsx | 296 ++-- packages/compass-collection/src/index.ts | 19 +- .../src/modules/app-registry.spec.ts | 49 - .../src/modules/app-registry.ts | 69 - .../src/modules/collection-tab.ts | 263 ++++ .../src/modules/data-service.spec.ts | 41 - .../src/modules/data-service.ts | 58 - .../src/modules/is-atlas.ts | 16 - .../src/modules/is-data-lake.spec.ts | 12 - .../src/modules/is-data-lake.ts | 39 - .../src/modules/namespace.spec.js | 29 - .../src/modules/namespace.ts | 46 - .../src/modules/server-version.spec.ts | 33 - .../src/modules/server-version.ts | 46 - .../compass-collection/src/modules/stats.ts | 143 -- .../compass-collection/src/modules/tabs.ts | 1232 +++++------------ packages/compass-collection/src/plugin.tsx | 2 +- .../src/stores/collection-tab.spec.ts | 173 +++ .../src/stores/collection-tab.ts | 112 ++ .../compass-collection/src/stores/context.tsx | 480 ------- .../src/stores/index.spec.ts | 245 ---- .../compass-collection/src/stores/index.ts | 395 ------ .../src/stores/tabs.spec.ts | 332 +++++ .../compass-collection/src/stores/tabs.ts | 155 +++ .../src/components/tab-nav-bar.tsx | 4 +- .../src/components/crud-toolbar.tsx | 6 +- .../compass-e2e-tests/helpers/selectors.ts | 3 +- .../src/stores/store.js | 26 +- .../src/stores/store.spec.js | 12 +- packages/compass-home/package.json | 1 + .../compass-home/src/components/home.spec.tsx | 2 - packages/compass-home/src/components/home.tsx | 20 +- .../src/preferences.ts | 38 + .../src/stores/open-item.ts | 22 +- .../compass/src/app/setup-plugin-manager.js | 7 +- packages/database-model/index.d.ts | 1 + .../drop-collection/drop-collection.ts | 1 - .../modules/drop-database/drop-database.ts | 1 - .../src/stores/collections-store.js | 45 +- .../src/app-registry.spec.ts | 27 +- .../hadron-app-registry/src/app-registry.ts | 21 +- packages/hadron-app-registry/src/index.ts | 4 +- packages/instance-model/index.d.ts | 13 +- 73 files changed, 2195 insertions(+), 3876 deletions(-) create mode 100644 packages/compass-collection/src/components/collection-header/badges.tsx delete mode 100644 packages/compass-collection/src/components/collection-header/clustered-badge.tsx delete mode 100644 packages/compass-collection/src/components/collection-header/fle-badge.tsx delete mode 100644 packages/compass-collection/src/components/collection-header/read-only-badge.tsx delete mode 100644 packages/compass-collection/src/components/collection-header/time-series-badge.tsx delete mode 100644 packages/compass-collection/src/components/collection-header/view-badge.tsx create mode 100644 packages/compass-collection/src/components/collection-tab.tsx delete mode 100644 packages/compass-collection/src/components/collection/collection.spec.tsx delete mode 100644 packages/compass-collection/src/components/collection/collection.tsx delete mode 100644 packages/compass-collection/src/components/collection/index.ts delete mode 100644 packages/compass-collection/src/components/document-stats-item/document-stats-item.spec.tsx delete mode 100644 packages/compass-collection/src/components/document-stats-item/document-stats-item.tsx delete mode 100644 packages/compass-collection/src/components/document-stats-item/index.ts delete mode 100644 packages/compass-collection/src/components/index-stats-item/index-stats-item.spec.tsx delete mode 100644 packages/compass-collection/src/components/index-stats-item/index-stats-item.tsx delete mode 100644 packages/compass-collection/src/components/index-stats-item/index.ts delete mode 100644 packages/compass-collection/src/modules/app-registry.spec.ts delete mode 100644 packages/compass-collection/src/modules/app-registry.ts create mode 100644 packages/compass-collection/src/modules/collection-tab.ts delete mode 100644 packages/compass-collection/src/modules/data-service.spec.ts delete mode 100644 packages/compass-collection/src/modules/data-service.ts delete mode 100644 packages/compass-collection/src/modules/is-atlas.ts delete mode 100644 packages/compass-collection/src/modules/is-data-lake.spec.ts delete mode 100644 packages/compass-collection/src/modules/is-data-lake.ts delete mode 100644 packages/compass-collection/src/modules/namespace.spec.js delete mode 100644 packages/compass-collection/src/modules/namespace.ts delete mode 100644 packages/compass-collection/src/modules/server-version.spec.ts delete mode 100644 packages/compass-collection/src/modules/server-version.ts delete mode 100644 packages/compass-collection/src/modules/stats.ts create mode 100644 packages/compass-collection/src/stores/collection-tab.spec.ts create mode 100644 packages/compass-collection/src/stores/collection-tab.ts delete mode 100644 packages/compass-collection/src/stores/context.tsx delete mode 100644 packages/compass-collection/src/stores/index.spec.ts delete mode 100644 packages/compass-collection/src/stores/index.ts create mode 100644 packages/compass-collection/src/stores/tabs.spec.ts create mode 100644 packages/compass-collection/src/stores/tabs.ts diff --git a/package-lock.json b/package-lock.json index 5cd5acb3170..d678e95f33d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44962,11 +44962,11 @@ "chai": "^4.3.6", "depcheck": "^1.4.1", "eslint": "^7.25.0", - "lodash": "^4.17.21", "mocha": "^10.2.0", "mongodb": "^6.0.0", "mongodb-collection-model": "^5.11.0", "mongodb-data-service": "^22.11.0", + "mongodb-instance-model": "^12.11.0", "mongodb-ns": "^2.4.0", "numeral": "^2.0.6", "nyc": "^15.1.0", @@ -46724,6 +46724,7 @@ "eslint": "^7.25.0", "eventemitter3": "^4.0.0", "mocha": "^10.2.0", + "mongodb-collection-model": "^5.11.0", "mongodb-data-service": "^22.11.0", "mongodb-ns": "^2.4.0", "nyc": "^15.1.0", @@ -58918,11 +58919,11 @@ "eslint": "^7.25.0", "hadron-app-registry": "^9.0.10", "hadron-ipc": "^3.2.0", - "lodash": "^4.17.21", "mocha": "^10.2.0", "mongodb": "^6.0.0", "mongodb-collection-model": "^5.11.0", "mongodb-data-service": "^22.11.0", + "mongodb-instance-model": "^12.11.0", "mongodb-ns": "^2.4.0", "numeral": "^2.0.6", "nyc": "^15.1.0", @@ -59749,6 +59750,7 @@ "hadron-app-registry": "^9.0.10", "hadron-ipc": "^3.2.0", "mocha": "^10.2.0", + "mongodb-collection-model": "^5.11.0", "mongodb-data-service": "^22.11.0", "mongodb-ns": "^2.4.0", "nyc": "^15.1.0", diff --git a/packages/collection-model/index.d.ts b/packages/collection-model/index.d.ts index 98d51fa536b..79075be5a5a 100644 --- a/packages/collection-model/index.d.ts +++ b/packages/collection-model/index.d.ts @@ -1,7 +1,51 @@ import type toNS from 'mongodb-ns'; import type { DataService } from 'mongodb-data-service'; -type Namespace = ReturnType; +type CollectionMetadata = { + /** + * Collection namespace (.) + */ + namespace: string; + /** + * Indicates that colleciton is read only + */ + isReadonly: boolean; + /** + * Indicates that colleciton is a time series collection + */ + isTimeSeries: boolean; + /** + * Indicates that collection is clustered / has a clustered index + */ + isClustered: boolean; + /** + * Indicates that collection has encrypted fields in it + */ + isFLE: boolean; + /** + * Indicates that MongoDB server supports search indexes (property is exposed + * on collection because the check is relevant for collection tab and requires + * collection namespace to perform the check) + */ + isSearchIndexesSupported: boolean; + /** + * View source namespace (.) + */ + sourceName?: string; + /** + * Indicates if a source collection is read only + */ + sourceReadonly?: boolean; + /** + * View source view namespace (this is the same as metadata namespace if + * present) + */ + sourceViewon?: string; + /** + * Aggregation pipeline view definition + */ + sourcePipeline?: unknown[]; +}; interface Collection { _id: string; @@ -40,18 +84,10 @@ interface Collection { fetchInfo?: boolean; force?: boolean; }): Promise; - fetchMetadata(opts: { dataService: DataService }): Promise<{ - namespace: string; - isReadonly: boolean; - isTimeSeries: boolean; - isClustered: boolean; - isFLE: boolean; - isSearchIndexesSupported: boolean; - sourceName?: string; - sourceReadonly?: boolean; - sourceViewon?: string; - sourcePipeline?: unknown[]; - }>; + fetchMetadata(opts: { + dataService: DataService; + }): Promise; + on(evt: string, fn: (...args: any) => void); toJSON(opts?: { derived: boolean }): this; } @@ -63,4 +99,4 @@ interface CollectionCollection extends Array { } export default Collection; -export { CollectionCollection }; +export { CollectionCollection, CollectionMetadata }; diff --git a/packages/compass-aggregations/src/plugin.jsx b/packages/compass-aggregations/src/plugin.jsx index 46bfc444bf3..bee28e03fc6 100644 --- a/packages/compass-aggregations/src/plugin.jsx +++ b/packages/compass-aggregations/src/plugin.jsx @@ -4,21 +4,20 @@ import Aggregations from './components/aggregations'; import { Provider } from 'react-redux'; import configureStore from './stores/store'; import { ConfirmationModalArea } from '@mongodb-js/compass-components'; +import { withPreferences } from 'compass-preferences-model'; -class Plugin extends Component { - static displayName = 'AggregationsPlugin'; - +class AggregationsPlugin extends Component { static propTypes = { store: PropTypes.object.isRequired, - showExportButton: PropTypes.bool, - showRunButton: PropTypes.bool, - showExplainButton: PropTypes.bool, + enableImportExport: PropTypes.bool, + enableAggregationBuilderRunPipeline: PropTypes.bool, + enableExplainPlan: PropTypes.bool, }; static defaultProps = { - showExportButton: false, - showRunButton: false, - showExplainButton: false, + enableImportExport: false, + enableAggregationBuilderRunPipeline: false, + enableExplainPlan: false, }; /** @@ -29,9 +28,9 @@ class Plugin extends Component { @@ -39,5 +38,15 @@ class Plugin extends Component { } } +const Plugin = withPreferences( + AggregationsPlugin, + [ + 'enableImportExport', + 'enableAggregationBuilderRunPipeline', + 'enableExplainPlan', + ], + React +); + export default Plugin; export { Plugin, configureStore }; diff --git a/packages/compass-aggregations/src/plugin.spec.tsx b/packages/compass-aggregations/src/plugin.spec.tsx index 2b39efe84b4..81f3dc252c1 100644 --- a/packages/compass-aggregations/src/plugin.spec.tsx +++ b/packages/compass-aggregations/src/plugin.spec.tsx @@ -6,9 +6,7 @@ import Aggregations from './plugin'; const renderPlugin = () => { const store = configureStore(); - render( - - ); + render(); }; describe('Aggregations [Plugin]', function () { diff --git a/packages/compass-app-stores/src/stores/instance-store.js b/packages/compass-app-stores/src/stores/instance-store.js index f921ff48f5d..ad57e5461be 100644 --- a/packages/compass-app-stores/src/stores/instance-store.js +++ b/packages/compass-app-stores/src/stores/instance-store.js @@ -23,6 +23,10 @@ function getTopologyDescription(topologyDescription) { const store = createStore(reducer); +store.getInstance = () => { + return store.getState().instance; +}; + store.refreshInstance = async (globalAppRegistry, refreshOptions) => { const { instance, dataService } = store.getState(); @@ -247,6 +251,20 @@ store.onActivated = (appRegistry) => { store.refreshInstance(appRegistry); }); + appRegistry.on('database-dropped', async () => { + const { instance, dataService } = store.getState(); + await instance.fetchDatabases({ dataService, force: true }); + }); + + appRegistry.on('collection-dropped', async (namespace) => { + const { instance, dataService } = store.getState(); + const { database } = toNS(namespace); + await instance.fetchDatabases({ dataService, force: true }); + const db = instance.databases.get(database); + // If it was last collection, there will be no db returned + await db?.fetchCollections({ dataService, force: true }); + }); + // Event emitted when the Databases grid needs to be refreshed // We additionally refresh the list of collections as well // since there is the side navigation which could be in expanded mode @@ -268,7 +286,6 @@ store.onActivated = (appRegistry) => { if (!instance.databases.get(database)) { await instance.fetchDatabases({ dataService, force: true }); } - const db = instance.databases.get(database); if (db) { await db.fetchCollectionsDetails({ dataService, force: true }); @@ -297,37 +314,61 @@ store.onActivated = (appRegistry) => { appRegistry.emit('select-namespace', metadata); }); - appRegistry.on('active-collection-dropped', async (ns) => { - const { instance, dataService } = store.getState(); - const { database } = toNS(ns); - await store.fetchDatabaseDetails(database); - const db = instance.databases.get(database); - await db.fetchCollections({ dataService, force: true }); - - if (db.collectionsLength) { - appRegistry.emit('select-database', database); - } else { - appRegistry.emit('open-instance-workspace', 'Databases'); - } + appRegistry.on('active-collection-dropped', (ns) => { + // This callback will fire after drop collection happened, we force it into + // a microtask to allow drop collections event handler to force start + // databases and collections list update before we run our check here + queueMicrotask(async () => { + const { instance, dataService } = store.getState(); + const { database } = toNS(ns); + await instance.fetchDatabases({ dataService }); + const db = instance.databases.get(database); + await db?.fetchCollections({ dataService }); + if (db?.collectionsLength) { + appRegistry.emit('select-database', database); + } else { + appRegistry.emit('open-instance-workspace', 'Databases'); + } + }); }); appRegistry.on('active-database-dropped', async () => { appRegistry.emit('open-instance-workspace', 'Databases'); }); - appRegistry.on('collections-list-select-collection', async ({ ns }) => { + /** + * Opens collection in the current active tab. No-op if currently open tab has + * the same namespace. Additional `query` and `agrregation` props can be + * passed with the namespace to open tab with initial query or aggregation + * pipeline + */ + const openCollectionInSameTab = async ({ ns, ...extraMetadata }) => { const metadata = await store.fetchCollectionMetadata(ns); - appRegistry.emit('select-namespace', metadata); - }); - - appRegistry.on('sidebar-select-collection', async ({ ns }) => { - const metadata = await store.fetchCollectionMetadata(ns); - appRegistry.emit('select-namespace', metadata); - }); + appRegistry.emit('select-namespace', { + ...metadata, + ...extraMetadata, + }); + }; - const openCollectionInNewTab = async ({ ns }) => { + appRegistry.on('collections-list-select-collection', openCollectionInSameTab); + appRegistry.on('sidebar-select-collection', openCollectionInSameTab); + appRegistry.on( + 'collection-workspace-select-namespace', + openCollectionInSameTab + ); + appRegistry.on('collection-tab-select-collection', openCollectionInSameTab); + + /** + * Opens collection in a new tab. Additional `query` and `agrregation` props + * can be passed with the namespace to open tab with initial query or + * aggregation pipeline + */ + const openCollectionInNewTab = async ({ ns, ...extraMetadata }) => { const metadata = await store.fetchCollectionMetadata(ns); - appRegistry.emit('open-namespace-in-new-tab', metadata); + appRegistry.emit('open-namespace-in-new-tab', { + ...metadata, + ...extraMetadata, + }); }; appRegistry.on('sidebar-open-collection-in-new-tab', openCollectionInNewTab); @@ -335,8 +376,13 @@ store.onActivated = (appRegistry) => { 'import-export-open-collection-in-new-tab', openCollectionInNewTab ); + appRegistry.on( + 'collection-workspace-open-collection-in-new-tab', + openCollectionInNewTab + ); + appRegistry.on('my-queries-open-saved-item', openCollectionInNewTab); - appRegistry.on('sidebar-modify-view', async ({ ns }) => { + const openModifyView = async ({ ns, sameTab }) => { const coll = await store.fetchCollectionDetails(ns); if (coll.sourceId && coll.pipeline) { // `modify-view` is currently implemented in a way where we are basically @@ -349,13 +395,21 @@ store.onActivated = (appRegistry) => { const metadata = await store.fetchCollectionMetadata(coll.sourceId); metadata.sourcePipeline = coll.pipeline; metadata.editViewName = coll.ns; - appRegistry.emit('open-namespace-in-new-tab', metadata); + appRegistry.emit( + sameTab ? 'select-namespace' : 'open-namespace-in-new-tab', + metadata + ); } else { debug( 'Tried to modify the view on a collection with required metadata missing', coll.toJSON() ); } + }; + + appRegistry.on('sidebar-modify-view', openModifyView); + appRegistry.on('collection-tab-modify-view', ({ ns }) => { + openModifyView({ ns, sameTab: true }); }); appRegistry.on('sidebar-duplicate-view', async ({ ns }) => { @@ -379,7 +433,6 @@ store.onActivated = (appRegistry) => { async function (namespace) { // Force-refresh specified namespace to update collections list and get // collection info / stats (in case of opening result collection we're - // always assuming the namespace wasn't yet updated) await store.refreshNamespace(toNS(namespace)); // Now we can get the metadata from already fetched collection and open a diff --git a/packages/compass-collection/package.json b/packages/compass-collection/package.json index 3601cfcb043..acb3476f077 100644 --- a/packages/compass-collection/package.json +++ b/packages/compass-collection/package.json @@ -90,11 +90,11 @@ "chai": "^4.3.6", "depcheck": "^1.4.1", "eslint": "^7.25.0", - "lodash": "^4.17.21", "mocha": "^10.2.0", "mongodb": "^6.0.0", "mongodb-collection-model": "^5.11.0", "mongodb-data-service": "^22.11.0", + "mongodb-instance-model": "^12.11.0", "mongodb-ns": "^2.4.0", "numeral": "^2.0.6", "nyc": "^15.1.0", diff --git a/packages/compass-collection/src/components/collection-header/badges.tsx b/packages/compass-collection/src/components/collection-header/badges.tsx new file mode 100644 index 00000000000..40670e27b34 --- /dev/null +++ b/packages/compass-collection/src/components/collection-header/badges.tsx @@ -0,0 +1,64 @@ +import { Badge, BadgeVariant, Icon, css } from '@mongodb-js/compass-components'; +import React from 'react'; + +const collectionHeaderBadgeStyles = css({ + whiteSpace: 'nowrap', +}); + +export type CollectionBadgeType = + | 'readonly' + | 'timeseries' + | 'view' + | 'fle' + | 'clustered'; + +const badges: Record< + CollectionBadgeType, + { label: React.ReactNode; variant?: BadgeVariant } +> = { + readonly: { + label: 'READ-ONLY', + variant: BadgeVariant.LightGray, + }, + timeseries: { + label: ( + <> + +  TIME-SERIES + + ), + }, + view: { + label: ( + <> + +  VIEW + + ), + }, + fle: { + label: ( + <> + {/* Queryable Encryption is the user-facing name of FLE2 */} + +  Queryable Encryption + + ), + }, + clustered: { + label: 'CLUSTERED', + }, +}; + +export const CollectionBadge = ({ type }: { type: CollectionBadgeType }) => { + const { label, variant } = badges[type]; + return ( + + {label} + + ); +}; diff --git a/packages/compass-collection/src/components/collection-header/clustered-badge.tsx b/packages/compass-collection/src/components/collection-header/clustered-badge.tsx deleted file mode 100644 index 49bf36090a6..00000000000 --- a/packages/compass-collection/src/components/collection-header/clustered-badge.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { Badge, BadgeVariant, css } from '@mongodb-js/compass-components'; - -const collectionHeaderBadgeStyles = css({ - whiteSpace: 'nowrap', -}); - -const ClusteredBadge = (): React.ReactElement => ( - - CLUSTERED - -); - -export default ClusteredBadge; diff --git a/packages/compass-collection/src/components/collection-header/collection-header.spec.tsx b/packages/compass-collection/src/components/collection-header/collection-header.spec.tsx index d1bcf256548..6d11508c5db 100644 --- a/packages/compass-collection/src/components/collection-header/collection-header.spec.tsx +++ b/packages/compass-collection/src/components/collection-header/collection-header.spec.tsx @@ -1,16 +1,13 @@ -import AppRegistry from 'hadron-app-registry'; import { expect } from 'chai'; import type { ComponentProps } from 'react'; import React from 'react'; import { render, screen, cleanup } from '@testing-library/react'; import { spy } from 'sinon'; import userEvent from '@testing-library/user-event'; - import CollectionHeader from '../collection-header'; -import { getCollectionStatsInitialState } from '../../modules/stats'; function renderCollectionHeader( - props: Partial> + props: Partial> = {} ) { return render( {}} - pipeline={[]} - stats={getCollectionStatsInitialState()} + stats={null} + onSelectDatabaseClick={() => { + /** noop */ + }} + onEditViewClick={() => { + /** noop */ + }} + onReturnToViewClick={() => { + /** noop */ + }} {...props} /> ); @@ -33,25 +36,8 @@ describe('CollectionHeader [Component]', function () { afterEach(cleanup); context('when the collection is not readonly', function () { - const globalAppRegistry = new AppRegistry(); - const selectOrCreateTabSpy = spy(); - beforeEach(function () { - render( - - ); + renderCollectionHeader(); }); it('renders the correct root classname', function () { @@ -84,26 +70,8 @@ describe('CollectionHeader [Component]', function () { }); context('when the collection is readonly', function () { - const globalAppRegistry = new AppRegistry(); - const selectOrCreateTabSpy = spy(); - beforeEach(function () { - render( - - ); + renderCollectionHeader({ isReadonly: true, sourceName: 'orig.coll' }); }); afterEach(cleanup); @@ -136,25 +104,8 @@ describe('CollectionHeader [Component]', function () { }); context('when the collection is readonly but not a view', function () { - const globalAppRegistry = new AppRegistry(); - const selectOrCreateTabSpy = spy(); - beforeEach(function () { - render( - - ); + renderCollectionHeader({ isReadonly: true, sourceName: undefined }); }); it('does not render the source collection', function () { @@ -171,25 +122,8 @@ describe('CollectionHeader [Component]', function () { }); context('when the collection is a time-series collection', function () { - const globalAppRegistry = new AppRegistry(); - const selectOrCreateTabSpy = spy(); - beforeEach(function () { - render( - - ); + renderCollectionHeader({ isTimeSeries: true }); }); it('does not render the source collection', function () { @@ -206,25 +140,8 @@ describe('CollectionHeader [Component]', function () { }); context('when the collection is a clustered collection', function () { - const globalAppRegistry = new AppRegistry(); - const selectOrCreateTabSpy = spy(); - beforeEach(function () { - render( - - ); + renderCollectionHeader({ isClustered: true }); }); it('does not render the source collection', function () { @@ -245,68 +162,25 @@ describe('CollectionHeader [Component]', function () { }); context('when the collection is a fle collection', function () { - const globalAppRegistry = new AppRegistry(); - const selectOrCreateTabSpy = spy(); - beforeEach(function () { - render( - - ); + renderCollectionHeader({ isFLE: true }); }); - it('renders the clustered badge', function () { - expect(screen.getByTestId('collection-header-badge-fle2')).to.exist; + it('renders the fle badge', function () { + expect(screen.getByTestId('collection-badge-fle')).to.exist; }); }); context('when the db name is clicked', function () { it('emits the open event to the app registry', function () { - const selectOrCreateTabSpy = spy(); - - let emmittedEventName; - let emmittedDbName; - - render( - { - emmittedEventName = eventName; - emmittedDbName = dbName; - }, - } as AppRegistry - } - sourceName="orig.coll" - namespace="db.coll" - selectOrCreateTab={selectOrCreateTabSpy} - sourceReadonly={false} - pipeline={[]} - stats={getCollectionStatsInitialState()} - /> - ); + const onSelectDatabaseClick = spy(); + + renderCollectionHeader({ onSelectDatabaseClick }); const link = screen.getByTestId('collection-header-title-db'); expect(link).to.exist; userEvent.click(link); - expect(emmittedEventName).to.equal('select-database'); - expect(emmittedDbName).to.equal('db'); + expect(onSelectDatabaseClick).to.have.been.calledOnce; }); }); @@ -314,7 +188,7 @@ describe('CollectionHeader [Component]', function () { it('should show an insight when $text is used in the pipeline source', function () { renderCollectionHeader({ showInsights: true, - pipeline: [{ $match: { $text: {} } }], + sourcePipeline: [{ $match: { $text: {} } }], }); expect(screen.getByTestId('insight-badge-button')).to.exist; userEvent.click(screen.getByTestId('insight-badge-button')); @@ -325,7 +199,7 @@ describe('CollectionHeader [Component]', function () { it('should show an insight when $regex is used in the pipeline source', function () { renderCollectionHeader({ showInsights: true, - pipeline: [{ $match: { $regex: {} } }], + sourcePipeline: [{ $match: { $regex: {} } }], }); expect(screen.getByTestId('insight-badge-button')).to.exist; userEvent.click(screen.getByTestId('insight-badge-button')); @@ -336,7 +210,7 @@ describe('CollectionHeader [Component]', function () { it('should show an insight when $lookup is used in the pipeline source', function () { renderCollectionHeader({ showInsights: true, - pipeline: [{ $lookup: {} }], + sourcePipeline: [{ $lookup: {} }], }); expect(screen.getByTestId('insight-badge-button')).to.exist; userEvent.click(screen.getByTestId('insight-badge-button')); 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 2e135cfc8e5..3edd4daf2a6 100644 --- a/packages/compass-collection/src/components/collection-header/collection-header.tsx +++ b/packages/compass-collection/src/components/collection-header/collection-header.tsx @@ -1,4 +1,3 @@ -import type AppRegistry from 'hadron-app-registry'; import { css, palette, @@ -11,19 +10,13 @@ import { PerformanceSignals, } from '@mongodb-js/compass-components'; import type { Signal } from '@mongodb-js/compass-components'; -import type { Document } from 'mongodb'; import React, { Component } from 'react'; import toNS from 'mongodb-ns'; import { withPreferences } from 'compass-preferences-model'; - import CollectionHeaderActions from '../collection-header-actions'; -import ReadOnlyBadge from './read-only-badge'; -import TimeSeriesBadge from './time-series-badge'; -import ViewBadge from './view-badge'; import CollectionStats from '../collection-stats'; -import type { CollectionStatsObject } from '../../modules/stats'; -import ClusteredBadge from './clustered-badge'; -import FLEBadge from './fle-badge'; +import type { CollectionState } from '../../modules/collection-tab'; +import { CollectionBadge } from './badges'; const collectionHeaderStyles = css({ paddingTop: spacing[3], @@ -107,23 +100,22 @@ const collectionHeaderTitleCollectionDarkStyles = css({ type CollectionHeaderProps = { darkMode?: boolean; showInsights: boolean; - globalAppRegistry: AppRegistry; + onSelectDatabaseClick(): void; + onEditViewClick(): void; + onReturnToViewClick(): void; namespace: string; isReadonly: boolean; isTimeSeries: boolean; isClustered: boolean; isFLE: boolean; isAtlas: boolean; - selectOrCreateTab: (options: any) => any; sourceName?: string; - sourceReadonly?: boolean; - sourceViewOn?: string; editViewName?: string; - pipeline: Document[]; - stats: CollectionStatsObject; + sourcePipeline?: unknown[]; + stats: CollectionState['stats']; }; -const getInsightsForPipeline = (pipeline: Document[], isAtlas: boolean) => { +const getInsightsForPipeline = (pipeline: any[], isAtlas: boolean) => { const insights = new Set(); for (const stage of pipeline) { if ('$match' in stage) { @@ -146,40 +138,16 @@ const getInsightsForPipeline = (pipeline: Document[], isAtlas: boolean) => { }; class CollectionHeader extends Component { - static displayName = 'CollectionHeaderComponent'; - onEditViewClicked = (): void => { - this.props.selectOrCreateTab({ - namespace: this.props.sourceName, - isReadonly: this.props.sourceReadonly, - isTimeSeries: this.props.isTimeSeries, - isClustered: this.props.isClustered, - isFLE: this.props.isFLE, - sourceName: this.props.sourceViewOn, - editViewName: this.props.namespace, - sourceReadonly: false, - sourceViewOn: null, - sourcePipeline: this.props.pipeline, - }); + this.props.onEditViewClick(); }; onReturnToViewClicked = (): void => { - this.props.selectOrCreateTab({ - namespace: this.props.editViewName, - isReadonly: true, - isTimeSeries: this.props.isTimeSeries, - isClustered: this.props.isClustered, - isFLE: this.props.isFLE, - sourceName: this.props.namespace, - editViewName: null, - sourceReadonly: this.props.isReadonly, - sourceViewOn: this.props.sourceName, - sourcePipeline: this.props.pipeline, - }); + this.props.onReturnToViewClick(); }; - handleDBClick = (db: string): void => { - this.props.globalAppRegistry.emit('select-database', db); + handleDBClick = (): void => { + this.props.onSelectDatabaseClick(); }; /** @@ -192,9 +160,10 @@ class CollectionHeader extends Component { const database = ns.database; const collection = ns.collection; const insights = - this.props.showInsights && this.props.pipeline?.length - ? getInsightsForPipeline(this.props.pipeline, this.props.isAtlas) + this.props.showInsights && this.props.sourcePipeline?.length + ? getInsightsForPipeline(this.props.sourcePipeline, this.props.isAtlas) : []; + return (
{ this.props.darkMode ? dbLinkDarkStyles : dbLinkLightStyles )} hideExternalIcon={true} - onClick={() => this.handleDBClick(database)} + onClick={() => this.handleDBClick()} >

{ {`.${collection}`}

- {this.props.isReadonly && } - {this.props.isTimeSeries && } - {this.props.isClustered && } - {this.props.isFLE && } - {this.props.isReadonly && this.props.sourceName && } + {this.props.isReadonly && } + {this.props.isTimeSeries && } + {this.props.isClustered && } + {this.props.isFLE && } + {this.props.isReadonly && this.props.sourceName && ( + + )} {!!insights.length && } { {!this.props.isReadonly && !this.props.editViewName && ( )} diff --git a/packages/compass-collection/src/components/collection-header/fle-badge.tsx b/packages/compass-collection/src/components/collection-header/fle-badge.tsx deleted file mode 100644 index ee5d34fb125..00000000000 --- a/packages/compass-collection/src/components/collection-header/fle-badge.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { Badge, BadgeVariant, css, Icon } from '@mongodb-js/compass-components'; - -const collectionHeaderBadgeStyles = css({ - whiteSpace: 'nowrap', -}); - -const FLEBadge = (): React.ReactElement => ( - - {/* Queryable Encryption is the user-facing name of FLE2 */} - -  Queryable Encryption - -); - -export default FLEBadge; diff --git a/packages/compass-collection/src/components/collection-header/read-only-badge.tsx b/packages/compass-collection/src/components/collection-header/read-only-badge.tsx deleted file mode 100644 index 602ca59e056..00000000000 --- a/packages/compass-collection/src/components/collection-header/read-only-badge.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Badge, BadgeVariant, css } from '@mongodb-js/compass-components'; -import React from 'react'; - -const collectionHeaderBadgeStyles = css({ - whiteSpace: 'nowrap', -}); - -const ReadOnlyBadge = (): React.ReactElement => ( - - READ-ONLY - -); - -export default ReadOnlyBadge; diff --git a/packages/compass-collection/src/components/collection-header/time-series-badge.tsx b/packages/compass-collection/src/components/collection-header/time-series-badge.tsx deleted file mode 100644 index 2e00e28a58c..00000000000 --- a/packages/compass-collection/src/components/collection-header/time-series-badge.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Badge, BadgeVariant, Icon, css } from '@mongodb-js/compass-components'; -import React from 'react'; - -const collectionHeaderBadgeStyles = css({ - whiteSpace: 'nowrap', -}); - -const TimeSeriesBadge = (): React.ReactElement => ( - - -  TIME-SERIES - -); - -export default TimeSeriesBadge; diff --git a/packages/compass-collection/src/components/collection-header/view-badge.tsx b/packages/compass-collection/src/components/collection-header/view-badge.tsx deleted file mode 100644 index 471f97d9554..00000000000 --- a/packages/compass-collection/src/components/collection-header/view-badge.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Badge, BadgeVariant, Icon, css } from '@mongodb-js/compass-components'; -import React from 'react'; - -const collectionHeaderBadgeStyles = css({ - whiteSpace: 'nowrap', -}); - -const ViewBadge = (): React.ReactElement => ( - - -  VIEW - -); - -export default ViewBadge; diff --git a/packages/compass-collection/src/components/collection-stats-item/collection-stats-item.spec.tsx b/packages/compass-collection/src/components/collection-stats-item/collection-stats-item.spec.tsx index 92f61af806e..480eebd0e21 100644 --- a/packages/compass-collection/src/components/collection-stats-item/collection-stats-item.spec.tsx +++ b/packages/compass-collection/src/components/collection-stats-item/collection-stats-item.spec.tsx @@ -10,22 +10,22 @@ describe('CollectionStatsItem [Component]', function () { beforeEach(function () { render( - + ); }); it('renders the correct root classname', function () { - expect(screen.getByTestId('test')).to.exist; + expect(screen.getByTestId('test-stats-item')).to.exist; }); it('renders the label', function () { - const label = screen.getByTestId('test-label'); + const label = screen.getByTestId('test-count-label'); expect(label).to.have.text('label'); expect(label).to.be.visible; }); it('renders the value', function () { - const value = screen.getByTestId('test-value'); + const value = screen.getByTestId('test-count-value'); expect(value).to.have.text('10kb'); expect(value).to.be.visible; }); @@ -36,22 +36,22 @@ describe('CollectionStatsItem [Component]', function () { beforeEach(function () { render( - + ); }); it('renders the correct root classname', function () { - expect(screen.getByTestId('test')).to.exist; + expect(screen.getByTestId('test-stats-item')).to.exist; }); it('renders the label', function () { - const label = screen.getByTestId('test-label'); + const label = screen.getByTestId('test-count-label'); expect(label).to.have.text('label'); expect(label).to.be.visible; }); it('renders the value', function () { - const value = screen.getByTestId('test-value'); + const value = screen.getByTestId('test-count-value'); expect(value).to.have.text('20kb'); expect(value).to.be.visible; }); diff --git a/packages/compass-collection/src/components/collection-stats-item/collection-stats-item.tsx b/packages/compass-collection/src/components/collection-stats-item/collection-stats-item.tsx index e49ebdc988f..5683f4ae244 100644 --- a/packages/compass-collection/src/components/collection-stats-item/collection-stats-item.tsx +++ b/packages/compass-collection/src/components/collection-stats-item/collection-stats-item.tsx @@ -8,6 +8,20 @@ import { useDarkMode, } from '@mongodb-js/compass-components'; +const statsItemContainerStyles = css({ + paddingLeft: spacing[1], + paddingRight: spacing[1], + flexBasis: 'auto', + flexGrow: 0, + flexShrink: 0, + display: 'flex', + alignItems: 'flex-end', + marginBottom: 0, + '&:last-child': { + borderRight: 'none', + }, +}); + const collectionStatsItemStyles = css({ display: 'flex', flexDirection: 'column', @@ -44,7 +58,7 @@ const lightThemeValueStyles = css({ type CollectionStatsItemProps = { label: string; value: string; - dataTestId: string; + ['data-testid']?: string; }; /** @@ -52,23 +66,35 @@ type CollectionStatsItemProps = { */ const CollectionStatsItem: React.FunctionComponent< CollectionStatsItemProps -> = ({ dataTestId, label, value }: CollectionStatsItemProps) => { +> = ({ + ['data-testid']: dataTestId, + label, + value, +}: CollectionStatsItemProps) => { const darkMode = useDarkMode(); return ( -
-

- {value} -

- +
- {label} - +

+ {value} +

+ + {label} + +
); }; diff --git a/packages/compass-collection/src/components/collection-stats/collection-stats.spec.tsx b/packages/compass-collection/src/components/collection-stats/collection-stats.spec.tsx index 6682eee1b8c..8d1ea23537c 100644 --- a/packages/compass-collection/src/components/collection-stats/collection-stats.spec.tsx +++ b/packages/compass-collection/src/components/collection-stats/collection-stats.spec.tsx @@ -1,35 +1,14 @@ import React from 'react'; import { render, screen, cleanup } from '@testing-library/react'; import { expect } from 'chai'; -import AppRegistry from 'hadron-app-registry'; - import CollectionStats from '../collection-stats'; describe('CollectionStats [Component]', function () { - beforeEach(function () { - (window as any).hadronApp = { - appRegistry: new AppRegistry(), - }; - }); - - afterEach(function () { - delete (window as any).hadronApp; - }); - describe('when rendered', function () { afterEach(cleanup); beforeEach(function () { - render( - - ); + render(); }); it('renders the correct root classname', function () { @@ -46,17 +25,7 @@ describe('CollectionStats [Component]', function () { afterEach(cleanup); beforeEach(function () { - render( - - ); + render(); }); it('does not render the document stats', function () { diff --git a/packages/compass-collection/src/components/collection-stats/collection-stats.tsx b/packages/compass-collection/src/components/collection-stats/collection-stats.tsx index 63a382f8976..6bd9b0ad8ea 100644 --- a/packages/compass-collection/src/components/collection-stats/collection-stats.tsx +++ b/packages/compass-collection/src/components/collection-stats/collection-stats.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useMemo } from 'react'; +import numeral from 'numeral'; import { css, Tooltip, spacing } from '@mongodb-js/compass-components'; - -import DocumentStatsItem from '../document-stats-item'; -import IndexStatsItem from '../index-stats-item'; +import type { CollectionState } from '../../modules/collection-tab'; +import CollectionStatsItem from '../collection-stats-item'; const collectionStatsStyles = css({ textAlign: 'right', @@ -31,24 +31,61 @@ const tooltipIndexeListStyles = css({ }); type CollectionStatsProps = { - documentCount: string; - storageSize: string; - avgDocumentSize: string; - indexCount: string; - totalIndexSize: string; - avgIndexSize: string; + stats: CollectionState['stats']; isTimeSeries?: boolean; }; +const INVALID = 'N/A'; + +const avg = (size: number, count: number) => { + if (count <= 0) { + return 0; + } + return size / count; +}; + +const isNumber = (val: any): val is number => { + return typeof val === 'number' && !isNaN(val); +}; + +const format = (value: any, format = 'a') => { + if (!isNumber(value)) { + return INVALID; + } + const precision = value <= 1000 ? '0' : '0.0'; + return numeral(value).format(precision + format); +}; + const CollectionStats: React.FunctionComponent = ({ isTimeSeries, - documentCount, - storageSize, - avgDocumentSize, - indexCount, - totalIndexSize, - avgIndexSize, + stats, }: CollectionStatsProps) => { + const { + documentCount, + storageSize, + avgDocumentSize, + indexCount, + totalIndexSize, + avgIndexSize, + } = useMemo(() => { + const { + document_count = NaN, + storage_size = NaN, + free_storage_size = NaN, + avg_document_size = NaN, + index_count = NaN, + index_size = NaN, + } = stats ?? {}; + return { + documentCount: format(document_count), + storageSize: format(storage_size - free_storage_size, 'b'), + avgDocumentSize: format(avg_document_size, 'b'), + indexCount: format(index_count), + totalIndexSize: format(index_size, 'b'), + avgIndexSize: format(avg(index_size, index_count), 'b'), + }; + }, [stats]); + return (
= ({
{!isTimeSeries && ( - + + )} + {!isTimeSeries && ( + )} - {!isTimeSeries && }
{children}
diff --git a/packages/compass-collection/src/components/collection-tab.tsx b/packages/compass-collection/src/components/collection-tab.tsx new file mode 100644 index 00000000000..e89885fff1d --- /dev/null +++ b/packages/compass-collection/src/components/collection-tab.tsx @@ -0,0 +1,143 @@ +import React, { useEffect } from 'react'; +import type { Store } from 'redux'; +import { connect, Provider } from 'react-redux'; +import { + returnToView, + selectDatabase, + type CollectionState, + editView, + selectTab, + renderScopedModals, + renderTabs, +} from '../modules/collection-tab'; +import { css, ErrorBoundary, TabNavBar } from '@mongodb-js/compass-components'; +import CollectionHeader from './collection-header'; +import createLoggerAndTelemetry from '@mongodb-js/compass-logging'; + +const { log, mongoLogId, track } = createLoggerAndTelemetry( + 'COMPASS-COLLECTION-TAB-UI' +); + +function trackingIdForTabName(name: string) { + return name.toLowerCase().replace(/ /g, '_'); +} + +const ConnectedCollectionHeader = connect( + (state: CollectionState) => { + return { + ...state.metadata, + editViewName: state.editViewName, + stats: state.stats, + }; + }, + { + onSelectDatabaseClick: selectDatabase, + onEditViewClick: editView, + onReturnToViewClick: returnToView, + } +)(CollectionHeader); + +const collectionStyles = css({ + display: 'flex', + alignItems: 'stretch', + height: '100%', + width: '100%', +}); + +const collectionContainerStyles = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + height: '100%', + width: '100%', +}); + +const collectionModalContainerStyles = css({ + zIndex: 100, +}); + +const CollectionTab: React.FunctionComponent<{ + currentTab: string; + renderScopedModals(): React.ReactElement[]; + renderTabs(): { name: string; component: React.ReactElement }[]; + onTabClick(name: string): void; +}> = ({ currentTab, renderScopedModals, renderTabs, onTabClick }) => { + const tabs = renderTabs(); + const activeTabIndex = tabs.findIndex((tab) => tab.name === currentTab); + + useEffect(() => { + const activeSubTabName = currentTab + ? trackingIdForTabName(currentTab) + : null; + + if (activeSubTabName) { + track('Screen', { + name: activeSubTabName, + }); + } + }, [currentTab]); + + return ( +
+
+ + { + return tab.name; + })} + views={tabs.map((tab) => { + return ( + { + log.error( + mongoLogId(1001000107), + 'Collection Workspace', + 'Rendering collection tab failed', + { name: tab.name, error: error.stack, errorInfo } + ); + }} + > + {tab.component} + + ); + })} + activeTabIndex={activeTabIndex} + onTabClicked={(id) => { + onTabClick(tabs[id].name); + }} + /> +
+
+ {renderScopedModals()} +
+
+ ); +}; + +const ConnectedCollectionTab = connect( + (state: CollectionState) => { + return { + currentTab: state.currentTab, + }; + }, + { + renderScopedModals: renderScopedModals, + renderTabs: renderTabs, + onTabClick: selectTab, + } +)(CollectionTab); + +const CollectionTabPlugin: React.FunctionComponent<{ store: Store }> = ({ + store, +}) => { + return ( + + + + ); +}; + +export default CollectionTabPlugin; diff --git a/packages/compass-collection/src/components/collection/collection.spec.tsx b/packages/compass-collection/src/components/collection/collection.spec.tsx deleted file mode 100644 index 753e7ecad49..00000000000 --- a/packages/compass-collection/src/components/collection/collection.spec.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import AppRegistry from 'hadron-app-registry'; -import { expect } from 'chai'; -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { spy } from 'sinon'; - -import Collection from '../collection'; - -function renderCollection( - props: Partial> = {} -) { - const localAppRegistry = new AppRegistry(); - const globalAppRegistry = new AppRegistry(); - const selectOrCreateTabSpy = spy(); - const sourceReadonly = false; - - return render( - {}} - id="collection" - namespace="db.coll" - selectOrCreateTab={selectOrCreateTabSpy} - sourceReadonly={sourceReadonly} - pipeline={[]} - stats={{ - 'db.coll': { - avgDocumentSize: '2B', - avgIndexSize: '1B', - documentCount: '1243', - indexCount: '1', - storageSize: '2B', - totalIndexSize: '1B', - }, - }} - {...props} - /> - ); -} - -describe('Collection [Component]', function () { - describe('when rendered', function () { - beforeEach(function () { - renderCollection(); - }); - - it('renders the correct root classname', function () { - expect(screen.getByTestId('collection')).to.exist; - }); - - it('must not show the view icon', function () { - expect(screen.queryByTestId('collection-badge-view')).to.not.exist; - }); - - it('must not include the collection name the view is based on', function () { - expect(screen.queryByTestId('collection-view-on')).to.not.exist; - }); - - it('renders the collection stats', function () { - expect(screen.queryByTestId('document-count')).to.be.visible; - }); - - it('renders the document count', function () { - expect(screen.getByText('1243')).to.be.visible; - }); - }); - - describe('when rendered without stats for the collection', function () { - beforeEach(function () { - renderCollection({ - stats: {}, - }); - }); - - it('renders the collection stats', function () { - expect(screen.queryByTestId('document-count')).to.be.visible; - }); - - it('renders the document count N/A', function () { - expect(screen.queryByTestId('document-count')?.textContent).to.equal( - 'N/ADocuments' - ); - }); - }); -}); diff --git a/packages/compass-collection/src/components/collection/collection.tsx b/packages/compass-collection/src/components/collection/collection.tsx deleted file mode 100644 index d06bae5a737..00000000000 --- a/packages/compass-collection/src/components/collection/collection.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import type AppRegistry from 'hadron-app-registry'; -import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; -import type { Document } from 'mongodb'; -import React, { useCallback, useEffect, useMemo } from 'react'; -import { TabNavBar, css } from '@mongodb-js/compass-components'; -import { usePreference } from 'compass-preferences-model'; -import CollectionHeader from '../collection-header'; -import { getCollectionStatsInitialState } from '../../modules/stats'; -import type { CollectionStatsMap } from '../../modules/stats'; - -const { track } = createLoggerAndTelemetry('COMPASS-COLLECTION-UI'); - -function trackingIdForTabName(name: string) { - return name.toLowerCase().replace(/ /g, '_'); -} - -const collectionStyles = css({ - display: 'flex', - alignItems: 'stretch', - height: '100%', - width: '100%', -}); - -const collectionContainerStyles = css({ - display: 'flex', - flexDirection: 'column', - alignItems: 'stretch', - height: '100%', - width: '100%', -}); - -const collectionModalContainerStyles = css({ - zIndex: 100, -}); - -type CollectionProps = { - darkMode?: boolean; - namespace: string; - isReadonly: boolean; - isTimeSeries: boolean; - isClustered: boolean; - isFLE: boolean; - editViewName?: string; - sourceReadonly?: boolean; - sourceViewOn?: string; - selectOrCreateTab: (options: any) => any; - pipeline: Document[]; - sourceName?: string; - activeSubTab: number; - id: string; - tabs: string[]; - views: JSX.Element[]; - localAppRegistry: AppRegistry; - globalAppRegistry: AppRegistry; - changeActiveSubTab: (activeSubTab: number, id: string) => void; - scopedModals: { - store: any; - component: React.ComponentType; - actions: any; - key: number | string; - }[]; - stats: CollectionStatsMap; - isAtlas: boolean; -}; - -const Collection: React.FunctionComponent = ({ - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - stats, - editViewName, - sourceReadonly, - sourceViewOn, - selectOrCreateTab, - pipeline, - sourceName, - activeSubTab, - id, - tabs: _tabs, - views: _views, - localAppRegistry, - globalAppRegistry, - changeActiveSubTab, - scopedModals, - isAtlas, -}: CollectionProps) => { - const newExplainPlan = usePreference('newExplainPlan', React); - const explainPlanTabId = _tabs.indexOf('Explain Plan'); - const tabs = useMemo(() => { - return _tabs.filter((_, id) => { - return !newExplainPlan || id !== explainPlanTabId; - }); - }, [newExplainPlan, explainPlanTabId, _tabs]); - const views = useMemo(() => { - return _views.filter((_, id) => { - return !newExplainPlan || id !== explainPlanTabId; - }); - }, [newExplainPlan, explainPlanTabId, _views]); - - const activeSubTabName = - tabs && tabs.length > 0 - ? trackingIdForTabName(tabs[activeSubTab] || 'Unknown') - : null; - - useEffect(() => { - if (activeSubTabName) { - track('Screen', { - name: activeSubTabName, - }); - } - }, [activeSubTabName]); - - const onSubTabClicked = useCallback( - (idx, name) => { - if (activeSubTab === idx) { - return; - } - localAppRegistry.emit('subtab-changed', name); - globalAppRegistry.emit('compass:screen:viewed', { screen: name }); - changeActiveSubTab(idx, id); - }, - [id, activeSubTab, localAppRegistry, globalAppRegistry, changeActiveSubTab] - ); - - useEffect(() => { - const indexesTabId = tabs.indexOf('Indexes'); - const onOpenCreateIndexEvent = onSubTabClicked.bind( - null, - indexesTabId, - tabs[indexesTabId] - ); - localAppRegistry.on('open-create-index-modal', onOpenCreateIndexEvent); - return () => { - localAppRegistry.removeListener( - 'open-create-index-modal', - onOpenCreateIndexEvent - ); - }; - }, [localAppRegistry, onSubTabClicked, tabs]); - - useEffect(() => { - const aggregationsTabId = tabs.indexOf('Aggregations'); - const onGenerateAggregationFromQueryEvent = onSubTabClicked.bind( - null, - aggregationsTabId, - tabs[aggregationsTabId] - ); - localAppRegistry.on( - 'generate-aggregation-from-query', - onGenerateAggregationFromQueryEvent - ); - return () => { - localAppRegistry.removeListener( - 'generate-aggregation-from-query', - onGenerateAggregationFromQueryEvent - ); - }; - }, [localAppRegistry, onSubTabClicked, tabs]); - - return ( -
-
- - onSubTabClicked(tabIdx, tabs[tabIdx])} - /> -
-
- {scopedModals.map((modal) => ( - - ))} -
-
- ); -}; - -export default Collection; diff --git a/packages/compass-collection/src/components/collection/index.ts b/packages/compass-collection/src/components/collection/index.ts deleted file mode 100644 index c6c77e71cd8..00000000000 --- a/packages/compass-collection/src/components/collection/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import Collection from './collection'; -export default Collection; diff --git a/packages/compass-collection/src/components/document-stats-item/document-stats-item.spec.tsx b/packages/compass-collection/src/components/document-stats-item/document-stats-item.spec.tsx deleted file mode 100644 index b768174f436..00000000000 --- a/packages/compass-collection/src/components/document-stats-item/document-stats-item.spec.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import { render, screen, cleanup } from '@testing-library/react'; -import { expect } from 'chai'; - -import DocumentStatsItem from '../document-stats-item'; - -describe('DocumentStatsItem [Component]', function () { - afterEach(cleanup); - - beforeEach(function () { - render(); - }); - - it('renders the correct root classname', function () { - expect(screen.getByTestId('document-stats-item')).to.exist; - }); - - it('renders the document count value', function () { - const value = screen.getByTestId('document-count-value'); - expect(value).to.have.text('10'); - expect(value).to.be.visible; - }); - - it('renders the document count label', function () { - const label = screen.getByTestId('document-count-label'); - expect(label).to.have.text('Documents'); - expect(label).to.be.visible; - }); -}); diff --git a/packages/compass-collection/src/components/document-stats-item/document-stats-item.tsx b/packages/compass-collection/src/components/document-stats-item/document-stats-item.tsx deleted file mode 100644 index 35f776ec667..00000000000 --- a/packages/compass-collection/src/components/document-stats-item/document-stats-item.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { css, spacing } from '@mongodb-js/compass-components'; - -import CollectionStatsItem from '../collection-stats-item'; - -const documentStatsItemStyles = css({ - paddingLeft: spacing[1], - paddingRight: spacing[1], - flexBasis: 'auto', - flexGrow: 0, - flexShrink: 0, - display: 'flex', - alignItems: 'flex-end', - marginBottom: 0, - '&:last-child': { - borderRight: 'none', - }, -}); - -type DocumentStatsItemProps = { - documentCount: string; -}; - -/** - * The document stats item component. - */ -const DocumentStatsItem: React.FunctionComponent = ({ - documentCount, -}: DocumentStatsItemProps) => { - return ( -
- -
- ); -}; - -export default DocumentStatsItem; diff --git a/packages/compass-collection/src/components/document-stats-item/index.ts b/packages/compass-collection/src/components/document-stats-item/index.ts deleted file mode 100644 index c4684836adb..00000000000 --- a/packages/compass-collection/src/components/document-stats-item/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import DocumentStatsItem from './document-stats-item'; - -export default DocumentStatsItem; -export { DocumentStatsItem }; diff --git a/packages/compass-collection/src/components/index-stats-item/index-stats-item.spec.tsx b/packages/compass-collection/src/components/index-stats-item/index-stats-item.spec.tsx deleted file mode 100644 index c91989e659e..00000000000 --- a/packages/compass-collection/src/components/index-stats-item/index-stats-item.spec.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import { render, screen, cleanup } from '@testing-library/react'; -import { expect } from 'chai'; - -import IndexStatsItem from '../index-stats-item'; - -describe('IndexStatsItem [Component]', function () { - afterEach(cleanup); - - beforeEach(function () { - render(); - }); - - it('renders the correct root classname', function () { - expect(screen.getByTestId('index-stats-item')).to.exist; - }); - - it('renders the document count value', function () { - const value = screen.getByTestId('index-count-value'); - expect(value).to.have.text('10'); - expect(value).to.be.visible; - }); - - it('renders the document count label', function () { - const label = screen.getByTestId('index-count-label'); - expect(label).to.have.text('Indexes'); - expect(label).to.be.visible; - }); -}); diff --git a/packages/compass-collection/src/components/index-stats-item/index-stats-item.tsx b/packages/compass-collection/src/components/index-stats-item/index-stats-item.tsx deleted file mode 100644 index 15f095cc9d1..00000000000 --- a/packages/compass-collection/src/components/index-stats-item/index-stats-item.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { css, spacing } from '@mongodb-js/compass-components'; - -import CollectionStatsItem from '../collection-stats-item'; - -const indexStatsItemStyles = css({ - paddingLeft: spacing[1], - paddingRight: spacing[1], - flexBasis: 'auto', - flexGrow: 0, - flexShrink: 0, - display: 'flex', - alignItems: 'flex-end', - marginBottom: 0, - '&:last-child': { - borderRight: 'none', - }, -}); - -type IndexStatsItemProps = { - indexCount: string; -}; - -/** - * The index stats item component. - */ -const IndexStatsItem: React.FunctionComponent = ({ - indexCount, -}: IndexStatsItemProps) => { - return ( -
- -
- ); -}; - -export default IndexStatsItem; diff --git a/packages/compass-collection/src/components/index-stats-item/index.ts b/packages/compass-collection/src/components/index-stats-item/index.ts deleted file mode 100644 index 34133f5cfd8..00000000000 --- a/packages/compass-collection/src/components/index-stats-item/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import IndexStatsItem from './index-stats-item'; - -export default IndexStatsItem; -export { IndexStatsItem }; diff --git a/packages/compass-collection/src/components/workspace/index.ts b/packages/compass-collection/src/components/workspace/index.ts index d68fb7b94e1..a0e265a1eb5 100644 --- a/packages/compass-collection/src/components/workspace/index.ts +++ b/packages/compass-collection/src/components/workspace/index.ts @@ -1,4 +1,2 @@ import MappedWorkspace from './workspace'; -import { getTabType } from './workspace'; export default MappedWorkspace; -export { getTabType }; diff --git a/packages/compass-collection/src/components/workspace/workspace.spec.tsx b/packages/compass-collection/src/components/workspace/workspace.spec.tsx index 5bec80f4ace..05689b46186 100644 --- a/packages/compass-collection/src/components/workspace/workspace.spec.tsx +++ b/packages/compass-collection/src/components/workspace/workspace.spec.tsx @@ -1,73 +1,58 @@ import { expect } from 'chai'; import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { spy } from 'sinon'; -import type AppRegistry from 'hadron-app-registry'; - -import { Workspace, getTabType } from './workspace'; - -describe.skip('Workspace [Component]', function () { - const tabs = [{ isActive: true }, { isActive: false }]; - - let prevTabSpy; - let nextTabSpy; - - beforeEach(function () { - prevTabSpy = spy(); - nextTabSpy = spy(); - - render( - ({ - type: 'type', - index: 1, - })} - moveTab={() => ({ - type: 'type', - fromIndex: 1, - toIndex: 2, - })} - selectTab={() => ({ - type: 'type', - index: 1, - })} - appRegistry={{} as AppRegistry} - prevTab={prevTabSpy} - nextTab={nextTabSpy} - selectOrCreateTab={() => {}} - changeActiveSubTab={() => ({ - type: 'type', - activeSubTab: 1, - id: '123', - })} - createNewTab={() => {}} - /> - ); - }); - - afterEach(function () { - prevTabSpy = null; - nextTabSpy = null; - }); - - it('renders the tab div', function () { - expect(screen.getByTestId('workspace-tabs')).to.exist; - }); - - describe('#getTabType', function () { - it('should return "timeseries" for a timeseries collection', function () { - expect(getTabType(true, false)).to.equal('timeseries'); - }); - - it('should return "view" for a view', function () { - expect(getTabType(false, true)).to.equal('view'); - }); - - it('should return "collection" when its not time series or readonly', function () { - expect(getTabType(false, false)).to.equal('collection'); - }); +import { cleanup, render, screen } from '@testing-library/react'; + +import { Workspace } from './workspace'; + +function createTab(id: string) { + return { + id, + namespace: id, + type: 'collection', + selectedSubTabName: 'Documents', + localAppRegistry: {} as any, + component:
Tab {id} content
, + }; +} + +function renderWorkspace( + props: Partial> = {} +) { + return render( + { + /** noop */ + }} + onSelectNextTab={() => { + /** noop */ + }} + onSelectPreviousTab={() => { + /** noop */ + }} + onMoveTab={() => { + /** noop */ + }} + onCloseTab={() => { + /** noop */ + }} + onCreateNewTab={() => { + /** noop */ + }} + {...props} + > + ); +} + +describe('Workspace', function () { + afterEach(cleanup); + + it('renders the tabs', function () { + renderWorkspace(); + expect(screen.getByTitle('a - Documents')).to.exist; + expect(screen.getByTitle('b - Documents')).to.exist; + expect(screen.getByTitle('c - Documents')).to.exist; + expect(screen.getByText('Tab a content')).to.exist; }); }); diff --git a/packages/compass-collection/src/components/workspace/workspace.tsx b/packages/compass-collection/src/components/workspace/workspace.tsx index 79df2a2cc6f..81455c07b35 100644 --- a/packages/compass-collection/src/components/workspace/workspace.tsx +++ b/packages/compass-collection/src/components/workspace/workspace.tsx @@ -1,26 +1,16 @@ -import type AppRegistry from 'hadron-app-registry'; -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { connect } from 'react-redux'; +import { WorkspaceTabs, css, useHotkeys } from '@mongodb-js/compass-components'; import { - WorkspaceTabs, - css, - cx, - useHotkeys, -} from '@mongodb-js/compass-components'; - -import { - createNewTab, - selectOrCreateTab, - closeTab, - prevTab, - nextTab, - moveTab, - selectTab, - changeActiveSubTab, + selectNextTab, + type CollectionTab, + type CollectionTabsState, + selectPreviousTab, + moveTabByIndex, + closeTabAtIndex, + openNewTabForCurrentCollection, + selectTabByIndex, } from '../../modules/tabs'; -import type { WorkspaceTabObject } from '../../modules/tabs'; -import type { CollectionStatsMap } from '../../modules/stats'; -import Collection from '../collection'; const workspaceStyles = css({ width: '100%', @@ -43,29 +33,6 @@ const workspaceViewTabStyles = css({ width: '100%', }); -const workspaceHiddenStyles = css({ - display: 'none', -}); - -function getTabType(isTimeSeries: boolean, isReadonly: boolean): string { - if (isTimeSeries) { - return 'timeseries'; - } - if (isReadonly) { - return 'view'; - } - return 'collection'; -} - -const DEFAULT_NEW_TAB = { - namespace: '', - isReadonly: false, - isTimeSeries: false, - isClustered: false, - isFLE: false, - sourceName: '', -}; - function getIconGlyphForCollectionType(type: string) { switch (type) { case 'timeseries': @@ -77,145 +44,53 @@ function getIconGlyphForCollectionType(type: string) { } } -type WorkspaceProps = { - tabs: WorkspaceTabObject[]; - closeTab: (index: number) => void; - createNewTab: (props: any) => any; - selectOrCreateTab: (props: any) => any; - appRegistry: AppRegistry; - prevTab: () => void; - nextTab: () => void; - moveTab: ( - fromIndex: number, - toIndex: number - ) => { - type: string; - fromIndex: number; - toIndex: number; - }; - selectTab: (index: number) => { - type: string; - index: number; - }; - changeActiveSubTab: (activeSubTab: number, id: string) => void; - stats: CollectionStatsMap; - isAtlas: boolean; -}; - -const WorkspaceTab = ({ - tab, - changeActiveSubTab, - selectOrCreateTab, - globalAppRegistry, - localAppRegistry, - stats, - isAtlas, -}: { - tab: WorkspaceTabObject; - changeActiveSubTab: (activeSubTab: number, id: string) => void; - selectOrCreateTab: (props: any) => any; - globalAppRegistry: AppRegistry; - localAppRegistry: AppRegistry; - stats: CollectionStatsMap; - isAtlas: boolean; -}) => { - return ( -
- -
- ); -}; - /** * The collection workspace contains tabs of multiple collections. */ const Workspace = ({ tabs, - closeTab, - createNewTab, - selectOrCreateTab, - appRegistry, - prevTab, - nextTab, - moveTab, - selectTab, - changeActiveSubTab, - stats, - isAtlas, -}: WorkspaceProps) => { - const onCreateNewTab = useCallback(() => { - const activeTab = tabs.find((tab: WorkspaceTabObject) => tab.isActive); - const newTabProps = activeTab - ? { - namespace: activeTab.namespace, - isReadonly: activeTab.isReadonly, - isTimeSeries: activeTab.isTimeSeries, - isClustered: activeTab.isClustered, - isFLE: activeTab.isFLE, - sourceName: activeTab.sourceName, - editViewName: activeTab.editViewName, - sourceReadonly: activeTab.sourceReadonly, - sourceViewOn: activeTab.sourceViewOn, - sourcePipeline: activeTab.pipeline, - } - : DEFAULT_NEW_TAB; - createNewTab(newTabProps); - }, [tabs, createNewTab]); - - const formatCompassComponentsWorkspaceTabs = useMemo((): any => { - return tabs.map((tab: WorkspaceTabObject) => ({ - title: tab.activeSubTabName, - subtitle: tab.namespace, - tabContentId: tab.id, - iconGlyph: getIconGlyphForCollectionType( - getTabType(tab.isTimeSeries, tab.isReadonly) - ), - })); + activeTabId, + onSelectTab, + onSelectNextTab, + onSelectPreviousTab, + onMoveTab, + onCloseTab, + onCreateNewTab, +}: { + tabs: CollectionTab[]; + activeTabId: string | null; + onSelectTab(index: number): void; + onSelectNextTab(): void; + onSelectPreviousTab(): void; + onMoveTab(fromIndex: number, toIndex: number): void; + onCloseTab(index: number): void; + onCreateNewTab(): void; +}) => { + const tabsForHeader = useMemo(() => { + return tabs.map((tab) => { + return { + title: tab.selectedSubTabName, + subtitle: tab.namespace, + tabContentId: tab.id, + iconGlyph: getIconGlyphForCollectionType(tab.type), + } as const; + }); }, [tabs]); - const selectedTabIndex = useMemo( - () => tabs.findIndex((tab: WorkspaceTabObject) => tab.isActive), - [tabs] - ); + const selectedTabIndex = useMemo(() => { + return tabs.findIndex((tab) => tab.id === activeTabId); + }, [tabs, activeTabId]); + + const activeTab = tabs.find((tab) => tab.id === activeTabId); - useHotkeys('ctrl + tab', nextTab); - useHotkeys('ctrl + shift + tab', prevTab); - useHotkeys('mod + shift + ]', nextTab); - useHotkeys('mod + shift + [', prevTab); + useHotkeys('ctrl + tab', onSelectNextTab); + useHotkeys('ctrl + shift + tab', onSelectPreviousTab); + useHotkeys('mod + shift + ]', onSelectNextTab); + useHotkeys('mod + shift + [', onSelectPreviousTab); useHotkeys( 'mod + w', (e) => { - closeTab(selectedTabIndex); + onCloseTab(selectedTabIndex); // This prevents the browser from closing the window // as this shortcut is used to exit the app. e.preventDefault(); @@ -229,58 +104,43 @@ const Workspace = ({ -
- {tabs.map((tab: WorkspaceTabObject) => ( - - ))} -
+ {activeTab && ( +
+
+ {activeTab?.component} +
+
+ )}
); }; -/** - * Map the store state to properties to pass to the components. - * - * @param {Object} state - The store state. - * - * @returns {Object} The mapped properties. - */ -const mapStateToProps = (state: any) => ({ - tabs: state.tabs, - appRegistry: state.appRegistry, - stats: state.stats, - isAtlas: state.isAtlas, -}); - -/** - * Connect the redux store to the component. - * (dispatch) - */ -const MappedWorkspace = connect(mapStateToProps, { - createNewTab, - selectOrCreateTab, - closeTab, - prevTab, - nextTab, - moveTab, - selectTab, - changeActiveSubTab, -})(Workspace); +const MappedWorkspace = connect( + (state: CollectionTabsState) => { + return { + tabs: state.tabs, + activeTabId: state.activeTabId, + }; + }, + { + onSelectTab: selectTabByIndex, + onSelectNextTab: selectNextTab, + onSelectPreviousTab: selectPreviousTab, + onMoveTab: moveTabByIndex, + onCloseTab: closeTabAtIndex, + onCreateNewTab: openNewTabForCurrentCollection, + } +)(Workspace); export default MappedWorkspace; -export { Workspace, getTabType }; +export { Workspace }; diff --git a/packages/compass-collection/src/index.ts b/packages/compass-collection/src/index.ts index c9084a21a89..5c91054af38 100644 --- a/packages/compass-collection/src/index.ts +++ b/packages/compass-collection/src/index.ts @@ -1,15 +1,23 @@ import type AppRegistry from 'hadron-app-registry'; +import CollectionTabsPlugin from './plugin'; +import CollectionTabsStore from './stores/tabs'; +import CollectionTab from './components/collection-tab'; +import { configureStore } from './stores/collection-tab'; -import CollectionPlugin from './plugin'; -import CollectionStore from './stores'; +const COLLECTION_TAB_ROLE = { + name: 'CollectionTab', + component: CollectionTab, + configureStore, +}; /** * Activate all the components in the Collection package. * @param {Object} appRegistry - The Hadron appRegisrty to activate this plugin with. **/ function activate(appRegistry: AppRegistry): void { - appRegistry.registerComponent('Collection.Workspace', CollectionPlugin); - appRegistry.registerStore('Collection.Store', CollectionStore); + appRegistry.registerComponent('Collection.Workspace', CollectionTabsPlugin); + appRegistry.registerStore('Collection.Store', CollectionTabsStore); + appRegistry.registerRole('CollectionTab.Content', COLLECTION_TAB_ROLE); } /** @@ -19,8 +27,9 @@ function activate(appRegistry: AppRegistry): void { function deactivate(appRegistry: AppRegistry): void { appRegistry.deregisterComponent('Collection.Workspace'); appRegistry.deregisterStore('Collection.Store'); + appRegistry.deregisterRole('CollectionTab.Content', COLLECTION_TAB_ROLE); } -export default CollectionPlugin; +export default CollectionTabsPlugin; export { activate, deactivate }; export { default as metadata } from '../package.json'; diff --git a/packages/compass-collection/src/modules/app-registry.spec.ts b/packages/compass-collection/src/modules/app-registry.spec.ts deleted file mode 100644 index dd2f2f54318..00000000000 --- a/packages/compass-collection/src/modules/app-registry.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type AppRegistry from 'hadron-app-registry'; -import { expect } from 'chai'; -import sinon from 'sinon'; - -import reducer, { - appRegistryActivated, - appRegistryEmit, - APP_REGISTRY_ACTIVATED, -} from './app-registry'; - -describe('app registry module', function () { - describe('#appRegistryActivated', function () { - it('returns the APP_REGISTRY_ACTIVATED action', function () { - expect(appRegistryActivated({} as AppRegistry)).to.deep.equal({ - type: APP_REGISTRY_ACTIVATED, - appRegistry: {}, - }); - }); - }); - - describe('#appRegistryEmit', function () { - const spy = sinon.spy(); - const appRegistry = { emit: spy }; - const getState = () => { - return { appRegistry: appRegistry }; - }; - - it('emits the action on the app registry', function () { - appRegistryEmit('name', { name: 'test' })(null, getState); - expect(spy.calledWith('name', { name: 'test' })).to.equal(true); - }); - }); - - describe('#reducer', function () { - context('when the action is not app registry activated', function () { - it('returns the default state', function () { - expect(reducer(undefined, { type: 'test' })).to.equal(null); - }); - }); - - context('when the action is app registry activated', function () { - it('returns the new state', function () { - expect( - reducer(undefined, appRegistryActivated({} as AppRegistry)) - ).to.deep.equal({}); - }); - }); - }); -}); diff --git a/packages/compass-collection/src/modules/app-registry.ts b/packages/compass-collection/src/modules/app-registry.ts deleted file mode 100644 index 38ca6cb1a76..00000000000 --- a/packages/compass-collection/src/modules/app-registry.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { AnyAction } from 'redux'; -import type AppRegistry from 'hadron-app-registry'; - -/** - * The prefix. - */ -const PREFIX = 'collection/app-registry'; - -/** - * App registry activated. - */ -export const APP_REGISTRY_ACTIVATED = `${PREFIX}/APP_REGISTRY_ACTIVATED`; - -/** - * The initial state. - */ -export const INITIAL_STATE = null; - -/** - * Reducer function for handle state changes to the app registry. - * - * @param {String} state - The app registry state. - * @param {Object} action - The action. - * - * @returns {String} The new state. - */ -export default function reducer( - state = INITIAL_STATE, - action: AnyAction -): AppRegistry | null { - if (action.type === APP_REGISTRY_ACTIVATED) { - return action.appRegistry; - } - return state; -} - -/** - * Action creator for app registry activated events. - * - * @param {AppRegistry} appRegistry - The app registry. - * - * @returns {Object} The app registry activated event. - */ -export const appRegistryActivated = ( - appRegistry: AppRegistry -): { - type: string; - appRegistry: AppRegistry; -} => ({ - type: APP_REGISTRY_ACTIVATED, - appRegistry: appRegistry, -}); - -/** - * Emit an event to the app registry. - * - * @param {String} name - The event name. - * @param {Object} metadata - The metadata. - * - * @returns {Function} The thunk function. - */ -export const appRegistryEmit = (name: string, metadata?: any): any => { - return (dispatch: any, getState: any) => { - const state = getState(); - if (state.appRegistry) { - state.appRegistry.emit(name, metadata); - } - }; -}; diff --git a/packages/compass-collection/src/modules/collection-tab.ts b/packages/compass-collection/src/modules/collection-tab.ts new file mode 100644 index 00000000000..92628e00cf7 --- /dev/null +++ b/packages/compass-collection/src/modules/collection-tab.ts @@ -0,0 +1,263 @@ +import type { Reducer, AnyAction } from 'redux'; +import type { CollectionMetadata } from 'mongodb-collection-model'; +import type Collection from 'mongodb-collection-model'; +import type { ThunkAction } from 'redux-thunk'; +import type AppRegistry from 'hadron-app-registry'; +import type { DataService } from 'mongodb-data-service'; +import toNs from 'mongodb-ns'; +import preferencesAccess from 'compass-preferences-model'; +import React from 'react'; + +type CollectionThunkAction< + ReturnType, + Action extends AnyAction = AnyAction +> = ThunkAction< + ReturnType, + CollectionState, + { + globalAppRegistry: AppRegistry; + localAppRegistry: AppRegistry; + dataService: DataService; + }, + Action +>; + +export type CollectionStateMetadata = CollectionMetadata & { + serverVersion: string; + isAtlas: boolean; + isDataLake: boolean; +}; + +export type CollectionState = { + stats: Pick< + Collection, + | 'document_count' + | 'index_count' + | 'index_size' + | 'status' + | 'avg_document_size' + | 'storage_size' + | 'free_storage_size' + > | null; + metadata: CollectionStateMetadata; + currentTab: + | 'Documents' + | 'Aggregations' + | 'Schema' + | 'Indexes' + | 'Validation'; + initialQuery?: unknown; + initialAggregation?: unknown; + editViewName?: string; +}; + +export function pickCollectionStats( + collection: Collection +): CollectionState['stats'] { + const { + document_count, + index_count, + index_size, + status, + avg_document_size, + storage_size, + free_storage_size, + } = collection.toJSON(); + return { + document_count, + index_count, + index_size, + status, + avg_document_size, + storage_size, + free_storage_size, + }; +} + +const initialMetadata: CollectionStateMetadata = { + namespace: '', + isReadonly: false, + isTimeSeries: false, + isClustered: false, + isFLE: false, + isSearchIndexesSupported: false, + isAtlas: false, + isDataLake: false, + serverVersion: '0.0.0', +}; + +enum CollectionActions { + CollectionStatsFetched = 'compass-collection/CollectionStatsFetched', + CollectionMetadataFetched = 'compass-collection/CollectionMetadataFetched', + ChangeTab = 'compass-collection/ChangeTab', +} + +const reducer: Reducer = ( + state = { + stats: null, + metadata: initialMetadata, + currentTab: 'Documents', + }, + action +) => { + if (action.type === CollectionActions.CollectionStatsFetched) { + return { + ...state, + stats: pickCollectionStats(action.collection), + }; + } + if (action.type === CollectionActions.CollectionMetadataFetched) { + return { + ...state, + metadata: action.metadata, + }; + } + if (action.type === CollectionActions.ChangeTab) { + return { + ...state, + currentTab: action.tabName, + }; + } + return state; +}; + +export const collectionStatsFetched = (collection: Collection) => { + return { type: CollectionActions.CollectionStatsFetched, collection }; +}; + +export const collectionMetadataFetched = (metadata: CollectionMetadata) => { + return { type: CollectionActions.CollectionMetadataFetched, metadata }; +}; + +export const selectTab = ( + tabName: CollectionState['currentTab'] +): CollectionThunkAction => { + return (dispatch, _getState, { localAppRegistry }) => { + dispatch({ type: CollectionActions.ChangeTab, tabName }); + localAppRegistry.emit('subtab-changed', tabName); + }; +}; + +export const selectDatabase = (): CollectionThunkAction => { + return (dispatch, getState, { globalAppRegistry }) => { + const { metadata } = getState(); + const { database } = toNs(metadata.namespace); + globalAppRegistry.emit('select-database', database); + }; +}; + +export const editView = (): CollectionThunkAction => { + return (dispatch, getState, { globalAppRegistry }) => { + const { metadata } = getState(); + globalAppRegistry.emit('collection-tab-modify-view', { + ns: metadata.namespace, + }); + }; +}; + +export const returnToView = (): CollectionThunkAction => { + return (dispatch, getState, { globalAppRegistry }) => { + const { editViewName } = getState(); + globalAppRegistry.emit('collection-tab-select-collection', { + ns: editViewName, + }); + }; +}; + +const setupRole = ( + roleName: string +): CollectionThunkAction<{ name: string; component: React.ReactElement }[]> => { + return ( + dispatch, + getState, + { localAppRegistry, globalAppRegistry, dataService } + ) => { + const roles = globalAppRegistry.getRole(roleName) ?? []; + + return roles.map((role) => { + localAppRegistry.registerRole(roleName, role); + + const { + name, + component, + storeName, + configureStore, + actionName, + configureActions, + } = role; + + const collectionStoreMetadata = { + ...getState().metadata, + localAppRegistry, + globalAppRegistry, + dataProvider: { + // Even though this is technically impossible, all scoped plugins + // expect error key to be present + error: null, + dataProvider: dataService, + }, + query: getState().initialQuery, + aggregation: getState().initialAggregation, + editViewName: getState().editViewName, + }; + + let actions; + if (actionName && configureActions) { + actions = + localAppRegistry.getAction(actionName) ?? + localAppRegistry + .registerAction(actionName, configureActions()) + .getAction(actionName); + } + + let store; + if (storeName && configureStore) { + store = + localAppRegistry.getStore(storeName) ?? + localAppRegistry + .registerStore( + storeName, + configureStore({ ...collectionStoreMetadata, actions }) + ) + .getStore(storeName); + } + + return { + name, + component: React.createElement(component, { store, actions }), + }; + }); + }; +}; + +export const renderScopedModals = (): CollectionThunkAction< + React.ReactElement[] +> => { + return (dispatch) => { + return dispatch(setupRole('Collection.ScopedModal')).map((role) => { + return role.component; + }); + }; +}; + +export const renderTabs = (): CollectionThunkAction< + { name: string; component: React.ReactElement }[] +> => { + return (dispatch) => { + // TODO(COMPASS-7020): we don't actually render query bar in the collection + // tab, but compass-crud and compass-schema expect some additional roles and + // stores to be already set up when they are rendered instead of handling + // this on their own. We do this here and ignore the return value, this just + // makes sure that other plugins can use query bar + dispatch(setupRole('Query.QueryBar')); + + return dispatch(setupRole('Collection.Tab')).filter((role) => { + return !( + preferencesAccess.getPreferences().newExplainPlan && + role.name === 'Explain Plan' + ); + }); + }; +}; + +export default reducer; diff --git a/packages/compass-collection/src/modules/data-service.spec.ts b/packages/compass-collection/src/modules/data-service.spec.ts deleted file mode 100644 index 64dab907370..00000000000 --- a/packages/compass-collection/src/modules/data-service.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect } from 'chai'; -import type { DataService } from 'mongodb-data-service'; - -import reducer, { - dataServiceConnected, - DATA_SERVICE_CONNECTED, -} from './data-service'; - -describe('data service module', function () { - describe('#dataServiceConnected', function () { - it('returns the DATA_SERVICE_CONNECTED action', function () { - expect(dataServiceConnected(null, {} as DataService)).to.deep.equal({ - type: DATA_SERVICE_CONNECTED, - error: null, - dataService: {}, - }); - }); - }); - - describe('#reducer', function () { - context('when the action is not data service connected', function () { - it('returns the default state', function () { - expect(reducer(undefined, { type: 'test' })).to.deep.equal({ - error: null, - dataService: null, - }); - }); - }); - - context('when the action is data service connected', function () { - it('returns the new state', function () { - expect( - reducer(undefined, dataServiceConnected(null, {} as DataService)) - ).to.deep.equal({ - error: null, - dataService: {}, - }); - }); - }); - }); -}); diff --git a/packages/compass-collection/src/modules/data-service.ts b/packages/compass-collection/src/modules/data-service.ts deleted file mode 100644 index 024ba4ad625..00000000000 --- a/packages/compass-collection/src/modules/data-service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { AnyAction } from 'redux'; -import type { DataService } from 'mongodb-data-service'; - -/** - * The prefix. - */ -const PREFIX = 'collection/data-service'; - -/** - * Data service connected. - */ -export const DATA_SERVICE_CONNECTED = `${PREFIX}/DATA_SERVICE_CONNECTED`; - -/** - * The initial state. - */ -export const INITIAL_STATE = { - error: null, - dataService: null, -}; - -/** - * Reducer function for handling data service connected actions. - * - * @param {Object} state - The data service state. - * @param {Object} action - The action. - * - * @returns {String} The new state. - */ -export default function reducer( - state = INITIAL_STATE, - action: AnyAction -): { - error: any; - dataService: DataService | null; -} { - if (action.type === DATA_SERVICE_CONNECTED) { - return { - error: action.error, - dataService: action.dataService, - }; - } - return state; -} - -/** - * Action creator for data service connected events. - * - * @param {Error} error - The connection error. - * @param {DataService} dataService - The data service. - * - * @returns {Object} The data service connected action. - */ -export const dataServiceConnected = (error: any, dataService: DataService) => ({ - type: DATA_SERVICE_CONNECTED, - error: error, - dataService: dataService, -}); diff --git a/packages/compass-collection/src/modules/is-atlas.ts b/packages/compass-collection/src/modules/is-atlas.ts deleted file mode 100644 index 0954ecfa3a7..00000000000 --- a/packages/compass-collection/src/modules/is-atlas.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { AnyAction } from 'redux'; - -export const INITIAL_STATE = false; - -export const isAtlasChanged = (isAtlas: boolean) => ({ - type: 'IS_ATLAS_CHANGED', - isAtlas: isAtlas, -}); - -export default function reducer(state = INITIAL_STATE, action: AnyAction) { - if (action.type === 'IS_ATLAS_CHANGED') { - return action.isAtlas; - } - - return state; -} diff --git a/packages/compass-collection/src/modules/is-data-lake.spec.ts b/packages/compass-collection/src/modules/is-data-lake.spec.ts deleted file mode 100644 index b5967bbfcaf..00000000000 --- a/packages/compass-collection/src/modules/is-data-lake.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect } from 'chai'; -import reducer from './is-data-lake'; - -describe('is data lake module', function () { - describe('#reducer', function () { - context('when the action is not isDataLake changed', function () { - it('returns the default state', function () { - expect(reducer(undefined, { type: 'test' })).to.equal(false); - }); - }); - }); -}); diff --git a/packages/compass-collection/src/modules/is-data-lake.ts b/packages/compass-collection/src/modules/is-data-lake.ts deleted file mode 100644 index 297385ce953..00000000000 --- a/packages/compass-collection/src/modules/is-data-lake.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { AnyAction } from 'redux'; - -/** - * The prefix. - */ -const PREFIX = 'collection'; - -/** - * Is data lake changed. - */ -export const IS_DATA_LAKE_CHANGED = `${PREFIX}/is-data-lake/IS_DATA_LAKE_CHANGED`; - -/** - * The initial state. - */ -export const INITIAL_STATE = false; - -/** - * Reducer function for handle state changes to is data lake. - * - * @param {String} state - The is data lake state. - * @param {Object} action - The action. - * - * @returns {String} The new state. - */ -export default function reducer( - state = INITIAL_STATE, - action: AnyAction -): boolean { - if (action.type === IS_DATA_LAKE_CHANGED) { - return action.isDataLake; - } - return state; -} - -export const dataLakeChanged = (isDataLake: boolean): AnyAction => ({ - type: IS_DATA_LAKE_CHANGED, - isDataLake, -}); diff --git a/packages/compass-collection/src/modules/namespace.spec.js b/packages/compass-collection/src/modules/namespace.spec.js deleted file mode 100644 index 2b0f172c672..00000000000 --- a/packages/compass-collection/src/modules/namespace.spec.js +++ /dev/null @@ -1,29 +0,0 @@ -import reducer, { namespaceChanged, NAMESPACE_CHANGED } from './namespace'; -import { expect } from 'chai'; - -describe('namespace module', function () { - describe('#namespaceChanged', function () { - it('returns the NAMESPACE_CHANGED action', function () { - expect(namespaceChanged('db.coll')).to.deep.equal({ - type: NAMESPACE_CHANGED, - namespace: 'db.coll', - }); - }); - }); - - describe('#reducer', function () { - context('when the action is not namespace changed', function () { - it('returns the default state', function () { - expect(reducer(undefined, { type: 'test' })).to.equal(''); - }); - }); - - context('when the action is namespace changed', function () { - it('returns the new state', function () { - expect(reducer(undefined, namespaceChanged('db.coll'))).to.equal( - 'db.coll' - ); - }); - }); - }); -}); diff --git a/packages/compass-collection/src/modules/namespace.ts b/packages/compass-collection/src/modules/namespace.ts deleted file mode 100644 index 4df0b7c751d..00000000000 --- a/packages/compass-collection/src/modules/namespace.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { AnyAction } from 'redux'; - -/** - * Namespace changed action. - */ -export const NAMESPACE_CHANGED = 'aggregations/namespace/NAMESPACE_CHANGED'; - -/** - * The initial state. - */ -export const INITIAL_STATE = ''; - -/** - * Reducer function for handle state changes to namespace. - * - * @param {String} state - The namespace state. - * @param {Object} action - The action. - * - * @returns {String} The new state. - */ -export default function reducer( - state = INITIAL_STATE, - action: AnyAction -): string { - if (action.type === NAMESPACE_CHANGED) { - return action.namespace; - } - return state; -} - -/** - * Action creator for namespace changed events. - * - * @param {String} namespace - The namespace value. - * - * @returns {Object} The namespace changed action. - */ -export const namespaceChanged = ( - namespace: string -): { - type: string; - namespace: string; -} => ({ - type: NAMESPACE_CHANGED, - namespace: namespace, -}); diff --git a/packages/compass-collection/src/modules/server-version.spec.ts b/packages/compass-collection/src/modules/server-version.spec.ts deleted file mode 100644 index dbcc7e1e7ed..00000000000 --- a/packages/compass-collection/src/modules/server-version.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect } from 'chai'; - -import reducer, { - serverVersionChanged, - SERVER_VERSION_CHANGED, -} from './server-version'; - -describe('server version module', function () { - describe('#serverVersionChanged', function () { - it('returns the SERVER_VERSION_CHANGED action', function () { - expect(serverVersionChanged('3.0.0')).to.deep.equal({ - type: SERVER_VERSION_CHANGED, - version: '3.0.0', - }); - }); - }); - - describe('#reducer', function () { - context('when the action is not server version changed', function () { - it('returns the default state', function () { - expect(reducer(undefined, { type: 'test' })).to.equal('4.0.0'); - }); - }); - - context('when the action is server version changed', function () { - it('returns the new state', function () { - expect(reducer(undefined, serverVersionChanged('3.0.0'))).to.equal( - '3.0.0' - ); - }); - }); - }); -}); diff --git a/packages/compass-collection/src/modules/server-version.ts b/packages/compass-collection/src/modules/server-version.ts deleted file mode 100644 index 24e2cbf7d61..00000000000 --- a/packages/compass-collection/src/modules/server-version.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { AnyAction } from 'redux'; - -/** - * The prefix. - */ -const PREFIX = 'collection'; - -/** - * Server version changed action. - */ -export const SERVER_VERSION_CHANGED = `${PREFIX}/server-version/SERVER_VERSION_CHANGED`; - -/** - * The initial state. - */ -export const INITIAL_STATE = '4.0.0'; - -/** - * Reducer function for handle state changes to server version. - * - * @param {String} state - The version state. - * @param {Object} action - The action. - * - * @returns {String} The new state. - */ -export default function reducer( - state = INITIAL_STATE, - action: AnyAction -): string { - if (action.type === SERVER_VERSION_CHANGED) { - return action.version || state; - } - return state; -} - -/** - * Action creator for server version changed events. - * - * @param {String} version - The version value. - * - * @returns {Object} The server version changed action. - */ -export const serverVersionChanged = (version: string) => ({ - type: SERVER_VERSION_CHANGED, - version: version, -}); diff --git a/packages/compass-collection/src/modules/stats.ts b/packages/compass-collection/src/modules/stats.ts deleted file mode 100644 index 49b726b19a4..00000000000 --- a/packages/compass-collection/src/modules/stats.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { AnyAction } from 'redux'; -import type Collection from 'mongodb-collection-model'; -import numeral from 'numeral'; -import { omit } from 'lodash'; - -export enum ActionTypes { - UpdateCollectionDetails = 'collection/stats/UPDATE_COLLECTION_DETAILS', - ResetCollectionDetails = 'collection/stats/RESET_COLLECTION_DETAILS', -} - -/** - * Invalid stats. - */ -const INVALID = 'N/A'; - -export interface CollectionStatsObject { - documentCount: string; - storageSize: string; - avgDocumentSize: string; - indexCount: string; - totalIndexSize: string; - avgIndexSize: string; -} - -export type CollectionStatsMap = { - [namespace: string]: CollectionStatsObject; -}; - -type UpdateCollectionDetailsAction = { - type: ActionTypes.UpdateCollectionDetails; - namespace: string; - stats: CollectionStatsObject; -}; - -type ResetCollectionDetailsAction = { - type: ActionTypes.ResetCollectionDetails; - namespace: string; -}; - -export type Actions = - | UpdateCollectionDetailsAction - | ResetCollectionDetailsAction; - -type State = CollectionStatsMap; - -const avg = (size: number, count: number) => { - if (count <= 0) { - return 0; - } - return size / count; -}; - -const isNumber = (val: any) => { - return typeof val === 'number' && !isNaN(val); -}; - -const format = (value: any, format = 'a') => { - if (!isNumber(value)) { - return INVALID; - } - const precision = value <= 1000 ? '0' : '0.0'; - return numeral(value).format(precision + format); -}; - -export const getCollectionStatsInitialState = (): CollectionStatsObject => ({ - documentCount: INVALID, - storageSize: INVALID, - avgDocumentSize: INVALID, - indexCount: INVALID, - totalIndexSize: INVALID, - avgIndexSize: INVALID, -}); - -export const getInitialState = (): State => ({}); - -export const resetCollectionDetails = ( - namespace: string -): ResetCollectionDetailsAction => ({ - type: ActionTypes.ResetCollectionDetails, - namespace, -}); - -/** - * Action creator for clearing tabs. - * - * @returns {Object} The action. - */ -export const updateCollectionDetails = ( - collectionModel: Collection, - namespace: string -): UpdateCollectionDetailsAction => { - const { - document_count, - index_count, - index_size, - status, - avg_document_size, - storage_size, - free_storage_size, - } = collectionModel; - let stats = getCollectionStatsInitialState(); - - if (!['initial', 'fetching', 'error'].includes(status)) { - stats = { - documentCount: format(document_count), - storageSize: format(storage_size - free_storage_size, 'b'), - avgDocumentSize: format(avg_document_size, 'b'), - indexCount: format(index_count), - totalIndexSize: format(index_size, 'b'), - avgIndexSize: format(avg(index_size, index_count), 'b'), - }; - } - - return { - type: ActionTypes.UpdateCollectionDetails, - namespace, - stats, - }; -}; - -/** - * Reducer function for handle state changes to stats. - * - * @param {Object} state - The input documents state. - * @param {Object} action - The action. - * - * @returns {any} The new state. - */ -const reducer = (state = getInitialState(), action: AnyAction): State => { - switch (action.type) { - case ActionTypes.UpdateCollectionDetails: - return { - ...state, - [action.namespace]: action.stats, - }; - case ActionTypes.ResetCollectionDetails: - return omit(state, action.namespace); - default: - return state; - } -}; - -export default reducer; diff --git a/packages/compass-collection/src/modules/tabs.ts b/packages/compass-collection/src/modules/tabs.ts index 85bd8281b32..a51363db8b6 100644 --- a/packages/compass-collection/src/modules/tabs.ts +++ b/packages/compass-collection/src/modules/tabs.ts @@ -1,948 +1,390 @@ -import type { AnyAction, Dispatch } from 'redux'; -import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; -import type AppRegistry from 'hadron-app-registry'; -import type { Document } from 'mongodb'; +import React from 'react'; +import type { AnyAction, Reducer } from 'redux'; +import type { ThunkAction } from 'redux-thunk'; +import AppRegistry from 'hadron-app-registry'; import { ObjectId } from 'bson'; -import toNS from 'mongodb-ns'; -import preferences from 'compass-preferences-model'; +import type { CollectionMetadata } from 'mongodb-collection-model'; +import type { DataService } from 'mongodb-data-service'; +import toNs from 'mongodb-ns'; -import createContext from '../stores/context'; -import type { ContextProps } from '../stores/context'; -import { appRegistryEmit } from './app-registry'; -import type { RootState } from '../stores'; -import { resetCollectionDetails } from './stats'; - -/** - * The prefix. - */ -const PREFIX = 'collection'; - -/** - * Namespace selected action name. - */ -export const SELECT_NAMESPACE = `${PREFIX}/tabs/SELECT_NAMESPACE`; - -/** - * Create tab action name. - */ -export const CREATE_TAB = `${PREFIX}/tabs/CREATE_TAB`; - -/** - * Close tab action name. - */ -export const CLOSE_TAB = `${PREFIX}/tabs/CLOSE_TAB`; - -/** - * Select tab action name. - */ -export const SELECT_TAB = `${PREFIX}/tabs/SELECT_TAB`; - -/** - * Move tab action name. - */ -export const MOVE_TAB = `${PREFIX}/tabs/MOVE_TAB`; - -/** - * Prev tab action name. - */ -export const PREV_TAB = `${PREFIX}/tabs/PREV_TAB`; - -/** - * Next tab action name. - */ -export const NEXT_TAB = `${PREFIX}/tabs/NEXT_TAB`; - -/** - * Change active subtab action name. - */ -export const CHANGE_ACTIVE_SUB_TAB = `${PREFIX}/tabs/CHANGE_ACTIVE_SUBTAB`; - -/** - * Clear tabs - */ -export const CLEAR_TABS = `${PREFIX}/tabs/CLEAR`; -export const COLLECTION_DROPPED = `${PREFIX}/tabs/COLLECTION_DROPPED`; -export const DATABASE_DROPPED = `${PREFIX}/tabs/DATABASE_DROPPED`; - -export interface WorkspaceTabObject { +export type CollectionTab = { id: string; namespace: string; - isActive: boolean; - activeSubTab: number; - activeSubTabName: string; - isReadonly: boolean; - isTimeSeries: boolean; - isClustered: boolean; - isFLE: boolean; - tabs: string[]; - views: JSX.Element[]; - subtab: WorkspaceTabObject; - pipeline: Document[]; - scopedModals: { - store: any; - component: React.ComponentType; - actions: any; - key: number | string; - }[]; - sourceName: string; - editViewName: string; - sourceReadonly?: boolean; - sourceViewOn?: string; + type: string; + selectedSubTabName: string; + // TODO(COMPASS-7020): this doesn't belong in the state, but this is how + // collection tabs currently work, this will go away when we switch to using + // new compass-workspace plugin in combination with registerHadronPlugin localAppRegistry: AppRegistry; - isSearchIndexesSupported: boolean; -} - -/** - * The initial state. - */ -export const INITIAL_STATE = []; - -type State = WorkspaceTabObject[]; - -const showCollectionSubmenu = ({ isReadOnly }: { isReadOnly: boolean }) => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { ipcRenderer } = require('hadron-ipc'); - - if (ipcRenderer) { - ipcRenderer.call('window:show-collection-submenu', { - isReadOnly, - }); - } + component: React.ReactElement; }; -/** - * Clear all the tabs. - */ -const doClearTabs = () => { - return INITIAL_STATE; +export type CollectionTabsState = { + tabs: CollectionTab[]; + activeTabId: string | null; }; -/** - * Handles namespace selected actions. - * - * @param {Object} state - The state. - * @param {Object} action - The action. - * - * @returns {Object} The new state. - */ -const doSelectNamespace = (state: State, action: AnyAction) => { - return state.reduce((newState: State, tab: WorkspaceTabObject) => { - if (tab.isActive) { - const subTabIndex = action.editViewName ? 1 : 0; - newState.push({ - id: action.id, - namespace: action.namespace, - isActive: true, - activeSubTab: subTabIndex, - activeSubTabName: action.context.tabs[subTabIndex], - isReadonly: action.isReadonly, - isTimeSeries: action.isTimeSeries, - isClustered: action.isClustered, - isFLE: action.isFLE, - tabs: action.context.tabs, - views: action.context.views, - subtab: action.context.subtab, - pipeline: action.context.sourcePipeline, - scopedModals: action.context.scopedModals, - sourceName: action.sourceName, - editViewName: action.editViewName, - sourceReadonly: action.sourceReadonly, - sourceViewOn: action.sourceViewOn, - localAppRegistry: action.context.localAppRegistry, - isSearchIndexesSupported: action.isSearchIndexesSupported, - }); - } else { - newState.push({ ...tab }); - } - return newState; - }, []); -}; - -/** - * Handle create tab actions. - * - * @param {Object} state - The state. - * @param {Object} action - The action. - * - * @returns {Object} The new state. - */ -const doCreateTab = (state: State, action: AnyAction) => { - const newState = state.map((tab: WorkspaceTabObject) => { - return { ...tab, isActive: false }; - }); - - const subTabIndex = action.query - ? 0 - : action.aggregation - ? 1 - : action.editViewName - ? 1 - : 0; - - newState.push({ - id: action.id, - namespace: action.namespace, - isActive: true, - activeSubTab: subTabIndex, - activeSubTabName: action.context.tabs[subTabIndex], - isReadonly: action.isReadonly, - isTimeSeries: action.isTimeSeries, - isClustered: action.isClustered, - isFLE: action.isFLE, - tabs: action.context.tabs, - views: action.context.views, - subtab: action.context.subtab, - scopedModals: action.context.scopedModals, - sourceName: action.sourceName, - pipeline: action.context.sourcePipeline, - editViewName: action.editViewName, - sourceReadonly: action.sourceReadonly, - sourceViewOn: action.sourceViewOn, - localAppRegistry: action.context.localAppRegistry, - isSearchIndexesSupported: action.isSearchIndexesSupported, - }); - return newState; -}; - -/** - * Handle close tab actions. - * - * @param {Object} state - The state. - * @param {Object} action - The action. - * - * @returns {Object} The new state. - */ -const doCloseTab = (state: State, action: AnyAction) => { - const closeIndex = action.index; - const activeIndex = state.findIndex((tab: WorkspaceTabObject) => { - return tab.isActive; - }); - const numTabs = state.length; - - return state.reduce((newState: State, tab: WorkspaceTabObject, i: number) => { - if (closeIndex !== i) { - // We follow standard browser behavior with tabs on how we - // handle which tab gets activated if we close the active tab. - // If the active tab is the last tab, we activate the one before - // it, otherwise we activate the next tab. - if (activeIndex === closeIndex) { - newState.push({ - ...tab, - isActive: isTabAfterCloseActive(closeIndex, i, numTabs), - }); - } else { - newState.push({ ...tab }); - } - } - return newState; - }, []); -}; +enum CollectionTabsActions { + OpenCollection = 'compass-collection/OpenCollection', + OpenCollectionInNewTab = 'compass-collection/OpenCollectionInNewTab', + SelectTab = 'compass-collection/SelectTab', + MoveTab = 'compass-collection/MoveTab', + SelectPreviousTab = 'compass-collection/SelectPreviousTab', + SelectNextTab = 'compass-collection/SelectNextTab', + CloseTab = 'compass-collection/CloseTab', + SubtabChanged = 'compass-colection/SubtabChanged', + CollectionDropped = 'compass-collection/CollectionDropped', + DatabaseDropped = 'compass-collection/DatabaseDropped', + DataServiceConnected = 'compass-collection/DataServiceConnected', + DataServiceDisconnected = 'compass-collection/DataServiceDisconnected', +} -const doCollectionDropped = (state: State, action: AnyAction) => { - const tabs = state.filter((tab: WorkspaceTabObject) => { - return tab.namespace !== action.namespace; - }); - if (tabs.length > 0) { - if (tabs.findIndex((tab: WorkspaceTabObject) => tab.isActive) < 0) { - tabs[0].isActive = true; +type CollectionTabsThunkAction< + ReturnType, + Action extends AnyAction = AnyAction +> = ThunkAction< + ReturnType, + CollectionTabsState, + { + globalAppRegistry: AppRegistry; + dataService: DataService | null; + }, + Action +>; + +const reducer: Reducer = ( + state = { tabs: [], activeTabId: null }, + action +) => { + if (action.type === CollectionTabsActions.OpenCollection) { + const activeTabIndex = getActiveTabIndex(state); + if (activeTabIndex !== -1) { + const newTabs = [...state.tabs]; + newTabs.splice(activeTabIndex, 1, action.tab); + return { + activeTabId: action.tab.id, + tabs: newTabs, + }; } + return { + activeTabId: action.tab.id, + tabs: [...state.tabs, action.tab], + }; } - return tabs; -}; - -const doDatabaseDropped = (state: State, action: AnyAction) => { - const tabs = state.filter((tab: WorkspaceTabObject) => { - const tabDbName = toNS(tab.namespace).database; - return tabDbName !== action.name; - }); - if (tabs.length > 0) { - if (tabs.findIndex((tab: WorkspaceTabObject) => tab.isActive) < 0) { - tabs[0].isActive = true; - } + if (action.type === CollectionTabsActions.OpenCollectionInNewTab) { + return { + activeTabId: action.tab.id, + tabs: [...state.tabs, action.tab], + }; } - return tabs; -}; - -/** - * Handle move tab actions. - * - * @param {Object} state - The state. - * @param {Object} action - The action. - * - * @returns {Object} The new state. - */ -const doMoveTab = (state: State, action: AnyAction) => { - if (action.fromIndex === action.toIndex) return state; - const newState = state.map((tab: WorkspaceTabObject) => ({ ...tab })); - newState.splice(action.toIndex, 0, newState.splice(action.fromIndex, 1)[0]); - return newState; -}; - -/** - * Activate the next tab. - * - * @param {Object} state - The state. - * - * @returns {Object} The new state. - */ -const doNextTab = (state: State) => { - const activeIndex = state.findIndex( - (tab: WorkspaceTabObject) => tab.isActive - ); - return state.map((tab: WorkspaceTabObject, i: number) => { + if (action.type === CollectionTabsActions.SelectTab) { + const newActiveTab = state.tabs[action.index]; return { - ...tab, - isActive: isTabAfterNextActive(activeIndex, i, state.length), + ...state, + activeTabId: newActiveTab.id ?? state.activeTabId, }; - }); -}; - -/** - * Activate the prev tab. - * - * @param {Object} state - The state. - * - * @returns {Object} The new state. - */ -const doPrevTab = (state: State) => { - const activeIndex = state.findIndex( - (tab: WorkspaceTabObject) => tab.isActive - ); - return state.map((tab: WorkspaceTabObject, i: number) => { + } + if (action.type === CollectionTabsActions.SelectNextTab) { + const newActiveTabIndex = + (getActiveTabIndex(state) + 1) % state.tabs.length; + const newActiveTab = state.tabs[newActiveTabIndex]; return { - ...tab, - isActive: isTabAfterPrevActive(activeIndex, i, state.length), + ...state, + activeTabId: newActiveTab.id ?? state.activeTabId, }; - }); + } + if (action.type === CollectionTabsActions.SelectPreviousTab) { + const currentActiveTabIndex = getActiveTabIndex(state); + const newActiveTabIndex = + getActiveTabIndex(state) === 0 + ? state.tabs.length - 1 + : currentActiveTabIndex - 1; + const newActiveTab = state.tabs[newActiveTabIndex]; + return { + ...state, + activeTabId: newActiveTab.id ?? state.activeTabId, + }; + } + if (action.type === CollectionTabsActions.MoveTab) { + const newTabs = [...state.tabs]; + newTabs.splice(action.toIndex, 0, newTabs.splice(action.fromIndex, 1)[0]); + return { + ...state, + tabs: newTabs, + }; + } + if (action.type === CollectionTabsActions.CloseTab) { + const tabToClose = state.tabs[action.index]; + const tabIndex = state.tabs.findIndex((tab) => tab.id === tabToClose.id); + const newTabs = [...state.tabs]; + newTabs.splice(action.index, 1); + const newActiveTabId = + tabToClose.id === state.activeTabId + ? // We follow standard browser behavior with tabs on how we handle + // which tab gets activated if we close the active tab. If the active + // tab is the last tab, we activate the one before it, otherwise we + // activate the next tab. + (state.tabs[tabIndex + 1] ?? newTabs[newTabs.length - 1])?.id ?? null + : state.activeTabId; + return { + activeTabId: newActiveTabId, + tabs: newTabs, + }; + } + if (action.type === CollectionTabsActions.CollectionDropped) { + const newTabs = state.tabs.filter((tab) => { + return tab.namespace !== action.namespace; + }); + const isActiveTabRemoved = !newTabs.some((tab) => { + return tab.id === state.activeTabId; + }); + return { + activeTabId: isActiveTabRemoved + ? newTabs[0]?.id ?? null + : state.activeTabId, + tabs: newTabs, + }; + } + if (action.type === CollectionTabsActions.DatabaseDropped) { + const { database } = toNs(action.namespace); + const newTabs = state.tabs.filter((tab) => { + const { database: tabDatabase } = toNs(tab.namespace); + return tabDatabase !== database; + }); + const isActiveTabRemoved = !newTabs.some((tab) => { + return tab.id === state.activeTabId; + }); + return { + activeTabId: isActiveTabRemoved + ? newTabs[0]?.id ?? null + : state.activeTabId, + tabs: newTabs, + }; + } + if ( + action.type === CollectionTabsActions.DataServiceConnected || + action.type === CollectionTabsActions.DataServiceDisconnected + ) { + return { + activeTabId: null, + tabs: [], + }; + } + if (action.type === CollectionTabsActions.SubtabChanged) { + const tabIndex = state.tabs.findIndex((tab) => { + return tab.id === action.id; + }); + const tab = state.tabs[tabIndex]; + const newTabs = [...state.tabs]; + newTabs.splice(tabIndex, 1, { ...tab, selectedSubTabName: action.name }); + return { + ...state, + tabs: newTabs, + }; + } + return state; }; -/** - * Handle select tab actions. - * - * @param {Object} state - The state. - * @param {Object} action - The action. - * - * @returns {Object} The new state. - */ -const doSelectTab = (state: State, action: AnyAction) => { - return state.map((tab: WorkspaceTabObject, i: number) => { - return { ...tab, isActive: action.index === i ? true : false }; - }); +const subtabChanged = (id: string, name: string) => { + return { type: CollectionTabsActions.SubtabChanged, id, name }; }; -/** - * Handle the changing of the active subtab for a collection tab. - * - * @param {Object} state - The state. - * @param {Object} action - The action. - * - * @returns {Array} The new state. - */ -const doChangeActiveSubTab = (state: State, action: AnyAction) => { - return state.map((tab: WorkspaceTabObject) => { - const { newExplainPlan } = preferences.getPreferences(); - - const explainPlanTabId = tab.tabs.indexOf('Explain Plan'); - const tabs = tab.tabs.filter((_, id) => { - return !newExplainPlan || id !== explainPlanTabId; +const createNewTab = ( + collectionMetadata: CollectionMetadata +): CollectionTabsThunkAction => { + return (dispatch, getState, { globalAppRegistry, dataService }) => { + const collectionTabRole = globalAppRegistry.getRole( + 'CollectionTab.Content' + )?.[0]; + if (!collectionTabRole || !collectionTabRole.configureStore) { + throw new Error( + "Can't open a colleciton tab if collection tab role is not registered" + ); + } + if (!dataService) { + throw new Error( + "Can't open a collection tab while data service is not connected" + ); + } + const localAppRegistry = new AppRegistry(); + const store = collectionTabRole.configureStore({ + dataService, + globalAppRegistry, + localAppRegistry, + ...collectionMetadata, }); - const subTab = - action.id === tab.id ? action.activeSubTab : tab.activeSubTab; - - return { - ...tab, - activeSubTab: subTab, - activeSubTabName: tabs[subTab], + const component = React.createElement(collectionTabRole.component, { + store, + }); + const tab: CollectionTab = { + id: new ObjectId().toHexString(), + selectedSubTabName: store.getState().currentTab, + namespace: collectionMetadata.namespace, + type: collectionMetadata.isTimeSeries + ? 'timeseries' + : collectionMetadata.isReadonly + ? 'view' + : 'collection', + localAppRegistry, + component, }; - }); + localAppRegistry.on('subtab-changed', (name: string) => { + dispatch(subtabChanged(tab.id, name)); + }); + return tab; + }; }; -/** - * The action to state modifier mappings. - */ -const MAPPINGS = { - [SELECT_NAMESPACE]: doSelectNamespace, - [CREATE_TAB]: doCreateTab, - [CLOSE_TAB]: doCloseTab, - [MOVE_TAB]: doMoveTab, - [NEXT_TAB]: doNextTab, - [PREV_TAB]: doPrevTab, - [SELECT_TAB]: doSelectTab, - [CHANGE_ACTIVE_SUB_TAB]: doChangeActiveSubTab, - [CLEAR_TABS]: doClearTabs, - [COLLECTION_DROPPED]: doCollectionDropped, - [DATABASE_DROPPED]: doDatabaseDropped, +export const openCollectionInNewTab = ( + // NB: now that we have clean separation between tabs and collection content, + // we can make collection fetch its own metadata without the need for this to + // happen in instance store + collectionMetadata: CollectionMetadata +): CollectionTabsThunkAction => { + return (dispatch) => { + const tab = dispatch(createNewTab(collectionMetadata)); + dispatch({ type: CollectionTabsActions.OpenCollectionInNewTab, tab }); + }; }; -/** - * Reducer function for handle state changes to the tabs. - * - * @param {String} state - The tabs state. - * @param {Object} action - The action. - * - * @returns {String} The new state. - */ -export default function reducer(state = INITIAL_STATE, action: AnyAction): any { - const fn = MAPPINGS[action.type]; - return fn ? fn(state, action) : state; -} +export const openCollection = ( + collectionMetadata: CollectionMetadata +): CollectionTabsThunkAction => { + return (dispatch, getState) => { + // If current active tab namespace is the same, do nothing + if (getActiveTab(getState())?.namespace === collectionMetadata.namespace) { + return; + } + const tab = dispatch(createNewTab(collectionMetadata)); + dispatch({ type: CollectionTabsActions.OpenCollection, tab }); + }; +}; -/** - * Action creator for create tab. - * - * @parma {Object} options - * @property {String} options.id - The tab id. - * @property {String} options.namespace - The namespace. - * @property {Boolean} options.isReadonly - Is the collection readonly? - * @property {String} options.sourceName - The source namespace. - * @property {String} options.editViewName - The name of the view we are editing. - * @property {Object} options.context - The tab context. - * @property {Boolean} options.sourceReadonly - * @property {String} options.sourceViewOn - * - * @returns {Object} The create tab action. - */ -export const createTab = ({ - id, - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - context, - sourceReadonly, - sourceViewOn, - query, - aggregation, - isSearchIndexesSupported, -}: Pick< - WorkspaceTabObject, - | 'id' - | 'namespace' - | 'isReadonly' - | 'isTimeSeries' - | 'isClustered' - | 'isFLE' - | 'sourceName' - | 'editViewName' - | 'sourceReadonly' - | 'sourceViewOn' - | 'isSearchIndexesSupported' -> & { - context: ContextProps; - query?: any; // TODO(COMPASS-6162): type query. - aggregation?: any; // TODO(COMPASS-6162): type aggregation. -}): AnyAction => ({ - type: CREATE_TAB, - id, - namespace, - isReadonly: !!isReadonly, - isTimeSeries: !!isTimeSeries, - isClustered: !!isClustered, - isFLE: !!isFLE, - sourceName, - editViewName, - context, - sourceReadonly, - sourceViewOn, - query, - aggregation, - isSearchIndexesSupported, -}); +export const selectTabByIndex = ( + index: number +): CollectionTabsThunkAction => { + return (dispatch, getState, { globalAppRegistry }) => { + dispatch({ type: CollectionTabsActions.SelectTab, index }); + // NB: this will cause `openTab` action to dispatch, but it will be a no-op + // as we are "selecting" already selected namespace. This is needed so that + // other parts of the application can sync namespace correctly when user + // switches between multiple open tabs + globalAppRegistry.emit('collection-workspace-select-namespace', { + ns: getActiveTab(getState())?.namespace, + }); + }; +}; -/** - * Action creator for namespace selected. - * - * @param {String} id - The tab id. - * @param {String} namespace - The namespace. - * @param {Boolean} isReadonly - Is the collection readonly? - * @param {Boolean} isTimeSeries - Is the collection time-series? - * @param {Boolean} isClustered - Is the collection clustered? - * @param {Boolean} isFLE - Is the collection FLE? - * @param {String} sourceName - The source namespace. - * @param {String} editViewName - The name of the view we are editing. - * @param {Object} context - The tab context. - * @param {Object} sourceReadonly - * @param {Object} sourceViewOn - * - * @returns {Object} The namespace selected action. - */ -export const selectNamespace = ({ - id, - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - context, - sourceReadonly, - sourceViewOn, - isSearchIndexesSupported, -}: Pick< - WorkspaceTabObject, - | 'id' - | 'namespace' - | 'isReadonly' - | 'isTimeSeries' - | 'isClustered' - | 'isFLE' - | 'sourceName' - | 'editViewName' - | 'sourceReadonly' - | 'sourceViewOn' - | 'isSearchIndexesSupported' -> & { - context: ContextProps; -}): AnyAction => ({ - type: SELECT_NAMESPACE, - id, - namespace, - isReadonly: !!isReadonly, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - context, - sourceReadonly, - sourceViewOn, - isSearchIndexesSupported, -}); +export const selectPreviousTab = (): CollectionTabsThunkAction => { + return (dispatch, getState, { globalAppRegistry }) => { + dispatch({ type: CollectionTabsActions.SelectPreviousTab }); + globalAppRegistry.emit('collection-workspace-select-namespace', { + ns: getActiveTab(getState())?.namespace, + }); + }; +}; -/** - * Action creator for close tab. - * - * @param {Number} index - The tab index. - * - * @returns {Object} The close tab action. - */ -export const closeTab = - (index: number) => (dispatch: Dispatch, getState: () => RootState) => { - const { - tabs, - }: { - tabs: WorkspaceTabObject[]; - } = getState(); - if (tabs.length === 1) { - dispatch(appRegistryEmit('all-collection-tabs-closed')); - } - dispatch({ type: CLOSE_TAB, index: index }); - // Clear the stats of the closed tab's namespace if it's the last one in use. - if ( - tabs.findIndex( - (tab, tabIndex) => - tab.namespace === tabs[index].namespace && tabIndex !== index - ) === -1 - ) { - dispatch(resetCollectionDetails(tabs[index].namespace)); - } +export const selectNextTab = (): CollectionTabsThunkAction => { + return (dispatch, getState, { globalAppRegistry }) => { + dispatch({ type: CollectionTabsActions.SelectNextTab }); + globalAppRegistry.emit('collection-workspace-select-namespace', { + ns: getActiveTab(getState())?.namespace, + }); }; +}; -/** - * Action creator for move tab. - * - * @param {Number} fromIndex - The from tab index. - * @param {Number} toIndex - The to tab index. - * - * @returns {Object} The move tab action. - */ -export const moveTab = ( +export const moveTabByIndex = ( fromIndex: number, toIndex: number -): { - type: string; - fromIndex: number; - toIndex: number; -} => ({ - type: MOVE_TAB, - fromIndex: fromIndex, - toIndex: toIndex, -}); +): CollectionTabsThunkAction => { + return (dispatch, getState, { globalAppRegistry }) => { + dispatch({ type: CollectionTabsActions.MoveTab, fromIndex, toIndex }); + globalAppRegistry.emit('collection-workspace-select-namespace', { + ns: getActiveTab(getState())?.namespace, + }); + }; +}; -/** - * Action creator for next tab. - * - * @returns {Object} The next tab action. - */ -export const nextTab = (): { - type: string; -} => ({ - type: NEXT_TAB, -}); +export const closeTabAtIndex = ( + index: number +): CollectionTabsThunkAction => { + return (dispatch, getState, { globalAppRegistry }) => { + const lastActiveTab = getActiveTab(getState()); + dispatch({ type: CollectionTabsActions.CloseTab, index }); + if (lastActiveTab && getState().tabs.length === 0) { + const { database } = toNs(lastActiveTab.namespace); + globalAppRegistry.emit('select-database', database); + } + }; +}; -/** - * Action creator for prev tab. - * - * @returns {Object} The prev tab action. - */ -export const prevTab = (): { - type: string; -} => ({ - type: PREV_TAB, -}); +export const getActiveTabIndex = (state: CollectionTabsState) => { + const { activeTabId, tabs } = state; + return tabs.findIndex((tab) => tab.id === activeTabId); +}; -/** - * Action creator for selecting tabs. - * - * @param {Number} index - The tab index. - * - * @returns {Object} The action. - */ -export const selectTab = ( - index: number -): { - type: string; - index: number; -} => ({ - type: SELECT_TAB, - index: index, -}); +export const getActiveTab = ( + state: CollectionTabsState +): CollectionTab | null => { + return state.tabs[getActiveTabIndex(state)] ?? null; +}; -/** - * Action creator for clearing tabs. - * - * @returns {Object} The action. - */ -export const clearTabs = (): { - type: string; -} => ({ - type: CLEAR_TABS, -}); +export const openNewTabForCurrentCollection = + (): CollectionTabsThunkAction => { + return (dispatch, getState, { globalAppRegistry }) => { + const activeTab = getActiveTab(getState()); + // Create new tab always uses the current active tab namespace, without + // active tab, we can't create new tab + if (!activeTab) { + throw new Error("Can't create new tab when no tabs are on the screen"); + } + // TODO(COMPASS-7020): we can remove this indirection when moving the + // logic to compass-workspace plugin and make sure that compass-collection + // tab is responsible for getting all required metadata + globalAppRegistry.emit( + 'collection-workspace-open-collection-in-new-tab', + { ns: activeTab.namespace } + ); + }; + }; export const collectionDropped = ( namespace: string -): { - type: string; - namespace: string; -} => ({ - type: COLLECTION_DROPPED, - namespace: namespace, -}); - -export const databaseDropped = ( - name: string -): { - type: string; - name: string; -} => ({ - type: DATABASE_DROPPED, - name: name, -}); - -/** - * Action creator for changing subtabs. - * - * @param {Number} activeSubTab - The active subtab index. - * @param {String} id - The tab id. - * - * @returns {Object} The action. - */ -export const changeActiveSubTab = ( - activeSubTab: number, - id: string -): { - type: string; - activeSubTab: number; - id: string; -} => ({ - type: CHANGE_ACTIVE_SUB_TAB, - activeSubTab: activeSubTab, - id: id, -}); - -/** - * Checks if we need to select a namespace or actually create a new - * tab, then dispatches the correct events. - * - * @param {CollectionTabOptions} options - */ -export const selectOrCreateTab = ({ - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - sourceReadonly, - sourceViewOn, - sourcePipeline, - isSearchIndexesSupported, -}: Pick< - WorkspaceTabObject, - | 'namespace' - | 'isReadonly' - | 'isTimeSeries' - | 'isClustered' - | 'isFLE' - | 'sourceName' - | 'editViewName' - | 'sourceReadonly' - | 'sourceViewOn' - | 'isSearchIndexesSupported' -> & { - sourcePipeline: Document[]; -}): ThunkAction => { - return ( - dispatch: ThunkDispatch, - getState: () => RootState - ) => { - const state = getState(); - if (state.tabs.length === 0) { - dispatch( - createNewTab({ - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - sourceReadonly, - sourceViewOn, - sourcePipeline, - isSearchIndexesSupported, - }) +): CollectionTabsThunkAction => { + return (dispath, getState, { globalAppRegistry }) => { + const lastActiveTab = getActiveTab(getState()); + dispath({ type: CollectionTabsActions.CollectionDropped, namespace }); + // We just removed last tab, emit event and let instance store figure out + // what to open based on that + if (lastActiveTab && getState().tabs.length === 0) { + globalAppRegistry.emit( + 'active-collection-dropped', + lastActiveTab.namespace ); - } else { - // If the namespace is equal to the active tab's namespace, then - // there is no need to do anything. - const activeIndex = state.tabs.findIndex( - (tab: WorkspaceTabObject) => tab.isActive - ); - const activeNamespace: string = state.tabs[activeIndex].namespace; - if (namespace !== activeNamespace) { - dispatch( - replaceTabContent({ - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - sourceReadonly, - sourceViewOn, - sourcePipeline, - isSearchIndexesSupported, - }) - ); - // Clear the stats of the closed tab's namespace if it's the last one in use. - if ( - state.tabs.findIndex( - (tab: WorkspaceTabObject, tabIndex: number) => - tab.namespace === activeNamespace && tabIndex !== activeIndex - ) === -1 - ) { - dispatch(resetCollectionDetails(activeNamespace)); - } - } } }; }; -/** - * Handles all the setup of tab creation by creating the stores for each - * of the roles in the global app registry. - * - * @param {CollectionTabOptions} options - */ -export const createNewTab = ({ - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - sourceReadonly, - sourceViewOn, - sourcePipeline, - query, - aggregation, - isSearchIndexesSupported, -}: Pick< - WorkspaceTabObject, - | 'namespace' - | 'isReadonly' - | 'isTimeSeries' - | 'isClustered' - | 'isFLE' - | 'sourceName' - | 'editViewName' - | 'sourceReadonly' - | 'sourceViewOn' - | 'isSearchIndexesSupported' -> & { - sourcePipeline?: Document[]; - query?: any; // TODO(COMPASS-6162): type query. - aggregation?: any; // TODO(COMPASS-6162): type aggregation. -}): ThunkAction => { - return (dispatch: Dispatch, getState: () => RootState) => { - const state = getState(); - const context = createContext({ - state, - namespace, - isReadonly, - isDataLake: state.isDataLake, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - sourcePipeline, - query, - aggregation, - isSearchIndexesSupported, - }); - dispatch( - createTab({ - id: new ObjectId().toHexString(), - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - context, - sourceReadonly: !!sourceReadonly, - sourceViewOn, - query, - aggregation, - isSearchIndexesSupported, - }) - ); - showCollectionSubmenu({ isReadOnly: isReadonly }); - }; -}; - -/** - * Handles all the setup of replacing tab content by creating the stores for each - * of the roles in the global app registry. - * - * @param {CollectionTabOptions} options - */ -export const replaceTabContent = ({ - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - sourceReadonly, - sourceViewOn, - sourcePipeline, - isSearchIndexesSupported, -}: Pick< - WorkspaceTabObject, - | 'namespace' - | 'isReadonly' - | 'isTimeSeries' - | 'isClustered' - | 'isFLE' - | 'sourceName' - | 'editViewName' - | 'sourceReadonly' - | 'sourceViewOn' - | 'isSearchIndexesSupported' -> & { - sourcePipeline?: Document[]; -}): ThunkAction => { - return (dispatch: Dispatch, getState: () => RootState) => { - const state = getState(); - const context = createContext({ - state, - namespace, - isReadonly, - isDataLake: state.isDataLake, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - sourcePipeline, - isSearchIndexesSupported, - }); - dispatch( - selectNamespace({ - id: new ObjectId().toHexString(), - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - sourceName, - editViewName, - context, - sourceReadonly: !!sourceReadonly, - sourceViewOn, - isSearchIndexesSupported, - }) - ); - showCollectionSubmenu({ isReadOnly: isReadonly }); +export const databaseDropped = ( + namespace: string +): CollectionTabsThunkAction => { + return (dispath, getState, { globalAppRegistry }) => { + const lastActiveTab = getActiveTab(getState()); + dispath({ type: CollectionTabsActions.DatabaseDropped, namespace }); + if (lastActiveTab && getState().tabs.length === 0) { + const { database } = toNs(lastActiveTab.namespace); + globalAppRegistry.emit('active-database-dropped', database); + } }; }; -/** - * Determine if a tab is active after the prev action. - * - * @param {Number} activeIndex - The current active tab index. - * @param {Number} currentIndex - The currently iterated tab index. - * @param {Number} numTabs - The total number of tabs. - * - * @returns {Boolean} If the tab is active. - */ -const isTabAfterPrevActive = ( - activeIndex: number, - currentIndex: number, - numTabs: number -) => { - return activeIndex === 0 - ? currentIndex === numTabs - 1 - : currentIndex === activeIndex - 1; +export const dataServiceConnected = () => { + return { type: CollectionTabsActions.DataServiceConnected }; }; -/** - * Determine if a tab becomes active after an active tab - * is closed. - * - * @param {Number} closeIndex - The index of the tab being closed. - * @param {Number} currentIndex - The current tab index. - * @param {Number} numTabs - The number of tabs. - * - * @returns {Boolean} If the tab must be active. - */ -const isTabAfterCloseActive = ( - closeIndex: number, - currentIndex: number, - numTabs: number -) => { - return closeIndex === numTabs - 1 - ? currentIndex === numTabs - 2 - : currentIndex === closeIndex + 1; +export const dataServiceDisconnected = () => { + // TODO: get localAppRegistry for existing tabs and clean up + return { type: CollectionTabsActions.DataServiceDisconnected }; }; -/** - * Determine if a tab is active after the next action. - * - * @param {Number} activeIndex - The current active tab index. - * @param {Number} currentIndex - The currently iterated tab index. - * @param {Number} numTabs - The total number of tabs. - * - * @returns {Boolean} If the tab is active. - */ -const isTabAfterNextActive = ( - activeIndex: number, - currentIndex: number, - numTabs: number -) => { - return activeIndex === numTabs - 1 - ? currentIndex === 0 - : currentIndex === activeIndex + 1; -}; +export default reducer; diff --git a/packages/compass-collection/src/plugin.tsx b/packages/compass-collection/src/plugin.tsx index a878fe225eb..97d37b4b771 100644 --- a/packages/compass-collection/src/plugin.tsx +++ b/packages/compass-collection/src/plugin.tsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; import Workspace from './components/workspace'; import { Provider } from 'react-redux'; -import store from './stores'; +import store from './stores/tabs'; class Plugin extends Component { static displayName = 'CollectionWorkspacePlugin'; diff --git a/packages/compass-collection/src/stores/collection-tab.spec.ts b/packages/compass-collection/src/stores/collection-tab.spec.ts new file mode 100644 index 00000000000..4dcd0163eae --- /dev/null +++ b/packages/compass-collection/src/stores/collection-tab.spec.ts @@ -0,0 +1,173 @@ +import type { CollectionTabOptions } from './collection-tab'; +import { configureStore as _configureStore } from './collection-tab'; +import { + selectTab, + selectDatabase, + editView, + renderScopedModals, + renderTabs, +} from '../modules/collection-tab'; +import Sinon from 'sinon'; +import AppRegistry from 'hadron-app-registry'; +import { expect } from 'chai'; + +const defaultMetadata = { + namespace: 'test.foo', + isReadonly: false, + isTimeSeries: false, + isClustered: false, + isFLE: false, + isSearchIndexesSupported: false, +}; + +describe('Collection Tab Content store', function () { + const sandbox = Sinon.createSandbox(); + + const globalAppRegistry = sandbox.spy(new AppRegistry()); + const localAppRegistry = sandbox.spy(new AppRegistry()); + const dataService = {} as any; + const instance = { + databases: { get() {} }, + dataLake: {}, + build: {}, + } as any; + + const scopedModalRole = { + name: 'ScopedModal', + component: () => 'ScopedModalComponent', + configureStore: sandbox.stub().returns({}), + storeName: 'ScopedModalStore', + configureActions: sandbox.stub().returns({}), + actionName: 'ScopedModalAction', + }; + + const collectionSubTabRole = { + name: 'CollectionSubTab', + component: () => 'CollectionSubTabComponent', + configureStore: sandbox.stub().returns({}), + storeName: 'CollectionSubTabStore', + configureActions: sandbox.stub().returns({}), + actionName: 'CollectionSubTabAction', + }; + + const configureStore = (options: Partial = {}) => { + return _configureStore({ + dataService, + globalAppRegistry, + localAppRegistry, + ...defaultMetadata, + ...options, + }); + }; + + beforeEach(function () { + globalAppRegistry.registerStore('App.InstanceStore', { + getInstance() { + return instance; + }, + } as any); + globalAppRegistry.registerRole( + 'Collection.ScopedModal', + scopedModalRole as any + ); + globalAppRegistry.registerRole( + 'Collection.Tab', + collectionSubTabRole as any + ); + }); + + afterEach(function () { + globalAppRegistry.roles = {}; + globalAppRegistry.stores = {}; + globalAppRegistry.actions = {}; + globalAppRegistry.components = {}; + + localAppRegistry.roles = {}; + localAppRegistry.stores = {}; + localAppRegistry.actions = {}; + localAppRegistry.components = {}; + + sandbox.resetHistory(); + }); + + describe('selectTab', function () { + it('should set active tab', function () { + const store = configureStore(); + store.dispatch(selectTab('Documents')); + expect(store.getState()).to.have.property('currentTab', 'Documents'); + }); + }); + + describe('selectDatabase', function () { + it("should emit 'select-database' event", function () { + const store = configureStore(); + store.dispatch(selectDatabase()); + expect(globalAppRegistry.emit).to.have.been.calledWith( + 'select-database', + 'test' + ); + }); + }); + + describe('editView', function () { + it("should emit 'collection-tab-modify-view' event", function () { + const store = configureStore(); + store.dispatch(editView()); + expect(globalAppRegistry.emit).to.have.been.calledWith( + 'collection-tab-modify-view', + { ns: 'test.foo' } + ); + }); + }); + + describe('renderScopedModals', function () { + it('should set up scoped modals state in local app registry', function () { + const store = configureStore(); + const modals = store.dispatch(renderScopedModals()); + expect(modals.map((el) => (el as any).type())).to.deep.eq([ + 'ScopedModalComponent', + ]); + expect(localAppRegistry.getStore('ScopedModalStore')).to.exist; + expect(localAppRegistry.getAction('ScopedModalAction')).to.exist; + }); + + it('should only configure scoped modals store and actions once', function () { + const store = configureStore(); + store.dispatch(renderScopedModals()); + store.dispatch(renderScopedModals()); + store.dispatch(renderScopedModals()); + expect(scopedModalRole.configureStore).to.have.been.called.calledOnce; + expect(scopedModalRole.configureActions).to.have.been.called.calledOnce; + }); + }); + + describe('renderTabs', function () { + it('should set up tabs state in local app registry', function () { + const store = configureStore(); + const tabs = store.dispatch(renderTabs()); + expect( + tabs.map((tab) => { + return { + name: tab.name, + component: (tab.component as any).type(), + }; + }) + ).to.deep.eq([ + { name: 'CollectionSubTab', component: 'CollectionSubTabComponent' }, + ]); + expect(localAppRegistry.getStore('CollectionSubTabStore')).to.exist; + expect(localAppRegistry.getAction('CollectionSubTabAction')).to.exist; + }); + + it('should only configure tabs store and actions once', function () { + const store = configureStore(); + store.dispatch(renderTabs()); + store.dispatch(renderTabs()); + store.dispatch(renderTabs()); + expect(collectionSubTabRole.configureStore).to.have.been.called + .calledOnce; + expect(collectionSubTabRole.configureActions).to.have.been.called + .calledOnce; + }); + }); +}); diff --git a/packages/compass-collection/src/stores/collection-tab.ts b/packages/compass-collection/src/stores/collection-tab.ts new file mode 100644 index 00000000000..368bd6e6ebf --- /dev/null +++ b/packages/compass-collection/src/stores/collection-tab.ts @@ -0,0 +1,112 @@ +import type AppRegistry from 'hadron-app-registry'; +import type { DataService } from 'mongodb-data-service'; +import { createStore, applyMiddleware } from 'redux'; +import thunk from 'redux-thunk'; +import reducer, { + collectionStatsFetched, + pickCollectionStats, + selectTab, +} from '../modules/collection-tab'; +import type Collection from 'mongodb-collection-model'; +import toNs from 'mongodb-ns'; +import type { MongoDBInstance } from 'mongodb-instance-model'; +import type { CollectionMetadata } from 'mongodb-collection-model'; + +export type CollectionTabOptions = { + dataService: DataService; + globalAppRegistry: AppRegistry; + localAppRegistry: AppRegistry; + query?: unknown; + aggregation?: unknown; + editViewName?: string; +} & CollectionMetadata; + +export function configureStore(options: CollectionTabOptions) { + const { + dataService, + globalAppRegistry, + localAppRegistry, + query, + aggregation, + editViewName, + ...collectionMetadata + } = options; + + const instance = ( + globalAppRegistry.getStore('App.InstanceStore') as + | { + getInstance(): MongoDBInstance; + } + | undefined + )?.getInstance(); + + if (!instance) { + throw new Error('Expected to get instance from App.InstanceStore'); + } + + const configureFieldStore = globalAppRegistry.getStore('Field.Store') as ( + ...args: any + ) => void | undefined; // our handcrafted d.ts file doesn't match the actual code + + configureFieldStore?.({ + localAppRegistry: localAppRegistry, + globalAppRegistry: globalAppRegistry, + namespace: collectionMetadata.namespace, + serverVersion: instance.build.version, + }); + + const { database, collection } = toNs(collectionMetadata.namespace); + + const collectionModel = instance.databases + .get(database) + ?.collections.get(collection, 'name'); + + const store = createStore( + reducer, + { + metadata: { + ...collectionMetadata, + // NB: While it's technically possible for these values to change during + // MongoDB server lifecycle, we (mostly) never accounted for this in the + // scope of collection tab plugin and its children plugins. The cases + // where this can happen are rare, so we are okay with just ignoring + // this at the moment. If we ever decide to change that, don't forget to + // account for that change in all plugins that implement + // `Collection.Tab` and `Collection.ScopedModal` roles + isDataLake: instance.dataLake.isDataLake, + isAtlas: instance.env === 'atlas', + serverVersion: instance.build.version, + }, + stats: collectionModel ? pickCollectionStats(collectionModel) : null, + initialQuery: query, + initialAggregation: aggregation, + // If aggregation is passed or we opened view to edit source pipeline, + // select aggregation tab right away + currentTab: aggregation || editViewName ? 'Aggregations' : 'Documents', + editViewName, + }, + applyMiddleware( + thunk.withExtraArgument({ + globalAppRegistry, + localAppRegistry, + dataService, + }) + ) + ); + + collectionModel?.on('change:status', (model: Collection, status: string) => { + if (status === 'ready') { + store.dispatch(collectionStatsFetched(model)); + } + }); + + localAppRegistry.on('open-create-index-modal', () => { + store.dispatch(selectTab('Indexes')); + }); + + localAppRegistry.on('generate-aggregation-from-query', () => { + store.dispatch(selectTab('Aggregations')); + }); + + return store; +} diff --git a/packages/compass-collection/src/stores/context.tsx b/packages/compass-collection/src/stores/context.tsx deleted file mode 100644 index e725f656056..00000000000 --- a/packages/compass-collection/src/stores/context.tsx +++ /dev/null @@ -1,480 +0,0 @@ -import React from 'react'; -import type { ErrorInfo } from 'react'; -import AppRegistry from 'hadron-app-registry'; -import { ErrorBoundary } from '@mongodb-js/compass-components'; -import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; -import type { Document } from 'mongodb'; -import type { DataService } from 'mongodb-data-service'; -import type { Role } from 'hadron-app-registry'; - -const { log, mongoLogId } = createLoggerAndTelemetry( - 'mongodb-compass:compass-collection:context' -); - -// TODO: replace this with state coming from the right layer. -// this kind of information should not be derived -// from dataService as it operates on a lower level, -// as a consequence here we have to remove the `appName` that only -// the dataService should be using. -function getCurrentlyConnectedUri(dataService: DataService) { - let connectionStringUrl; - - try { - connectionStringUrl = dataService.getConnectionString().clone(); - } catch (e) { - return ''; - } - - if ( - /^mongodb compass/i.exec( - connectionStringUrl.searchParams.get('appName') || '' - ) - ) { - connectionStringUrl.searchParams.delete('appName'); - } - - return connectionStringUrl.href; -} - -/** - * Setup scoped actions for a plugin. - * - * @param {Object} role - The role. - * @param {Object} localAppRegistry - The scoped app registry to the collection. - * - * @returns {Object} The configured actions. - */ -const setupActions = (role: Role, localAppRegistry: AppRegistry) => { - if (!role.actionName || !role.configureActions) { - return; - } - - const actions = role.configureActions(); - localAppRegistry.registerAction(role.actionName, actions); - return actions; -}; - -export type ContextProps = { - tabs?: string[]; - views?: JSX.Element[]; - globalAppRegistry?: AppRegistry; - localAppRegistry?: AppRegistry; - dataService?: { - error?: Error; - dataService: DataService; - }; - namespace?: string; - serverVersion?: string; - isReadonly?: boolean; - isTimeSeries?: boolean; - isClustered?: boolean; - isFLE?: boolean; - actions?: any; - sourceName?: string; - editViewName?: string; - sourcePipeline?: Document[]; - query?: any; // TODO(COMPASS-6162): type query. - aggregation?: any; // TODO(COMPASS-6162): type aggregation. - key?: number; - state?: any; - isDataLake?: boolean; - scopedModals?: any[]; - connectionString?: string; - isSearchIndexesSupported?: boolean; -}; - -type ContextWithAppRegistry = ContextProps & { - globalAppRegistry: AppRegistry; - localAppRegistry: AppRegistry; -}; - -/** - * Setup a scoped store to the collection. - * - * @param {Object} options - The plugin store options. - * @property {Object} options.role - The role. - * @property {Object} options.globalAppRegistry - The global app registry. - * @property {Object} options.localAppRegistry - The scoped app registry to the collection. - * @property {Object} options.dataService - The data service. - * @property {String} options.namespace - The namespace. - * @property {String} options.serverVersion - The server version. - * @property {Boolean} options.isReadonly - If the collection is a readonly view. - * @property {Object} options.actions - The actions for the store. - * @property {String} options.sourceName - The source namespace for the view. - * @property {String} options.editViewName - The name of the view we are editing. - * - * @returns {Object} The configured store. - */ -const setupStore = ({ - role, - globalAppRegistry, - localAppRegistry, - dataService, - namespace, - serverVersion, - isReadonly, - isTimeSeries, - isDataLake, - isClustered, - isFLE, - actions, - sourceName, - editViewName, - sourcePipeline, - query, - aggregation, - connectionString, - isSearchIndexesSupported, -}: ContextWithAppRegistry & { role: Role }) => { - if (!role.storeName || !role.configureStore) { - return; - } - - const store = role.configureStore({ - localAppRegistry, - globalAppRegistry, - dataProvider: { - error: dataService?.error, - dataProvider: dataService?.dataService, - }, - namespace, - serverVersion, - isReadonly, - isTimeSeries, - isDataLake, - isClustered, - isFLE, - actions, - sourceName, - editViewName, - sourcePipeline, - query, - aggregation, - connectionString, - isSearchIndexesSupported, - }); - localAppRegistry.registerStore(role.storeName, store); - - return store; -}; - -/** - * Setup a scoped plugin to the tab. - * - * @param {Object} options - The plugin options. - * @property {Object} options.role - The role. - * @property {Object} options.globalAppRegistry - The global app registry. - * @property {Object} options.localAppRegistry - The scoped app registry to the collection. - * @property {Object} options.dataService - The data service. - * @property {String} options.namespace - The namespace. - * @property {String} options.serverVersion - The server version. - * @property {Boolean} options.isReadonly - If the collection is a readonly view. - * @property {Boolean} options.isTimeSeries - If the collection is a time-series collection. - * @property {Boolean} options.isClustered - If the collection is a clustered index collection. - * @property {Boolean} options.isFLE - If the collection is a FLE collection. - * @property {String} options.key - The plugin key. - * - * @returns {Component} The plugin. - */ -const setupPlugin = ({ - role, - globalAppRegistry, - localAppRegistry, - dataService, - namespace, - serverVersion, - isReadonly, - isTimeSeries, - isDataLake, - isClustered, - isFLE, - sourceName, - connectionString, - key, -}: ContextWithAppRegistry & { role: Role }) => { - const actions = role.configureActions?.(); - const store = setupStore({ - role, - globalAppRegistry, - localAppRegistry, - dataService, - namespace, - serverVersion, - isReadonly, - isTimeSeries, - isDataLake, - isClustered, - isFLE, - sourceName, - actions, - connectionString, - }); - const plugin = role.component; - return { - component: plugin, - store: store, - actions: actions, - key: key, - }; -}; - -/** - * Setup every scoped modal role. - * - * @param {Object} options - The scope modal plugin options. - * @property {Object} options.globalAppRegistry - The global app registry. - * @property {Object} options.localAppRegistry - The scoped app registry to the collection. - * @property {Object} options.dataService - The data service. - * @property {String} options.namespace - The namespace. - * @property {String} options.serverVersion - The server version. - * @property {Boolean} options.isReadonly - If the collection is a readonly view. - * @property {Boolean} options.isTimeSeries - If the collection is a time-series. - * @property {Boolean} options.isClustered - If the collection is a time-series. - * @property {Boolean} options.isFLE - If the collection is a FLE collection. - * - * @returns {Array} The components. - */ -const setupScopedModals = ({ - globalAppRegistry, - localAppRegistry, - dataService, - namespace, - serverVersion, - isReadonly, - isTimeSeries, - isDataLake, - isClustered, - isFLE, - sourceName, - connectionString, -}: ContextWithAppRegistry) => { - const roles = globalAppRegistry?.getRole('Collection.ScopedModal'); - if (roles) { - return roles.map((role: Role, i: number) => { - return setupPlugin({ - role: role, - globalAppRegistry, - localAppRegistry, - dataService, - namespace, - serverVersion, - isReadonly, - isTimeSeries, - isDataLake, - isClustered, - isFLE, - sourceName, - connectionString, - key: i, - }); - }); - } - return []; -}; - -/** - * Setup the query bar plugins. Need to instantiate the store and actions - * and put them in the app registry for use by all the plugins. This way - * there is only 1 query bar store per collection tab instead of one per - * plugin that uses it. - */ -const setupQueryPlugins = ({ - globalAppRegistry, - localAppRegistry, - serverVersion, - state, - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - query, - aggregation, -}: Omit): void => { - const queryBarRole = globalAppRegistry.getRole('Query.QueryBar')?.[0]; - if (queryBarRole) { - localAppRegistry.registerRole('Query.QueryBar', queryBarRole); - setupStore({ - role: queryBarRole, - globalAppRegistry, - localAppRegistry, - dataService: state.dataService, - namespace, - serverVersion, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - query, - aggregation, - }); - } - - const queryHistoryRole = globalAppRegistry.getRole('Query.QueryHistory')?.[0]; - if (queryHistoryRole) { - localAppRegistry.registerRole('Query.QueryHistory', queryHistoryRole); - const queryHistoryActions = setupActions( - queryHistoryRole, - localAppRegistry - ); - setupStore({ - role: queryHistoryRole, - globalAppRegistry, - localAppRegistry, - dataService: state.dataService, - namespace, - serverVersion, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - actions: queryHistoryActions, - query, - aggregation, - }); - } -}; - -/** - * Create the context in which a tab is created. - * - * @param {Object} options - The options for creating the context. - * @property {Object} options.state - The store state. - * @property {String} options.namespace - The namespace. - * @property {Boolean} options.isReadonly - Is the namespace readonly. - * @property {Boolean} options.isDataLake - If we are hitting the data lake. - * @property {String} options.sourceName - The name of the view source. - * @property {String} options.editViewName - The name of the view we are editing. - * @property {String} options.sourcePipeline - * - * @returns {Object} The tab context. - */ -const createContext = ({ - state, - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - isDataLake, - sourceName, - editViewName, - sourcePipeline, - query, - aggregation, - isSearchIndexesSupported, -}: ContextProps): ContextProps => { - const serverVersion = state.serverVersion; - const localAppRegistry = new AppRegistry(); - const globalAppRegistry = state.appRegistry; - const roles = globalAppRegistry.getRole('Collection.Tab') || []; - - const tabs: string[] = []; - const views: JSX.Element[] = []; - - setupQueryPlugins({ - globalAppRegistry, - localAppRegistry, - serverVersion, - state, - namespace, - isReadonly, - isTimeSeries, - isClustered, - isFLE, - query, - aggregation, - }); - - // Setup each of the tabs inside the collection tab. They will all get - // passed the same information and can determine whether they want to - // use it or not. - roles.forEach((role: Role, i: number) => { - const actions = setupActions(role, localAppRegistry); - const store = setupStore({ - role, - globalAppRegistry, - localAppRegistry, - dataService: state.dataService, - namespace, - serverVersion, - isReadonly, - isTimeSeries, - isDataLake, - isClustered, - isFLE, - actions, - sourceName, - editViewName, - sourcePipeline, - query, - aggregation, - isSearchIndexesSupported, - }); - - // Add the tab. - tabs.push(role.name); - - const tabProps = { - store, - actions, - ...(role.name === 'Aggregations' && { - showExportButton: true, - showRunButton: true, - showExplainButton: true, - }), - }; - - // Add the view. - views.push( - { - log.error( - mongoLogId(1001000107), - 'Collection Workspace', - 'Rendering collection tab failed', - { name: role.name, error: error.stack, errorInfo } - ); - }} - > - - - ); - }); - - // Setup the scoped modals - const scopedModals = setupScopedModals({ - globalAppRegistry, - localAppRegistry, - dataService: state.dataService, - namespace, - serverVersion, - isReadonly, - isTimeSeries, - isDataLake, - isClustered, - isFLE, - sourceName, - connectionString: getCurrentlyConnectedUri(state.dataService.dataService), - }); - - const configureFieldStore = globalAppRegistry.getStore('Field.Store'); - configureFieldStore({ - localAppRegistry: localAppRegistry, - globalAppRegistry: globalAppRegistry, - namespace: namespace, - serverVersion: serverVersion, - }); - - return { - tabs, - views, - scopedModals, - localAppRegistry, - sourcePipeline, - }; -}; - -export default createContext; diff --git a/packages/compass-collection/src/stores/index.spec.ts b/packages/compass-collection/src/stores/index.spec.ts deleted file mode 100644 index 5da74de90fb..00000000000 --- a/packages/compass-collection/src/stores/index.spec.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { expect } from 'chai'; -import { EventEmitter } from 'events'; -import AppRegistry from 'hadron-app-registry'; -import sinon from 'sinon'; -import store, { INITIAL_STATE, reset, kInstance } from './index'; -import { dataLakeChanged } from '../modules/is-data-lake'; -import { namespaceChanged } from '../modules/namespace'; -import { serverVersionChanged } from '../modules/server-version'; -import type Collection from 'mongodb-collection-model'; - -class FakeDataLake extends EventEmitter { - isDataLake = false; -} - -class FakeBuild extends EventEmitter { - version = '0.0.0'; -} - -class FakeInstance extends EventEmitter { - isAtlas = false; - dataLake = new FakeDataLake(); - build = new FakeBuild(); -} - -describe('Collection Store', function () { - let appRegistry: AppRegistry; - let dispatchSpy: sinon.SinonSpy; - - before(function () { - appRegistry = new AppRegistry(); - store.onActivated(appRegistry); - }); - - beforeEach(function () { - store.dispatch(reset()); - dispatchSpy = sinon.spy(store, 'dispatch'); - }); - - afterEach(function () { - sinon.restore(); - }); - - describe('#onActivated', function () { - describe('on instance-created', function () { - let instance: FakeInstance; - - before(function () { - instance = new FakeInstance(); - appRegistry.emit('instance-created', { instance }); - }); - - it('ignores instance change:collections.status ready for other namespaces', function () { - store.dispatch(namespaceChanged('foo.bar')); - const collection = { ns: 'foo.baz' }; - dispatchSpy.resetHistory(); - instance.emit('change:collections.status', collection, 'ready'); - expect(dispatchSpy.args).to.deep.equal([ - [ - { - namespace: 'foo.baz', - stats: { - avgDocumentSize: 'N/A', - avgIndexSize: 'N/A', - documentCount: 'N/A', - indexCount: 'N/A', - storageSize: 'N/A', - totalIndexSize: 'N/A', - }, - type: 'collection/stats/UPDATE_COLLECTION_DETAILS', - }, - ], - ]); - }); - - it('responds to instance change:collections.status', function () { - store.dispatch(namespaceChanged('foo.bar')); - - const collection = { ns: 'foo.bar' }; - - expect(store.getState().stats).to.deep.equal({}); - - instance.emit( - 'change:collections.status', - { ...collection, document_count: 1 }, - 'ready' - ); - expect(store.getState().stats['foo.bar'].documentCount).to.equal('1'); - - instance.emit('change:collections.status', collection, 'error'); - expect(store.getState().stats['foo.bar']).to.equal(undefined); - }); - - it('responds to instance.dataLake change:isDataLake', function () { - expect(store.getState().isDataLake).to.equal(false); - instance.dataLake.emit('change:isDataLake', {}, true); - expect(store.getState().isDataLake).to.equal(true); - }); - - it('responds to instance.build change:version', function () { - // NOTE: from initial state, not instance.build.version at instance-created time - expect(store.getState().serverVersion).to.equal('4.0.0'); - instance.build.emit('change:version', {}, '1.2.3'); - expect(store.getState().serverVersion).to.equal('1.2.3'); - }); - }); - - it('responds to instance-destroyed', function () { - const initialState = { - ...INITIAL_STATE, - appRegistry, - }; - - expect(store.getState()).to.deep.equal(initialState); - - store.dispatch(dataLakeChanged(true)); - store.dispatch(serverVersionChanged('1.2.3')); - - expect(store.getState()).to.deep.equal({ - ...initialState, - isDataLake: true, - serverVersion: '1.2.3', - }); - - appRegistry.emit('instance-destroyed'); - - expect(store.getState()).to.deep.equal(initialState); - }); - - describe('collection appRegistry events', function () { - beforeEach(function () { - const mockCollectionModel: Partial = { - document_count: 1, - index_count: 1, - index_size: 1, - status: 'ready', - avg_document_size: 2, - storage_size: 2, - free_storage_size: 0, - }; - - const mockInstance = { - databases: { - get: () => ({ - collections: { - get: () => mockCollectionModel, - }, - }), - }, - }; - - sinon.replace(store, kInstance, mockInstance); - const mockGetStore: any = () => {}; - sinon.replace(appRegistry, 'getStore', () => mockGetStore); - }); - - it('updates collection stats on appRegistry `select-namespace` event', function () { - appRegistry.emit('select-namespace', { - namespace: 'orange.pineapple', - }); - - const state = store.getState(); - expect(state.namespace).to.equal('orange.pineapple'); - expect(state.stats).to.deep.equal({ - 'orange.pineapple': { - avgDocumentSize: '2B', - avgIndexSize: '1B', - documentCount: '1', - indexCount: '1', - storageSize: '2B', - totalIndexSize: '1B', - }, - }); - }); - - it('updates and removes collection stats on appRegistry `select-namespace` event', function () { - appRegistry.emit('select-namespace', { - namespace: 'orange.pineapple', - }); - - appRegistry.emit('select-namespace', { - namespace: 'orange.not_pineapple', - }); - - const state = store.getState(); - expect(state.namespace).to.equal('orange.not_pineapple'); - expect(state.stats).to.deep.equal({ - 'orange.not_pineapple': { - avgDocumentSize: '2B', - avgIndexSize: '1B', - documentCount: '1', - indexCount: '1', - storageSize: '2B', - totalIndexSize: '1B', - }, - }); - }); - - it('updates collection stats on appRegistry `open-namespace-in-new-tab` event', function () { - appRegistry.emit('open-namespace-in-new-tab', { - namespace: 'test123.pineapple', - }); - - const state = store.getState(); - expect(state.namespace).to.equal('test123.pineapple'); - expect(state.stats).to.deep.equal({ - 'test123.pineapple': { - avgDocumentSize: '2B', - avgIndexSize: '1B', - documentCount: '1', - indexCount: '1', - storageSize: '2B', - totalIndexSize: '1B', - }, - }); - - appRegistry.emit('open-namespace-in-new-tab', { - namespace: 'test123.pineappleNumberTwo', - }); - - const stateAfterSecondEmit = store.getState(); - expect(stateAfterSecondEmit.namespace).to.equal( - 'test123.pineappleNumberTwo' - ); - expect(stateAfterSecondEmit.stats).to.deep.equal({ - 'test123.pineapple': { - avgDocumentSize: '2B', - avgIndexSize: '1B', - documentCount: '1', - indexCount: '1', - storageSize: '2B', - totalIndexSize: '1B', - }, - 'test123.pineappleNumberTwo': { - avgDocumentSize: '2B', - avgIndexSize: '1B', - documentCount: '1', - indexCount: '1', - storageSize: '2B', - totalIndexSize: '1B', - }, - }); - }); - }); - }); -}); diff --git a/packages/compass-collection/src/stores/index.ts b/packages/compass-collection/src/stores/index.ts deleted file mode 100644 index 6c34ab20e8c..00000000000 --- a/packages/compass-collection/src/stores/index.ts +++ /dev/null @@ -1,395 +0,0 @@ -import type AppRegistry from 'hadron-app-registry'; -import type Collection from 'mongodb-collection-model'; -import { combineReducers } from 'redux'; -import { createStore, applyMiddleware } from 'redux'; -import type { AnyAction } from 'redux'; -import thunk from 'redux-thunk'; -import toNS from 'mongodb-ns'; -import type { DataService } from 'mongodb-data-service'; - -import appRegistry, { - appRegistryActivated, - INITIAL_STATE as APP_REGISTRY_INITIAL_STATE, -} from '../modules/app-registry'; -import dataService, { - dataServiceConnected, - INITIAL_STATE as DATA_SERVICE_INITIAL_STATE, -} from '../modules/data-service'; -import serverVersion, { - serverVersionChanged, - INITIAL_STATE as SERVER_VERSION_INITIAL_STATE, -} from '../modules/server-version'; -import isDataLake, { - dataLakeChanged, - INITIAL_STATE as IS_DATA_LAKE_INITIAL_STATE, -} from '../modules/is-data-lake'; -import stats, { - updateCollectionDetails, - resetCollectionDetails, - getInitialState as getInitialStatsState, -} from '../modules/stats'; -import tabs, { - selectOrCreateTab, - createNewTab, - clearTabs, - collectionDropped, - databaseDropped, - INITIAL_STATE as TABS_INITIAL_STATE, -} from '../modules/tabs'; -import namespace, { - namespaceChanged, - INITIAL_STATE as NS_INITIAL_STATE, -} from '../modules/namespace'; -import type { WorkspaceTabObject } from '../modules/tabs'; -import isAtlas, { - isAtlasChanged, - INITIAL_STATE as IS_ATLAS_INITIAL_STATE, -} from '../modules/is-atlas'; - -/** - * Reset action constant. - */ -export const RESET = 'collection/reset'; - -/** - * Reset the entire state. - * - * @returns {Object} The action. - */ -export const reset = (): { type: string } => ({ - type: RESET, -}); - -/** - * The initial state. - */ -export const INITIAL_STATE = { - appRegistry: APP_REGISTRY_INITIAL_STATE, - dataService: DATA_SERVICE_INITIAL_STATE, - serverVersion: SERVER_VERSION_INITIAL_STATE, - tabs: TABS_INITIAL_STATE, - isDataLake: IS_DATA_LAKE_INITIAL_STATE, - stats: getInitialStatsState(), - namespace: NS_INITIAL_STATE, - isAtlas: IS_ATLAS_INITIAL_STATE, -}; - -/** - * Handle the reset. - */ -const doReset = ({ appRegistry }: RootState) => ({ - ...INITIAL_STATE, - appRegistry, -}); - -/** - * The action to state modifier mappings. - */ -const MAPPINGS: any = { - [RESET]: doReset, -}; - -const appReducer = combineReducers({ - appRegistry, - dataService, - serverVersion, - tabs, - isDataLake, - stats, - namespace, - isAtlas, -}); - -export type RootState = ReturnType; - -/** - * The root reducer. - * - * @param {Object} state - The state. - * @param {Object} action - The action. - * - * @returns {Object} The new state. - */ -const rootReducer = (state: any, action: AnyAction) => { - const fn = MAPPINGS[action.type]; - return fn ? fn(state, action) : appReducer(state, action); -}; - -const store: any = createStore(rootReducer, applyMiddleware(thunk)); - -// We use these symbols so that nothing from outside can access these values on -// the store. Exported for tests. -export const kInstance = Symbol('instance'); - -/** - * This hook is Compass specific to listen to app registry events. - * - * @param {AppRegistry} appRegistry - The app registry. - */ -store.onActivated = (appRegistry: AppRegistry) => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const ipc = require('hadron-ipc'); - - /** - * When instance is created. - */ - appRegistry.on('instance-created', ({ instance }) => { - if (store[kInstance]) { - // we should probably throw in this case - return; - } - - store[kInstance] = instance; - - instance.on( - 'change:collections.status', - (collectionModel: Collection, status: string) => { - if (status === 'ready') { - store.dispatch( - updateCollectionDetails(collectionModel, collectionModel.ns) - ); - } - if (status === 'error') { - store.dispatch(resetCollectionDetails(collectionModel.ns)); - } - } - ); - - instance.on('change:env', (_model: unknown, value: string) => { - store.dispatch(isAtlasChanged(value === 'atlas')); - }); - store.dispatch(isAtlasChanged(instance.env === 'atlas')); - - instance.dataLake.on( - 'change:isDataLake', - (_model: unknown, value: boolean) => { - store.dispatch(dataLakeChanged(value)); - } - ); - // TODO: is it even possible that instance.dataLake.isDataLake already makes sense before the event arrives? - store.dispatch(dataLakeChanged(instance.dataLake.isDataLake)); - - instance.build.on('change:version', (_model: unknown, value: string) => { - store.dispatch(serverVersionChanged(value)); - }); - }); - - /** - * When instance is destroyed. - */ - appRegistry.on('instance-destroyed', () => { - store[kInstance] = null; - store.dispatch(reset()); - }); - - /** - * When a collection namespace is opened in a new tab. - * - * @param {Object} metadata - The metadata. - */ - appRegistry.on('open-namespace-in-new-tab', (metadata) => { - if (!metadata.namespace) { - return; - } - - store.dispatch(namespaceChanged(metadata.namespace)); - - const namespace = toNS(metadata.namespace); - const { database, collection, ns } = namespace; - - if (database === '' || collection === '') { - return; - } - - const collectionModel = - store[kInstance].databases.get(database).collections.get(ns) ?? null; - - if (!collectionModel) { - return; - } - - store.dispatch(updateCollectionDetails(collectionModel as Collection, ns)); - - store.dispatch( - createNewTab({ - namespace: metadata.namespace, - isReadonly: metadata.isReadonly, - sourceName: metadata.sourceName, - editViewName: metadata.editViewName, - sourceReadonly: metadata.sourceReadonly, - isTimeSeries: !!metadata.isTimeSeries, - isClustered: !!metadata.isClustered, - isFLE: !!metadata.isFLE, - sourceViewOn: metadata.sourceViewOn, - sourcePipeline: metadata.sourcePipeline, - query: metadata.query, - aggregation: metadata.aggregation, - isSearchIndexesSupported: metadata.isSearchIndexesSupported, - }) - ); - }); - - /** - * When a collection namespace is selected in the sidebar. - * - * @param {Object} metatada - The metadata. - */ - appRegistry.on('select-namespace', (metadata) => { - if (!metadata.namespace) { - return; - } - - store.dispatch(namespaceChanged(metadata.namespace)); - - const namespace = toNS(metadata.namespace); - const { database, collection, ns } = namespace; - - if (database === '' || collection === '') { - return; - } - - const collectionModel = - store[kInstance].databases.get(database).collections.get(ns) ?? null; - - if (!collectionModel) { - return; - } - - store.dispatch(updateCollectionDetails(collectionModel as Collection, ns)); - - store.dispatch( - selectOrCreateTab({ - namespace: metadata.namespace, - isReadonly: metadata.isReadonly, - isTimeSeries: metadata.isTimeSeries, - isClustered: metadata.isClustered, - isFLE: metadata.isFLE, - sourceName: metadata.sourceName, - editViewName: metadata.editViewName, - sourceReadonly: metadata.sourceReadonly, - sourceViewOn: metadata.sourceViewOn, - sourcePipeline: metadata.sourcePipeline, - isSearchIndexesSupported: metadata.isSearchIndexesSupported, - }) - ); - }); - - /** - * Clear the tabs when selecting a database. - */ - appRegistry.on('database-selected', () => { - store.dispatch(clearTabs()); - }); - - /** - * Remove any open tabs when collection dropped. - * - * @param {String} namespace - The namespace. - */ - appRegistry.on('collection-dropped', (namespace: string) => { - store.dispatch(collectionDropped(namespace)); - - const currentNamespace = store.getState().namespace; - if (namespace === currentNamespace) { - appRegistry.emit('active-collection-dropped', namespace); - } - }); - - /** - * Remove any open tabs when database dropped. - * - * @param {String} name - The name. - */ - appRegistry.on('database-dropped', (name: string) => { - store.dispatch(databaseDropped(name)); - - const currentNamespace = store.getState().namespace; - if (currentNamespace) { - const { database } = toNS(currentNamespace as string); - if (name === database) { - appRegistry.emit('active-database-dropped', name); - } - } - }); - - /** - * Set the data service in the store when connected. - */ - appRegistry.on( - 'data-service-connected', - (error, dataService: DataService) => { - store.dispatch(dataServiceConnected(error, dataService)); - } - ); - - /** - * When we disconnect from the instance, clear all the tabs. - */ - appRegistry.on('data-service-disconnected', () => { - store.dispatch(clearTabs()); - }); - - // TODO: importing hadron-ipc in unit tests doesn't work right now - if (ipc.on) { - /** - * When `Share Schema as JSON` clicked in menu send event to the active tab. - */ - ipc.on('window:menu-share-schema-json', () => { - const state = store.getState(); - if (state.tabs) { - const activeTab = state.tabs.find( - (tab: WorkspaceTabObject) => tab.isActive === true - ); - if (activeTab.localAppRegistry) { - activeTab.localAppRegistry.emit('menu-share-schema-json'); - } - } - }); - - ipc.on('compass:open-export', () => { - const state = store.getState(); - if (!state.tabs) { - return; - } - const activeTab = state.tabs.find( - (tab: WorkspaceTabObject) => tab.isActive === true - ); - if (!activeTab) { - return; - } - const crudStore = activeTab.localAppRegistry.getStore('CRUD.Store'); - const { query: crudQuery, count } = crudStore.state; - - const { filter, project, collation, limit, skip, sort } = crudQuery; - appRegistry.emit('open-export', { - exportFullCollection: true, - namespace: activeTab.namespace, - query: { filter, project, collation, limit, skip, sort }, - count, - origin: 'menu', - }); - }); - - ipc.on('compass:open-import', () => { - const state = store.getState(); - if (state.tabs) { - const activeTab = state.tabs.find( - (tab: WorkspaceTabObject) => tab.isActive === true - ); - if (activeTab) { - appRegistry.emit('open-import', { - namespace: activeTab.namespace, - origin: 'menu', - }); - } - } - }); - } - - /** - * Set the app registry to use later. - */ - store.dispatch(appRegistryActivated(appRegistry)); -}; - -export default store; diff --git a/packages/compass-collection/src/stores/tabs.spec.ts b/packages/compass-collection/src/stores/tabs.spec.ts new file mode 100644 index 00000000000..e60f5816553 --- /dev/null +++ b/packages/compass-collection/src/stores/tabs.spec.ts @@ -0,0 +1,332 @@ +import { expect } from 'chai'; +import { configureStore } from './tabs'; +import { + openCollection, + openCollectionInNewTab, + openNewTabForCurrentCollection, + selectNextTab, + selectPreviousTab, + selectTabByIndex, + moveTabByIndex, + closeTabAtIndex, + getActiveTab, + databaseDropped, + collectionDropped, +} from '../modules/tabs'; +import Sinon from 'sinon'; +import AppRegistry from 'hadron-app-registry'; +import type { DataService } from 'mongodb-data-service'; + +describe('Collection Tabs Store', function () { + const sandbox = Sinon.createSandbox(); + const globalAppRegistry = sandbox.spy(new AppRegistry()); + const dataService = sandbox.spy({ + isConnected() { + return true; + }, + } as DataService); + const CollectionTabRole = { + name: 'CollectionTab', + component: () => null, + configureStore: sandbox.stub().returns({ + getState() { + return { currentTab: 'Documents' }; + }, + }), + }; + + before(function () { + globalAppRegistry.registerRole('CollectionTab.Content', CollectionTabRole); + }); + + afterEach(function () { + sandbox.resetHistory(); + }); + + describe('openCollectionInNewTab', function () { + it('should set up new collection tab', function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.bar' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.buz' } as any)); + + const state = store.getState(); + + expect(state).to.have.property('tabs').have.lengthOf(3); + expect(state.tabs.map((tab) => tab.namespace)).to.deep.eq([ + 'test.foo', + 'test.bar', + 'test.buz', + ]); + expect(state.tabs[0].localAppRegistry).not.eq( + state.tabs[1].localAppRegistry + ); + expect(state.tabs[0].localAppRegistry).not.eq( + state.tabs[2].localAppRegistry + ); + expect(state.tabs[1].localAppRegistry).not.eq( + state.tabs[2].localAppRegistry + ); + expect(CollectionTabRole.configureStore).to.have.been.calledThrice; + }); + }); + + describe('openCollection', function () { + it('should open collection in the same tab', function () { + const store = configureStore({ globalAppRegistry, dataService }); + + store.dispatch(openCollection({ namespace: 'test.foo' } as any)); + expect(store.getState()).to.have.property('tabs').have.lengthOf(1); + expect(store.getState()).to.have.nested.property( + 'tabs[0].namespace', + 'test.foo' + ); + + store.dispatch(openCollection({ namespace: 'test.bar' } as any)); + expect(store.getState()).to.have.property('tabs').have.lengthOf(1); + expect(store.getState()).to.have.nested.property( + 'tabs[0].namespace', + 'test.bar' + ); + + store.dispatch(openCollection({ namespace: 'test.buz' } as any)); + expect(store.getState()).to.have.property('tabs').have.lengthOf(1); + expect(store.getState()).to.have.nested.property( + 'tabs[0].namespace', + 'test.buz' + ); + }); + + it('should do nothing when opening tab for the same namespace as the active tab', function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollection({ namespace: 'test.foo' } as any)); + const stateWithOneTab = store.getState(); + + store.dispatch(openCollection({ namespace: 'test.foo' } as any)); + store.dispatch(openCollection({ namespace: 'test.foo' } as any)); + store.dispatch(openCollection({ namespace: 'test.foo' } as any)); + + expect(store.getState()).to.eq(stateWithOneTab); + }); + }); + + describe('openNewTabForCurrentCollection', function () { + it('should emit an event to open new tab with the same namespace as the active tab', function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollection({ namespace: 'test.foo' } as any)); + store.dispatch(openNewTabForCurrentCollection()); + expect(globalAppRegistry.emit).to.have.been.calledWith( + 'collection-workspace-open-collection-in-new-tab', + { ns: 'test.foo' } + ); + }); + }); + + describe('selectNextTab', function () { + it('should select next tab circular', function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.bar' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.buz' } as any)); + + store.dispatch(selectNextTab()); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.foo' + ); + + store.dispatch(selectNextTab()); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.bar' + ); + + store.dispatch(selectNextTab()); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.buz' + ); + + store.dispatch(selectNextTab()); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.foo' + ); + }); + }); + + describe('selectPreviousTab', function () { + it('should select previous tab circular', function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.bar' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.buz' } as any)); + + store.dispatch(selectPreviousTab()); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.bar' + ); + + store.dispatch(selectPreviousTab()); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.foo' + ); + + store.dispatch(selectPreviousTab()); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.buz' + ); + + store.dispatch(selectPreviousTab()); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.bar' + ); + }); + }); + + describe('selectTabByIndex', function () { + it('should select tab by index', function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.bar' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.buz' } as any)); + + store.dispatch(selectTabByIndex(0)); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.foo' + ); + + store.dispatch(selectTabByIndex(1)); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.bar' + ); + + store.dispatch(selectTabByIndex(2)); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.buz' + ); + }); + }); + + describe('moveTabByIndex', function () { + it('should move tab by index without changing active tab', function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.bar' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.buz' } as any)); + + store.dispatch(moveTabByIndex(2, 0)); + expect( + store.getState().tabs.map((tab) => { + return tab.namespace; + }) + ).to.deep.eq(['test.buz', 'test.foo', 'test.bar']); + expect(getActiveTab(store.getState())).to.have.property( + 'namespace', + 'test.buz' + ); + }); + }); + + describe('closeTabAtIndex', function () { + it('should remove the tab on close', function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.bar' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.buz' } as any)); + store.dispatch(closeTabAtIndex(0)); + expect( + store.getState().tabs.map((tab) => { + return tab.namespace; + }) + ).to.deep.eq(['test.bar', 'test.buz']); + }); + + it("should emit 'select-database' when last tab is closed", function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(closeTabAtIndex(0)); + expect(store.getState().tabs).to.have.lengthOf(0); + expect(globalAppRegistry.emit).to.have.been.calledWith( + 'select-database', + 'test' + ); + }); + }); + + describe('databaseDropped', function () { + it('should remove all tabs with dropped database', function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.bar' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'meow.buz' } as any)); + store.dispatch(databaseDropped('test')); + expect( + store.getState().tabs.map((tab) => { + return tab.namespace; + }) + ).to.deep.eq(['meow.buz']); + }); + + it("should emit 'active-database-dropped' when action closes all open tabs", function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.bar' } as any)); + store.dispatch(databaseDropped('test')); + expect(store.getState().tabs).to.have.lengthOf(0); + expect(globalAppRegistry.emit).to.have.been.calledWith( + 'active-database-dropped', + 'test' + ); + }); + }); + + describe('collectionDropped', function () { + it('should remove all tabs with dropped collection', function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'meow.buz' } as any)); + store.dispatch(collectionDropped('test.foo')); + expect( + store.getState().tabs.map((tab) => { + return tab.namespace; + }) + ).to.deep.eq(['meow.buz']); + }); + + it("should emit 'active-collection-dropped' when action closes all open tabs", function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(openCollectionInNewTab({ namespace: 'test.foo' } as any)); + store.dispatch(collectionDropped('test.foo')); + expect(store.getState().tabs).to.have.lengthOf(0); + expect(globalAppRegistry.emit).to.have.been.calledWith( + 'active-collection-dropped', + 'test.foo' + ); + }); + }); + + describe('onActivated', function () { + it('should set up listeners on globalAppRegistry', function () { + const store = configureStore({ globalAppRegistry, dataService }); + store.onActivated(globalAppRegistry); + expect(globalAppRegistry['_emitter'].eventNames()).to.deep.eq([ + 'open-namespace-in-new-tab', + 'select-namespace', + 'collection-dropped', + 'database-dropped', + 'data-service-connected', + 'data-service-disconnected', + ]); + }); + }); +}); diff --git a/packages/compass-collection/src/stores/tabs.ts b/packages/compass-collection/src/stores/tabs.ts new file mode 100644 index 00000000000..f35d6216dbb --- /dev/null +++ b/packages/compass-collection/src/stores/tabs.ts @@ -0,0 +1,155 @@ +import type AppRegistry from 'hadron-app-registry'; +import type { AnyAction, Store } from 'redux'; +import { createStore, applyMiddleware } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; +import thunk from 'redux-thunk'; +import type { DataService } from 'mongodb-data-service'; +import type { CollectionTabsState } from '../modules/tabs'; +import tabs, { + collectionDropped, + databaseDropped, + openCollectionInNewTab, + openCollection, + getActiveTab, + dataServiceDisconnected, + dataServiceConnected, +} from '../modules/tabs'; +import { globalAppRegistry } from 'hadron-app-registry'; + +type ThunkExtraArg = { + globalAppRegistry: AppRegistry; + dataService: DataService | null; +}; + +type RootStore = Store & { + dispatch: ThunkDispatch< + any, + { + globalAppRegistry: Readonly; + dataService: DataService | null; + }, + AnyAction + >; +} & { + onActivated(globalAppRegistry: AppRegistry): void; +}; + +export function configureStore({ + globalAppRegistry: _globalAppRegistry, + dataService, +}: Partial = {}): RootStore { + const thunkExtraArg = { + globalAppRegistry: _globalAppRegistry ?? globalAppRegistry, + dataService: dataService ?? null, + }; + + const store = createStore( + tabs, + applyMiddleware(thunk.withExtraArgument(thunkExtraArg)) + ); + + Object.assign(store, { + onActivated: (globalAppRegistry: AppRegistry) => { + thunkExtraArg.globalAppRegistry = globalAppRegistry; + /** + * When emitted, will always open a collection namespace in new tab + */ + globalAppRegistry.on('open-namespace-in-new-tab', (metadata) => { + if (!metadata.namespace) { + return; + } + store.dispatch(openCollectionInNewTab(metadata)); + }); + + /** + * When emitted, will either replace content of the current tab if namespace + * doesn't match current tab namespace, or will do nothing when "selecting" + * namespace is the same as currently active + */ + globalAppRegistry.on('select-namespace', (metadata) => { + if (!metadata.namespace) { + return; + } + store.dispatch(openCollection(metadata)); + }); + + globalAppRegistry.on('collection-dropped', (namespace: string) => { + store.dispatch(collectionDropped(namespace)); + }); + + globalAppRegistry.on('database-dropped', (namespace: string) => { + store.dispatch(databaseDropped(namespace)); + }); + + /** + * Set the data service in the store when connected. + */ + globalAppRegistry.on( + 'data-service-connected', + (error, dataService: DataService) => { + thunkExtraArg.dataService = dataService; + store.dispatch(dataServiceConnected()); + } + ); + + /** + * When we disconnect from the instance, clear all the tabs. + */ + globalAppRegistry.on('data-service-disconnected', () => { + store.dispatch(dataServiceDisconnected()); + thunkExtraArg.dataService = null; + }); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const ipc = require('hadron-ipc'); + + // TODO: importing hadron-ipc in unit tests doesn't work right now + if (ipc?.on) { + ipc.on('window:menu-share-schema-json', () => { + const activeTab = getActiveTab(store.getState()); + + if (!activeTab) { + return; + } + + activeTab.localAppRegistry.emit('menu-share-schema-json'); + }); + + ipc.on('compass:open-export', () => { + const activeTab = getActiveTab(store.getState()); + + if (!activeTab) { + return; + } + + globalAppRegistry.emit('open-export', { + exportFullCollection: true, + namespace: activeTab.namespace, + origin: 'menu', + }); + }); + + ipc.on('compass:open-import', () => { + const activeTab = getActiveTab(store.getState()); + + if (!activeTab) { + return; + } + + globalAppRegistry.emit('open-import', { + namespace: activeTab.namespace, + origin: 'menu', + }); + }); + } + }, + }); + + return store as RootStore; +} + +const store = configureStore(); + +export type RootState = ReturnType; + +export default store; diff --git a/packages/compass-components/src/components/tab-nav-bar.tsx b/packages/compass-components/src/components/tab-nav-bar.tsx index a290243977c..d6864d7330f 100644 --- a/packages/compass-components/src/components/tab-nav-bar.tsx +++ b/packages/compass-components/src/components/tab-nav-bar.tsx @@ -42,7 +42,7 @@ type TabNavBarProps = { 'aria-label': string; activeTabIndex: number; tabs: string[]; - views: JSX.Element[]; + views: React.ReactElement[]; onTabClicked: (tabIndex: number) => void; }; @@ -58,7 +58,7 @@ function TabNavBar({ tabs, views, onTabClicked, -}: TabNavBarProps): JSX.Element { +}: TabNavBarProps): React.ReactElement | null { const darkMode = useDarkMode(); return ( diff --git a/packages/compass-crud/src/components/crud-toolbar.tsx b/packages/compass-crud/src/components/crud-toolbar.tsx index 2df09983e45..9a9982bb64d 100644 --- a/packages/compass-crud/src/components/crud-toolbar.tsx +++ b/packages/compass-crud/src/components/crud-toolbar.tsx @@ -16,8 +16,8 @@ import { import type { MenuAction } from '@mongodb-js/compass-components'; import { ViewSwitcher } from './view-switcher'; import type { DocumentView } from '../stores/crud-store'; - import { AddDataMenu } from './add-data-menu'; +import { usePreference } from 'compass-preferences-model'; const { track } = createLoggerAndTelemetry('COMPASS-CRUD-UI'); @@ -170,6 +170,8 @@ const CrudToolbar: React.FunctionComponent = ({ [count, page] ); + const enableExplainPlan = usePreference('enableExplainPlan', React); + return (
@@ -181,7 +183,7 @@ const CrudToolbar: React.FunctionComponent = ({ buttonLabel="Find" onApply={onApplyClicked} onReset={onResetClicked} - showExplainButton + showExplainButton={enableExplainPlan} insights={ isCollectionScan ? { diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index fa30a05ef33..138b7537fe9 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -319,8 +319,7 @@ export const CreateCollectionFLE2CheckboxLabel = '[data-testid="fle2-fields"] [data-testid="fle2-fields-label"]'; export const CreateCollectionFLE2 = '[data-testid="fle2-fields"]'; export const CollectionListFLE2Badge = '[data-testid="collection-badge-fle2"]'; -export const CollectionHeaderFLE2Badge = - '[data-testid="collection-header-badge-fle2"]'; +export const CollectionHeaderFLE2Badge = '[data-testid="collection-badge-fle"]'; export const CreateCollectionFLE2EncryptedFields = '[data-testid="fle2-encryptedFields"]'; export const CreateCollectionFLE2KeyEncryptionKey = diff --git a/packages/compass-export-to-language/src/stores/store.js b/packages/compass-export-to-language/src/stores/store.js index cb2238497fb..52853ed7fa8 100644 --- a/packages/compass-export-to-language/src/stores/store.js +++ b/packages/compass-export-to-language/src/stores/store.js @@ -10,6 +10,26 @@ import { } from '@mongodb-js/mongodb-redux-common/app-registry'; import reducer from '../modules'; +function getCurrentlyConnectedUri(dataService) { + let connectionStringUrl; + + try { + connectionStringUrl = dataService.getConnectionString().clone(); + } catch (e) { + return ''; + } + + if ( + /^mongodb compass/i.exec( + connectionStringUrl.searchParams.get('appName') || '' + ) + ) { + connectionStringUrl.searchParams.delete('appName'); + } + + return connectionStringUrl.href; +} + /** * Set the namespace in the store. * @@ -79,8 +99,10 @@ const configureStore = (options = {}) => { setNamespace(store, options.namespace); } - if (options.connectionString) { - store.dispatch(uriChanged(options.connectionString)); + if (options.dataProvider) { + store.dispatch( + uriChanged(getCurrentlyConnectedUri(options.dataProvider.dataProvider)) + ); } return store; diff --git a/packages/compass-export-to-language/src/stores/store.spec.js b/packages/compass-export-to-language/src/stores/store.spec.js index bf931b4dbeb..d5def237d7d 100644 --- a/packages/compass-export-to-language/src/stores/store.spec.js +++ b/packages/compass-export-to-language/src/stores/store.spec.js @@ -27,7 +27,17 @@ describe('ExportToLanguage Store', function () { localAppRegistry: localAppRegistry, globalAppRegistry: globalAppRegistry, namespace: 'db.coll', - connectionString: 'mongodb://localhost/', + dataProvider: { + dataProvider: { + getConnectionString() { + return { + clone() { + return new URL('mongodb://localhost/'); + }, + }; + }, + }, + }, }); }); afterEach(function () { diff --git a/packages/compass-home/package.json b/packages/compass-home/package.json index 505bed7e4b3..b60c1fab024 100644 --- a/packages/compass-home/package.json +++ b/packages/compass-home/package.json @@ -71,6 +71,7 @@ "eslint": "^7.25.0", "eventemitter3": "^4.0.0", "mocha": "^10.2.0", + "mongodb-collection-model": "^5.11.0", "mongodb-data-service": "^22.11.0", "mongodb-ns": "^2.4.0", "nyc": "^15.1.0", diff --git a/packages/compass-home/src/components/home.spec.tsx b/packages/compass-home/src/components/home.spec.tsx index 0d535d83554..7f55c011ec4 100644 --- a/packages/compass-home/src/components/home.spec.tsx +++ b/packages/compass-home/src/components/home.spec.tsx @@ -173,7 +173,6 @@ describe('Home [Component]', function () { 'select-namespace', 'open-instance-workspace', 'open-namespace-in-new-tab', - 'all-collection-tabs-closed', ]; events.forEach((name) => { @@ -200,7 +199,6 @@ describe('Home [Component]', function () { 'select-namespace', 'open-instance-workspace', 'open-namespace-in-new-tab', - 'all-collection-tabs-closed', 'darkmode-enable', 'darkmode-disable', ]; diff --git a/packages/compass-home/src/components/home.tsx b/packages/compass-home/src/components/home.tsx index b66c6b92445..077917f3b9e 100644 --- a/packages/compass-home/src/components/home.tsx +++ b/packages/compass-home/src/components/home.tsx @@ -42,6 +42,7 @@ import updateTitle from '../modules/update-title'; import Workspace from './workspace'; import { SignalHooksProvider } from '@mongodb-js/compass-components'; import { AtlasSignIn } from '@mongodb-js/atlas-service/renderer'; +import type { CollectionMetadata } from 'mongodb-collection-model'; const { track } = createLoggerAndTelemetry('COMPASS-HOME-UI'); @@ -149,6 +150,10 @@ function reducer(state: State, action: Action): State { } } +function showCollectionSubMenu({ isReadonly }: { isReadonly: boolean }) { + void ipc.ipcRenderer?.call('window:show-collection-submenu', { isReadonly }); +} + function hideCollectionSubMenu() { void ipc.ipcRenderer?.call('window:hide-collection-submenu'); } @@ -219,11 +224,12 @@ function Home({ }); } - function onSelectNamespace(meta: { namespace: string }) { + function onSelectNamespace(meta: CollectionMetadata) { dispatch({ type: 'update-namespace', namespace: toNS(meta.namespace), }); + showCollectionSubMenu({ isReadonly: meta.isReadonly }); } function onInstanceWorkspaceOpenTap() { @@ -234,18 +240,12 @@ function Home({ }); } - function onOpenNamespaceInNewTab(meta: { namespace: string }) { + function onOpenNamespaceInNewTab(meta: CollectionMetadata) { dispatch({ type: 'update-namespace', namespace: toNS(meta.namespace), }); - } - - function onAllTabsClosed() { - dispatch({ - type: 'update-namespace', - namespace: toNS(''), - }); + showCollectionSubMenu({ isReadonly: meta.isReadonly }); } const onDataServiceDisconnected = useCallback(() => { @@ -296,7 +296,6 @@ function Home({ appRegistry.on('select-namespace', onSelectNamespace); appRegistry.on('open-instance-workspace', onInstanceWorkspaceOpenTap); appRegistry.on('open-namespace-in-new-tab', onOpenNamespaceInNewTab); - appRegistry.on('all-collection-tabs-closed', onAllTabsClosed); return () => { // Clean up the app registry listeners. @@ -318,7 +317,6 @@ function Home({ 'open-namespace-in-new-tab', onOpenNamespaceInNewTab ); - appRegistry.removeListener('all-collection-tabs-closed', onAllTabsClosed); }; }, [appRegistry, onDataServiceDisconnected]); diff --git a/packages/compass-preferences-model/src/preferences.ts b/packages/compass-preferences-model/src/preferences.ts index 49abbe8ed64..ac368e7f3ed 100644 --- a/packages/compass-preferences-model/src/preferences.ts +++ b/packages/compass-preferences-model/src/preferences.ts @@ -48,6 +48,11 @@ export type UserConfigurablePreferences = PermanentFeatureFlags & // except for user preferences doesn't allow required preferences to be // defined, so we are sticking it here atlasServiceConfigPreset: 'compass-dev' | 'compass' | 'atlas-dev' | 'atlas'; + // Features that are enabled by default in Compass, but are disabled in Data + // Explorer + enableExplainPlan: boolean; + enableImportExport: boolean; + enableAggregationBuilderRunPipeline: boolean; }; export type InternalUserPreferences = { @@ -650,6 +655,39 @@ export const storedUserPreferencesProps: Required<{ type: 'string', }, + enableImportExport: { + ui: true, + cli: true, + global: true, + description: { + short: 'Enable import / export feature', + }, + validator: z.boolean().default(true), + type: 'boolean', + }, + + enableAggregationBuilderRunPipeline: { + ui: true, + cli: true, + global: true, + description: { + short: 'Enable "Run Pipeline" feature in aggregation builder', + }, + validator: z.boolean().default(true), + type: 'boolean', + }, + + enableExplainPlan: { + ui: true, + cli: true, + global: true, + description: { + short: 'Enable explain plan feature in CRUD and aggregation view', + }, + validator: z.boolean().default(true), + type: 'boolean', + }, + ...allFeatureFlagsProps, }; diff --git a/packages/compass-saved-aggregations-queries/src/stores/open-item.ts b/packages/compass-saved-aggregations-queries/src/stores/open-item.ts index 7dc1111143e..40d43b8a955 100644 --- a/packages/compass-saved-aggregations-queries/src/stores/open-item.ts +++ b/packages/compass-saved-aggregations-queries/src/stores/open-item.ts @@ -208,25 +208,13 @@ const openItem = database: string, collection: string ): ThunkAction => - async (dispatch, getState) => { - const { dataService, instance, appRegistry } = getState(); - - if (!instance || !dataService || !appRegistry) { - return; - } - - const coll = await instance.getNamespace({ - dataService, - database, - collection, - }); + (dispatch, getState) => { + const { appRegistry } = getState(); - if (!coll) { + if (!appRegistry) { return; } - const metadata = await coll.fetchMetadata({ dataService }); - track( item.type === 'aggregation' ? 'Aggregation Opened' @@ -237,8 +225,8 @@ const openItem = } ); - appRegistry.emit('open-namespace-in-new-tab', { - ...metadata, + appRegistry.emit('my-queries-open-saved-item', { + ns: `${database}.${collection}`, aggregation: item.type === 'aggregation' ? item.aggregation : null, query: item.type === 'query' ? item.query : null, }); diff --git a/packages/compass/src/app/setup-plugin-manager.js b/packages/compass/src/app/setup-plugin-manager.js index a8477bdbd9a..58a178df365 100644 --- a/packages/compass/src/app/setup-plugin-manager.js +++ b/packages/compass/src/app/setup-plugin-manager.js @@ -3,15 +3,12 @@ const remote = require('@electron/remote'); const app = require('hadron-app'); const pkg = require('../../package.json'); const path = require('path'); -const { AppRegistry } = require('hadron-app-registry'); +const { globalAppRegistry } = require('hadron-app-registry'); const PluginManager = require('@mongodb-js/hadron-plugin-manager'); const debug = require('debug')('mongodb-compass:setup-plugin-manager'); -/** - * Create a new app registry and prevent modification. - */ -const appRegistry = Object.freeze(new AppRegistry()); +const appRegistry = globalAppRegistry; app.appRegistry = appRegistry; diff --git a/packages/database-model/index.d.ts b/packages/database-model/index.d.ts index a8979f866c8..ac3c37ed0dd 100644 --- a/packages/database-model/index.d.ts +++ b/packages/database-model/index.d.ts @@ -27,6 +27,7 @@ interface Database { nameOnly?: boolean; force?: boolean; }): Promise; + on(evt: string, fn: (...args: any) => void); toJSON(opts?: { derived: boolean }): this; } diff --git a/packages/databases-collections/src/modules/drop-collection/drop-collection.ts b/packages/databases-collections/src/modules/drop-collection/drop-collection.ts index e9d3cc8efd8..e6aea3e8a96 100644 --- a/packages/databases-collections/src/modules/drop-collection/drop-collection.ts +++ b/packages/databases-collections/src/modules/drop-collection/drop-collection.ts @@ -102,7 +102,6 @@ export const dropCollection = (): ThunkAction< await ds.dropCollection(namespace); const { appRegistry } = getState(); appRegistry?.emit('collection-dropped', namespace); - appRegistry?.emit('refresh-data'); dispatch(reset()); } catch (e) { dispatch(toggleIsRunning(false)); diff --git a/packages/databases-collections/src/modules/drop-database/drop-database.ts b/packages/databases-collections/src/modules/drop-database/drop-database.ts index 3c184cedf6c..22ea98238ec 100644 --- a/packages/databases-collections/src/modules/drop-database/drop-database.ts +++ b/packages/databases-collections/src/modules/drop-database/drop-database.ts @@ -100,7 +100,6 @@ export const dropDatabase = (): ThunkAction< await ds.dropDatabase(dbName); const { appRegistry } = getState(); appRegistry?.emit('database-dropped', dbName); - appRegistry?.emit('refresh-data'); dispatch(reset()); } catch (e: any) { dispatch(toggleIsRunning(false)); diff --git a/packages/databases-collections/src/stores/collections-store.js b/packages/databases-collections/src/stores/collections-store.js index b8e9d1fe27c..455eeb78752 100644 --- a/packages/databases-collections/src/stores/collections-store.js +++ b/packages/databases-collections/src/stores/collections-store.js @@ -43,26 +43,30 @@ store.onActivated = (appRegistry) => { } const prevDb = store.instance?.databases.get(databaseName); - const nextDb = store.instance?.databases.get(ns); - - if (!nextDb) { - throw new Error(`Database ${ns} does not exist`); - } // Clean up listeners from previous database model (if exists) and set up // new ones prevDb?.off('change:collectionsStatus', onDatabaseCollectionStatusChange); - nextDb.on('change:collectionsStatus', onDatabaseCollectionStatusChange); prevDb?.off('change:collections.status', onDatabaseCollectionsChange); - nextDb.on('change:collections.status', onDatabaseCollectionsChange); // Cancel any pending collection change handlers as they are definitely from // the previous db onCollectionsChange.cancel(); - // Set initial collections based on the current database model state and - // update the collections collection status - store.dispatch(changeDatabaseName(ns, nextDb.collections.toJSON() ?? [])); - onDatabaseCollectionStatusChange(nextDb); + + if (ns) { + const nextDb = store.instance?.databases.get(ns); + if (!nextDb) { + throw new Error(`Database ${ns} does not exist`); + } + nextDb.on('change:collectionsStatus', onDatabaseCollectionStatusChange); + nextDb.on('change:collections.status', onDatabaseCollectionsChange); + // Set initial collections based on the current database model state and + // update the collections collection status + store.dispatch(changeDatabaseName(ns, nextDb.collections.toJSON() ?? [])); + onDatabaseCollectionStatusChange(nextDb); + } else { + store.dispatch(changeDatabaseName('')); + } }; appRegistry.on('instance-destroyed', () => { @@ -95,7 +99,23 @@ store.onActivated = (appRegistry) => { * * @param {String} ns - The namespace. */ - appRegistry.on('select-database', onSelectDatabase); + appRegistry.on('select-database', (dbName) => { + onSelectDatabase(dbName); + }); + + /** + * In all other cases reset current database name so that plugin is aware that + * it is not active + */ + appRegistry.on('open-instance-workspace', () => { + onSelectDatabase(); + }); + appRegistry.on('open-namespace-in-new-tab', () => { + onSelectDatabase(); + }); + appRegistry.on('select-namespace', () => { + onSelectDatabase(); + }); /** * Set the data service in the store when connected. @@ -118,7 +138,6 @@ store.onActivated = (appRegistry) => { appRegistry.on('database-dropped', (name) => { const currentDatabase = store.getState().databaseName; - if (name === currentDatabase) { appRegistry.emit('active-database-dropped', name); } diff --git a/packages/hadron-app-registry/src/app-registry.spec.ts b/packages/hadron-app-registry/src/app-registry.spec.ts index 027c8751391..e4b4df23f33 100644 --- a/packages/hadron-app-registry/src/app-registry.spec.ts +++ b/packages/hadron-app-registry/src/app-registry.spec.ts @@ -15,31 +15,8 @@ describe('AppRegistry', function () { storeLike = registry.getStore('Test.Store'); }); - it('does not return undefined', function () { - expect(storeLike).to.not.equal(undefined); - }); - - it('returns a store-like object', function (done) { - const unsubscribe = storeLike.listen((value) => { - expect(value).to.equal('test'); - unsubscribe(); - done(); - }); - storeLike.trigger('test'); - }); - - it('flags the non-existant request', function () { - expect(registry.storeMisses['Test.Store']).to.equal(1); - }); - - context('when asking for a missing store more than once', function () { - before(function () { - registry.getStore('Test.Store'); - }); - - it('updates the miss count', function () { - expect(registry.storeMisses['Test.Store']).to.equal(2); - }); + it('returns undefined', function () { + expect(storeLike).to.equal(undefined); }); }); }); diff --git a/packages/hadron-app-registry/src/app-registry.ts b/packages/hadron-app-registry/src/app-registry.ts index 9a96e499eea..2b9a49190d8 100644 --- a/packages/hadron-app-registry/src/app-registry.ts +++ b/packages/hadron-app-registry/src/app-registry.ts @@ -1,5 +1,4 @@ import type { Store as RefluxStore } from 'reflux'; -import Reflux from 'reflux'; import EventEmitter from 'eventemitter3'; import { Actions } from './actions'; @@ -10,12 +9,6 @@ import { Actions } from './actions'; */ const INT8_MAX = 127; -/** - * Returning a fake store when asking for a store that does not - * exist. - */ -const STUB_STORE = Reflux.createStore({}); - interface Role { name: string; component: React.ComponentType; @@ -161,13 +154,8 @@ export class AppRegistry { * * @returns {Store} The store. */ - getStore(name: string): Store { - const store = this.stores[name]; - if (store === undefined) { - this.storeMisses[name] = (this.storeMisses[name] || 0) + 1; - return STUB_STORE; - } - return store; + getStore(name: string): Store | undefined { + return this.stores[name]; } /** @@ -387,4 +375,9 @@ export class AppRegistry { } } +/** + * Create a global app registry and prevent modification. + */ +export const globalAppRegistry = Object.freeze(new AppRegistry()); + export type { Role }; diff --git a/packages/hadron-app-registry/src/index.ts b/packages/hadron-app-registry/src/index.ts index 73cf8f0feb0..4022f5359cb 100644 --- a/packages/hadron-app-registry/src/index.ts +++ b/packages/hadron-app-registry/src/index.ts @@ -1,5 +1,5 @@ -import { AppRegistry } from './app-registry'; +import { AppRegistry, globalAppRegistry } from './app-registry'; import type { Role } from './app-registry'; -export { AppRegistry }; +export { AppRegistry, globalAppRegistry }; export type { Role }; export default AppRegistry; diff --git a/packages/instance-model/index.d.ts b/packages/instance-model/index.d.ts index ad00f8e5c3a..37cdd8f8fe0 100644 --- a/packages/instance-model/index.d.ts +++ b/packages/instance-model/index.d.ts @@ -38,14 +38,14 @@ interface DataLake { } interface Server { - type: string - address: string + type: string; + address: string; } interface TopologyDescription { - type: string, - servers: Server[], - setName: string + type: string; + servers: Server[]; + setName: string; } declare class MongoDBInstanceProps { @@ -75,7 +75,7 @@ declare class MongoDBInstanceProps { auth: AuthInfo; databases: DatabaseCollection; csfleMode: 'enabled' | 'disabled' | 'unavailable'; - topologyDescription: TopologyDescription + topologyDescription: TopologyDescription; } declare class MongoDBInstance extends MongoDBInstanceProps { @@ -99,6 +99,7 @@ declare class MongoDBInstance extends MongoDBInstanceProps { collection: string; }): Promise; removeAllListeners(): void; + on(evt: string, fn: (...args: any) => void); toJSON(opts?: { derived?: boolean }): this; }