diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index 38151f82351..6d38308bf1d 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -918,10 +918,10 @@ export const explainPlanSummaryStat = ( // Indexes tab export const IndexList = '[data-testid="indexes-list"]'; export const indexComponent = (name: string): string => { - return `[data-testid="index-row-${name}"]`; + return `[data-testid="indexes-row-${name}"]`; }; -export const IndexFieldName = '[data-testid="index-name-field"]'; -export const IndexFieldType = '[data-testid="index-type-field"]'; +export const IndexFieldName = '[data-testid="indexes-name-field"]'; +export const IndexFieldType = '[data-testid="indexes-type-field"]'; export const IndexToggleOptions = '[data-testid="create-index-modal-toggle-options"]'; export const indexToggleOption = (fieldName: string) => { 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 08dbf46ac33..885a2a967ce 100644 --- a/packages/compass-indexes/src/components/indexes-table/indexes-table.tsx +++ b/packages/compass-indexes/src/components/indexes-table/indexes-table.tsx @@ -12,7 +12,7 @@ import { useDOMRect, } from '@mongodb-js/compass-components'; -type SortDirection = 'asc' | 'desc'; +import type { SortDirection } from '../../modules'; // When row is hovered, we show the delete button const rowStyles = css({ @@ -142,7 +142,7 @@ export function IndexesTable({ const _columns = sortColumns.map((name) => { return ( ({ return ( {info.fields.map((field) => { return ( {field.children} @@ -194,7 +194,7 @@ export function IndexesTable({ {/* Index actions column is conditional */} {canModifyIndex && ( {info.actions && ( 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 18c14092415..f0d7df8a9fe 100644 --- a/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx +++ b/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx @@ -1,4 +1,7 @@ import React, { useCallback } from 'react'; +import { connect } from 'react-redux'; +import type AppRegistry from 'hadron-app-registry'; +import { withPreferences } from 'compass-preferences-model'; import { Button, ErrorSummary, @@ -15,9 +18,11 @@ import { SegmentedControl, SegmentedControlOption, } from '@mongodb-js/compass-components'; -import type AppRegistry from 'hadron-app-registry'; import { usePreference } from 'compass-preferences-model'; +import type { RootState } from '../../modules'; +import { SearchIndexesStatuses } from '../../modules/search-indexes'; + const containerStyles = css({ margin: `${spacing[3]}px 0`, }); @@ -45,16 +50,21 @@ const createIndexButtonContainerStyles = css({ export type IndexView = 'regular-indexes' | 'search-indexes'; type IndexesToolbarProps = { + // passed props: errorMessage: string | null; + hasTooManyIndexes: boolean; + isRefreshing: boolean; + onRefreshIndexes: () => void; + onChangeIndexView: (newView: IndexView) => void; + + // connected: isReadonlyView: boolean; isWritable: boolean; - hasTooManyIndexes: boolean; localAppRegistry: AppRegistry; - isRefreshing: boolean; writeStateDescription?: string; isAtlasSearchSupported: boolean; - onRefreshIndexes: () => void; - onChangeIndexView: (newView: IndexView) => void; + + // via withPreferences: readOnly?: boolean; }; @@ -101,7 +111,7 @@ export const IndexesToolbar: React.FunctionComponent = ({ ); return ( -
+
{!isReadonlyView && (
@@ -266,3 +276,27 @@ export const CreateIndexButton: React.FunctionComponent< ); }; + +const mapState = ({ + isWritable, + isReadonlyView, + description, + serverVersion, + appRegistry, + searchIndexes, +}: RootState) => ({ + isWritable, + isReadonlyView, + writeStateDescription: description, + localAppRegistry: (appRegistry as any).localAppRegistry, + serverVersion, + isAtlasSearchSupported: + searchIndexes.status !== SearchIndexesStatuses.NOT_AVAILABLE, +}); + +const mapDispatch = {}; + +export default connect( + mapState, + mapDispatch +)(withPreferences(IndexesToolbar, ['readOnly'], React)); diff --git a/packages/compass-indexes/src/components/indexes/indexes.spec.tsx b/packages/compass-indexes/src/components/indexes/indexes.spec.tsx index 0a823475ae5..8a07c061060 100644 --- a/packages/compass-indexes/src/components/indexes/indexes.spec.tsx +++ b/packages/compass-indexes/src/components/indexes/indexes.spec.tsx @@ -1,40 +1,60 @@ import React from 'react'; -import { cleanup, render, screen, within } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { + cleanup, + render, + screen, + within, + fireEvent, + waitFor, +} from '@testing-library/react'; import { expect } from 'chai'; -import AppRegistry from 'hadron-app-registry'; -import type { IndexDefinition } from '../../modules/regular-indexes'; -import { Indexes } from './indexes'; - -const renderIndexes = ( - props: Partial> = {} -) => { - const appRegistry = new AppRegistry(); +import sinon from 'sinon'; +import preferencesAccess from 'compass-preferences-model'; +import type { RegularIndex } from '../../modules/regular-indexes'; +import type { SearchIndex } from 'mongodb-data-service'; +import type Store from '../../stores'; +import type { IndexesDataService } from '../../stores/store'; +import Indexes from './indexes'; +import { setupStore } from '../../../test/setup-store'; + +const renderIndexes = (props: Partial = {}) => { + const store = setupStore(); + + const allProps: Partial = { + regularIndexes: { indexes: [], error: null, isRefreshing: false }, + searchIndexes: { indexes: [], error: null, status: 'PENDING' }, + ...props, + }; + + Object.assign(store.getState(), allProps); + render( - {}} - refreshIndexes={() => {}} - dropFailedIndex={() => {}} - onHideIndex={() => {}} - onUnhideIndex={() => {}} - isAtlasSearchSupported={false} - {...props} - /> + + + ); + + return store; }; describe('Indexes Component', function () { before(cleanup); afterEach(cleanup); + let sandbox: sinon.SinonSandbox; + + afterEach(function () { + return sandbox.restore(); + }); + + beforeEach(function () { + sandbox = sinon.createSandbox(); + sandbox.stub(preferencesAccess, 'getPreferences').returns({ + enableAtlasSearchIndexManagement: true, + } as any); + }); + it('renders indexes card', function () { renderIndexes(); expect(screen.getByTestId('indexes')).to.exist; @@ -45,192 +65,266 @@ describe('Indexes Component', function () { expect(screen.getByTestId('indexes-toolbar')).to.exist; }); - it('does not render indexes toolbar when its a readonly view', function () { + it('renders indexes toolbar when there is a regular indexes error', function () { renderIndexes({ - indexes: [], - isReadonlyView: true, - error: undefined, + regularIndexes: { + indexes: [], + error: 'Some random error', + isRefreshing: false, + }, }); - expect(() => { - screen.getByTestId('indexes-toolbar'); - }).to.throw; + expect(screen.getByTestId('indexes-toolbar')).to.exist; + // TODO: actually check for the error }); - it('renders indexes toolbar when there is an error', function () { - renderIndexes({ - indexes: [], - isReadonlyView: false, - error: 'Some random error', + it('renders indexes toolbar when there is a search indexes error', async function () { + const store = renderIndexes(); + + // the component will load the search indexes the moment we switch to them + store.getState()!.dataService!.getSearchIndexes = function () { + return Promise.reject(new Error('This is an error.')); + }; + + const toolbar = screen.getByTestId('indexes-toolbar'); + expect(toolbar).to.exist; + + // switch to the Search Indexes tab + const button = within(toolbar).getByText('Search Indexes'); + fireEvent.click(button); + + // the error message should show up next to the toolbar + const container = screen.getByTestId('indexes-toolbar-container'); + await waitFor(() => { + expect(within(container).getByText('This is an error.')).to.exist; }); - expect(screen.getByTestId('indexes-toolbar')).to.exist; }); - it('does not render indexes list when its a readonly view', function () { + it('does not render the indexes list if isReadonlyView is true', function () { renderIndexes({ - indexes: [], + regularIndexes: { + indexes: [], + }, isReadonlyView: true, - error: undefined, }); - expect(() => { - screen.getByTestId('indexes-list'); - }).to.throw; - }); - it('does not render indexes list when there is an error', function () { - renderIndexes({ - indexes: [], - isReadonlyView: false, - error: 'Some random error', - }); expect(() => { screen.getByTestId('indexes-list'); }).to.throw; }); - it('renders indexes list', function () { - renderIndexes({ - indexes: [ - { - ns: 'db.coll', - cardinality: 'single', - name: '_id_', - size: 12, - relativeSize: 20, - type: 'hashed', - extra: {}, - properties: ['unique'], - fields: [ + context('regular indexes', function () { + it('renders indexes list', function () { + renderIndexes({ + regularIndexes: { + indexes: [ { - field: '_id', - value: 1, + ns: 'db.coll', + cardinality: 'single', + name: '_id_', + size: 12, + relativeSize: 20, + type: 'hashed', + extra: {}, + properties: ['unique'], + fields: [ + { + field: '_id', + value: 1, + }, + ], + usageCount: 20, }, - ], - usageCount: 20, + ] as RegularIndex[], + error: null, + isRefreshing: false, }, - ] as IndexDefinition[], - isReadonlyView: false, - error: undefined, - }); + }); - const indexesList = screen.getByTestId('indexes-list'); - expect(indexesList).to.exist; - expect(within(indexesList).getByTestId('index-row-_id_')).to.exist; - }); + const indexesList = screen.getByTestId('indexes-list'); + expect(indexesList).to.exist; + expect(within(indexesList).getByTestId('indexes-row-_id_')).to.exist; + }); - it('renders indexes list with in progress index', function () { - renderIndexes({ - indexes: [ - { - ns: 'db.coll', - cardinality: 'single', - name: '_id_', - size: 12, - relativeSize: 20, - type: 'hashed', - extra: {}, - properties: ['unique'], - fields: [ + it('renders indexes list with in progress index', function () { + renderIndexes({ + regularIndexes: { + indexes: [ { - field: '_id', - value: 1, + ns: 'db.coll', + cardinality: 'single', + name: '_id_', + size: 12, + relativeSize: 20, + type: 'hashed', + extra: {}, + properties: ['unique'], + fields: [ + { + field: '_id', + value: 1, + }, + ], + usageCount: 20, }, - ], - usageCount: 20, - }, - { - ns: 'db.coll', - cardinality: 'single', - name: 'item', - size: 0, - relativeSize: 0, - type: 'hashed', - extra: { - status: 'inprogress', - }, - properties: [], - fields: [ { - field: 'item', - value: 1, + ns: 'db.coll', + cardinality: 'single', + name: 'item', + size: 0, + relativeSize: 0, + type: 'hashed', + extra: { + status: 'inprogress', + }, + properties: [], + fields: [ + { + field: 'item', + value: 1, + }, + ], + usageCount: 0, }, - ], - usageCount: 0, + ] as RegularIndex[], + error: null, + isRefreshing: false, }, - ] as IndexDefinition[], - isReadonlyView: false, - error: undefined, + }); + + const indexesList = screen.getByTestId('indexes-list'); + const inProgressIndex = + within(indexesList).getByTestId('indexes-row-item'); + const indexPropertyField = within(inProgressIndex).getByTestId( + 'indexes-property-field' + ); + + expect(indexPropertyField).to.contain.text('In Progress ...'); + + const dropIndexButton = within(inProgressIndex).queryByTestId( + 'index-actions-delete-action' + ); + expect(dropIndexButton).to.not.exist; }); - const indexesList = screen.getByTestId('indexes-list'); - const inProgressIndex = within(indexesList).getByTestId('index-row-item'); - const indexPropertyField = within(inProgressIndex).getByTestId( - 'index-property-field' - ); + it('renders indexes list with failed index', function () { + renderIndexes({ + regularIndexes: { + indexes: [ + { + ns: 'db.coll', + cardinality: 'single', + name: '_id_', + size: 12, + relativeSize: 20, + type: 'hashed', + extra: {}, + properties: ['unique'], + fields: [ + { + field: '_id', + value: 1, + }, + ], + usageCount: 20, + }, + { + ns: 'db.coll', + cardinality: 'single', + name: 'item', + size: 0, + relativeSize: 0, + type: 'hashed', + extra: { + status: 'failed', + regularError: 'regularError message', + }, + properties: [], + fields: [ + { + field: 'item', + value: 1, + }, + ], + usageCount: 0, + }, + ] as RegularIndex[], + error: null, + isRefreshing: false, + }, + }); + + const indexesList = screen.getByTestId('indexes-list'); + const failedIndex = within(indexesList).getByTestId('indexes-row-item'); + const indexPropertyField = within(failedIndex).getByTestId( + 'indexes-property-field' + ); - expect(indexPropertyField).to.contain.text('In Progress ...'); + expect(indexPropertyField).to.contain.text('Failed'); - const dropIndexButton = within(inProgressIndex).queryByTestId( - 'index-actions-delete-action' - ); - expect(dropIndexButton).to.not.exist; + const dropIndexButton = within(failedIndex).getByTestId( + 'index-actions-delete-action' + ); + expect(dropIndexButton).to.exist; + }); }); - it('renders indexes list with failed index', function () { - renderIndexes({ - indexes: [ + context('search indexes', function () { + it('renders the search indexes table if the current view changes to search indexes', async function () { + const store = renderIndexes(); + + const indexes: SearchIndex[] = [ { - ns: 'db.coll', - cardinality: 'single', - name: '_id_', - size: 12, - relativeSize: 20, - type: 'hashed', - extra: {}, - properties: ['unique'], - fields: [ - { - field: '_id', - value: 1, - }, - ], - usageCount: 20, + id: '1', + name: 'default', + status: 'READY', + queryable: true, + latestDefinition: {}, }, { - ns: 'db.coll', - cardinality: 'single', - name: 'item', - size: 0, - relativeSize: 0, - type: 'hashed', - extra: { - status: 'failed', - error: 'Error message', - }, - properties: [], - fields: [ - { - field: 'item', - value: 1, - }, - ], - usageCount: 0, + id: '2', + name: 'another', + status: 'READY', + queryable: true, + latestDefinition: {}, }, - ] as IndexDefinition[], - isReadonlyView: false, - error: undefined, + ]; + + store.getState()!.dataService!.getSearchIndexes = function () { + return Promise.resolve(indexes); + }; + + // switch to the Search Indexes tab + const toolbar = screen.getByTestId('indexes-toolbar'); + const button = within(toolbar).getByText('Search Indexes'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId('search-indexes-list')).to.exist; + }); }); - const indexesList = screen.getByTestId('indexes-list'); - const failedIndex = within(indexesList).getByTestId('index-row-item'); - const indexPropertyField = within(failedIndex).getByTestId( - 'index-property-field' - ); + it('refreshes the search indexes if the search indexes view is active', async function () { + const store = renderIndexes(); + + const spy = sinon.spy( + store.getState()?.dataService as IndexesDataService, + 'getSearchIndexes' + ); - expect(indexPropertyField).to.contain.text('Failed'); + // switch to the Search Indexes tab + const toolbar = screen.getByTestId('indexes-toolbar'); + fireEvent.click(within(toolbar).getByText('Search Indexes')); - const dropIndexButton = within(failedIndex).getByTestId( - 'index-actions-delete-action' - ); - expect(dropIndexButton).to.exist; + expect(spy.callCount).to.equal(1); + + // click the refresh button + const refreshButton = within(toolbar).getByText('Refresh'); + await waitFor( + () => expect(refreshButton.getAttribute('disabled')).to.be.null + ); + fireEvent.click(refreshButton); + + expect(spy.callCount).to.equal(2); + }); }); }); diff --git a/packages/compass-indexes/src/components/indexes/indexes.tsx b/packages/compass-indexes/src/components/indexes/indexes.tsx index 4989f91a912..dbd323488cb 100644 --- a/packages/compass-indexes/src/components/indexes/indexes.tsx +++ b/packages/compass-indexes/src/components/indexes/indexes.tsx @@ -1,27 +1,22 @@ -import React, { useState } from 'react'; -import { css, spacing } from '@mongodb-js/compass-components'; +import React, { useState, useCallback, useEffect } from 'react'; import { connect } from 'react-redux'; -import type AppRegistry from 'hadron-app-registry'; -import { withPreferences } from 'compass-preferences-model'; - -import { - sortIndexes, - dropFailedIndex, - hideIndex, - unhideIndex, - refreshIndexes, -} from '../../modules/regular-indexes'; -import type { - IndexDefinition, - SortColumn, - SortDirection, -} from '../../modules/regular-indexes'; +import { css, spacing } from '@mongodb-js/compass-components'; import type { IndexView } from '../indexes-toolbar/indexes-toolbar'; -import { IndexesToolbar } from '../indexes-toolbar/indexes-toolbar'; -import { RegularIndexesTable } from '../regular-indexes-table/regular-indexes-table'; -import type { RootState } from '../../modules'; +import IndexesToolbar from '../indexes-toolbar/indexes-toolbar'; +import RegularIndexesTable from '../regular-indexes-table/regular-indexes-table'; +import SearchIndexesTable from '../search-indexes-table/search-indexes-table'; +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 type { RootState } from '../../modules'; + +// This constant is used as a trigger to show an insight whenever number of +// indexes in a collection is more than what is specified here. +const IDEAL_NUMBER_OF_MAX_INDEXES = 10; const containerStyles = css({ margin: spacing[3], @@ -32,120 +27,104 @@ const containerStyles = css({ }); type IndexesProps = { - indexes: IndexDefinition[]; - isWritable: boolean; - isReadonlyView: boolean; - description?: string; - error: string | null; - localAppRegistry: AppRegistry; - isRefreshing: boolean; - serverVersion: string; - sortIndexes: (name: SortColumn, direction: SortDirection) => void; - refreshIndexes: () => void; - dropFailedIndex: (id: string) => void; - onHideIndex: (name: string) => void; - onUnhideIndex: (name: string) => void; - readOnly?: boolean; - isAtlasSearchSupported: boolean; + isReadonlyView?: boolean; + regularIndexes: Pick< + RegularIndexesState, + 'indexes' | 'error' | 'isRefreshing' + >; + searchIndexes: Pick; + refreshRegularIndexes: () => void; + refreshSearchIndexes: () => void; }; -// This constant is used as a trigger to show an insight whenever number of -// indexes in a collection is more than what is specified here. -const IDEAL_NUMBER_OF_MAX_INDEXES = 10; +function isRefreshingStatus(status: SearchIndexesStatus) { + return ( + status === SearchIndexesStatuses.PENDING || + status === SearchIndexesStatuses.REFRESHING + ); +} -export const Indexes: React.FunctionComponent = ({ - indexes, - isWritable, +export function Indexes({ isReadonlyView, - description, - error, - localAppRegistry, - isRefreshing, - serverVersion, - sortIndexes, - refreshIndexes, - dropFailedIndex, - onHideIndex, - onUnhideIndex, - readOnly, // preferences readOnly. - isAtlasSearchSupported, -}) => { + regularIndexes, + searchIndexes, + refreshRegularIndexes, + refreshSearchIndexes, +}: IndexesProps) { const [currentIndexesView, setCurrentIndexesView] = useState('regular-indexes'); - const deleteIndex = (index: IndexDefinition) => { - if (index.extra.status === 'failed') { - return dropFailedIndex(String(index.extra.id)); + const errorMessage = + currentIndexesView === 'regular-indexes' + ? regularIndexes.error + : searchIndexes.error; + + const hasTooManyIndexes = + currentIndexesView === 'regular-indexes' && + regularIndexes.indexes.length > IDEAL_NUMBER_OF_MAX_INDEXES; + + const isRefreshing = + currentIndexesView === 'regular-indexes' + ? regularIndexes.isRefreshing === true + : isRefreshingStatus(searchIndexes.status); + + const onRefreshIndexes = + currentIndexesView === 'regular-indexes' + ? refreshRegularIndexes + : refreshSearchIndexes; + + const loadIndexes = useCallback(() => { + if (currentIndexesView === 'regular-indexes') { + refreshRegularIndexes(); + } else { + refreshSearchIndexes(); } + }, [currentIndexesView, refreshRegularIndexes, refreshSearchIndexes]); + + const changeIndexView = useCallback( + (view: IndexView) => { + setCurrentIndexesView(view); + loadIndexes(); + }, + [loadIndexes] + ); - return localAppRegistry.emit('toggle-drop-index-modal', true, index.name); - }; + useEffect(() => { + loadIndexes(); + }, [loadIndexes]); return (
IDEAL_NUMBER_OF_MAX_INDEXES} - isAtlasSearchSupported={isAtlasSearchSupported} - onRefreshIndexes={refreshIndexes} - onChangeIndexView={setCurrentIndexesView} + onRefreshIndexes={onRefreshIndexes} + onChangeIndexView={changeIndexView} /> - {!isReadonlyView && - !error && - currentIndexesView === 'regular-indexes' && ( - - )} - - {!isReadonlyView && !error && currentIndexesView === 'search-indexes' && ( -

In Progress feature

+ {!isReadonlyView && currentIndexesView === 'regular-indexes' && ( + + )} + {!isReadonlyView && currentIndexesView === 'search-indexes' && ( + )}
); -}; +} const mapState = ({ - isWritable, isReadonlyView, - description, - serverVersion, - appRegistry, - regularIndexes: { indexes, isRefreshing, error }, - searchIndexes: { status }, + regularIndexes, + searchIndexes, }: RootState) => ({ - indexes, - isWritable, isReadonlyView, - description, - error, - localAppRegistry: (appRegistry as any).localAppRegistry, - isRefreshing, - serverVersion, - isAtlasSearchSupported: status !== SearchIndexesStatuses.NOT_AVAILABLE, + regularIndexes, + searchIndexes, }); const mapDispatch = { - sortIndexes, - refreshIndexes, - dropFailedIndex, - onHideIndex: hideIndex, - onUnhideIndex: unhideIndex, + refreshRegularIndexes, + refreshSearchIndexes, }; -export default connect( - mapState, - mapDispatch -)(withPreferences(Indexes, ['readOnly'], React)); +export default connect(mapState, mapDispatch)(Indexes); diff --git a/packages/compass-indexes/src/components/regular-indexes-table/index-actions.tsx b/packages/compass-indexes/src/components/regular-indexes-table/index-actions.tsx index 88677c2c18b..969f108e934 100644 --- a/packages/compass-indexes/src/components/regular-indexes-table/index-actions.tsx +++ b/packages/compass-indexes/src/components/regular-indexes-table/index-actions.tsx @@ -2,12 +2,12 @@ import semver from 'semver'; import React, { useCallback, useMemo } from 'react'; import type { GroupedItemAction } from '@mongodb-js/compass-components'; import { ItemActionGroup } from '@mongodb-js/compass-components'; -import type { IndexDefinition } from '../../modules/regular-indexes'; +import type { RegularIndex } from '../../modules/regular-indexes'; type IndexActionsProps = { - index: IndexDefinition; + index: RegularIndex; serverVersion: string; - onDeleteIndex: (index: IndexDefinition) => void; + onDeleteIndex: (index: RegularIndex) => void; onHideIndex: (name: string) => void; onUnhideIndex: (name: string) => void; }; diff --git a/packages/compass-indexes/src/components/regular-indexes-table/property-field.tsx b/packages/compass-indexes/src/components/regular-indexes-table/property-field.tsx index 83114ac30f5..85cf0a0c92b 100644 --- a/packages/compass-indexes/src/components/regular-indexes-table/property-field.tsx +++ b/packages/compass-indexes/src/components/regular-indexes-table/property-field.tsx @@ -10,7 +10,7 @@ import { BadgeVariant, useDarkMode, } from '@mongodb-js/compass-components'; -import type { IndexDefinition } from '../../modules/regular-indexes'; +import type { RegularIndex } from '../../modules/regular-indexes'; import BadgeWithIconLink from './badge-with-icon-link'; const containerStyles = css({ @@ -30,7 +30,7 @@ const ttlTooltip = (expireAfterSeconds: number) => { export const getPropertyTooltip = ( property: string | undefined, - extra: IndexDefinition['extra'] + extra: RegularIndex['extra'] ): string | null => { return property === 'ttl' ? ttlTooltip(extra.expireAfterSeconds as number) @@ -81,9 +81,9 @@ const ErrorBadgeWithTooltip: React.FunctionComponent<{ }; type PropertyFieldProps = { - extra: IndexDefinition['extra']; - properties: IndexDefinition['properties']; - cardinality: IndexDefinition['cardinality']; + extra: RegularIndex['extra']; + properties: RegularIndex['properties']; + cardinality: RegularIndex['cardinality']; }; const HIDDEN_INDEX_TEXT = 'HIDDEN'; 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 a0345c672c1..d08f77b58a7 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 @@ -5,7 +5,8 @@ import userEvent from '@testing-library/user-event'; import { spy } from 'sinon'; import { RegularIndexesTable } from './regular-indexes-table'; -import type { IndexDefinition } from '../../modules/regular-indexes'; +import type { RegularIndex } from '../../modules/regular-indexes'; +import type AppRegistry from 'hadron-app-registry'; const indexes = [ { @@ -92,18 +93,20 @@ const indexes = [ ], usageCount: 25, }, -] as IndexDefinition[]; +] as RegularIndex[]; const renderIndexList = ( props: Partial> = {} ) => { render( {}} - onDeleteIndex={() => {}} + dropFailedIndex={() => {}} onHideIndex={() => {}} onUnhideIndex={() => {}} {...props} @@ -116,27 +119,27 @@ describe('RegularIndexesTable Component', function () { afterEach(cleanup); it('renders indexes list', function () { - renderIndexList({ canModifyIndex: true, indexes: indexes }); + renderIndexList({ isWritable: true, readOnly: false, indexes: indexes }); const indexesList = screen.getByTestId('indexes-list'); expect(indexesList).to.exist; // Renders indexes list (table rows) indexes.forEach((index) => { - const indexRow = screen.getByTestId(`index-row-${index.name}`); + const indexRow = screen.getByTestId(`indexes-row-${index.name}`); expect(indexRow, 'it renders each index in a row').to.exist; // Renders index fields (table cells) [ - 'index-name-field', - 'index-type-field', - 'index-size-field', - 'index-usage-field', - 'index-property-field', - 'index-actions-field', + 'indexes-name-field', + 'indexes-type-field', + 'indexes-size-field', + 'indexes-usage-field', + 'indexes-property-field', + 'indexes-actions-field', ].forEach((indexCell) => { // For _id index we always hide drop index field - if (index.name !== '_id_' && indexCell !== 'index-actions-field') { + if (index.name !== '_id_' && indexCell !== 'indexes-actions-field') { expect(within(indexRow).getByTestId(indexCell)).to.exist; } else { expect(() => { @@ -147,14 +150,49 @@ describe('RegularIndexesTable Component', function () { }); }); - it('does not render delete and hide/unhide button when a user can not modify indexes', function () { - renderIndexList({ canModifyIndex: false, indexes: indexes }); + it('does not render the list if there is an error', function () { + renderIndexList({ + isWritable: true, + readOnly: false, + indexes: indexes, + error: 'moo', + }); + + expect(() => { + screen.getByTestId('indexes-list'); + }).to.throw; + }); + + it('renders the delete and hide/unhide button when a user can modify indexes', function () { + renderIndexList({ isWritable: true, readOnly: false, indexes: indexes }); + const indexesList = screen.getByTestId('indexes-list'); + expect(indexesList).to.exist; + indexes.forEach((index) => { + const indexRow = screen.getByTestId(`indexes-row-${index.name}`); + expect(within(indexRow).getByTestId('indexes-actions-field')).to.exist; + }); + }); + + it('does not render delete and hide/unhide button when a user can not modify indexes (!isWritable)', function () { + renderIndexList({ isWritable: false, readOnly: false, indexes: indexes }); + const indexesList = screen.getByTestId('indexes-list'); + expect(indexesList).to.exist; + indexes.forEach((index) => { + const indexRow = screen.getByTestId(`indexes-row-${index.name}`); + expect(() => { + within(indexRow).getByTestId('indexes-actions-field'); + }).to.throw; + }); + }); + + it('does not render delete and hide/unhide button when a user can not modify indexes (isWritable, readOnly)', function () { + renderIndexList({ isWritable: true, readOnly: true, indexes: indexes }); const indexesList = screen.getByTestId('indexes-list'); expect(indexesList).to.exist; indexes.forEach((index) => { - const indexRow = screen.getByTestId(`index-row-${index.name}`); + const indexRow = screen.getByTestId(`indexes-row-${index.name}`); expect(() => { - within(indexRow).getByTestId('index-actions-field'); + within(indexRow).getByTestId('indexes-actions-field'); }).to.throw; }); }); @@ -164,7 +202,8 @@ describe('RegularIndexesTable Component', function () { it(`sorts table by ${column}`, function () { const onSortTableSpy = spy(); renderIndexList({ - canModifyIndex: true, + isWritable: true, + readOnly: false, indexes: indexes, onSortTable: onSortTableSpy, }); @@ -172,7 +211,7 @@ describe('RegularIndexesTable Component', function () { const indexesList = screen.getByTestId('indexes-list'); const columnheader = within(indexesList).getByTestId( - `index-header-${column}` + `indexes-header-${column}` ); const sortButton = within(columnheader).getByRole('button', { name: /sort/i, 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 f06a393105b..102f9c99198 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,12 @@ import React from 'react'; - +import { connect } from 'react-redux'; +import type AppRegistry from 'hadron-app-registry'; import { IndexKeysBadge } from '@mongodb-js/compass-components'; +import { withPreferences } from 'compass-preferences-model'; + +import type { RootState } from '../../modules'; + +import { IndexesTable } from '../indexes-table'; import TypeField from './type-field'; import SizeField from './size-field'; @@ -8,35 +14,62 @@ import UsageField from './usage-field'; import PropertyField from './property-field'; import IndexActions from './index-actions'; -import { IndexesTable } from '../indexes-table'; +import { + sortRegularIndexes, + dropFailedIndex, + hideIndex, + unhideIndex, +} from '../../modules/regular-indexes'; -import type { - IndexDefinition, - SortColumn, - SortDirection, +import { + type RegularIndex, + type RegularSortColumn, } from '../../modules/regular-indexes'; +import type { SortDirection } from '../../modules'; + type RegularIndexesTableProps = { - indexes: IndexDefinition[]; - canModifyIndex: boolean; + indexes: RegularIndex[]; serverVersion: string; - onDeleteIndex: (index: IndexDefinition) => void; + isWritable?: boolean; + dropFailedIndex: (name: string) => void; onHideIndex: (name: string) => void; onUnhideIndex: (name: string) => void; - onSortTable: (column: SortColumn, direction: SortDirection) => void; + onSortTable: (column: RegularSortColumn, direction: SortDirection) => void; + localAppRegistry: AppRegistry; + readOnly?: boolean; + error?: string | null; }; export const RegularIndexesTable: React.FunctionComponent< RegularIndexesTableProps > = ({ + isWritable, + readOnly, indexes, - canModifyIndex, serverVersion, - onDeleteIndex, onHideIndex, onUnhideIndex, onSortTable, + error, + localAppRegistry, }) => { + if (error) { + // We don't render the table if there is an error. The toolbar takes care of + // displaying it. + return null; + } + + const deleteIndex = (index: RegularIndex) => { + if (index.extra.status === 'failed') { + return dropFailedIndex(String(index.extra.id)); + } + + return localAppRegistry.emit('toggle-drop-index-modal', true, index.name); + }; + + const canModifyIndex = isWritable && !readOnly; + const columns = [ 'Name and Definition', 'Type', @@ -48,30 +81,30 @@ export const RegularIndexesTable: React.FunctionComponent< const data = indexes.map((index) => { return { key: index.name, - 'data-testid': `index-row-${index.name}`, + 'data-testid': `row-${index.name}`, fields: [ { - 'data-testid': 'index-name-field', + 'data-testid': 'name-field', children: index.name, }, { - 'data-testid': 'index-type-field', + 'data-testid': 'type-field', children: , }, { - 'data-testid': 'index-size-field', + 'data-testid': 'size-field', children: ( ), }, { - 'data-testid': 'index-usage-field', + 'data-testid': 'usage-field', children: ( ), }, { - 'data-testid': 'index-property-field', + 'data-testid': 'property-field', children: ( @@ -105,3 +138,28 @@ export const RegularIndexesTable: React.FunctionComponent< /> ); }; + +const mapState = ({ + serverVersion, + regularIndexes, + isWritable, + appRegistry, +}: RootState) => ({ + isWritable, + serverVersion, + indexes: regularIndexes.indexes, + error: regularIndexes.error, + localAppRegistry: (appRegistry as any).localAppRegistry, +}); + +const mapDispatch = { + dropFailedIndex, + onHideIndex: hideIndex, + onUnhideIndex: unhideIndex, + onSortTable: sortRegularIndexes, +}; + +export default connect( + mapState, + mapDispatch +)(withPreferences(RegularIndexesTable, ['readOnly'], React)); diff --git a/packages/compass-indexes/src/components/regular-indexes-table/type-field.tsx b/packages/compass-indexes/src/components/regular-indexes-table/type-field.tsx index f8e672b4ec1..55e7b1f1383 100644 --- a/packages/compass-indexes/src/components/regular-indexes-table/type-field.tsx +++ b/packages/compass-indexes/src/components/regular-indexes-table/type-field.tsx @@ -2,20 +2,20 @@ import React from 'react'; import getIndexHelpLink from '../../utils/index-link-helper'; import { Tooltip, Body } from '@mongodb-js/compass-components'; -import type { IndexDefinition } from '../../modules/regular-indexes'; +import type { RegularIndex } from '../../modules/regular-indexes'; import BadgeWithIconLink from './badge-with-icon-link'; -export const canRenderTooltip = (type: IndexDefinition['type']) => { +export const canRenderTooltip = (type: RegularIndex['type']) => { return ['text', 'wildcard', 'columnstore'].indexOf(type ?? '') !== -1; }; type TypeFieldProps = { - type: IndexDefinition['type']; - extra: IndexDefinition['extra']; + type: RegularIndex['type']; + extra: RegularIndex['extra']; }; export const IndexTypeTooltip: React.FunctionComponent<{ - extra: IndexDefinition['extra']; + extra: RegularIndex['extra']; }> = ({ extra }) => { const allowedProps = [ 'weights', 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 new file mode 100644 index 00000000000..89a00458c73 --- /dev/null +++ b/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.spec.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import { expect } from 'chai'; +import userEvent from '@testing-library/user-event'; +import { spy } from 'sinon'; + +import { SearchIndexesTable } from './search-indexes-table'; +import type { SearchIndex } from 'mongodb-data-service'; +import { SearchIndexesStatuses } from '../../modules/search-indexes'; + +const indexes: SearchIndex[] = [ + { + id: '1', + name: 'default', + status: 'READY', + queryable: true, + latestDefinition: {}, + }, + { + id: '2', + name: 'another', + status: 'READY', + queryable: true, + latestDefinition: {}, + }, +]; + +const renderIndexList = ( + props: Partial> = {} +) => { + render( + {}} + {...props} + /> + ); +}; + +describe('SearchIndexesTable Component', function () { + before(cleanup); + afterEach(cleanup); + + for (const status of [ + SearchIndexesStatuses.READY, + SearchIndexesStatuses.REFRESHING, + ]) { + it(`renders indexes list if the status is ${status}`, function () { + renderIndexList({ status }); + + const indexesList = screen.getByTestId('search-indexes-list'); + expect(indexesList).to.exist; + + // Renders indexes list (table rows) + for (const index of indexes) { + const indexRow = screen.getByTestId(`search-indexes-row-${index.name}`); + expect(indexRow, 'it renders each index in a row').to.exist; + + // Renders index fields (table cells) + for (const indexCell of [ + 'search-indexes-name-field', + 'search-indexes-status-field', + ]) { + expect(within(indexRow).getByTestId(indexCell)).to.exist; + } + } + }); + } + + for (const status of [ + SearchIndexesStatuses.PENDING, + SearchIndexesStatuses.ERROR, + ]) { + it(`does not render the list if the status is ${status}`, function () { + renderIndexList({ + status, + }); + + expect(() => { + screen.getByTestId('search-indexes-list'); + }).to.throw; + }); + } + + it('does not render the table if there are no indexes', function () { + renderIndexList({ + indexes: [], + }); + + expect(() => { + screen.getByTestId('search-indexes-list'); + }).to.throw; + }); + + for (const column of ['Name and Fields', 'Status']) { + it(`sorts table by ${column}`, function () { + const onSortTableSpy = spy(); + renderIndexList({ + onSortTable: onSortTableSpy, + }); + + const indexesList = screen.getByTestId('search-indexes-list'); + + const columnheader = within(indexesList).getByTestId( + `search-indexes-header-${column}` + ); + const sortButton = within(columnheader).getByRole('button', { + name: /sort/i, + }); + + expect(onSortTableSpy.callCount).to.equal(0); + + userEvent.click(sortButton); + expect(onSortTableSpy.callCount).to.equal(1); + expect(onSortTableSpy.getCalls()[0].args).to.deep.equal([column, 'desc']); + + userEvent.click(sortButton); + expect(onSortTableSpy.callCount).to.equal(2); + expect(onSortTableSpy.getCalls()[1].args).to.deep.equal([column, 'asc']); + }); + } +}); 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 new file mode 100644 index 00000000000..d9c95868530 --- /dev/null +++ b/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import type { SearchIndex } from 'mongodb-data-service'; +import { withPreferences } from 'compass-preferences-model'; + +import type { SearchSortColumn } from '../../modules/search-indexes'; +import { SearchIndexesStatuses } from '../../modules/search-indexes'; +import type { SearchIndexesStatus } from '../../modules/search-indexes'; +import { sortSearchIndexes } from '../../modules/search-indexes'; +import type { SortDirection, RootState } from '../../modules'; + +import { IndexesTable } from '../indexes-table'; + +type SearchIndexesTableProps = { + indexes: SearchIndex[]; + isWritable?: boolean; + readOnly?: boolean; + onSortTable: (column: SearchSortColumn, direction: SortDirection) => void; + status: SearchIndexesStatus; +}; + +function isReadyStatus(status: SearchIndexesStatus) { + return ( + status === SearchIndexesStatuses.READY || + status === SearchIndexesStatuses.REFRESHING + ); +} + +export const SearchIndexesTable: React.FunctionComponent< + SearchIndexesTableProps +> = ({ indexes, isWritable, readOnly, onSortTable, status }) => { + if (!isReadyStatus(status)) { + // If there's an error or the search indexes are still pending or search + // indexes aren't available, then that's all handled by the toolbar and we + // don't render the table. + return null; + } + + if (indexes.length === 0) { + // TODO(COMPASS-7204): render the zero state + return null; + } + + const canModifyIndex = isWritable && !readOnly; + + const columns = ['Name and Fields', 'Status'] as const; + + const data = indexes.map((index) => { + return { + key: index.name, + 'data-testid': `row-${index.name}`, + fields: [ + { + 'data-testid': 'name-field', + children: index.name, + }, + { + 'data-testid': 'status-field', + children: index.status, // TODO(COMPASS-7205): show some badge, not just text + }, + ], + + // TODO(COMPASS-7206): details for the nested row + }; + }); + + return ( + onSortTable(column, direction)} + /> + ); +}; + +const mapState = ({ searchIndexes, isWritable }: RootState) => ({ + isWritable, + indexes: searchIndexes.indexes, + status: searchIndexes.status, +}); + +const mapDispatch = { + onSortTable: sortSearchIndexes, +}; + +export default connect( + mapState, + mapDispatch +)(withPreferences(SearchIndexesTable, ['readOnly'], React)); diff --git a/packages/compass-indexes/src/modules/create-index/index.ts b/packages/compass-indexes/src/modules/create-index/index.ts index da878a6e381..f8f157c20bf 100644 --- a/packages/compass-indexes/src/modules/create-index/index.ts +++ b/packages/compass-indexes/src/modules/create-index/index.ts @@ -256,7 +256,7 @@ export const createIndex = () => { dispatch( localAppRegistryEmit('in-progress-indexes-removed', inProgressIndex.id) ); - dispatch(localAppRegistryEmit('refresh-data')); + dispatch(localAppRegistryEmit('refresh-regular-indexes')); } catch (err) { dispatch(toggleInProgress(false)); dispatch(handleError((err as Error).message)); diff --git a/packages/compass-indexes/src/modules/drop-index/index.ts b/packages/compass-indexes/src/modules/drop-index/index.ts index 61caa6224ba..03b17279563 100644 --- a/packages/compass-indexes/src/modules/drop-index/index.ts +++ b/packages/compass-indexes/src/modules/drop-index/index.ts @@ -85,7 +85,7 @@ export const dropIndex = (indexName: string) => { await state.dataService?.dropIndex(ns, indexName); track('Index Dropped'); dispatch(resetForm()); - dispatch(localAppRegistryEmit('refresh-data')); + dispatch(localAppRegistryEmit('refresh-regular-indexes')); dispatch(clearError()); dispatch(toggleInProgress(false)); dispatch(toggleIsVisible(false)); diff --git a/packages/compass-indexes/src/modules/index.ts b/packages/compass-indexes/src/modules/index.ts index 834db5254d1..3c72885a211 100644 --- a/packages/compass-indexes/src/modules/index.ts +++ b/packages/compass-indexes/src/modules/index.ts @@ -23,6 +23,8 @@ const reducer = combineReducers({ searchIndexes, }); +export type SortDirection = 'asc' | 'desc'; + export type RootState = ReturnType; export type IndexesThunkDispatch = ThunkDispatch< RootState, diff --git a/packages/compass-indexes/src/modules/regular-indexes.spec.ts b/packages/compass-indexes/src/modules/regular-indexes.spec.ts index 01fedac8a73..aa5199f58ca 100644 --- a/packages/compass-indexes/src/modules/regular-indexes.spec.ts +++ b/packages/compass-indexes/src/modules/regular-indexes.spec.ts @@ -4,9 +4,9 @@ import Sinon from 'sinon'; import { ActionTypes, fetchIndexes, - setIndexes, - sortIndexes, - refreshIndexes, + setRegularIndexes, + sortRegularIndexes, + refreshRegularIndexes, inProgressIndexAdded, inProgressIndexRemoved, inProgressIndexFailed, @@ -30,32 +30,32 @@ describe('regular-indexes module', function () { store = setupStore(); }); - it('#setIndexes action - it only sets indexes and does not sort them', function () { + it('#setRegularIndexes action - it only sets indexes and does not sort them', function () { { - store.dispatch(setIndexes([])); + store.dispatch(setRegularIndexes([])); expect(store.getState().regularIndexes.indexes).to.deep.equal([]); } { - store.dispatch(setIndexes(defaultSortedIndexes as any)); + store.dispatch(setRegularIndexes(defaultSortedIndexes as any)); expect(store.getState().regularIndexes.indexes).to.deep.equal( defaultSortedIndexes ); } { - store.dispatch(setIndexes(usageSortedDesc as any)); + store.dispatch(setRegularIndexes(usageSortedDesc as any)); expect(store.getState().regularIndexes.indexes).to.deep.equal( usageSortedDesc ); } }); - it('#sortIndexes action - it sorts indexes as defined', function () { - store.dispatch(setIndexes(defaultSortedDesc as any)); + it('#sortRegularIndexes action - it sorts indexes as defined', function () { + store.dispatch(setRegularIndexes(defaultSortedDesc as any)); { - store.dispatch(sortIndexes('Name and Definition', 'asc')); + store.dispatch(sortRegularIndexes('Name and Definition', 'asc')); const state = store.getState().regularIndexes; expect(state.sortColumn).to.equal('Name and Definition'); expect(state.sortOrder).to.equal('asc'); @@ -63,7 +63,7 @@ describe('regular-indexes module', function () { } { - store.dispatch(sortIndexes('Name and Definition', 'desc')); + store.dispatch(sortRegularIndexes('Name and Definition', 'desc')); const state = store.getState().regularIndexes; expect(state.sortColumn).to.equal('Name and Definition'); expect(state.sortOrder).to.equal('desc'); @@ -71,7 +71,7 @@ describe('regular-indexes module', function () { } { - store.dispatch(sortIndexes('Usage', 'asc')); + store.dispatch(sortRegularIndexes('Usage', 'asc')); const state = store.getState().regularIndexes; expect(state.sortColumn).to.equal('Usage'); expect(state.sortOrder).to.equal('asc'); @@ -79,7 +79,7 @@ describe('regular-indexes module', function () { } { - store.dispatch(sortIndexes('Usage', 'desc')); + store.dispatch(sortRegularIndexes('Usage', 'desc')); const state = store.getState().regularIndexes; expect(state.sortColumn).to.equal('Usage'); expect(state.sortOrder).to.equal('desc'); @@ -102,14 +102,13 @@ describe('regular-indexes module', function () { }); // Add indexes in the store - store.dispatch(setIndexes(defaultSortedIndexes as any)); + store.dispatch(setRegularIndexes(defaultSortedIndexes as any)); store.dispatch(readonlyViewChanged(true)); await store.dispatch(fetchIndexes()); expect(store.getState().regularIndexes.indexes).to.have.lengthOf(0); - // One because we also call fetchIndexes when setting up the store. - expect(indexesSpy.callCount).to.equal(1); + expect(indexesSpy.callCount).to.equal(0); }); it('when dataService is not connected, sets refreshing to false', async function () { @@ -171,7 +170,7 @@ describe('regular-indexes module', function () { }, }); // Set indexes to empty - store.dispatch(setIndexes([])); + store.dispatch(setRegularIndexes([])); await store.dispatch(fetchIndexes()); const state = store.getState().regularIndexes; @@ -192,8 +191,8 @@ describe('regular-indexes module', function () { }, }); // Set indexes to empty - store.dispatch(setIndexes([])); - store.dispatch(sortIndexes('Usage', 'desc')); + store.dispatch(setRegularIndexes([])); + store.dispatch(sortRegularIndexes('Usage', 'desc')); await store.dispatch(fetchIndexes()); @@ -216,7 +215,7 @@ describe('regular-indexes module', function () { }); // Set indexes to empty - store.dispatch(setIndexes([])); + store.dispatch(setRegularIndexes([])); store.dispatch( inProgressIndexAdded({ id: 'citibike.trips.z', @@ -261,7 +260,7 @@ describe('regular-indexes module', function () { }); }); - describe('#refreshIndexes action', function () { + describe('#refreshRegularIndexes action', function () { it('sets isRefreshing when indexes are refreshed', async function () { const store = setupStore({ dataProvider: { @@ -277,7 +276,7 @@ describe('regular-indexes module', function () { }, }); - store.dispatch(refreshIndexes() as any); + store.dispatch(refreshRegularIndexes() as any); expect(store.getState().regularIndexes.isRefreshing).to.be.true; await wait(100); diff --git a/packages/compass-indexes/src/modules/regular-indexes.ts b/packages/compass-indexes/src/modules/regular-indexes.ts index 9a26981193c..2ec8a39acac 100644 --- a/packages/compass-indexes/src/modules/regular-indexes.ts +++ b/packages/compass-indexes/src/modules/regular-indexes.ts @@ -7,7 +7,7 @@ import { cloneDeep } from 'lodash'; import { isAction } from './../utils/is-action'; import type { CreateIndexSpec } from './create-index'; -import type { IndexesThunkAction } from '.'; +import type { SortDirection, IndexesThunkAction } from '.'; import { hideModalDescription, unhideModalDescription, @@ -15,10 +15,9 @@ import { const { debug } = createLoggerAndTelemetry('COMPASS-INDEXES'); -export type SortColumn = keyof typeof sortColumnToProps; -export type SortDirection = 'asc' | 'desc'; +export type RegularSortColumn = keyof typeof sortColumnToProps; type SortField = keyof Pick< - IndexDefinition, + RegularIndex, 'name' | 'type' | 'size' | 'usageCount' | 'properties' >; @@ -30,7 +29,7 @@ const sortColumnToProps = { Properties: 'properties', } as const; -export type IndexDefinition = Omit< +export type RegularIndex = Omit< _IndexDefinition, 'type' | 'cardinality' | 'properties' | 'version' > & @@ -65,14 +64,14 @@ export enum ActionTypes { type IndexesAddedAction = { type: ActionTypes.IndexesAdded; - indexes: IndexDefinition[]; + indexes: RegularIndex[]; }; type IndexesSortedAction = { type: ActionTypes.IndexesSorted; - indexes: IndexDefinition[]; + indexes: RegularIndex[]; sortOrder: SortDirection; - sortColumn: SortColumn; + sortColumn: RegularSortColumn; }; type SetIsRefreshingAction = { @@ -111,9 +110,9 @@ type RegularIndexesActions = | InProgressIndexFailedAction; export type State = { - indexes: IndexDefinition[]; + indexes: RegularIndex[]; sortOrder: SortDirection; - sortColumn: SortColumn; + sortColumn: RegularSortColumn; isRefreshing: boolean; inProgressIndexes: InProgressIndex[]; error: string | null; @@ -212,7 +211,9 @@ export default function reducer(state = INITIAL_STATE, action: AnyAction) { return state; } -export const setIndexes = (indexes: IndexDefinition[]): IndexesAddedAction => ({ +export const setRegularIndexes = ( + indexes: RegularIndex[] +): IndexesAddedAction => ({ type: ActionTypes.IndexesAdded, indexes, }); @@ -222,16 +223,16 @@ const setIsRefreshing = (isRefreshing: boolean): SetIsRefreshingAction => ({ isRefreshing, }); -export const setError = (error: string | null): SetErrorAction => ({ +const setError = (error: string | null): SetErrorAction => ({ type: ActionTypes.SetError, error, }); const _handleIndexesChanged = ( - indexes: IndexDefinition[] + indexes: RegularIndex[] ): IndexesThunkAction => { return (dispatch) => { - dispatch(setIndexes(indexes)); + dispatch(setRegularIndexes(indexes)); dispatch(setIsRefreshing(false)); dispatch(localAppRegistryEmit('indexes-changed', indexes)); }; @@ -275,8 +276,8 @@ export const fetchIndexes = (): IndexesThunkAction< }; }; -export const sortIndexes = ( - column: SortColumn, +export const sortRegularIndexes = ( + column: RegularSortColumn, order: SortDirection ): IndexesThunkAction => { return (dispatch, getState) => { @@ -297,7 +298,7 @@ export const sortIndexes = ( }; }; -export const refreshIndexes = (): IndexesThunkAction => { +export const refreshRegularIndexes = (): IndexesThunkAction => { return (dispatch) => { dispatch(setIsRefreshing(true)); void dispatch(fetchIndexes()); @@ -406,7 +407,7 @@ export const unhideIndex = ( }; function _mergeInProgressIndexes( - _indexes: IndexDefinition[], + _indexes: RegularIndex[], inProgressIndexes: InProgressIndex[] ) { const indexes = cloneDeep(_indexes); @@ -429,7 +430,7 @@ function _mergeInProgressIndexes( } const _getSortFunctionForProperties = (order: 1 | -1) => { - return function (a: IndexDefinition, b: IndexDefinition) { + return function (a: RegularIndex, b: RegularIndex) { const aValue = a.cardinality === 'compound' ? 'compound' : a.properties?.[0] || ''; const bValue = @@ -449,7 +450,7 @@ const _getSortFunction = (field: SortField, sortOrder: SortDirection) => { if (field === 'properties') { return _getSortFunctionForProperties(order); } - return function (a: IndexDefinition, b: IndexDefinition) { + return function (a: RegularIndex, b: RegularIndex) { if (typeof b[field] === 'undefined') { return order; } @@ -466,6 +467,6 @@ const _getSortFunction = (field: SortField, sortOrder: SortDirection) => { }; }; -const _mapColumnToProp = (column: SortColumn): SortField => { +const _mapColumnToProp = (column: RegularSortColumn): SortField => { return sortColumnToProps[column]; }; diff --git a/packages/compass-indexes/src/modules/search-indexes.spec.ts b/packages/compass-indexes/src/modules/search-indexes.spec.ts index 413fb24abac..1e6a26eaf6d 100644 --- a/packages/compass-indexes/src/modules/search-indexes.spec.ts +++ b/packages/compass-indexes/src/modules/search-indexes.spec.ts @@ -1,24 +1,184 @@ import { expect } from 'chai'; -import { SearchIndexesStatuses, setStatus } from './search-indexes'; +import sinon from 'sinon'; +import type { SearchIndex } from 'mongodb-data-service'; +import { + SearchIndexesStatuses, + fetchSearchIndexes, + sortSearchIndexes, +} from './search-indexes'; +import type { IndexesDataService } from '../stores/store'; import { setupStore } from '../../test/setup-store'; +import { readonlyViewChanged } from './is-readonly-view'; + +const searchIndexes: SearchIndex[] = [ + { + id: '1', + name: 'default', + status: 'READY', + queryable: true, + latestDefinition: {}, + }, + { + id: '2', + name: 'another', + status: 'FAILED', + queryable: true, + latestDefinition: {}, + }, +]; describe('search-indexes module', function () { let store: ReturnType; + let getSearchIndexesStub: any; beforeEach(function () { - store = setupStore(); + store = setupStore({ + isSearchIndexesSupported: true, + }); + + getSearchIndexesStub = sinon + .stub( + store.getState().dataService as IndexesDataService, + 'getSearchIndexes' + ) + .resolves(searchIndexes); }); it('has not available search indexes state by default', function () { + store = setupStore(); expect(store.getState().searchIndexes.status).to.equal( SearchIndexesStatuses.NOT_AVAILABLE ); }); - it('sets the status of the search indexes', function () { - store.dispatch(setStatus(SearchIndexesStatuses.PENDING)); - expect(store.getState().searchIndexes.status).to.equal( - SearchIndexesStatuses.PENDING - ); + context('#fetchSearchIndexes action', function () { + it('does nothing if isReadonlyView is true', function () { + store.dispatch(readonlyViewChanged(true)); + + expect(store.getState().isReadonlyView).to.equal(true); + expect(getSearchIndexesStub.callCount).to.equal(0); + + store.dispatch(fetchSearchIndexes); + + expect(getSearchIndexesStub.callCount).to.equal(0); + expect(store.getState().searchIndexes.status).to.equal('PENDING'); + }); + + it('does nothing if there is no dataService', function () { + store.getState().dataService = null; + store.dispatch(fetchSearchIndexes); + // would throw if it tried to use it + }); + + it('fetches the indexes', async function () { + expect(getSearchIndexesStub.callCount).to.equal(0); + expect(store.getState().searchIndexes.status).to.equal('PENDING'); + + await store.dispatch(fetchSearchIndexes()); + + expect(getSearchIndexesStub.callCount).to.equal(1); + 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('PENDING'); + + await store.dispatch(fetchSearchIndexes()); + + expect(getSearchIndexesStub.callCount).to.equal(1); + expect(store.getState().searchIndexes.status).to.equal('READY'); + + // replace the stub + getSearchIndexesStub.restore(); + getSearchIndexesStub = sinon + .stub( + store.getState().dataService as IndexesDataService, + 'getSearchIndexes' + ) + .callsFake(() => { + return new Promise(() => { + // never resolves + }); + }); + + // not awaiting because REFRESHING happens during the action + void store.dispatch(fetchSearchIndexes()); + + expect(store.getState().searchIndexes.status).to.equal('REFRESHING'); + }); + + it('loads and sorts the indexes', async function () { + await store.dispatch(fetchSearchIndexes()); + const state = store.getState(); + expect(state.searchIndexes.indexes).to.deep.equal([ + { + id: '2', + name: 'another', + status: 'FAILED', + queryable: true, + latestDefinition: {}, + }, + { + id: '1', + name: 'default', + status: 'READY', + queryable: true, + latestDefinition: {}, + }, + ]); + expect(state.searchIndexes.sortColumn).to.equal('Name and Fields'); + expect(state.searchIndexes.sortOrder).to.equal('asc'); + }); + + it('sets the status to ERROR if loading the indexes fails', async function () { + // replace the stub + getSearchIndexesStub.restore(); + getSearchIndexesStub = sinon + .stub( + store.getState().dataService as IndexesDataService, + 'getSearchIndexes' + ) + .rejects(new Error('this is an error')); + + await store.dispatch(fetchSearchIndexes()); + + expect(store.getState().searchIndexes.status).to.equal('ERROR'); + expect(store.getState().searchIndexes.error).to.equal('this is an error'); + }); + }); + + context('#sortSearchIndexes action', function () { + it('sorts the indexes as specified', async function () { + await store.dispatch(fetchSearchIndexes()); + let state = store.getState(); + + expect(state.searchIndexes.sortColumn).to.equal('Name and Fields'); + expect(state.searchIndexes.sortOrder).to.equal('asc'); + + store.dispatch(sortSearchIndexes('Status', 'desc')); + + state = store.getState(); + + expect(state.searchIndexes.sortColumn).to.equal('Status'); + expect(state.searchIndexes.sortOrder).to.equal('desc'); + + expect(state.searchIndexes.indexes).to.deep.equal([ + { + id: '1', + name: 'default', + status: 'READY', + queryable: true, + latestDefinition: {}, + }, + { + id: '2', + name: 'another', + status: 'FAILED', + queryable: true, + latestDefinition: {}, + }, + ]); + }); }); }); diff --git a/packages/compass-indexes/src/modules/search-indexes.ts b/packages/compass-indexes/src/modules/search-indexes.ts index 6c127d2d104..e23a0cae55a 100644 --- a/packages/compass-indexes/src/modules/search-indexes.ts +++ b/packages/compass-indexes/src/modules/search-indexes.ts @@ -1,5 +1,18 @@ import type { AnyAction } from 'redux'; +import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; import { isAction } from './../utils/is-action'; +import type { SortDirection, IndexesThunkAction } from '.'; + +import type { SearchIndex } from 'mongodb-data-service'; + +const { debug } = createLoggerAndTelemetry('COMPASS-INDEXES'); + +export type SearchSortColumn = keyof typeof sortColumnToProps; + +const sortColumnToProps = { + 'Name and Fields': 'name', + Status: 'status', +} as const; export enum SearchIndexesStatuses { /** @@ -18,39 +31,206 @@ export enum SearchIndexesStatuses { * Supported and we are refreshing the list. */ REFRESHING = 'REFRESHING', + /** + * Loading/refreshing the list failed. + */ + ERROR = 'ERROR', } -type SearchIndexesStatus = keyof typeof SearchIndexesStatuses; +export type SearchIndexesStatus = keyof typeof SearchIndexesStatuses; export enum ActionTypes { - SetStatus = 'indexes/regular-indexes/SetStatus', + SetIsRefreshing = 'indexes/search-indexes/SetIsRefreshing', + + SetSearchIndexes = 'indexes/search-indexes/SetSearchIndexes', + SearchIndexesSorted = 'indexes/search-indexes/SearchIndexesSorted', + + SetError = 'indexes/search-indexes/SetError', } -type SetStatusAction = { - type: ActionTypes.SetStatus; - status: SearchIndexesStatus; +type SetIsRefreshingAction = { + type: ActionTypes.SetIsRefreshing; +}; + +type SetSearchIndexesAction = { + type: ActionTypes.SetSearchIndexes; + indexes: SearchIndex[]; +}; + +type SearchIndexesSortedAction = { + type: ActionTypes.SearchIndexesSorted; + indexes: SearchIndex[]; + sortOrder: SortDirection; + sortColumn: SearchSortColumn; }; +type SetErrorAction = { + type: ActionTypes.SetError; + error: string | null; +}; + +type SearchIndexesActions = + | SetIsRefreshingAction + | SetSearchIndexesAction + | SearchIndexesSortedAction + | SetErrorAction; + export type State = { status: SearchIndexesStatus; + indexes: SearchIndex[]; + sortOrder: SortDirection; + sortColumn: SearchSortColumn; + error: string | null; }; export const INITIAL_STATE: State = { - status: 'NOT_AVAILABLE', + status: SearchIndexesStatuses.NOT_AVAILABLE, + indexes: [], + sortOrder: 'asc', + sortColumn: 'Name and Fields', + error: null, }; -export default function reducer(state = INITIAL_STATE, action: AnyAction) { - if (isAction(action, ActionTypes.SetStatus)) { +export default function reducer( + state = INITIAL_STATE, + action: AnyAction +): State { + if (isAction(action, ActionTypes.SetIsRefreshing)) { + return { + ...state, + status: SearchIndexesStatuses.REFRESHING, + error: null, + }; + } + + if (isAction(action, ActionTypes.SetSearchIndexes)) { + return { + ...state, + indexes: action.indexes, + status: SearchIndexesStatuses.READY, + }; + } + + if ( + isAction(action, ActionTypes.SearchIndexesSorted) + ) { return { ...state, - status: action.status, + indexes: action.indexes, + sortOrder: action.sortOrder, + sortColumn: action.sortColumn, + }; + } + + if (isAction(action, ActionTypes.SetError)) { + return { + ...state, + indexes: [], + error: action.error, + status: SearchIndexesStatuses.ERROR, }; } return state; } -export const setStatus = (status: SearchIndexesStatus): SetStatusAction => ({ - type: ActionTypes.SetStatus, - status, +const setSearchIndexes = (indexes: SearchIndex[]): SetSearchIndexesAction => ({ + type: ActionTypes.SetSearchIndexes, + indexes, }); + +const setError = (error: string | null): SetErrorAction => ({ + type: ActionTypes.SetError, + error, +}); + +const setIsRefreshing = (): SetIsRefreshingAction => ({ + type: ActionTypes.SetIsRefreshing, +}); + +export const fetchSearchIndexes = (): IndexesThunkAction< + Promise, + SearchIndexesActions +> => { + return async (dispatch, getState) => { + const { + isReadonlyView, + dataService, + namespace, + searchIndexes: { sortColumn, sortOrder, status }, + } = getState(); + + if (isReadonlyView) { + return; + } + + if (!dataService || !dataService.isConnected()) { + debug('warning: trying to load indexes but dataService is disconnected'); + return; + } + + if (status !== SearchIndexesStatuses.PENDING) { + // 2nd time onwards set the status to refreshing + dispatch(setIsRefreshing()); + } + + try { + const indexes = await dataService.getSearchIndexes(namespace); + const sortedIndexes = _sortIndexes(indexes, sortColumn, sortOrder); + dispatch(setSearchIndexes(sortedIndexes)); + } catch (err) { + dispatch(setError((err as Error).message)); + } + }; +}; + +export const refreshSearchIndexes = (): IndexesThunkAction => { + return (dispatch) => { + void dispatch(fetchSearchIndexes()); + }; +}; + +export const sortSearchIndexes = ( + column: SearchSortColumn, + direction: SortDirection +): IndexesThunkAction => { + return (dispatch, getState) => { + const { + searchIndexes: { indexes }, + } = getState(); + + const sortedIndexes = _sortIndexes(indexes, column, direction); + + dispatch({ + type: ActionTypes.SearchIndexesSorted, + indexes: sortedIndexes, + sortOrder: direction, + sortColumn: column, + }); + }; +}; + +function _sortIndexes( + indexes: SearchIndex[], + column: SearchSortColumn, + direction: SortDirection +) { + const order = direction === 'asc' ? 1 : -1; + const field = sortColumnToProps[column]; + + return [...indexes].sort(function (a: SearchIndex, b: SearchIndex) { + if (typeof b[field] === 'undefined') { + return order; + } + if (typeof a[field] === 'undefined') { + return -order; + } + if (a[field]! > b[field]!) { + return order; + } + if (a[field]! < b[field]!) { + return -order; + } + return 0; + }); +} diff --git a/packages/compass-indexes/src/stores/store.ts b/packages/compass-indexes/src/stores/store.ts index 38351a907f4..73dce494384 100644 --- a/packages/compass-indexes/src/stores/store.ts +++ b/packages/compass-indexes/src/stores/store.ts @@ -14,14 +14,27 @@ import { inProgressIndexFailed, type InProgressIndex, } from '../modules/regular-indexes'; -import { SearchIndexesStatuses } from '../modules/search-indexes'; +import { + INITIAL_STATE as SEARCH_INDEXES_INITIAL_STATE, + fetchSearchIndexes, + SearchIndexesStatuses, +} from '../modules/search-indexes'; import type { DataService } from 'mongodb-data-service'; import type AppRegistry from 'hadron-app-registry'; export type IndexesDataService = Pick< DataService, - 'indexes' | 'isConnected' | 'updateCollection' | 'createIndex' | 'dropIndex' + | 'indexes' + | 'isConnected' + | 'updateCollection' + | 'createIndex' + | 'dropIndex' + | 'getSearchIndexes' + | 'createSearchIndex' + | 'updateSearchIndex' + | 'dropSearchIndex' >; + export type ConfigureStoreOptions = { dataProvider: { dataProvider?: IndexesDataService; @@ -52,6 +65,7 @@ const configureStore = (options: ConfigureStoreOptions) => { serverVersion: options.serverVersion, isReadonlyView: options.isReadonly, searchIndexes: { + ...SEARCH_INDEXES_INITIAL_STATE, status: options.isSearchIndexesSupported ? SearchIndexesStatuses.PENDING : SearchIndexesStatuses.NOT_AVAILABLE, @@ -65,7 +79,7 @@ const configureStore = (options: ConfigureStoreOptions) => { const localAppRegistry = options.localAppRegistry; store.dispatch(localAppRegistryActivated(localAppRegistry)); - localAppRegistry.on('refresh-data', () => { + localAppRegistry.on('refresh-regular-indexes', () => { void store.dispatch(fetchIndexes()); }); @@ -94,6 +108,7 @@ const configureStore = (options: ConfigureStoreOptions) => { globalAppRegistry.on('refresh-data', () => { void store.dispatch(fetchIndexes()); + void store.dispatch(fetchSearchIndexes()); }); const instanceStore: any = globalAppRegistry.getStore('App.InstanceStore'); @@ -113,7 +128,6 @@ const configureStore = (options: ConfigureStoreOptions) => { } } - void store.dispatch(fetchIndexes()); return store; }; diff --git a/packages/compass-indexes/test/setup-store.ts b/packages/compass-indexes/test/setup-store.ts index 184d0c33aaa..2951d1591f0 100644 --- a/packages/compass-indexes/test/setup-store.ts +++ b/packages/compass-indexes/test/setup-store.ts @@ -37,6 +37,22 @@ export const setupStore = (options: Partial = {}) => { dropIndex(ns, name) { return Promise.resolve({}); }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getSearchIndexes(ns: string) { + return Promise.resolve([]); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + createSearchIndex(ns: string, name: string, spec: any) { + return Promise.resolve('new-id'); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + updateSearchIndex(ns: string, name: string, spec: any) { + return Promise.resolve(); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + dropSearchIndex(ns: string, name: string) { + return Promise.resolve(); + }, }; return configureStore({ diff --git a/packages/compass-preferences-model/src/feature-flags.ts b/packages/compass-preferences-model/src/feature-flags.ts index 52ac7171a84..f6c12c3a842 100644 --- a/packages/compass-preferences-model/src/feature-flags.ts +++ b/packages/compass-preferences-model/src/feature-flags.ts @@ -97,7 +97,7 @@ export const featureFlags: Required<{ enableAtlasSearchIndexManagement: { stage: 'development', description: { - short: 'Enables Atlas Search Index management.', + short: 'Enable Atlas Search Index management.', long: 'Allows listing, creating, updating and deleting Atlas Search indexes.', }, },