diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index dfc51e7aa46..eef07a2763f 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -1,5 +1,5 @@ The following third-party software is used by and included in **Mongodb Compass**. -This document was automatically generated on Mon Sep 23 2024. +This document was automatically generated on Tue Sep 24 2024. ## List of dependencies diff --git a/docs/tracking-plan.md b/docs/tracking-plan.md index 91fc5606256..ab201723132 100644 --- a/docs/tracking-plan.md +++ b/docs/tracking-plan.md @@ -1,7 +1,7 @@ # Compass Tracking Plan -Generated on Mon, Sep 23, 2024 at 01:02 PM +Generated on Tue, Sep 24, 2024 at 03:03 PM ## Table of Contents diff --git a/package-lock.json b/package-lock.json index bc4acf39ccd..a3c7b57ca36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45555,6 +45555,7 @@ "version": "5.41.0", "license": "SSPL", "dependencies": { + "@mongodb-js/atlas-service": "^0.28.3", "@mongodb-js/compass-app-stores": "^7.28.0", "@mongodb-js/compass-components": "^1.29.4", "@mongodb-js/compass-connections": "^1.42.0", @@ -45573,6 +45574,7 @@ "mongodb": "^6.8.0", "mongodb-collection-model": "^5.23.3", "mongodb-data-service": "^22.23.3", + "mongodb-ns": "^2.4.2", "mongodb-query-parser": "^4.2.3", "numeral": "^2.0.6", "react": "^17.0.2", @@ -45582,7 +45584,6 @@ "semver": "^7.6.2" }, "devDependencies": { - "@mongodb-js/atlas-service": "^0.28.2", "@mongodb-js/eslint-config-compass": "^1.1.7", "@mongodb-js/mocha-config-compass": "^1.4.2", "@mongodb-js/prettier-config-compass": "^1.0.2", @@ -45595,7 +45596,6 @@ "electron-mocha": "^12.2.0", "eslint": "^7.25.0", "mocha": "^10.2.0", - "mongodb-ns": "^2.4.2", "nyc": "^15.1.0", "react-dom": "^17.0.2", "sinon": "^9.2.3", @@ -57221,7 +57221,7 @@ "@mongodb-js/compass-indexes": { "version": "file:packages/compass-indexes", "requires": { - "@mongodb-js/atlas-service": "^0.28.2", + "@mongodb-js/atlas-service": "^0.28.3", "@mongodb-js/compass-app-stores": "^7.28.0", "@mongodb-js/compass-components": "^1.29.4", "@mongodb-js/compass-connections": "^1.42.0", diff --git a/packages/compass-components/src/components/item-action-controls.tsx b/packages/compass-components/src/components/item-action-controls.tsx index 95d6a06eba4..5297b7a6211 100644 --- a/packages/compass-components/src/components/item-action-controls.tsx +++ b/packages/compass-components/src/components/item-action-controls.tsx @@ -504,6 +504,7 @@ export function DropdownMenuButton({ buttonProps, iconSize = ItemActionButtonSize.Default, 'data-testid': dataTestId, + hideOnNarrow = true, }: { actions: MenuAction[]; onAction(actionName: Action): void; @@ -514,6 +515,7 @@ export function DropdownMenuButton({ 'data-testid'?: string; buttonText: string; buttonProps: ButtonProps; + hideOnNarrow?: boolean; }) { // this ref is used by the Menu component to calculate the height and position // of the menu, and by us to give back the focus to the trigger when the menu @@ -567,7 +569,9 @@ export function DropdownMenuButton({ rightGlyph={} title={buttonText} > - {buttonText} + + {buttonText} + {children} ); diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index 496556241e2..14ef0e39b79 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -2064,10 +2064,6 @@ export function activateDocumentsPlugin( } }); - on(localAppRegistry, 'refresh-collection-stats', () => { - void collection.fetch({ dataService, force: true }); - }); - if (!options.noRefreshOnConfigure) { queueMicrotask(() => { void store.refreshDocuments(); diff --git a/packages/compass-indexes/package.json b/packages/compass-indexes/package.json index ae10f59c8d6..cf4db773739 100644 --- a/packages/compass-indexes/package.json +++ b/packages/compass-indexes/package.json @@ -48,7 +48,6 @@ "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." }, "devDependencies": { - "@mongodb-js/atlas-service": "^0.28.2", "@mongodb-js/eslint-config-compass": "^1.1.7", "@mongodb-js/mocha-config-compass": "^1.4.2", "@mongodb-js/prettier-config-compass": "^1.0.2", @@ -61,7 +60,6 @@ "electron-mocha": "^12.2.0", "eslint": "^7.25.0", "mocha": "^10.2.0", - "mongodb-ns": "^2.4.2", "nyc": "^15.1.0", "react-dom": "^17.0.2", "sinon": "^9.2.3", @@ -69,6 +67,7 @@ "xvfb-maybe": "^0.2.1" }, "dependencies": { + "@mongodb-js/atlas-service": "^0.28.3", "@mongodb-js/compass-app-stores": "^7.28.0", "@mongodb-js/compass-components": "^1.29.4", "@mongodb-js/compass-connections": "^1.42.0", @@ -88,6 +87,7 @@ "mongodb-collection-model": "^5.23.3", "mongodb-data-service": "^22.23.3", "mongodb-query-parser": "^4.2.3", + "mongodb-ns": "^2.4.2", "numeral": "^2.0.6", "react": "^17.0.2", "react-redux": "^8.1.3", diff --git a/packages/compass-indexes/src/components/indexes-table/indexes-table.tsx b/packages/compass-indexes/src/components/indexes-table/indexes-table.tsx index ff6fc876476..4546db7687e 100644 --- a/packages/compass-indexes/src/components/indexes-table/indexes-table.tsx +++ b/packages/compass-indexes/src/components/indexes-table/indexes-table.tsx @@ -18,7 +18,6 @@ import { import type { LGColumnDef, LGTableDataType, - HeaderGroup, LeafyGreenTableCell, LeafyGreenTableRow, SortingState, @@ -76,6 +75,7 @@ const tableHeadDarkModeStyles = css({ }); const tableHeadCellStyles = css({ + whiteSpace: 'nowrap', '> div': { // Push the sort button to the right of the head cell. justifyContent: 'space-between', @@ -126,7 +126,7 @@ export function IndexesTable({ isSticky className={cx(tableHeadStyles, darkMode && tableHeadDarkModeStyles)} > - {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( + {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { return ( diff --git a/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.spec.tsx b/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.spec.tsx index ce7e488b03d..b13e8821cbc 100644 --- a/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.spec.tsx +++ b/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.spec.tsx @@ -37,7 +37,7 @@ describe('IndexesToolbar Component', function () { isWritable={true} writeStateDescription={undefined} onRefreshIndexes={() => {}} - isAtlasSearchSupported={false} + isSearchIndexesSupported={false} isRefreshing={false} onIndexViewChanged={() => {}} onCreateRegularIndexClick={() => {}} @@ -75,7 +75,7 @@ describe('IndexesToolbar Component', function () { showInsights: true, }); - renderIndexesToolbar({ isAtlasSearchSupported: true }); + renderIndexesToolbar({ isSearchIndexesSupported: true }); }); it('should render the create index dropdown button enabled', async function () { @@ -100,7 +100,7 @@ describe('IndexesToolbar Component', function () { showInsights: true, }); - renderIndexesToolbar({ isAtlasSearchSupported: false }); + renderIndexesToolbar({ isSearchIndexesSupported: false }); }); it('should render the create index button only', function () { @@ -198,7 +198,7 @@ describe('IndexesToolbar Component', function () { it('calls onCreateRegularIndexClick when index button is clicked', function () { const onCreateRegularIndexClickSpy = sinon.spy(); renderIndexesToolbar({ - isAtlasSearchSupported: true, + isSearchIndexesSupported: true, onCreateRegularIndexClick: onCreateRegularIndexClickSpy, }); @@ -221,7 +221,7 @@ describe('IndexesToolbar Component', function () { it('calls onCreateSearchIndexClick when index button is clicked', function () { const onCreateSearchIndexClickSpy = sinon.spy(); renderIndexesToolbar({ - isAtlasSearchSupported: true, + isSearchIndexesSupported: true, onCreateSearchIndexClick: onCreateSearchIndexClickSpy, }); @@ -308,7 +308,7 @@ describe('IndexesToolbar Component', function () { it('when it supports search management, it changes tab view', function () { renderIndexesToolbar({ - isAtlasSearchSupported: true, + isSearchIndexesSupported: true, onIndexViewChanged: onChangeViewCallback, }); const segmentControl = screen.getByText('Search Indexes'); @@ -320,7 +320,7 @@ describe('IndexesToolbar Component', function () { it('when it does not support search management, it renders tab as disabled', function () { renderIndexesToolbar({ - isAtlasSearchSupported: false, + isSearchIndexesSupported: false, onIndexViewChanged: onChangeViewCallback, }); const segmentControl = screen.getByText('Search Indexes'); diff --git a/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx b/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx index 1ae1c99ecdf..2b8632dc57f 100644 --- a/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx +++ b/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx @@ -21,10 +21,7 @@ import { } from '@mongodb-js/compass-components'; import type { RootState } from '../../modules'; -import { - SearchIndexesStatuses, - createSearchIndexOpened, -} from '../../modules/search-indexes'; +import { createSearchIndexOpened } from '../../modules/search-indexes'; import { createIndexOpened } from '../../modules/create-index'; import type { IndexView } from '../../modules/index-view'; import { indexViewChanged } from '../../modules/index-view'; @@ -61,7 +58,7 @@ type IndexesToolbarProps = { onCreateRegularIndexClick: () => void; onCreateSearchIndexClick: () => void; writeStateDescription?: string; - isAtlasSearchSupported: boolean; + isSearchIndexesSupported: boolean; // via withPreferences: readOnly?: boolean; }; @@ -76,7 +73,7 @@ export const IndexesToolbar: React.FunctionComponent = ({ isRefreshing, writeStateDescription, hasTooManyIndexes, - isAtlasSearchSupported, + isSearchIndexesSupported, onRefreshIndexes, onIndexViewChanged, readOnly, // preferences readOnly. @@ -106,7 +103,7 @@ export const IndexesToolbar: React.FunctionComponent = ({
= ({ > Indexes - {!isAtlasSearchSupported && ( + {!isSearchIndexesSupported && ( = ({

)} - {isAtlasSearchSupported && ( + {isSearchIndexesSupported && ( = ({ type CreateIndexButtonProps = { isSearchManagementActive: boolean; - isAtlasSearchSupported: boolean; + isSearchIndexesSupported: boolean; isWritable: boolean; onCreateRegularIndexClick: () => void; onCreateSearchIndexClick: () => void; @@ -212,7 +209,7 @@ export const CreateIndexButton: React.FunctionComponent< CreateIndexButtonProps > = ({ isSearchManagementActive, - isAtlasSearchSupported, + isSearchIndexesSupported, isWritable, onCreateRegularIndexClick, onCreateSearchIndexClick, @@ -229,7 +226,7 @@ export const CreateIndexButton: React.FunctionComponent< [onCreateRegularIndexClick, onCreateSearchIndexClick] ); - if (isAtlasSearchSupported && isSearchManagementActive) { + if (isSearchIndexesSupported && isSearchManagementActive) { return ( ); } @@ -264,6 +262,7 @@ export const CreateIndexButton: React.FunctionComponent< const mapState = ({ isWritable, isReadonlyView, + isSearchIndexesSupported, description, serverVersion, searchIndexes, @@ -271,11 +270,11 @@ const mapState = ({ }: RootState) => ({ isWritable, isReadonlyView, + isSearchIndexesSupported, writeStateDescription: description, indexView, serverVersion, - isAtlasSearchSupported: - searchIndexes.status !== SearchIndexesStatuses.NOT_AVAILABLE, + searchIndexes, }); const mapDispatch = { diff --git a/packages/compass-indexes/src/components/indexes/indexes.spec.tsx b/packages/compass-indexes/src/components/indexes/indexes.spec.tsx index 5b0e2fa2320..d1ec4a6b5eb 100644 --- a/packages/compass-indexes/src/components/indexes/indexes.spec.tsx +++ b/packages/compass-indexes/src/components/indexes/indexes.spec.tsx @@ -10,45 +10,56 @@ import { } from '@mongodb-js/testing-library-compass'; import { expect } from 'chai'; import sinon from 'sinon'; -import type { RegularIndex } from '../../modules/regular-indexes'; -import type { IndexesDataService } from '../../stores/store'; +import { type RegularIndex } from '../../modules/regular-indexes'; +import type { + IndexesDataService, + IndexesPluginOptions, +} from '../../stores/store'; import Indexes from './indexes'; import { setupStore } from '../../../test/setup-store'; import { searchIndexes } from '../../../test/fixtures/search-indexes'; import type { RootState } from '../../modules'; -const DEFAULT_PROPS: Partial = { - regularIndexes: { - indexes: [], - inProgressIndexes: [], - error: null, - isRefreshing: false, - }, - searchIndexes: { - indexes: [], - error: null, - status: 'PENDING', - createIndex: { - isModalOpen: false, - }, - updateIndex: { - isModalOpen: false, - }, - }, -} as any; - -const renderIndexes = ( - props: Partial = {}, - dataProvider: Partial = {} +const renderIndexes = async ( + options: Partial = {}, + dataProvider: Partial = {}, + props?: Partial ) => { - const store = setupStore(undefined, dataProvider); + const store = setupStore( + { ...options, isSearchIndexesSupported: true }, + dataProvider + ); + + // activating the store dispatches refreshRegular/Search indexes, but doesn't + // wait for it + await waitFor(() => { + expect(store.getState().regularIndexes.status).to.be.oneOf([ + 'READY', + 'ERROR', + ]); + expect(store.getState().searchIndexes.status).to.be.oneOf([ + 'READY', + 'ERROR', + ]); + }); + + if (props) { + const state = store.getState(); - const allProps: Partial = { - ...DEFAULT_PROPS, - ...props, - }; + const allProps: Partial = { + indexView: props.indexView ?? 'regular-indexes', + regularIndexes: { + ...state.regularIndexes, + ...props.regularIndexes, + }, + searchIndexes: { + ...state.searchIndexes, + ...props.searchIndexes, + }, + }; - Object.assign(store.getState(), allProps); + Object.assign(store.getState(), allProps); + } render( @@ -63,23 +74,25 @@ describe('Indexes Component', function () { before(cleanup); afterEach(cleanup); - it('renders indexes', function () { - renderIndexes(); + it('renders indexes', async function () { + await renderIndexes(); expect(screen.getByTestId('indexes-list')).to.exist; }); - it('renders indexes toolbar', function () { - renderIndexes(); + it('renders indexes toolbar', async function () { + await renderIndexes(); expect(screen.getByTestId('indexes-toolbar')).to.exist; }); - it('renders indexes toolbar when there is a regular indexes error', function () { - renderIndexes({ + it('renders indexes toolbar when there is a regular indexes error', async function () { + await renderIndexes(undefined, undefined, { + indexView: 'regular-indexes', regularIndexes: { indexes: [], error: 'Some random error', - isRefreshing: false, - } as any, + status: 'ERROR', + inProgressIndexes: [], + }, }); expect(screen.getByTestId('indexes-toolbar')).to.exist; // TODO: actually check for the error @@ -93,7 +106,11 @@ describe('Indexes Component', function () { const dataProvider = { getSearchIndexes: getSearchIndexesStub, }; - renderIndexes({}, dataProvider); + await renderIndexes(undefined, dataProvider, { + indexView: 'search-indexes', + }); + + expect(getSearchIndexesStub.callCount).to.equal(1); const toolbar = screen.getByTestId('indexes-toolbar'); expect(toolbar).to.exist; @@ -109,12 +126,14 @@ describe('Indexes Component', function () { }); }); - it('does not render the indexes list if isReadonlyView is true', function () { - renderIndexes({ + it('does not render the indexes list if isReadonlyView is true', async function () { + await renderIndexes(undefined, undefined, { + indexView: 'regular-indexes', regularIndexes: { + status: 'NOT_READY', inProgressIndexes: [], indexes: [], - } as any, + }, isReadonlyView: true, }); @@ -124,8 +143,9 @@ describe('Indexes Component', function () { }); context('regular indexes', function () { - it('renders indexes list', function () { - renderIndexes({ + it('renders indexes list', async function () { + await renderIndexes(undefined, undefined, { + indexView: 'regular-indexes', regularIndexes: { indexes: [ { @@ -146,9 +166,10 @@ describe('Indexes Component', function () { usageCount: 20, }, ] as RegularIndex[], - error: null, - isRefreshing: false, - } as any, + error: undefined, + status: 'READY', + inProgressIndexes: [], + }, }); const indexesList = screen.getByTestId('indexes-list'); @@ -156,11 +177,13 @@ describe('Indexes Component', function () { expect(within(indexesList).getByText('_id_')).to.exist; }); - it('renders indexes list with in progress index', function () { - renderIndexes({ + it('renders indexes list with in progress index', async function () { + await renderIndexes(undefined, undefined, { + indexView: 'regular-indexes', regularIndexes: { indexes: [ { + key: {}, ns: 'db.coll', cardinality: 'single', name: '_id_', @@ -178,6 +201,7 @@ describe('Indexes Component', function () { usageCount: 20, }, { + key: {}, ns: 'db.coll', cardinality: 'single', name: 'item', @@ -196,10 +220,11 @@ describe('Indexes Component', function () { ], usageCount: 0, }, - ] as RegularIndex[], - error: null, - isRefreshing: false, - } as any, + ], + inProgressIndexes: [], + error: undefined, + status: 'READY', + }, }); const indexesList = screen.getByTestId('indexes-list'); @@ -215,11 +240,13 @@ describe('Indexes Component', function () { expect(dropIndexButton).to.not.exist; }); - it('renders indexes list with failed index', function () { - renderIndexes({ + it('renders indexes list with failed index', async function () { + await renderIndexes(undefined, undefined, { + indexView: 'regular-indexes', regularIndexes: { indexes: [ { + key: {}, ns: 'db.coll', cardinality: 'single', name: '_id_', @@ -237,6 +264,7 @@ describe('Indexes Component', function () { usageCount: 20, }, { + key: {}, ns: 'db.coll', cardinality: 'single', name: 'item', @@ -256,10 +284,11 @@ describe('Indexes Component', function () { ], usageCount: 0, }, - ] as RegularIndex[], - error: null, - isRefreshing: false, - } as any, + ], + inProgressIndexes: [], + error: undefined, + status: 'READY', + }, }); const indexesList = screen.getByTestId('indexes-list'); @@ -282,7 +311,7 @@ describe('Indexes Component', function () { const dataProvider = { getSearchIndexes: getSearchIndexesStub, }; - renderIndexes({}, dataProvider); + await renderIndexes(undefined, dataProvider); // switch to the Search Indexes tab const toolbar = screen.getByTestId('indexes-toolbar'); @@ -299,7 +328,9 @@ describe('Indexes Component', function () { const dataProvider = { getSearchIndexes: getSearchIndexesStub, }; - renderIndexes({}, dataProvider); + await renderIndexes(undefined, dataProvider, { + indexView: 'search-indexes', + }); // switch to the Search Indexes tab const toolbar = screen.getByTestId('indexes-toolbar'); @@ -316,27 +347,5 @@ describe('Indexes Component', function () { expect(getSearchIndexesStub.callCount).to.equal(2); }); - - it('switches to the search indexes table when a search index is created', async function () { - renderIndexes({ - // render with the create search index modal open - ...DEFAULT_PROPS, - searchIndexes: { - ...DEFAULT_PROPS.searchIndexes, - createIndex: { - ...DEFAULT_PROPS.searchIndexes!.createIndex, - isModalOpen: true, - }, - } as any, - }); - - // check that the search indexes table is not visible - expect(screen.queryByTestId('search-indexes')).is.null; - // click the create index button - (await screen.findByTestId('search-index-submit-button')).click(); - // we are not creating the index (due to the test) - // but we are switch, so we will see the zero-graphic - expect(await screen.findByText('No search indexes yet')).is.visible; - }); }); }); diff --git a/packages/compass-indexes/src/components/indexes/indexes.tsx b/packages/compass-indexes/src/components/indexes/indexes.tsx index 66a6df51638..61c8f18e3c8 100644 --- a/packages/compass-indexes/src/components/indexes/indexes.tsx +++ b/packages/compass-indexes/src/components/indexes/indexes.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React from 'react'; import { connect } from 'react-redux'; import { Banner, @@ -16,8 +16,8 @@ import { refreshRegularIndexes } from '../../modules/regular-indexes'; import { refreshSearchIndexes } from '../../modules/search-indexes'; import type { State as RegularIndexesState } from '../../modules/regular-indexes'; import type { State as SearchIndexesState } from '../../modules/search-indexes'; -import { SearchIndexesStatuses } from '../../modules/search-indexes'; -import type { SearchIndexesStatus } from '../../modules/search-indexes'; +import { FetchStatuses } from '../../utils/fetch-status'; +import type { FetchStatus } from '../../utils/fetch-status'; import type { RootState } from '../../modules'; import { CreateSearchIndexModal, @@ -67,20 +67,16 @@ const AtlasIndexesBanner = () => { type IndexesProps = { isReadonlyView?: boolean; - regularIndexes: Pick< - RegularIndexesState, - 'indexes' | 'error' | 'isRefreshing' - >; + regularIndexes: Pick; searchIndexes: Pick; currentIndexesView: IndexView; refreshRegularIndexes: () => void; refreshSearchIndexes: () => void; }; -function isRefreshingStatus(status: SearchIndexesStatus) { +function isRefreshingStatus(status: FetchStatus) { return ( - status === SearchIndexesStatuses.FETCHING || - status === SearchIndexesStatuses.REFRESHING + status === FetchStatuses.FETCHING || status === FetchStatuses.REFRESHING ); } @@ -110,7 +106,7 @@ export function Indexes({ const isRefreshing = currentIndexesView === 'regular-indexes' - ? regularIndexes.isRefreshing === true + ? isRefreshingStatus(regularIndexes.status) : isRefreshingStatus(searchIndexes.status); const onRefreshIndexes = @@ -118,18 +114,6 @@ export function Indexes({ ? refreshRegularIndexes : refreshSearchIndexes; - const loadIndexes = useCallback(() => { - if (currentIndexesView === 'regular-indexes') { - refreshRegularIndexes(); - } else { - refreshSearchIndexes(); - } - }, [currentIndexesView, refreshRegularIndexes, refreshSearchIndexes]); - - useEffect(() => { - loadIndexes(); - }, [loadIndexes]); - const enableAtlasSearchIndexes = usePreference('enableAtlasSearchIndexes'); return ( @@ -176,8 +160,6 @@ const mapState = ({ }); const mapDispatch = { - // TODO(COMPASS-8214): loading, polling, refreshing the indexes should all - // happen in the store, not the UI. refreshRegularIndexes, refreshSearchIndexes, }; diff --git a/packages/compass-indexes/src/components/regular-indexes-table/regular-indexes-table.spec.tsx b/packages/compass-indexes/src/components/regular-indexes-table/regular-indexes-table.spec.tsx index ad123bcf338..4164b1e9e9a 100644 --- a/packages/compass-indexes/src/components/regular-indexes-table/regular-indexes-table.spec.tsx +++ b/packages/compass-indexes/src/components/regular-indexes-table/regular-indexes-table.spec.tsx @@ -111,6 +111,8 @@ const renderIndexList = ( onHideIndexClick={() => {}} onUnhideIndexClick={() => {}} onDeleteIndexClick={() => {}} + onRegularIndexesOpened={() => {}} + onRegularIndexesClosed={() => {}} {...props} /> ); diff --git a/packages/compass-indexes/src/components/regular-indexes-table/regular-indexes-table.tsx b/packages/compass-indexes/src/components/regular-indexes-table/regular-indexes-table.tsx index 77014926fa7..4325f27c3ad 100644 --- a/packages/compass-indexes/src/components/regular-indexes-table/regular-indexes-table.tsx +++ b/packages/compass-indexes/src/components/regular-indexes-table/regular-indexes-table.tsx @@ -1,6 +1,7 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useEffect } from 'react'; import { connect } from 'react-redux'; import { withPreferences } from 'compass-preferences-model/provider'; +import { useWorkspaceTabId } from '@mongodb-js/compass-workspaces/provider'; import { IndexKeysBadge } from '@mongodb-js/compass-components'; import type { LGColumnDef, @@ -21,6 +22,8 @@ import { dropIndex, hideIndex, unhideIndex, + startPollingRegularIndexes, + stopPollingRegularIndexes, } from '../../modules/regular-indexes'; import { type RegularIndex } from '../../modules/regular-indexes'; @@ -34,6 +37,8 @@ type RegularIndexesTableProps = { onDeleteIndexClick: (name: string) => void; readOnly?: boolean; error?: string | null; + onRegularIndexesOpened: (tabId: string) => void; + onRegularIndexesClosed: (tabId: string) => void; }; type IndexInfo = { @@ -145,8 +150,19 @@ export const RegularIndexesTable: React.FunctionComponent< onHideIndexClick, onUnhideIndexClick, onDeleteIndexClick, + onRegularIndexesOpened, + onRegularIndexesClosed, error, }) => { + const tabId = useWorkspaceTabId(); + + useEffect(() => { + onRegularIndexesOpened(tabId); + return () => { + onRegularIndexesClosed(tabId); + }; + }, [tabId, onRegularIndexesOpened, onRegularIndexesClosed]); + const data = useMemo[]>( () => indexes.map((index) => ({ @@ -224,6 +240,8 @@ const mapDispatch = { onDeleteIndexClick: dropIndex, onHideIndexClick: hideIndex, onUnhideIndexClick: unhideIndex, + onRegularIndexesOpened: startPollingRegularIndexes, + onRegularIndexesClosed: stopPollingRegularIndexes, }; export default connect( diff --git a/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.spec.tsx b/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.spec.tsx index 2571f787352..ebfd350b258 100644 --- a/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.spec.tsx +++ b/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.spec.tsx @@ -5,14 +5,13 @@ import { screen, fireEvent, within, - waitFor, userEvent, } from '@mongodb-js/testing-library-compass'; import { expect } from 'chai'; import sinon from 'sinon'; import type { Document } from 'mongodb'; import { SearchIndexesTable } from './search-indexes-table'; -import { SearchIndexesStatuses } from '../../modules/search-indexes'; +import { FetchStatuses } from '../../utils/fetch-status'; import { searchIndexes as indexes, vectorSearchIndexes, @@ -33,7 +32,8 @@ const renderIndexList = ( onDropIndexClick={noop} onEditIndexClick={noop} onOpenCreateModalClick={noop} - onPollIndexes={noop} + onSearchIndexesOpened={noop} + onSearchIndexesClosed={noop} {...props} /> ); @@ -43,10 +43,7 @@ describe('SearchIndexesTable Component', function () { before(cleanup); afterEach(cleanup); - for (const status of [ - SearchIndexesStatuses.READY, - SearchIndexesStatuses.REFRESHING, - ]) { + for (const status of [FetchStatuses.READY, FetchStatuses.REFRESHING]) { it(`renders indexes list if the status is ${status}`, function () { renderIndexList({ status }); @@ -98,10 +95,7 @@ describe('SearchIndexesTable Component', function () { }); } - for (const status of [ - SearchIndexesStatuses.FETCHING, - SearchIndexesStatuses.NOT_READY, - ]) { + for (const status of [FetchStatuses.FETCHING, FetchStatuses.NOT_READY]) { it(`does not render the list if the status is ${status}`, function () { renderIndexList({ status, @@ -183,25 +177,6 @@ describe('SearchIndexesTable Component', function () { }); }); - describe('connectivity', function () { - it('does poll the index for changes in online mode', async function () { - const onPollIndexesSpy = sinon.spy(); - const testPollingInterval = 50; - renderIndexList({ - onPollIndexes: onPollIndexesSpy, - isWritable: true, - pollingInterval: testPollingInterval, - }); - - await waitFor( - () => { - expect(onPollIndexesSpy.callCount).to.equal(1); - }, - { timeout: testPollingInterval * 1.5 } - ); - }); - }); - describe('sorting', function () { function getIndexNames() { return screen.getAllByTestId('search-indexes-name-field').map((el) => { diff --git a/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.tsx b/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.tsx index 4105c857f99..6285951d677 100644 --- a/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.tsx +++ b/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useMemo, useEffect } from 'react'; import { connect } from 'react-redux'; import type { Document } from 'mongodb'; import type { SearchIndex, SearchIndexStatus } from 'mongodb-data-service'; @@ -21,43 +21,43 @@ import type { LGTableDataType, } from '@mongodb-js/compass-components'; +import { FetchStatuses } from '../../utils/fetch-status'; import { - SearchIndexesStatuses, dropSearchIndex, getInitialSearchIndexPipeline, getInitialVectorSearchIndexPipelineText, - pollSearchIndexes, createSearchIndexOpened, updateSearchIndexOpened, + startPollingSearchIndexes, + stopPollingSearchIndexes, } from '../../modules/search-indexes'; -import type { SearchIndexesStatus } from '../../modules/search-indexes'; +import type { FetchStatus } from '../../utils/fetch-status'; import { IndexesTable } from '../indexes-table'; import SearchIndexActions from './search-index-actions'; import { ZeroGraphic } from './zero-graphic'; import type { RootState } from '../../modules'; import BadgeWithIconLink from '../indexes-table/badge-with-icon-link'; import { useConnectionInfo } from '@mongodb-js/compass-connections/provider'; - -export const POLLING_INTERVAL = 5000; +import { useWorkspaceTabId } from '@mongodb-js/compass-workspaces/provider'; type SearchIndexesTableProps = { namespace: string; indexes: SearchIndex[]; isWritable?: boolean; readOnly?: boolean; - status: SearchIndexesStatus; + status: FetchStatus; onDropIndexClick: (name: string) => void; onEditIndexClick: (name: string) => void; onOpenCreateModalClick: () => void; - onPollIndexes: () => void; - pollingInterval?: number; + onSearchIndexesOpened: (tabId: string) => void; + onSearchIndexesClosed: (tabId: string) => void; }; -function isReadyStatus(status: SearchIndexesStatus) { +function isReadyStatus(status: FetchStatus) { return ( - status === SearchIndexesStatuses.READY || - status === SearchIndexesStatuses.REFRESHING || - status === SearchIndexesStatuses.POLLING + status === FetchStatuses.READY || + status === FetchStatuses.REFRESHING || + status === FetchStatuses.POLLING ); } @@ -282,18 +282,20 @@ export const SearchIndexesTable: React.FunctionComponent< onOpenCreateModalClick, onEditIndexClick, onDropIndexClick, - onPollIndexes, - pollingInterval = POLLING_INTERVAL, + onSearchIndexesOpened, + onSearchIndexesClosed, }) => { const { openCollectionWorkspace } = useOpenWorkspace(); const { id: connectionId } = useConnectionInfo(); + const tabId = useWorkspaceTabId(); + useEffect(() => { - const id = setInterval(onPollIndexes, pollingInterval); + onSearchIndexesOpened(tabId); return () => { - clearInterval(id); + onSearchIndexesClosed(tabId); }; - }, [onPollIndexes, pollingInterval]); + }, [tabId, onSearchIndexesOpened, onSearchIndexesClosed]); const data = useMemo[]>( () => @@ -399,7 +401,8 @@ const mapDispatch = { onDropIndexClick: dropSearchIndex, onOpenCreateModalClick: createSearchIndexOpened, onEditIndexClick: updateSearchIndexOpened, - onPollIndexes: pollSearchIndexes, + onSearchIndexesOpened: startPollingSearchIndexes, + onSearchIndexesClosed: stopPollingSearchIndexes, }; export default connect( diff --git a/packages/compass-indexes/src/index.ts b/packages/compass-indexes/src/index.ts index f92d5a56b68..6c5072b58e8 100644 --- a/packages/compass-indexes/src/index.ts +++ b/packages/compass-indexes/src/index.ts @@ -17,6 +17,7 @@ import { import { createLoggerLocator } from '@mongodb-js/compass-logging/provider'; import { telemetryLocator } from '@mongodb-js/compass-telemetry/provider'; import { IndexesTabTitle } from './plugin-title'; +import { atlasServiceLocator } from '@mongodb-js/atlas-service/provider'; const CompassIndexesHadronPlugin = registerHadronPlugin( { @@ -34,6 +35,7 @@ const CompassIndexesHadronPlugin = registerHadronPlugin( logger: createLoggerLocator('COMPASS-INDEXES-UI'), track: telemetryLocator, collection: collectionModelLocator, + atlasService: atlasServiceLocator, } ); diff --git a/packages/compass-indexes/src/modules/create-index.tsx b/packages/compass-indexes/src/modules/create-index.tsx index a466561522c..f9ada33fa4a 100644 --- a/packages/compass-indexes/src/modules/create-index.tsx +++ b/packages/compass-indexes/src/modules/create-index.tsx @@ -9,7 +9,7 @@ import type { InProgressIndex } from './regular-indexes'; import type { IndexesThunkAction } from '.'; import { hasColumnstoreIndex } from '../utils/columnstore-indexes'; import type { RootState } from '.'; -import { fetchIndexes } from './regular-indexes'; +import { refreshRegularIndexes } from './regular-indexes'; export enum ActionTypes { FieldAdded = 'compass-indexes/create-index/fields/field-added', @@ -67,7 +67,7 @@ type ErrorClearedAction = { type: ActionTypes.ErrorCleared; }; -type CreateIndexOpenedAction = { +export type CreateIndexOpenedAction = { type: ActionTypes.CreateIndexOpened; }; @@ -530,7 +530,7 @@ export const createIndex = (): IndexesThunkAction< // Start a new fetch so that the newly added index's details can be // loaded. indexCreationSucceeded() will remove the in-progress one, but // we still need the new info. - await dispatch(fetchIndexes()); + await dispatch(refreshRegularIndexes()); } catch (err) { dispatch(indexCreationFailed(inProgressIndex.id, (err as Error).message)); } diff --git a/packages/compass-indexes/src/modules/index-view.ts b/packages/compass-indexes/src/modules/index-view.ts index 612ef0f5e7a..1abceef58d3 100644 --- a/packages/compass-indexes/src/modules/index-view.ts +++ b/packages/compass-indexes/src/modules/index-view.ts @@ -1,6 +1,12 @@ import type { AnyAction } from 'redux'; import { isAction } from '../utils/is-action'; +import type { CreateIndexOpenedAction } from './create-index'; +import { ActionTypes as CreateIndexActionTypes } from './create-index'; + +import type { CreateSearchIndexOpenedAction } from './search-indexes'; +import { ActionTypes as SearchIndexActionTypes } from './search-indexes'; + export type IndexView = 'regular-indexes' | 'search-indexes'; export enum ActionTypes { @@ -18,6 +24,27 @@ export default function reducer( state = INITIAL_STATE, action: AnyAction ): IndexView { + // The create index button has a dropdown where you can select regular or + // search index and then open the appropriate modal regardless of what view + // the user is on. This switches to the appropriate view so the user can see + // the newly created index. + if ( + isAction( + action, + CreateIndexActionTypes.CreateIndexOpened + ) + ) { + return 'regular-indexes'; + } + if ( + isAction( + action, + SearchIndexActionTypes.CreateSearchIndexOpened + ) + ) { + return 'search-indexes'; + } + if (isAction(action, ActionTypes.IndexViewChanged)) { return action.view; } diff --git a/packages/compass-indexes/src/modules/index.ts b/packages/compass-indexes/src/modules/index.ts index e2f999b65ea..0fb8f7f1d1f 100644 --- a/packages/compass-indexes/src/modules/index.ts +++ b/packages/compass-indexes/src/modules/index.ts @@ -4,6 +4,7 @@ import type AppRegistry from 'hadron-app-registry'; import isWritable from './is-writable'; import indexView from './index-view'; import isReadonlyView from './is-readonly-view'; +import isSearchIndexesSupported from './is-search-indexes-supported'; import description from './description'; import regularIndexes from './regular-indexes'; import searchIndexes from './search-indexes'; @@ -18,6 +19,7 @@ import type { TrackFunction } from '@mongodb-js/compass-telemetry'; import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; import type { IndexesDataServiceProps } from '../stores/store'; import type { Collection } from '@mongodb-js/compass-app-stores/provider'; +import type { RollingIndexesService } from './rolling-indexes-service'; const reducer = combineReducers({ // From instance.isWritable. Used to know if the create button should be // enabled. @@ -27,6 +29,9 @@ const reducer = combineReducers({ // CollectionProps) Used to know if many things should even be visible. isReadonlyView, + // Does this collection support search indexes + isSearchIndexesSupported, + // 'regular-indexes' or 'search-indexes' indexView, @@ -66,6 +71,7 @@ export type IndexesExtraArgs = { dataService: Pick; connectionInfoRef: ConnectionInfoRef; collection: Collection; + rollingIndexesService: RollingIndexesService; }; export type IndexesThunkDispatch = ThunkDispatch< RootState, diff --git a/packages/compass-indexes/src/modules/is-search-indexes-supported.ts b/packages/compass-indexes/src/modules/is-search-indexes-supported.ts new file mode 100644 index 00000000000..a21e22029e3 --- /dev/null +++ b/packages/compass-indexes/src/modules/is-search-indexes-supported.ts @@ -0,0 +1,40 @@ +import type { AnyAction } from 'redux'; + +export const IS_SEARCH_INDEXES_SUPPORTED_CHANGED = + 'indexes/is-search-indexes-supported/is-search-indexes-supported-changed'; + +/** + * The initial state of the is readonly view attribute. + */ +export const INITIAL_STATE = false; + +/** + * Reducer function for is readonly view state. + * + * @param {Boolean} state - The state. + * + * @returns {Boolean} The state. + */ +export default function reducer( + state = INITIAL_STATE, + action: AnyAction +): boolean { + if (action.type === IS_SEARCH_INDEXES_SUPPORTED_CHANGED) { + return action.isSearchIndexesSupported; + } + return state; +} + +/** + * Action creator for readonly view changed events. + * + * @param {Boolean} isReadonlyView - Is the view readonly. + * + * @returns {import('redux').AnyAction} The readonly view changed action. + */ +export const isSearchIndexesSupportedChanged = ( + isSearchIndexesSupported: boolean +) => ({ + type: IS_SEARCH_INDEXES_SUPPORTED_CHANGED, + isSearchIndexesSupported, +}); diff --git a/packages/compass-indexes/src/modules/regular-indexes.spec.ts b/packages/compass-indexes/src/modules/regular-indexes.spec.ts index d7276e772fc..c138e14b411 100644 --- a/packages/compass-indexes/src/modules/regular-indexes.spec.ts +++ b/packages/compass-indexes/src/modules/regular-indexes.spec.ts @@ -1,24 +1,30 @@ import { expect } from 'chai'; -import { setTimeout as wait } from 'timers/promises'; import sinon from 'sinon'; import { - fetchIndexes, refreshRegularIndexes, + pollRegularIndexes, dropIndex, hideIndex, unhideIndex, + startPollingRegularIndexes, + stopPollingRegularIndexes, } from './regular-indexes'; import { indexesList, defaultSortedIndexes, - //usageSortedIndexes, inProgressIndexes, } from '../../test/fixtures/regular-indexes'; import { readonlyViewChanged } from './is-readonly-view'; -import { setupStore } from '../../test/setup-store'; +import { + setupStore, + setupStoreAndWait, + createMockCollection, +} from '../../test/setup-store'; // Importing this to stub showConfirmation import * as regularIndexesSlice from './regular-indexes'; +import type { FetchStatus } from '../utils/fetch-status'; +import { waitFor } from '@mongodb-js/testing-library-compass'; describe('regular-indexes module', function () { before(() => { @@ -29,13 +35,40 @@ describe('regular-indexes module', function () { sinon.restore(); }); - describe('#fetchIndexes action', function () { + describe('#fetchRegularIndexees action', function () { + it('sets status to ERROR and sets the error when there is an error', async function () { + const error = new Error('failed to connect to server'); + const store = await setupStoreAndWait( + {}, + { + indexes: () => Promise.reject(error), + } + ); + + // Set some data to validate the empty array condition + Object.assign(store.getState(), { + regularIndexes: { + ...store.getState().regularIndexes, + indexes: defaultSortedIndexes.slice(), + }, + }); + + const state = store.getState().regularIndexes; + expect(state.error).to.equal(error.message); + expect(state.status).to.equal('ERROR'); + expect(state.indexes).to.deep.equal(defaultSortedIndexes); + }); + }); + + describe('#refreshRegularIndexes action', function () { it('sets indexes to empty array for views', async function () { - const indexesSpy = sinon.spy(); + const indexesStub = sinon.stub().resolves([]); const store = setupStore( - {}, { - indexes: indexesSpy, + isReadonly: true, + }, + { + indexes: indexesStub, } ); @@ -48,15 +81,15 @@ describe('regular-indexes module', function () { store.dispatch(readonlyViewChanged(true)); - await store.dispatch(fetchIndexes()); + await store.dispatch(refreshRegularIndexes()); expect(store.getState().regularIndexes.indexes).to.have.lengthOf(0); - expect(indexesSpy.callCount).to.equal(0); + expect(indexesStub.callCount).to.equal(0); }); - it('sets indexes to empty array when there is an error', async function () { + it('sets status to ready and sets the error when there is an error', async function () { const error = new Error('failed to connect to server'); - const store = setupStore( + const store = await setupStoreAndWait( {}, { indexes: () => Promise.reject(error), @@ -71,31 +104,31 @@ describe('regular-indexes module', function () { }, }); - await store.dispatch(fetchIndexes()); + await store.dispatch(refreshRegularIndexes()); const state = store.getState().regularIndexes; - expect(state.indexes).to.deep.equal(defaultSortedIndexes); expect(state.error).to.equal(error.message); - expect(state.isRefreshing).to.equal(false); + expect(state.status).to.equal('READY'); + expect(state.indexes).to.deep.equal(defaultSortedIndexes); }); it('sets indexes when fetched successfully', async function () { - const store = setupStore( + const store = await setupStoreAndWait( {}, { indexes: () => Promise.resolve(defaultSortedIndexes), } ); - await store.dispatch(fetchIndexes()); + await store.dispatch(refreshRegularIndexes()); const state = store.getState().regularIndexes; expect(state.indexes).to.deep.eq(defaultSortedIndexes); - expect(state.error).to.be.null; - expect(state.isRefreshing).to.equal(false); + expect(state.error).to.be.undefined; + expect(state.status).to.equal('READY'); }); it('merges with in progress indexes', async function () { - const store = setupStore( + const store = await setupStoreAndWait( {}, { indexes: () => Promise.resolve(indexesList), @@ -109,7 +142,7 @@ describe('regular-indexes module', function () { }, }); - await store.dispatch(fetchIndexes()); + await store.dispatch(refreshRegularIndexes()); const state = store.getState().regularIndexes; @@ -165,32 +198,158 @@ describe('regular-indexes module', function () { }, ]); - expect(state.error).to.be.null; - expect(state.isRefreshing).to.equal(false); + expect(state.error).to.be.undefined; + expect(state.status).to.equal('READY'); }); - }); - describe('#refreshRegularIndexes action', function () { - it('sets isRefreshing when indexes are refreshed', async function () { + it('sets status=FETCHING when indexes are being fetched and status is NOT_READY', async function () { + let statusBeforeFetch: FetchStatus | undefined = undefined; + + const indexesStub = sinon.stub().callsFake(async () => { + // make sure the store is done setting up before we try and use it + await Promise.resolve(); + + statusBeforeFetch = store.getState().regularIndexes.status; + return Promise.resolve(defaultSortedIndexes); + }); + const store = setupStore( {}, { - indexes: async () => { - await wait(100); - return defaultSortedIndexes; - }, + indexes: indexesStub, + } + ); + + await waitFor(() => { + expect(statusBeforeFetch).to.equal('FETCHING'); + }); + }); + + it('sets status=REFRESHING when indexes are being fetched and status is READY', async function () { + let calls = 0; + let statusBeforeFetch: FetchStatus | undefined = undefined; + + const indexesStub = sinon.stub().callsFake(async () => { + // make sure the store is done setting up before we try and use it + await Promise.resolve(); + + if (calls === 1) { + statusBeforeFetch = store.getState().regularIndexes.status; + } + calls++; + return Promise.resolve(defaultSortedIndexes); + }); + + const store = await setupStoreAndWait( + {}, + { + indexes: indexesStub, + } + ); + + Object.assign(store.getState(), { + regularIndexes: { + ...store.getState().regularIndexes, + error: 'the previous error', + status: 'READY', + indexes: defaultSortedIndexes.slice(), + }, + }); + + expect(indexesStub.callCount).to.equal(1); + await store.dispatch(refreshRegularIndexes()); + + await waitFor(() => { + expect(statusBeforeFetch).to.equal('REFRESHING'); + }); + + expect(indexesStub.callCount).to.equal(2); + }); + + it('calls collection.fetch() when the indexes change', async function () { + const changedSortedIndexes = [defaultSortedIndexes[0]]; + const indexesStub = sinon + .stub() + .onFirstCall() + .resolves(defaultSortedIndexes) + .onSecondCall() + .resolves(changedSortedIndexes); + + const collection = createMockCollection(); + + const store = await setupStoreAndWait( + {}, + { + indexes: indexesStub, + }, + { collection } + ); + + // the initial fetch + expect(indexesStub.callCount).to.equal(1); + + expect(collection.fetch.callCount).to.equal(0); + + await store.dispatch(refreshRegularIndexes()); + + expect(indexesStub.callCount).to.equal(2); + + expect(collection.fetch.callCount).to.equal(1); + }); + }); + + describe('#pollRegularIndexes action', function () { + it('sets status to READY and leaves error when there is an error', async function () { + const error = new Error('failed to connect to server'); + const store = await setupStoreAndWait( + {}, + { + indexes: () => Promise.reject(error), } ); - // don't await so we can check the intermediate result - void store.dispatch(refreshRegularIndexes()); - expect(store.getState().regularIndexes.isRefreshing).to.be.true; + // Set some data to validate the empty array condition + Object.assign(store.getState(), { + regularIndexes: { + ...store.getState().regularIndexes, + error: 'the previous error', + status: 'READY', + indexes: defaultSortedIndexes.slice(), + }, + }); + + await store.dispatch(pollRegularIndexes()); - await wait(100); - expect(store.getState().regularIndexes.isRefreshing).to.be.false; - expect(store.getState().regularIndexes.indexes).to.deep.equal( - defaultSortedIndexes + const state = store.getState().regularIndexes; + expect(state.error).to.equal('the previous error'); + expect(state.status).to.equal('READY'); + expect(state.indexes).to.deep.equal(defaultSortedIndexes); + }); + + it('sets status=POLLING when indexes are being fetched', async function () { + let statusBeforeFetch: FetchStatus | undefined = undefined; + const store = await setupStoreAndWait( + {}, + { + indexes: sinon.stub().callsFake(() => { + statusBeforeFetch = store.getState().regularIndexes.status; + return Promise.resolve(defaultSortedIndexes); + }), + } ); + + Object.assign(store.getState(), { + regularIndexes: { + ...store.getState().regularIndexes, + error: 'the previous error', + status: 'READY', + indexes: defaultSortedIndexes.slice(), + }, + }); + + await store.dispatch(pollRegularIndexes()); + expect(statusBeforeFetch).to.equal('POLLING'); + expect(store.getState().regularIndexes.status).to.equal('READY'); }); }); @@ -200,7 +359,7 @@ describe('regular-indexes module', function () { describe('#dropIndex (thunk)', function () { it('removes a failed in-progress index', async function () { - const store = setupStore( + const store = await setupStoreAndWait( {}, { indexes: () => Promise.resolve(indexesList), @@ -223,7 +382,7 @@ describe('regular-indexes module', function () { }); // fetch first so it merges the in-progress ones - await store.dispatch(fetchIndexes()); + await store.dispatch(refreshRegularIndexes()); // one of the real indexes is also in progress, so gets merged together const numOverlapping = 1; @@ -250,7 +409,7 @@ describe('regular-indexes module', function () { }); it('removes a real index', async function () { - const store = setupStore( + const store = await setupStoreAndWait( {}, { indexes: () => Promise.resolve(indexesList), @@ -270,7 +429,7 @@ describe('regular-indexes module', function () { }); // fetch first so it merges the in-progress ones - await store.dispatch(fetchIndexes()); + await store.dispatch(refreshRegularIndexes()); // one of the real indexes is also in progress, so gets merged together const numOverlapping = 1; @@ -296,7 +455,7 @@ describe('regular-indexes module', function () { describe('#hideIndex (thunk)', function () { it('hides an index', async function () { const updateCollection = sinon.stub().resolves({}); - const store = setupStore( + const store = await setupStoreAndWait( {}, { indexes: () => Promise.resolve(indexesList), @@ -305,7 +464,7 @@ describe('regular-indexes module', function () { ); // fetch indexes so there's something to hide - await store.dispatch(fetchIndexes()); + await store.dispatch(refreshRegularIndexes()); await store.dispatch(hideIndex('BBBB')); @@ -318,7 +477,7 @@ describe('regular-indexes module', function () { describe('#unhideIndex (thunk)', function () { it('unhides an index', async function () { const updateCollection = sinon.stub().resolves({}); - const store = setupStore( + const store = await setupStoreAndWait( {}, { indexes: () => Promise.resolve(indexesList), @@ -327,7 +486,7 @@ describe('regular-indexes module', function () { ); // fetch indexes so there's something to hide - await store.dispatch(fetchIndexes()); + await store.dispatch(refreshRegularIndexes()); await store.dispatch(unhideIndex('BBBB')); @@ -336,4 +495,92 @@ describe('regular-indexes module', function () { ]); }); }); + + describe('startPollingRegularIndexes and stopPollingRegularIndexes', function () { + let clock: sinon.SinonFakeTimers; + + after(() => { + clock.restore(); + }); + + it('starts and stops the polling', async function () { + const pollInterval = 5000; + const tabId = 'my-tab'; + + const collection = createMockCollection(); + + const indexesStub = sinon.stub().resolves(indexesList); + const store = await setupStoreAndWait( + {}, + { + indexes: indexesStub, + }, + { + collection, + } + ); + + const waitForStatus = async (status: FetchStatus) => { + await waitFor(() => { + expect(store.getState().regularIndexes.status).to.eq(status); + }); + }; + + clock = sinon.useFakeTimers(); + + expect(collection.fetch.callCount).to.equal(0); + + // before we start + expect(store.getState().regularIndexes.status).to.equal('READY'); + + // initial load + expect(indexesStub.callCount).to.equal(1); + + store.dispatch(startPollingRegularIndexes(tabId)); + + // poll + clock.tick(pollInterval); + await waitForStatus('POLLING'); + expect(indexesStub.callCount).to.equal(2); + await waitForStatus('READY'); + + // poll + clock.tick(pollInterval); + await waitForStatus('POLLING'); + expect(indexesStub.callCount).to.equal(3); + await waitForStatus('READY'); + + // stop + store.dispatch(stopPollingRegularIndexes(tabId)); + + // no more polling + clock.tick(pollInterval); + expect(indexesStub.callCount).to.equal(3); + await waitForStatus('READY'); + + // open again + store.dispatch(startPollingRegularIndexes(tabId)); + + // won't execute immediately + expect(indexesStub.callCount).to.equal(3); + await waitForStatus('READY'); + + // does poll after the interval + clock.tick(pollInterval); + await waitForStatus('POLLING'); + expect(indexesStub.callCount).to.equal(4); + await waitForStatus('READY'); + + // and again + clock.tick(pollInterval); + await waitForStatus('POLLING'); + expect(indexesStub.callCount).to.equal(5); + await waitForStatus('READY'); + + // clean up + store.dispatch(stopPollingRegularIndexes(tabId)); + + expect(collection.fetch.callCount).to.equal(0); + }); + }); }); diff --git a/packages/compass-indexes/src/modules/regular-indexes.ts b/packages/compass-indexes/src/modules/regular-indexes.ts index 8c4be684955..e51e1bcdcaa 100644 --- a/packages/compass-indexes/src/modules/regular-indexes.ts +++ b/packages/compass-indexes/src/modules/regular-indexes.ts @@ -1,12 +1,16 @@ +import { cloneDeep, isEqual, pick } from 'lodash'; import type { IndexDefinition } from 'mongodb-data-service'; import type { AnyAction } from 'redux'; import { openToast, showConfirmation as showConfirmationModal, } from '@mongodb-js/compass-components'; -import { cloneDeep } from 'lodash'; -import { isAction } from './../utils/is-action'; +import { FetchStatuses, NOT_FETCHABLE_STATUSES } from '../utils/fetch-status'; +import type { FetchStatus } from '../utils/fetch-status'; +import { FetchReasons } from '../utils/fetch-reason'; +import type { FetchReason } from '../utils/fetch-reason'; +import { isAction } from '../utils/is-action'; import { ActionTypes as CreateIndexActionTypes } from './create-index'; import type { CreateIndexSpec, @@ -14,7 +18,7 @@ import type { IndexCreationSucceededAction, IndexCreationFailedAction, } from './create-index'; -import type { IndexesThunkAction } from '.'; +import type { IndexesThunkAction, RootState } from '.'; import { hideModalDescription, unhideModalDescription, @@ -42,6 +46,9 @@ export type InProgressIndex = { }; export enum ActionTypes { + IndexesOpened = 'compass-indexes/regular-indexes/indexes-opened', + IndexesClosed = 'compass-indexes/regular-indexes/indexes-closed', + FetchIndexesStarted = 'compass-indexes/regular-indexes/fetch-indexes-started', FetchIndexesSucceeded = 'compass-indexes/regular-indexes/fetch-indexes-succeeded', FetchIndexesFailed = 'compass-indexes/regular-indexes/fetch-indexes-failed', @@ -52,9 +59,17 @@ export enum ActionTypes { FailedIndexRemoved = 'compass-indexes/regular-indexes/failed-index-removed', } +type IndexesOpenedAction = { + type: ActionTypes.IndexesOpened; +}; + +type IndexesClosedAction = { + type: ActionTypes.IndexesClosed; +}; + type FetchIndexesStartedAction = { type: ActionTypes.FetchIndexesStarted; - isRefreshing: boolean; + reason: FetchReason; }; type FetchIndexesSucceededAction = { @@ -74,29 +89,45 @@ type FailedIndexRemovedAction = { export type State = { indexes: RegularIndex[]; - isRefreshing: boolean; + status: FetchStatus; inProgressIndexes: InProgressIndex[]; - error: string | null; + error?: string; }; export const INITIAL_STATE: State = { + status: FetchStatuses.NOT_READY, indexes: [], inProgressIndexes: [], - isRefreshing: false, - error: null, + error: undefined, }; export default function reducer( state = INITIAL_STATE, action: AnyAction ): State { + if (isAction(action, ActionTypes.IndexesOpened)) { + return { + ...state, + }; + } + + if (isAction(action, ActionTypes.IndexesClosed)) { + return { + ...state, + }; + } + if ( isAction(action, ActionTypes.FetchIndexesStarted) ) { return { ...state, - error: null, - isRefreshing: action.isRefreshing, + status: + action.reason === FetchReasons.POLL + ? FetchStatuses.POLLING + : action.reason === FetchReasons.REFRESH + ? FetchStatuses.REFRESHING + : FetchStatuses.FETCHING, }; } @@ -116,7 +147,7 @@ export default function reducer( return { ...state, indexes: allIndexes, - isRefreshing: false, + status: FetchStatuses.READY, }; } @@ -125,8 +156,15 @@ export default function reducer( ) { return { ...state, - error: action.error, - isRefreshing: false, + // We do no set any error on poll or refresh and the + // previous list of indexes is shown to the user. + // If fetch fails for refresh or polling, set the status to READY again. + error: + state.status === FetchStatuses.FETCHING ? action.error : state.error, + status: + state.status === FetchStatuses.FETCHING + ? FetchStatuses.ERROR + : FetchStatuses.READY, }; } @@ -210,10 +248,10 @@ export default function reducer( } const fetchIndexesStarted = ( - isRefreshing: boolean + reason: FetchReason ): FetchIndexesStartedAction => ({ type: ActionTypes.FetchIndexesStarted, - isRefreshing, + reason, }); const fetchIndexesSucceeded = ( @@ -233,37 +271,114 @@ type FetchIndexesActions = | FetchIndexesSucceededAction | FetchIndexesFailedAction; -export const fetchIndexes = ( - isRefreshing = false +const collectionStatFields = ['name', 'size']; + +function pickCollectionStatFields(state: RootState) { + return state.regularIndexes.indexes.map((index) => + pick(index, collectionStatFields) + ); +} + +const fetchIndexes = ( + reason: FetchReason ): IndexesThunkAction, FetchIndexesActions> => { - return async (dispatch, getState, { dataService, localAppRegistry }) => { - const { isReadonlyView, namespace } = getState(); + return async (dispatch, getState, { dataService, collection }) => { + const { + isReadonlyView, + namespace, + regularIndexes: { status }, + } = getState(); if (isReadonlyView) { dispatch(fetchIndexesSucceeded([])); return; } + // If we are already fetching indexes, we will wait for that + if (NOT_FETCHABLE_STATUSES.includes(status)) { + return; + } + try { - dispatch(fetchIndexesStarted(isRefreshing)); - // This makes sure that when the user or something else triggers a - // re-fetch for the list of indexes with this action, the tab header also - // gets updated. - localAppRegistry.emit('refresh-collection-stats'); + dispatch(fetchIndexesStarted(reason)); const indexes = await dataService.indexes(namespace); + const indexesBefore = pickCollectionStatFields(getState()); dispatch(fetchIndexesSucceeded(indexes)); + const indexesAfter = pickCollectionStatFields(getState()); + if ( + reason !== FetchReasons.INITIAL_FETCH && + !isEqual(indexesBefore, indexesAfter) + ) { + // This makes sure that when the user or something else triggers a + // re-fetch for the list of indexes with this action and the total + // changed, the tab header also gets updated. The check against the + // total is a bit of an optimisation so that we don't also poll the + // collection stats. + collection.fetch({ dataService, force: true }).catch(() => { + /* ignore */ + }); + } } catch (err) { dispatch(fetchIndexesFailed((err as Error).message)); } }; }; +export const fetchRegularIndexes = (): IndexesThunkAction< + Promise, + FetchIndexesActions +> => { + return async (dispatch) => { + await dispatch(fetchIndexes(FetchReasons.INITIAL_FETCH)); + }; +}; export const refreshRegularIndexes = (): IndexesThunkAction< Promise, FetchIndexesActions > => { return async (dispatch) => { - return dispatch(fetchIndexes(true)); + await dispatch(fetchIndexes(FetchReasons.REFRESH)); + }; +}; + +export const pollRegularIndexes = (): IndexesThunkAction< + Promise, + FetchIndexesActions +> => { + return async (dispatch) => { + return await dispatch(fetchIndexes(FetchReasons.POLL)); + }; +}; + +export const POLLING_INTERVAL = 5000; + +const pollIntervalByTabId = new Map>(); + +export const startPollingRegularIndexes = ( + tabId: string +): IndexesThunkAction => { + return function (dispatch) { + if (pollIntervalByTabId.has(tabId)) { + return; + } + + pollIntervalByTabId.set( + tabId, + setInterval(() => { + void dispatch(pollRegularIndexes()); + }, POLLING_INTERVAL) + ); + }; +}; + +export const stopPollingRegularIndexes = (tabId: string) => { + return () => { + if (!pollIntervalByTabId.has(tabId)) { + return; + } + + clearInterval(pollIntervalByTabId.get(tabId)); + pollIntervalByTabId.delete(tabId); }; }; @@ -304,7 +419,7 @@ export const dropIndex = ( // By fetching the indexes again we make sure the merged list doesn't have // it either. - await dispatch(fetchIndexes()); + await dispatch(fetchIndexes(FetchReasons.REFRESH)); return; } @@ -329,7 +444,7 @@ export const dropIndex = ( title: `Index "${indexName}" dropped`, timeout: 3000, }); - await dispatch(fetchIndexes(true)); + await dispatch(fetchIndexes(FetchReasons.REFRESH)); } catch (err) { openToast('drop-index-error', { variant: 'important', @@ -362,7 +477,7 @@ export const hideIndex = ( hidden: true, }, }); - await dispatch(fetchIndexes()); + await dispatch(fetchIndexes(FetchReasons.REFRESH)); } catch (error) { openToast('hide-index-error', { title: 'Failed to hide the index', @@ -396,7 +511,7 @@ export const unhideIndex = ( hidden: false, }, }); - await dispatch(fetchIndexes()); + await dispatch(fetchIndexes(FetchReasons.REFRESH)); } catch (error) { openToast('unhide-index-error', { title: 'Failed to unhide the index', diff --git a/packages/compass-indexes/src/modules/search-indexes.spec.ts b/packages/compass-indexes/src/modules/search-indexes.spec.ts index 2484425dc32..6556a8f8da3 100644 --- a/packages/compass-indexes/src/modules/search-indexes.spec.ts +++ b/packages/compass-indexes/src/modules/search-indexes.spec.ts @@ -1,6 +1,8 @@ import { expect } from 'chai'; +import { waitFor } from '@mongodb-js/testing-library-compass'; +import type { FetchStatus } from '../utils/fetch-status'; +import { FetchStatuses } from '../utils/fetch-status'; import { - SearchIndexesStatuses, createSearchIndexClosed, createSearchIndexOpened, createIndex, @@ -9,11 +11,13 @@ import { updateSearchIndexOpened, updateSearchIndexClosed, updateIndex, + startPollingSearchIndexes, + stopPollingSearchIndexes, } from './search-indexes'; -import { setupStore } from '../../test/setup-store'; +import { setupStoreAndWait } from '../../test/setup-store'; import { searchIndexes } from '../../test/fixtures/search-indexes'; import sinon from 'sinon'; -import type { IndexesDataService } from '../stores/store'; +import type { IndexesDataService, IndexesStore } from '../stores/store'; import { readonlyViewChanged } from './is-readonly-view'; // Importing this to stub showConfirmation @@ -21,14 +25,14 @@ import * as searchIndexesSlice from './search-indexes'; import { writeStateChanged } from './is-writable'; describe('search-indexes module', function () { - let store: ReturnType; + let store: IndexesStore; let dataProvider: Partial; let createSearchIndexStub: sinon.SinonStub; let updateSearchIndexStub: sinon.SinonStub; let getSearchIndexesStub: sinon.SinonStub; let dropSearchIndexStub: sinon.SinonStub; - beforeEach(function () { + beforeEach(async function () { createSearchIndexStub = sinon.stub().resolves('foo'); updateSearchIndexStub = sinon.stub().resolves(); getSearchIndexesStub = sinon.stub().resolves(searchIndexes); @@ -40,7 +44,7 @@ describe('search-indexes module', function () { dropSearchIndex: dropSearchIndexStub, }; - store = setupStore( + store = await setupStoreAndWait( { namespace: 'citibike.trips', isSearchIndexesSupported: true, @@ -49,54 +53,58 @@ describe('search-indexes module', function () { ); }); - it('has not available search indexes state by default', function () { - store = setupStore(); + it('has not available search indexes state by default', async function () { + store = await setupStoreAndWait({ isSearchIndexesSupported: false }); expect(store.getState().searchIndexes.status).to.equal( - SearchIndexesStatuses.NOT_AVAILABLE + FetchStatuses.NOT_READY ); }); context('#refreshSearchIndexes action', function () { - it('does nothing if isReadonlyView is true', function () { + it('does nothing if isReadonlyView is true', async function () { + // already loaded once + expect(store.getState().isReadonlyView).to.equal(false); + expect(getSearchIndexesStub.callCount).to.equal(1); + store.dispatch(readonlyViewChanged(true)); expect(store.getState().isReadonlyView).to.equal(true); - expect(getSearchIndexesStub.callCount).to.equal(0); + expect(getSearchIndexesStub.callCount).to.equal(1); - store.dispatch(refreshSearchIndexes); + await store.dispatch(refreshSearchIndexes()); - expect(getSearchIndexesStub.callCount).to.equal(0); - expect(store.getState().searchIndexes.status).to.equal('NOT_READY'); + expect(getSearchIndexesStub.callCount).to.equal(1); + expect(store.getState().searchIndexes.status).to.equal('READY'); }); - it('does nothing if isWritable is false (offline mode)', function () { + it('does nothing if isWritable is false (offline mode)', async function () { + // already loaded once + expect(store.getState().isWritable).to.equal(true); + expect(getSearchIndexesStub.callCount).to.equal(1); + store.dispatch(writeStateChanged(false)); expect(store.getState().isWritable).to.equal(false); - expect(getSearchIndexesStub.callCount).to.equal(0); + expect(getSearchIndexesStub.callCount).to.equal(1); - store.dispatch(refreshSearchIndexes); + await store.dispatch(refreshSearchIndexes()); - expect(getSearchIndexesStub.callCount).to.equal(0); - expect(store.getState().searchIndexes.status).to.equal('NOT_READY'); + expect(getSearchIndexesStub.callCount).to.equal(1); + expect(store.getState().searchIndexes.status).to.equal('READY'); }); it('fetches the indexes', async function () { - expect(getSearchIndexesStub.callCount).to.equal(0); - expect(store.getState().searchIndexes.status).to.equal('NOT_READY'); + // already loaded once + expect(store.getState().searchIndexes.status).to.equal('READY'); + expect(getSearchIndexesStub.callCount).to.equal(1); await store.dispatch(refreshSearchIndexes()); - expect(getSearchIndexesStub.callCount).to.equal(1); + expect(getSearchIndexesStub.callCount).to.equal(2); expect(store.getState().searchIndexes.status).to.equal('READY'); }); - it('sets the status to REFRESHING if the status is READY', async function () { - expect(getSearchIndexesStub.callCount).to.equal(0); - expect(store.getState().searchIndexes.status).to.equal('NOT_READY'); - - await store.dispatch(refreshSearchIndexes()); - + it('sets the status to REFRESHING if the status is READY', function () { expect(getSearchIndexesStub.callCount).to.equal(1); expect(store.getState().searchIndexes.status).to.equal('READY'); @@ -138,11 +146,13 @@ describe('search-indexes module', function () { ]); }); - it('sets the status to ERROR if loading the indexes fails', async function () { - // replace the stub - getSearchIndexesStub.rejects(new Error('this is an error')); - - await store.dispatch(refreshSearchIndexes()); + it('sets the status to ERROR if initial loading of the indexes fails', async function () { + const getSearchIndexesStub = sinon + .stub() + .rejects(new Error('this is an error')); + store = await setupStoreAndWait(undefined, { + getSearchIndexes: getSearchIndexesStub, + }); expect(store.getState().searchIndexes.status).to.equal('ERROR'); expect(store.getState().searchIndexes.error).to.equal('this is an error'); @@ -269,6 +279,87 @@ describe('search-indexes module', function () { expect(store.getState().searchIndexes.error).to.be.undefined; }); }); + + describe('startPollingSearchIndexes and stopPollingSearchIndexes', function () { + let clock: sinon.SinonFakeTimers; + + after(() => { + clock.restore(); + }); + + it('starts and stops the polling', async function () { + const pollInterval = 5000; + const tabId = 'my-tab'; + + const getSearchIndexesStub = sinon.stub().resolves(searchIndexes); + const store = await setupStoreAndWait( + { + isSearchIndexesSupported: true, + }, + { + getSearchIndexes: getSearchIndexesStub, + } + ); + + clock = sinon.useFakeTimers(); + + const waitForStatus = async (status: FetchStatus) => { + await waitFor(() => { + expect(store.getState().searchIndexes.status).to.eq(status); + }); + }; + + // before we start + expect(store.getState().searchIndexes.status).to.equal('READY'); + + // initial load + expect(getSearchIndexesStub.callCount).to.equal(1); + + store.dispatch(startPollingSearchIndexes(tabId)); + + // poll + clock.tick(pollInterval); + await waitForStatus('POLLING'); + expect(getSearchIndexesStub.callCount).to.equal(2); + await waitForStatus('READY'); + + // poll + clock.tick(pollInterval); + await waitForStatus('POLLING'); + expect(getSearchIndexesStub.callCount).to.equal(3); + await waitForStatus('READY'); + + // stop + store.dispatch(stopPollingSearchIndexes(tabId)); + + // no more polling + clock.tick(pollInterval); + expect(getSearchIndexesStub.callCount).to.equal(3); + await waitForStatus('READY'); + + // open again + store.dispatch(startPollingSearchIndexes(tabId)); + + // won't execute immediately + expect(getSearchIndexesStub.callCount).to.equal(3); + await waitForStatus('READY'); + + // does poll after the interval + clock.tick(pollInterval); + await waitForStatus('POLLING'); + expect(getSearchIndexesStub.callCount).to.equal(4); + await waitForStatus('READY'); + + // and again + clock.tick(pollInterval); + await waitForStatus('POLLING'); + expect(getSearchIndexesStub.callCount).to.equal(5); + await waitForStatus('READY'); + + // clean up + store.dispatch(stopPollingSearchIndexes(tabId)); + }); + }); }); describe('#getInitialVectorSearchIndexPipelineText', function () { diff --git a/packages/compass-indexes/src/modules/search-indexes.ts b/packages/compass-indexes/src/modules/search-indexes.ts index ece3e51d8cf..8020d10f8df 100644 --- a/packages/compass-indexes/src/modules/search-indexes.ts +++ b/packages/compass-indexes/src/modules/search-indexes.ts @@ -7,7 +7,13 @@ import { import type { Document } from 'mongodb'; import type { SearchIndex } from 'mongodb-data-service'; -import { isAction } from './../utils/is-action'; +import { isAction } from '../utils/is-action'; +import { FetchStatuses, NOT_FETCHABLE_STATUSES } from '../utils/fetch-status'; +import type { FetchStatus } from '../utils/fetch-status'; + +import { FetchReasons } from '../utils/fetch-reason'; +import type { FetchReason } from '../utils/fetch-reason'; + import type { IndexesThunkAction } from '.'; import { switchToSearchIndexes } from './index-view'; import type { IndexViewChangedAction } from './index-view'; @@ -18,48 +24,6 @@ const ATLAS_SEARCH_SERVER_ERRORS: Record = { 'This index name is already in use. Please choose another one.', }; -export enum SearchIndexesStatuses { - /** - * No support. Default status. - */ - NOT_AVAILABLE = 'NOT_AVAILABLE', - /** - * We do not have list yet. - */ - NOT_READY = 'NOT_READY', - /** - * We have list of indexes. - */ - READY = 'READY', - /** - * We are fetching the list first time. - */ - FETCHING = 'FETCHING', - /** - * We are refreshing the list. - */ - REFRESHING = 'REFRESHING', - /** - * We are polling the list. - */ - POLLING = 'POLLING', - /** - * Loading the list failed. - */ - ERROR = 'ERROR', -} - -export type SearchIndexesStatus = keyof typeof SearchIndexesStatuses; - -// List of SearchIndex statuses when server should not be called -// to avoid multiple requests. -const NOT_FETCHABLE_STATUSES: SearchIndexesStatus[] = [ - 'NOT_AVAILABLE', - 'FETCHING', - 'POLLING', - 'REFRESHING', -]; - export enum ActionTypes { // Fetch indexes FetchSearchIndexesStarted = 'compass-indexes/search-indexes/fetch-search-indexes-started', @@ -83,7 +47,7 @@ export enum ActionTypes { type FetchSearchIndexesStartedAction = { type: ActionTypes.FetchSearchIndexesStarted; - status: 'REFRESHING' | 'POLLING' | 'FETCHING'; + reason: FetchReason; }; type FetchSearchIndexesSucceededAction = { @@ -96,7 +60,7 @@ type FetchSearchIndexesFailedAction = { error: string; }; -type CreateSearchIndexOpenedAction = { +export type CreateSearchIndexOpenedAction = { type: ActionTypes.CreateSearchIndexOpened; }; @@ -153,7 +117,7 @@ type UpdateSearchIndexState = { }; export type State = { - status: SearchIndexesStatus; + status: FetchStatus; createIndex: CreateSearchIndexState; updateIndex: UpdateSearchIndexState; error?: string; @@ -161,7 +125,7 @@ export type State = { }; export const INITIAL_STATE: State = { - status: SearchIndexesStatuses.NOT_AVAILABLE, + status: FetchStatuses.NOT_READY, createIndex: { isModalOpen: false, isBusy: false, @@ -346,7 +310,12 @@ export default function reducer( ) { return { ...state, - status: action.status, + status: + action.reason === FetchReasons.POLL + ? FetchStatuses.POLLING + : action.reason === FetchReasons.REFRESH + ? FetchStatuses.REFRESHING + : FetchStatuses.FETCHING, }; } @@ -359,7 +328,7 @@ export default function reducer( return { ...state, indexes: action.indexes, - status: SearchIndexesStatuses.READY, + status: FetchStatuses.READY, }; } @@ -375,12 +344,11 @@ export default function reducer( // previous list of indexes is shown to the user. // If fetch fails for refresh or polling, set the status to READY again. error: - state.status === SearchIndexesStatuses.FETCHING - ? action.error - : undefined, - status: SearchIndexesStatuses.FETCHING - ? SearchIndexesStatuses.ERROR - : SearchIndexesStatuses.READY, + state.status === FetchStatuses.FETCHING ? action.error : state.error, + status: + state.status === FetchStatuses.FETCHING + ? FetchStatuses.ERROR + : FetchStatuses.READY, }; } @@ -407,10 +375,10 @@ export const updateSearchIndexClosed = (): UpdateSearchIndexClosedAction => ({ }); const fetchSearchIndexesStarted = ( - status: 'REFRESHING' | 'POLLING' | 'FETCHING' + reason: FetchReason ): FetchSearchIndexesStartedAction => ({ type: ActionTypes.FetchSearchIndexesStarted, - status, + reason, }); const fetchSearchIndexesSucceeded = ( @@ -457,6 +425,38 @@ const updateSearchIndexSucceeded = (): UpdateSearchIndexSucceededAction => ({ type: ActionTypes.UpdateSearchIndexSucceeded, }); +export const POLLING_INTERVAL = 5000; + +const pollIntervalByTabId = new Map>(); + +export const startPollingSearchIndexes = ( + tabId: string +): IndexesThunkAction => { + return function (dispatch) { + if (pollIntervalByTabId.has(tabId)) { + return; + } + + pollIntervalByTabId.set( + tabId, + setInterval(() => { + void dispatch(pollSearchIndexes()); + }, POLLING_INTERVAL) + ); + }; +}; + +export const stopPollingSearchIndexes = (tabId: string) => { + return () => { + if (!pollIntervalByTabId.has(tabId)) { + return; + } + + clearInterval(pollIntervalByTabId.get(tabId)); + pollIntervalByTabId.delete(tabId); + }; +}; + export const createIndex = ({ name, type, @@ -523,7 +523,7 @@ export const createIndex = ({ }); dispatch(switchToSearchIndexes()); - await dispatch(fetchIndexes(SearchIndexesStatuses.REFRESHING)); + await dispatch(fetchIndexes(FetchReasons.REFRESH)); }; }; @@ -576,7 +576,7 @@ export const updateIndex = ({ timeout: 5000, variant: 'progress', }); - await dispatch(fetchIndexes(SearchIndexesStatuses.REFRESHING)); + await dispatch(fetchIndexes(FetchReasons.REFRESH)); } catch (e) { const error = (e as Error).message; dispatch( @@ -593,7 +593,7 @@ type FetchSearchIndexesActions = | FetchSearchIndexesFailedAction; const fetchIndexes = ( - newStatus: 'REFRESHING' | 'POLLING' | 'FETCHING' + reason: FetchReason ): IndexesThunkAction, FetchSearchIndexesActions> => { return async (dispatch, getState, { dataService }) => { const { @@ -607,14 +607,13 @@ const fetchIndexes = ( return; } - // If we are currently doing fetching indexes, we will - // wait for that + // If we are already fetching indexes, we will wait for that if (NOT_FETCHABLE_STATUSES.includes(status)) { return; } try { - dispatch(fetchSearchIndexesStarted(newStatus)); + dispatch(fetchSearchIndexesStarted(reason)); const indexes = await dataService.getSearchIndexes(namespace); dispatch(fetchSearchIndexesSucceeded(indexes)); } catch (err) { @@ -623,20 +622,21 @@ const fetchIndexes = ( }; }; +export const fetchSearchIndexes = (): IndexesThunkAction< + Promise, + FetchSearchIndexesActions +> => { + return async (dispatch) => { + await dispatch(fetchIndexes(FetchReasons.INITIAL_FETCH)); + }; +}; + export const refreshSearchIndexes = (): IndexesThunkAction< Promise, FetchSearchIndexesActions > => { - return async (dispatch, getState) => { - const { status } = getState().searchIndexes; - - // If we are in a READY state, then we have already fetched the data - // and are refreshing the list. - const newStatus: SearchIndexesStatus = - status === SearchIndexesStatuses.READY - ? SearchIndexesStatuses.REFRESHING - : SearchIndexesStatuses.FETCHING; - await dispatch(fetchIndexes(newStatus)); + return async (dispatch) => { + await dispatch(fetchIndexes(FetchReasons.REFRESH)); }; }; @@ -645,7 +645,7 @@ export const pollSearchIndexes = (): IndexesThunkAction< FetchSearchIndexesActions > => { return async (dispatch) => { - return await dispatch(fetchIndexes(SearchIndexesStatuses.POLLING)); + return await dispatch(fetchIndexes(FetchReasons.POLL)); }; }; @@ -690,7 +690,7 @@ export const dropSearchIndex = ( timeout: 5000, variant: 'progress', }); - await dispatch(fetchIndexes(SearchIndexesStatuses.REFRESHING)); + await dispatch(fetchIndexes(FetchReasons.REFRESH)); } catch (e) { openToast('search-index-delete-failed', { title: `Failed to drop index.`, diff --git a/packages/compass-indexes/src/stores/store.ts b/packages/compass-indexes/src/stores/store.ts index 529a98a51cb..5614cbe178a 100644 --- a/packages/compass-indexes/src/stores/store.ts +++ b/packages/compass-indexes/src/stores/store.ts @@ -7,11 +7,9 @@ import { writeStateChanged } from '../modules/is-writable'; import { getDescription } from '../modules/description'; import { INITIAL_STATE as INDEX_LIST_INITIAL_STATE } from '../modules/index-view'; import { createIndexOpened } from '../modules/create-index'; -import { fetchIndexes } from '../modules/regular-indexes'; +import { fetchRegularIndexes } from '../modules/regular-indexes'; import { - INITIAL_STATE as SEARCH_INDEXES_INITIAL_STATE, - refreshSearchIndexes, - SearchIndexesStatuses, + fetchSearchIndexes, createSearchIndexOpened, } from '../modules/search-indexes'; import type { DataService } from 'mongodb-data-service'; @@ -28,6 +26,8 @@ import { collectionStatsFetched, extractCollectionStats, } from '../modules/collection-stats'; +import type { AtlasService } from '@mongodb-js/atlas-service/provider'; +import { RollingIndexesService } from '../modules/rolling-indexes-service'; export type IndexesDataServiceProps = | 'indexes' @@ -55,6 +55,7 @@ export type IndexesPluginServices = { logger: Logger; collection: Collection; track: TrackFunction; + atlasService: AtlasService; }; export type IndexesPluginOptions = { @@ -79,6 +80,7 @@ export function activateIndexesPlugin( track, dataService, collection: collectionModel, + atlasService, }: IndexesPluginServices, { on, cleanup }: ActivateHelpers ) { @@ -90,13 +92,8 @@ export function activateIndexesPlugin( namespace: options.namespace, serverVersion: options.serverVersion, isReadonlyView: options.isReadonly, + isSearchIndexesSupported: options.isSearchIndexesSupported, indexView: INDEX_LIST_INITIAL_STATE, - searchIndexes: { - ...SEARCH_INDEXES_INITIAL_STATE, - status: options.isSearchIndexesSupported - ? SearchIndexesStatuses.NOT_READY - : SearchIndexesStatuses.NOT_AVAILABLE, - }, collectionStats: extractCollectionStats(collectionModel), }, applyMiddleware( @@ -107,6 +104,11 @@ export function activateIndexesPlugin( track, connectionInfoRef, dataService, + collection: collectionModel, + rollingIndexesService: new RollingIndexesService( + atlasService, + connectionInfoRef + ), }) ) ); @@ -120,8 +122,10 @@ export function activateIndexesPlugin( }); on(globalAppRegistry, 'refresh-data', () => { - void store.dispatch(fetchIndexes()); - void store.dispatch(refreshSearchIndexes()); + void store.dispatch(fetchRegularIndexes()); + if (options.isSearchIndexesSupported) { + void store.dispatch(fetchRegularIndexes()); + } }); // these can change later @@ -132,15 +136,15 @@ export function activateIndexesPlugin( store.dispatch(getDescription(instance.description)); }); + void store.dispatch(fetchRegularIndexes()); + if (options.isSearchIndexesSupported) { + void store.dispatch(fetchSearchIndexes()); + } on(collectionModel, 'change:status', (model: Collection, status: string) => { if (status === 'ready') { store.dispatch(collectionStatsFetched(model)); } }); - on(localAppRegistry, 'refresh-collection-stats', () => { - void collectionModel.fetch({ dataService, force: true }); - }); - return { store, deactivate: () => cleanup() }; } diff --git a/packages/compass-indexes/src/utils/fetch-reason.ts b/packages/compass-indexes/src/utils/fetch-reason.ts new file mode 100644 index 00000000000..ca4f800fcca --- /dev/null +++ b/packages/compass-indexes/src/utils/fetch-reason.ts @@ -0,0 +1,7 @@ +export enum FetchReasons { + INITIAL_FETCH = 'INITIAL_FETCH', + REFRESH = 'REFRESH', + POLL = 'POLL', +} + +export type FetchReason = keyof typeof FetchReasons; diff --git a/packages/compass-indexes/src/utils/fetch-status.ts b/packages/compass-indexes/src/utils/fetch-status.ts new file mode 100644 index 00000000000..c43cb762e65 --- /dev/null +++ b/packages/compass-indexes/src/utils/fetch-status.ts @@ -0,0 +1,39 @@ +export enum FetchStatuses { + /** + * We do not have a list yet. + */ + NOT_READY = 'NOT_READY', + /** + * We have a list of indexes. + */ + READY = 'READY', + /** + * We are fetching the list for first time. + */ + FETCHING = 'FETCHING', + /** + * We are refreshing the list. + */ + REFRESHING = 'REFRESHING', + /** + * We are polling the list. + */ + POLLING = 'POLLING', + /** + * Loading the list failed. + */ + ERROR = 'ERROR', +} + +export type FetchStatus = keyof typeof FetchStatuses; + +// Any the status which means we're busy fetching the list one way or another +export type FetchingStatus = 'REFRESHING' | 'POLLING' | 'FETCHING'; + +// List of fetch statuses when the server should not be called to avoid multiple +// requests. +export const NOT_FETCHABLE_STATUSES: FetchStatus[] = [ + 'FETCHING', + 'POLLING', + 'REFRESHING', +]; diff --git a/packages/compass-indexes/test/setup-store.ts b/packages/compass-indexes/test/setup-store.ts index 5252de87adf..2b8a7676ece 100644 --- a/packages/compass-indexes/test/setup-store.ts +++ b/packages/compass-indexes/test/setup-store.ts @@ -5,12 +5,16 @@ import type { IndexesDataService, IndexesPluginOptions, IndexesPluginServices, + IndexesStore, } from '../src/stores/store'; import { activateIndexesPlugin } from '../src/stores/store'; import { createActivateHelpers } from 'hadron-app-registry'; import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider'; import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; +import { waitFor } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import type { AtlasService } from '@mongodb-js/atlas-service/provider'; const NOOP_DATA_PROVIDER: IndexesDataService = { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -100,16 +104,20 @@ const defaultMetadata = { isSearchIndexesSupported: false, sourceName: 'test.bar', }; -const mockCollection = { - _id: defaultMetadata.namespace, - fetchMetadata() { - return Promise.resolve(defaultMetadata); - }, - toJSON() { - return this; - }, - on: Sinon.spy(), -}; + +export function createMockCollection() { + return { + _id: defaultMetadata.namespace, + fetch: Sinon.spy(), + fetchMetadata() { + return Promise.resolve(defaultMetadata); + }, + toJSON() { + return this; + }, + on: Sinon.spy(), + } as any; +} export const setupStore = ( options: Partial = {}, @@ -132,12 +140,14 @@ export const setupStore = ( }, } as ConnectionInfoRef; + const atlasService = {} as AtlasService; + return activateIndexesPlugin( { namespace: 'citibike.trips', serverVersion: '6.0.0', isReadonly: false, - isSearchIndexesSupported: false, + isSearchIndexesSupported: true, ...options, }, { @@ -147,10 +157,32 @@ export const setupStore = ( instance: fakeInstance as any, logger: createNoopLogger('TEST'), track: createNoopTrack(), - collection: mockCollection as any, + collection: createMockCollection(), connectionInfoRef, + atlasService, ...services, }, createActivateHelpers() ).store; }; + +export async function setupStoreAndWait( + options?: Partial, + dataProvider?: Partial, + services?: Partial +): Promise { + const store = setupStore(options, dataProvider, services); + await waitFor(() => { + expect(store.getState().regularIndexes.status).to.be.oneOf([ + 'READY', + 'ERROR', + ]); + if (store.getState().isSearchIndexesSupported) { + expect(store.getState().searchIndexes.status).to.be.oneOf([ + 'READY', + 'ERROR', + ]); + } + }); + return store; +} diff --git a/packages/compass-workspaces/src/provider.tsx b/packages/compass-workspaces/src/provider.tsx index 463841f3497..fb9bcca6211 100644 --- a/packages/compass-workspaces/src/provider.tsx +++ b/packages/compass-workspaces/src/provider.tsx @@ -363,7 +363,10 @@ export const workspacesServiceLocator = createServiceLocator( ); export { useWorkspacePlugins } from './components/workspaces-provider'; -export { useTabState } from './components/workspace-tab-state-provider'; +export { + useWorkspaceTabId, + useTabState, +} from './components/workspace-tab-state-provider'; export { useOnTabClose, useOnTabReplace,