diff --git a/package-lock.json b/package-lock.json index c102a834a70..ecc239218a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70008,8 +70008,6 @@ "hadron-app": "^5.0.0", "hadron-app-registry": "^9.0.0", "lodash.contains": "^2.4.3", - "lodash.map": "^4.6.0", - "lodash.max": "^4.0.1", "mocha": "^8.4.0", "mongodb": "^4.6.0", "mongodb-data-service": "^22.0.0", @@ -107550,8 +107548,6 @@ "hadron-app-registry": "^9.0.0", "hadron-react-components": "^6.0.0", "lodash.contains": "^2.4.3", - "lodash.map": "^4.6.0", - "lodash.max": "^4.0.1", "mocha": "^8.4.0", "mongodb": "^4.6.0", "mongodb-data-service": "^22.0.0", diff --git a/packages/compass-indexes/package.json b/packages/compass-indexes/package.json index 0abd12a4946..10124d521d1 100644 --- a/packages/compass-indexes/package.json +++ b/packages/compass-indexes/package.json @@ -80,8 +80,6 @@ "hadron-app": "^5.0.0", "hadron-app-registry": "^9.0.0", "lodash.contains": "^2.4.3", - "lodash.map": "^4.6.0", - "lodash.max": "^4.0.1", "mocha": "^8.4.0", "mongodb": "^4.6.0", "mongodb-data-service": "^22.0.0", diff --git a/packages/compass-indexes/src/components/create-index-modal/create-index-modal.tsx b/packages/compass-indexes/src/components/create-index-modal/create-index-modal.tsx index 5035ac206d9..b712074953d 100644 --- a/packages/compass-indexes/src/components/create-index-modal/create-index-modal.tsx +++ b/packages/compass-indexes/src/components/create-index-modal/create-index-modal.tsx @@ -110,7 +110,7 @@ function CreateIndexModal({ resetForm: () => void; isVisible: boolean; namespace: string; - error?: string; + error: string | null; clearError: () => void; inProgress: boolean; createIndex: () => void; diff --git a/packages/compass-indexes/src/components/indexes-table/indexes-table.spec.tsx b/packages/compass-indexes/src/components/indexes-table/indexes-table.spec.tsx index b569c9af300..da557dffa67 100644 --- a/packages/compass-indexes/src/components/indexes-table/indexes-table.spec.tsx +++ b/packages/compass-indexes/src/components/indexes-table/indexes-table.spec.tsx @@ -5,9 +5,9 @@ import userEvent from '@testing-library/user-event'; import { spy } from 'sinon'; import { IndexesTable } from './indexes-table'; -import type { IndexModel } from './indexes-table'; +import type { IndexDefinition } from '../../modules/indexes'; -const indexes: IndexModel[] = [ +const indexes: IndexDefinition[] = [ { cardinality: 'single', name: '_id_', @@ -26,6 +26,7 @@ const indexes: IndexModel[] = [ ]; }, }, + usageCount: 10, }, { cardinality: 'compound', @@ -49,6 +50,7 @@ const indexes: IndexModel[] = [ ]; }, }, + usageCount: 15, }, { cardinality: 'compound', @@ -73,6 +75,7 @@ const indexes: IndexModel[] = [ ]; }, }, + usageCount: 20, }, { cardinality: 'single', @@ -97,6 +100,7 @@ const indexes: IndexModel[] = [ ]; }, }, + usageCount: 25, }, ]; 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 58107d05988..7a40f5292c4 100644 --- a/packages/compass-indexes/src/components/indexes-table/indexes-table.tsx +++ b/packages/compass-indexes/src/components/indexes-table/indexes-table.tsx @@ -15,6 +15,11 @@ import SizeField from './size-field'; import UsageField from './usage-field'; import PropertyField from './property-field'; import DropField from './drop-field'; +import type { + IndexDefinition, + SortColumn, + SortDirection, +} from '../../modules/indexes'; // When row is hovered, we show the delete button const rowStyles = css({ @@ -53,27 +58,11 @@ const nameFieldStyles = css({ paddingBottom: spacing[2], }); -// todo: move to redux store when converting that to ts -export type IndexModel = { - name: string; - fields: { - serialize: () => { field: string; value: number | string }[]; - }; - type: 'geo' | 'hashed' | 'text' | 'wildcard' | 'clustered' | 'columnstore'; - cardinality: 'single' | 'compound'; - properties: ('unique' | 'sparse' | 'partial' | 'ttl' | 'collation')[]; - extra: Record>; - size: number; - relativeSize: number; - usageCount?: number; - usageSince?: Date; -}; - type IndexesTableProps = { darkMode?: boolean; - indexes: IndexModel[]; + indexes: IndexDefinition[]; canDeleteIndex: boolean; - onSortTable: (name: string, direction: 'asc' | 'desc') => void; + onSortTable: (column: SortColumn, direction: SortDirection) => void; onDeleteIndex: (name: string) => void; }; @@ -84,13 +73,14 @@ export const IndexesTable: React.FunctionComponent = ({ onDeleteIndex, }) => { const columns = useMemo(() => { - const _columns = [ + const sortColumns: SortColumn[] = [ 'Name and Definition', 'Type', 'Size', 'Usage', 'Properties', - ].map((name) => { + ]; + const _columns = sortColumns.map((name) => { return ( ; + keys: ReturnType; }; const NameField: React.FunctionComponent = ({ name, keys }) => { diff --git a/packages/compass-indexes/src/components/indexes-table/property-field.tsx b/packages/compass-indexes/src/components/indexes-table/property-field.tsx index 49bf53dc9af..8a644aa4702 100644 --- a/packages/compass-indexes/src/components/indexes-table/property-field.tsx +++ b/packages/compass-indexes/src/components/indexes-table/property-field.tsx @@ -2,7 +2,7 @@ import React from 'react'; import getIndexHelpLink from '../../utils/index-link-helper'; import { spacing, css, Tooltip, Body } from '@mongodb-js/compass-components'; -import type { IndexModel } from './indexes-table'; +import type { IndexDefinition } from '../../modules/indexes'; import BadgeWithIconLink from './badge-with-icon-link'; const containerStyles = css({ @@ -19,8 +19,8 @@ const ttlTooltip = (expireAfterSeconds: number) => { }; export const getPropertyTooltip = ( - property: IndexModel['properties'][0], - extra: IndexModel['extra'] + property: IndexDefinition['properties'][0], + extra: IndexDefinition['extra'] ): string | null => { return property === 'ttl' ? ttlTooltip(extra.expireAfterSeconds as number) @@ -50,9 +50,9 @@ const PropertyBadgeWithTooltip: React.FunctionComponent<{ }; type PropertyFieldProps = { - extra: IndexModel['extra']; - properties: IndexModel['properties']; - cardinality: IndexModel['cardinality']; + extra: IndexDefinition['extra']; + properties: IndexDefinition['properties']; + cardinality: IndexDefinition['cardinality']; }; const PropertyField: React.FunctionComponent = ({ diff --git a/packages/compass-indexes/src/components/indexes-table/type-field.tsx b/packages/compass-indexes/src/components/indexes-table/type-field.tsx index d0a6381bc30..2f954b45450 100644 --- a/packages/compass-indexes/src/components/indexes-table/type-field.tsx +++ b/packages/compass-indexes/src/components/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 { IndexModel } from './indexes-table'; +import type { IndexDefinition } from '../../modules/indexes'; import BadgeWithIconLink from './badge-with-icon-link'; -export const canRenderTooltip = (type: IndexModel['type']) => { +export const canRenderTooltip = (type: IndexDefinition['type']) => { return ['text', 'wildcard', 'columnstore'].indexOf(type) !== -1; }; type TypeFieldProps = { - type: IndexModel['type']; - extra: IndexModel['extra']; + type: IndexDefinition['type']; + extra: IndexDefinition['extra']; }; export const IndexTypeTooltip: React.FunctionComponent<{ - extra: IndexModel['extra']; + extra: IndexDefinition['extra']; }> = ({ extra }) => { const allowedProps = [ 'weights', 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 dfd2a0af0c8..9847d035315 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 @@ -1,5 +1,5 @@ import React from 'react'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { expect } from 'chai'; import AppRegistry from 'hadron-app-registry'; @@ -14,12 +14,14 @@ const renderIndexesToolbar = ( render( {}} + isRefreshing={false} {...props} /> ); @@ -128,4 +130,36 @@ describe('IndexesToolbar Component', function () { expect(emitSpy.firstCall.args[1]).to.equal(true); }); }); + + describe('when the refresh button is clicked', function () { + it('renders refresh button - enabled state', function () { + renderIndexesToolbar({ + isRefreshing: false, + }); + const refreshButton = screen.getByTestId('refresh-indexes-button'); + expect(refreshButton).to.exist; + expect(refreshButton.getAttribute('disabled')).to.be.null; + }); + + it('renders refresh button - disabled state', function () { + renderIndexesToolbar({ + isRefreshing: true, + }); + const refreshButton = screen.getByTestId('refresh-indexes-button'); + expect(refreshButton).to.exist; + expect(refreshButton.getAttribute('disabled')).to.not.be.null; + expect(within(refreshButton).getByTitle(/refreshing indexes/i)).to.exist; + }); + + it('should call onRefreshIndexes', function () { + const onRefreshIndexesSpy = sinon.spy(); + renderIndexesToolbar({ + onRefreshIndexes: onRefreshIndexesSpy, + }); + const refreshButton = screen.getByTestId('refresh-indexes-button'); + expect(onRefreshIndexesSpy.callCount).to.equal(0); + userEvent.click(refreshButton); + expect(onRefreshIndexesSpy).to.have.been.calledOnce; + }); + }); }); 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 3c10a168f73..1221f311deb 100644 --- a/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx +++ b/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx @@ -8,6 +8,8 @@ import { css, mergeProps, spacing, + Icon, + SpinLoader, } from '@mongodb-js/compass-components'; import type AppRegistry from 'hadron-app-registry'; @@ -23,18 +25,22 @@ const toolbarButtonsContainer = css({ justifyContent: 'flex-end', }); +const spinnerStyles = css({ marginRight: spacing[2] }); + const createIndexButtonContainerStyles = css({ display: 'inline-block', width: 'fit-content', }); type IndexesToolbarProps = { - errorMessage?: string; + errorMessage: string | null; isReadonly: boolean; isReadonlyView: boolean; isWritable: boolean; localAppRegistry: AppRegistry; + isRefreshing: boolean; writeStateDescription?: string; + onRefreshIndexes: () => void; }; export const IndexesToolbar: React.FunctionComponent = ({ @@ -43,17 +49,36 @@ export const IndexesToolbar: React.FunctionComponent = ({ isReadonlyView, isWritable, localAppRegistry, + isRefreshing, writeStateDescription, + onRefreshIndexes, }) => { const onClickCreateIndex = useCallback(() => { localAppRegistry.emit('toggle-create-index-modal', true); }, [localAppRegistry]); const showCreateIndexButton = !isReadonly && !isReadonlyView && !errorMessage; + const refreshButtonIcon = isRefreshing ? ( +
+ +
+ ) : ( + + ); return (
+ {showCreateIndexButton && ( {}} + onRefresh={() => {}} {...props} /> ); @@ -79,6 +81,7 @@ describe('Indexes Component', function () { ]; }, }, + usageCount: 20, }, ], isReadonlyView: false, diff --git a/packages/compass-indexes/src/components/indexes/indexes.tsx b/packages/compass-indexes/src/components/indexes/indexes.tsx index 56c2e88c653..f71b2970788 100644 --- a/packages/compass-indexes/src/components/indexes/indexes.tsx +++ b/packages/compass-indexes/src/components/indexes/indexes.tsx @@ -4,10 +4,16 @@ import { connect } from 'react-redux'; import type AppRegistry from 'hadron-app-registry'; import { sortIndexes } from '../../modules/indexes'; +import type { + IndexDefinition, + SortColumn, + SortDirection, +} from '../../modules/indexes'; import { IndexesToolbar } from '../indexes-toolbar/indexes-toolbar'; import { IndexesTable } from '../indexes-table/indexes-table'; -import type { IndexModel } from '../indexes-table/indexes-table'; +import { refreshIndexes } from '../../modules/is-refreshing'; +import type { RootState } from '../../modules'; const containerStyles = css({ margin: spacing[3], @@ -30,14 +36,16 @@ const indexTableStyles = css({ }); type IndexesProps = { - indexes: IndexModel[]; + indexes: IndexDefinition[]; isWritable: boolean; isReadonly: boolean; isReadonlyView: boolean; description?: string; - error?: string; + error: string | null; localAppRegistry: AppRegistry; - onSortTable: (name: string, direction: 'asc' | 'desc') => void; + isRefreshing: boolean; + onSortTable: (name: SortColumn, direction: SortDirection) => void; + onRefresh: () => void; }; export const Indexes: React.FunctionComponent = ({ @@ -48,7 +56,9 @@ export const Indexes: React.FunctionComponent = ({ description, error, localAppRegistry, + isRefreshing, onSortTable, + onRefresh, }) => { const onDeleteIndex = (name: string) => { return localAppRegistry.emit('toggle-drop-index-modal', true, name); @@ -62,7 +72,9 @@ export const Indexes: React.FunctionComponent = ({ isReadonlyView={isReadonlyView} errorMessage={error} localAppRegistry={localAppRegistry} + isRefreshing={isRefreshing} writeStateDescription={description} + onRefreshIndexes={() => onRefresh()} />
{!isReadonlyView && !error && ( @@ -86,19 +98,22 @@ const mapState = ({ isReadonlyView, description, error, - appRegistry: { localAppRegistry }, -}: any) => ({ + isRefreshing, + appRegistry, +}: RootState) => ({ indexes, isWritable, isReadonly, isReadonlyView, description, error, - localAppRegistry, + localAppRegistry: (appRegistry as any).localAppRegistry, + isRefreshing, }); const mapDispatch = { onSortTable: sortIndexes, + onRefresh: refreshIndexes, }; -export default connect(mapState, mapDispatch)(Indexes as any); +export default connect(mapState, mapDispatch)(Indexes); diff --git a/packages/compass-indexes/src/modules/create-index/fields.spec.js b/packages/compass-indexes/src/modules/create-index/fields.spec.js index b3a7d606bbb..37e9097de16 100644 --- a/packages/compass-indexes/src/modules/create-index/fields.spec.js +++ b/packages/compass-indexes/src/modules/create-index/fields.spec.js @@ -13,7 +13,7 @@ import reducer, { changeFields, CHANGE_FIELDS, } from '../create-index/fields'; -import { HANDLE_ERROR } from '../error'; +import { ActionTypes as ErrorActionTypes } from '../error'; import { CHANGE_SCHEMA_FIELDS } from '../create-index/schema-fields'; import { ActionTypes } from '../create-index/new-index-field'; @@ -159,7 +159,7 @@ describe('create index fields module', function () { it('returns handleError action with duplicate name', function () { const dispatch = (res) => { expect(res).to.deep.equal({ - type: HANDLE_ERROR, + type: ErrorActionTypes.HandleError, error: 'Index keys must be unique', }); actionSpy(); diff --git a/packages/compass-indexes/src/modules/create-index/index.spec.js b/packages/compass-indexes/src/modules/create-index/index.spec.js index 19aeef4215f..ad49af76dea 100644 --- a/packages/compass-indexes/src/modules/create-index/index.spec.js +++ b/packages/compass-indexes/src/modules/create-index/index.spec.js @@ -2,7 +2,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { createIndex } from '../create-index'; -import { HANDLE_ERROR, CLEAR_ERROR } from '../error'; +import { ActionTypes as ErrorActionTypes } from '../error'; import { TOGGLE_IN_PROGRESS } from '../in-progress'; import { TOGGLE_IS_VISIBLE } from '../is-visible'; import { RESET } from '../reset'; @@ -34,7 +34,7 @@ describe('create index module', function () { it('errors if fields are undefined', function () { const dispatch = (res) => { expect(res).to.deep.equal({ - type: HANDLE_ERROR, + type: ErrorActionTypes.HandleError, error: 'You must select a field name and type', }); errorSpy(); @@ -48,7 +48,7 @@ describe('create index module', function () { it('errors if TTL is not number', function () { const dispatch = (res) => { expect(res).to.deep.equal({ - type: HANDLE_ERROR, + type: ErrorActionTypes.HandleError, error: 'Bad TTL: "abc"', }); errorSpy(); @@ -64,7 +64,7 @@ describe('create index module', function () { it('errors if PFE is not JSON', function () { const dispatch = (res) => { expect(res).to.deep.equal({ - type: HANDLE_ERROR, + type: ErrorActionTypes.HandleError, error: 'Bad PartialFilterExpression: SyntaxError: Unexpected token a in JSON at position 0', }); @@ -88,7 +88,7 @@ describe('create index module', function () { case RESET: resetSpy(); break; - case CLEAR_ERROR: + case ErrorActionTypes.ClearError: clearErrorSpy(); break; case TOGGLE_IS_VISIBLE: @@ -158,7 +158,7 @@ describe('create index module', function () { case RESET: resetSpy(); break; - case CLEAR_ERROR: + case ErrorActionTypes.ClearError: clearErrorSpy(); break; case TOGGLE_IS_VISIBLE: @@ -221,10 +221,10 @@ describe('create index module', function () { case TOGGLE_IN_PROGRESS: progressSpy(); break; - case HANDLE_ERROR: + case ErrorActionTypes.HandleError: expect(res).to.deep.equal({ - type: HANDLE_ERROR, - error: 'test err', + type: ErrorActionTypes.HandleError, + error: { message: 'test err' }, }); errorSpy(); break; diff --git a/packages/compass-indexes/src/modules/create-index/index.ts b/packages/compass-indexes/src/modules/create-index/index.ts index fde3a429831..3fb28b0c282 100644 --- a/packages/compass-indexes/src/modules/create-index/index.ts +++ b/packages/compass-indexes/src/modules/create-index/index.ts @@ -3,7 +3,11 @@ import { combineReducers } from 'redux'; import type { AnyAction, Dispatch } from 'redux'; import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; import queryParser from 'mongodb-query-parser'; -import type { CollationOptions } from 'mongodb'; +import type { + IndexSpecification, + CreateIndexesOptions, + IndexDirection, +} from 'mongodb'; import dataService from '../data-service'; import appRegistry, { @@ -71,12 +75,9 @@ import schemaFields from '../create-index/schema-fields'; import newIndexField from '../create-index/new-index-field'; import { RESET_FORM } from '../reset-form'; import { RESET, reset } from '../reset'; -import { parseErrorMsg } from '../indexes'; const { track } = createLoggerAndTelemetry('COMPASS-INDEXES-UI'); -type CreateIndexSpec = { [name: string]: string | number }; - /** * The main reducer. */ @@ -152,7 +153,7 @@ export default rootReducer; export const createIndex = () => { return (dispatch: Dispatch, getState: () => RootState) => { const state = getState(); - const spec = {} as CreateIndexSpec; + const spec: IndexSpecification = {}; // Check for field errors. if ( @@ -165,28 +166,21 @@ export const createIndex = () => { } // Check for collaction errors. - const collation = queryParser.isCollationValid(state.collationString); + const collation = + queryParser.isCollationValid(state.collationString) || undefined; if (state.useCustomCollation && !collation) { dispatch(handleError('You must provide a valid collation object')); return; } state.fields.forEach((field: IndexField) => { - let type: string | number = field.type; - if (type === '1 (asc)') type = 1; - if (type === '-1 (desc)') type = -1; + let type = field.type as IndexDirection; + if ((type as string) === '1 (asc)') type = 1; + if ((type as string) === '-1 (desc)') type = -1; spec[field.name] = type; }); - const options: { - unique?: boolean; - name?: string; - collation?: false | CollationOptions | null; - expireAfterSeconds?: number; - wildcardProjection?: EJSON.SerializableTypes; - columnstoreProjection?: EJSON.SerializableTypes; - partialFilterExpression?: EJSON.SerializableTypes; - } = {}; + const options: CreateIndexesOptions = {}; options.unique = state.isUnique; // The server will generate a name when we don't provide one. if (state.name !== '') { @@ -204,7 +198,9 @@ export const createIndex = () => { } if (state.useWildcardProjection) { try { - options.wildcardProjection = EJSON.parse(state.wildcardProjection); + options.wildcardProjection = EJSON.parse( + state.wildcardProjection + ) as Document; } catch (err) { dispatch(handleError(`Bad WildcardProjection: ${String(err)}`)); return; @@ -221,9 +217,10 @@ export const createIndex = () => { if (state.useColumnstoreProjection) { try { - options.columnstoreProjection = EJSON.parse( + // columnstoreProjection is not part of CreateIndexesOptions yet + (options as any).columnstoreProjection = EJSON.parse( state.columnstoreProjection - ); + ) as Document; } catch (err) { dispatch(handleError(`Bad ColumnstoreProjection: ${String(err)}`)); return; @@ -233,7 +230,7 @@ export const createIndex = () => { try { options.partialFilterExpression = EJSON.parse( state.partialFilterExpression - ); + ) as Document; } catch (err) { dispatch(handleError(`Bad PartialFilterExpression: ${String(err)}`)); return; @@ -242,7 +239,7 @@ export const createIndex = () => { dispatch(toggleInProgress(true)); const ns = state.namespace; - state.dataService.createIndex(ns, spec, options, (createErr: Error) => { + state.dataService?.createIndex(ns, spec, options, (createErr: any) => { if (!createErr) { const trackEvent = { unique: state.isUnique, @@ -277,7 +274,7 @@ export const createIndex = () => { dispatch(toggleIsVisible(false)); } else { dispatch(toggleInProgress(false)); - dispatch(handleError(parseErrorMsg(createErr))); + dispatch(handleError(createErr)); } }); }; diff --git a/packages/compass-indexes/src/modules/data-service.spec.js b/packages/compass-indexes/src/modules/data-service.spec.js deleted file mode 100644 index 2d25a902fa1..00000000000 --- a/packages/compass-indexes/src/modules/data-service.spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import { expect } from 'chai'; - -import reducer, { - dataServiceConnected, - DATA_SERVICE_CONNECTED, -} from './data-service'; - -describe('data service module', function () { - describe('#dataServiceConnected', function () { - it('returns the DATA_SERVICE_CONNECTED action', function () { - expect(dataServiceConnected('ds')).to.deep.equal({ - type: DATA_SERVICE_CONNECTED, - dataService: 'ds', - }); - }); - }); - - describe('#reducer', function () { - context('when the action is not data service connected', function () { - it('returns the default state', function () { - expect(reducer(undefined, { type: 'test' })).to.deep.equal(null); - }); - }); - - context('when the action is data service connected', function () { - it('returns the new state', function () { - expect(reducer(undefined, dataServiceConnected('ds'))).to.deep.equal( - 'ds' - ); - }); - }); - }); -}); diff --git a/packages/compass-indexes/src/modules/data-service.spec.ts b/packages/compass-indexes/src/modules/data-service.spec.ts new file mode 100644 index 00000000000..dc2b443b461 --- /dev/null +++ b/packages/compass-indexes/src/modules/data-service.spec.ts @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import type { DataService } from 'mongodb-data-service'; + +import reducer, { + dataServiceConnected, + ActionTypes as DataServiceActions, +} from './data-service'; + +const mockDataService = new (class { + indexes() {} +})() as any as DataService; + +describe('data service module', function () { + describe('#dataServiceConnected', function () { + it('returns the connected action', function () { + expect(dataServiceConnected(mockDataService)).to.deep.equal({ + type: DataServiceActions.DataServiceConnected, + dataService: mockDataService, + }); + }); + }); + + describe('#reducer', function () { + context('when the action is not data service connected', function () { + it('returns the default state', function () { + expect(reducer(undefined, { type: 'test' } as any)).to.deep.equal(null); + }); + }); + + context('when the action is data service connected', function () { + it('returns the new state', function () { + expect( + reducer(undefined, dataServiceConnected(mockDataService)) + ).to.deep.equal(mockDataService); + }); + }); + }); +}); diff --git a/packages/compass-indexes/src/modules/data-service.ts b/packages/compass-indexes/src/modules/data-service.ts index 8e9566277d1..61f5846e7dd 100644 --- a/packages/compass-indexes/src/modules/data-service.ts +++ b/packages/compass-indexes/src/modules/data-service.ts @@ -1,44 +1,29 @@ -import type { AnyAction } from 'redux'; import type { DataService } from 'mongodb-data-service'; -/** - * The prefix. - */ -const PREFIX = 'indexes/data-service'; +export enum ActionTypes { + DataServiceConnected = 'indexes/data-service/DATA_SERVICE_CONNECTED', +} + +type DataServiceConnectedAction = { + type: ActionTypes.DataServiceConnected; + dataService: DataService; +}; -/** - * Data service connected. - */ -export const DATA_SERVICE_CONNECTED = `${PREFIX}/DATA_SERVICE_CONNECTED`; +type State = DataService | null; -/** - * The initial state. - */ -export const INITIAL_STATE = null; +const INITIAL_STATE: State = null; -/** - * Reducer function for handling data service connected actions. - * - * @param {Object} state - The data service state. - * @param {Object} action - The action. - * - * @returns {DataService} The data service connected action. - */ -export default function reducer(state = INITIAL_STATE, action: AnyAction) { - if (action.type === DATA_SERVICE_CONNECTED) { +export default function reducer( + state = INITIAL_STATE, + action: DataServiceConnectedAction +) { + if (action.type === ActionTypes.DataServiceConnected) { return action.dataService; } return state; } -/** - * Action creator for data service connected events. - * - * @param {DataService} dataService - The data service. - * - * @returns {Object} The data service connected action. - */ export const dataServiceConnected = (dataService: DataService) => ({ - type: DATA_SERVICE_CONNECTED, + type: ActionTypes.DataServiceConnected, dataService, }); diff --git a/packages/compass-indexes/src/modules/description.js b/packages/compass-indexes/src/modules/description.js index ffaa6ca2bbb..3a5bf124d3e 100644 --- a/packages/compass-indexes/src/modules/description.js +++ b/packages/compass-indexes/src/modules/description.js @@ -18,8 +18,6 @@ export const INITIAL_STATE = 'Topology type not yet discovered.'; * * @param {Boolean} state - The status state. * @param {Object} action - The action. - * - * @returns {Boolean} The new state. */ const reducer = (state = INITIAL_STATE, action) => { if (action.type === GET_DESCRIPTION) { diff --git a/packages/compass-indexes/src/modules/drop-index/index.js b/packages/compass-indexes/src/modules/drop-index/index.js index df6491017af..8fc55e35fc0 100644 --- a/packages/compass-indexes/src/modules/drop-index/index.js +++ b/packages/compass-indexes/src/modules/drop-index/index.js @@ -25,7 +25,6 @@ import confirmName, { import { RESET_FORM } from '../reset-form'; import { RESET, reset } from '../reset'; -import { parseErrorMsg } from '../indexes'; import namespace from '../namespace'; const { track } = createLoggerAndTelemetry('COMPASS-INDEXES-UI'); @@ -90,7 +89,7 @@ export const dropIndex = (indexName) => { dispatch(toggleIsVisible(false)); } else { dispatch(toggleInProgress(false)); - dispatch(handleError(parseErrorMsg(err))); + dispatch(handleError(err)); } }); }; diff --git a/packages/compass-indexes/src/modules/drop-index/index.spec.js b/packages/compass-indexes/src/modules/drop-index/index.spec.js index 2b82bb780d5..80ff06f9091 100644 --- a/packages/compass-indexes/src/modules/drop-index/index.spec.js +++ b/packages/compass-indexes/src/modules/drop-index/index.spec.js @@ -2,7 +2,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { dropIndex } from '../drop-index'; -import { HANDLE_ERROR, CLEAR_ERROR } from '../error'; +import { ActionTypes as ErrorActionTypes } from '../error'; import { TOGGLE_IN_PROGRESS } from '../in-progress'; import { TOGGLE_IS_VISIBLE } from '../is-visible'; import { RESET } from '../reset'; @@ -41,7 +41,7 @@ describe('drop index module', function () { case RESET: resetSpy(); break; - case CLEAR_ERROR: + case ErrorActionTypes.ClearError: clearErrorSpy(); break; case TOGGLE_IS_VISIBLE: @@ -88,10 +88,10 @@ describe('drop index module', function () { case TOGGLE_IN_PROGRESS: progressSpy(); break; - case HANDLE_ERROR: + case ErrorActionTypes.HandleError: expect(res).to.deep.equal({ - type: HANDLE_ERROR, - error: 'test err', + type: ErrorActionTypes.HandleError, + error: { message: 'test err' }, }); errorSpy(); break; diff --git a/packages/compass-indexes/src/modules/error.spec.js b/packages/compass-indexes/src/modules/error.spec.ts similarity index 58% rename from packages/compass-indexes/src/modules/error.spec.js rename to packages/compass-indexes/src/modules/error.spec.ts index af760639eb1..4bf65dcdf8e 100644 --- a/packages/compass-indexes/src/modules/error.spec.js +++ b/packages/compass-indexes/src/modules/error.spec.ts @@ -1,11 +1,11 @@ import { expect } from 'chai'; +import { MongoError } from 'mongodb'; import reducer, { + ActionTypes as ErrorActionTypes, INITIAL_STATE, clearError, handleError, - CLEAR_ERROR, - HANDLE_ERROR, } from './error'; describe('handle error name module', function () { @@ -14,8 +14,22 @@ describe('handle error name module', function () { describe('#reducer', function () { context('when an action is provided', function () { context('when the action is handle error', function () { - it('returns the new state', function () { - expect(reducer(undefined, handleError(error))).to.equal(error); + it('processes the error', function () { + expect(reducer(undefined, handleError(error))).to.equal( + error.message + ); + expect(reducer(undefined, handleError('something random'))).to.equal( + 'something random' + ); + expect( + reducer(undefined, handleError(new MongoError('legacy error'))) + ).to.equal('legacy error'); + expect(reducer(undefined, handleError(undefined))).to.equal( + 'Unknown error' + ); + expect(reducer(undefined, handleError(null))).to.equal( + 'Unknown error' + ); }); }); @@ -28,7 +42,7 @@ describe('handle error name module', function () { context('when an action is not provided', function () { it('returns the default state', function () { - expect(reducer(undefined, {})).to.equal(INITIAL_STATE); + expect(reducer(undefined, {} as any)).to.equal(INITIAL_STATE); }); }); }); @@ -36,7 +50,7 @@ describe('handle error name module', function () { describe('#handleError', function () { it('returns the action', function () { expect(handleError(error)).to.deep.equal({ - type: HANDLE_ERROR, + type: ErrorActionTypes.HandleError, error: error, }); }); @@ -45,7 +59,7 @@ describe('handle error name module', function () { describe('#clearError', function () { it('returns the action', function () { expect(clearError()).to.deep.equal({ - type: CLEAR_ERROR, + type: ErrorActionTypes.ClearError, }); }); }); diff --git a/packages/compass-indexes/src/modules/error.ts b/packages/compass-indexes/src/modules/error.ts index 3ca559e31f6..86f80c13886 100644 --- a/packages/compass-indexes/src/modules/error.ts +++ b/packages/compass-indexes/src/modules/error.ts @@ -1,59 +1,59 @@ -import type { AnyAction } from 'redux'; +import { MongoError } from 'mongodb'; +import type { AnyError } from 'mongodb'; -/** - * The action name prefix. - */ -const PREFIX = 'indexes/error'; +export type IndexesError = AnyError | string | null | undefined; -/** - * Handle error action name. - */ -export const HANDLE_ERROR = `${PREFIX}/HANDLE_ERROR`; +export enum ActionTypes { + HandleError = 'indexes/error/HANDLE_ERROR', + ClearError = 'indexes/error/CLEAR_ERROR', +} -/** - * Clear error action name. - */ -export const CLEAR_ERROR = `${PREFIX}/CLEAR_ERROR`; +export type HandleErrorAction = { + type: ActionTypes.HandleError; + error: IndexesError; +}; -/** - * The initial state of the error. - */ -export const INITIAL_STATE = null; +type ClearErrorAction = { + type: ActionTypes.ClearError; +}; -/** - * Reducer function for handle state changes to errors. - * - * @param {Error} state - The error state. - * @param {Object} action - The action. - * - * @returns {Error} The new state. - */ -export default function reducer(state = INITIAL_STATE, action: AnyAction) { - if (action.type === HANDLE_ERROR) { - return action.error; - } else if (action.type === CLEAR_ERROR) { - return null; +export type Actions = HandleErrorAction | ClearErrorAction; + +type State = string | null; +export const INITIAL_STATE: State = null; + +export default function reducer(state: State = INITIAL_STATE, action: Actions) { + if (action.type === ActionTypes.HandleError) { + return _parseError(action.error); + } else if (action.type === ActionTypes.ClearError) { + return INITIAL_STATE; } return state; } -/** - * Handle error action creator. - * - * @param {String} error - The error. - * - * @returns {String} The action. - */ -export const handleError = (error: string) => ({ - type: HANDLE_ERROR, +export const handleError = (error: IndexesError): HandleErrorAction => ({ + type: ActionTypes.HandleError, error, }); +export const clearError = (): ClearErrorAction => ({ + type: ActionTypes.ClearError, +}); + /** - * Clear error action creator. - * - * @returns {Object} The action. + * Data Service attaches string message property for some errors, but not all + * that can happen during index creation/dropping. + * Check for data service custom error, then node driver errmsg. */ -export const clearError = () => ({ - type: CLEAR_ERROR, -}); +const _parseError = (err: IndexesError): string => { + if (typeof err === 'string') { + return err; + } + if (typeof err?.message === 'string') { + return err.message; + } + if (err instanceof MongoError && err.errmsg === 'string') { + return err.errmsg; + } + return 'Unknown error'; +}; diff --git a/packages/compass-indexes/src/modules/index.js b/packages/compass-indexes/src/modules/index.ts similarity index 84% rename from packages/compass-indexes/src/modules/index.js rename to packages/compass-indexes/src/modules/index.ts index f2241288f50..a3c63d96de4 100644 --- a/packages/compass-indexes/src/modules/index.js +++ b/packages/compass-indexes/src/modules/index.ts @@ -1,4 +1,5 @@ import { combineReducers } from 'redux'; +import type { AnyAction } from 'redux'; import appRegistry from '@mongodb-js/mongodb-redux-common/app-registry'; import dataService from './data-service'; import { RESET } from './reset'; @@ -24,10 +25,10 @@ import serverVersion, { INITIAL_STATE as SV_INITIAL_STATE } from './error'; import namespace, { INITIAL_STATE as NAMESPACE_INITIAL_STATE, } from './namespace'; +import isRefreshing, { + INITIAL_STATE as REFRESHING_INITIAL_STATE, +} from './is-refreshing'; -/** - * The main reducer. - */ const reducer = combineReducers({ indexes, isWritable, @@ -41,17 +42,12 @@ const reducer = combineReducers({ error, serverVersion, namespace, + isRefreshing, }); -/** - * The root reducer. - * - * @param {Object} state - The state. - * @param {Object} action - The action. - * - * @returns {Object} The new state. - */ -const rootReducer = (state, action) => { +export type RootState = ReturnType; + +const rootReducer = (state: RootState, action: AnyAction): RootState => { if (action.type === RESET) { return { ...state, @@ -65,6 +61,7 @@ const rootReducer = (state, action) => { sortColumn: SORT_COLUMN_INITIAL_STATE, error: ERROR_INITIAL_STATE, namespace: NAMESPACE_INITIAL_STATE, + isRefreshing: REFRESHING_INITIAL_STATE, }; } return reducer(state, action); diff --git a/packages/compass-indexes/src/modules/indexes.js b/packages/compass-indexes/src/modules/indexes.js deleted file mode 100644 index 53c6e9c5619..00000000000 --- a/packages/compass-indexes/src/modules/indexes.js +++ /dev/null @@ -1,224 +0,0 @@ -const debug = require('debug')('mongodb-compass:modules:indexes'); - -import IndexModel from 'mongodb-index-model'; -import map from 'lodash.map'; -import max from 'lodash.max'; -import { handleError } from './error'; -import { localAppRegistryEmit } from '@mongodb-js/mongodb-redux-common/app-registry'; - -/** - * The module action prefix. - */ -const PREFIX = 'indexes'; - -/** - * The loadIndexes action type. - */ -export const LOAD_INDEXES = `${PREFIX}/indexes/LOAD_INDEXES`; - -/** - * The sortIndexes action type. - */ -export const SORT_INDEXES = `${PREFIX}/indexes/SORT_INDEXES`; - -/** - * Default sortOrder - */ -export const DEFAULT = 'Name and Definition'; -export const ASC = 'asc'; -export const DESC = 'desc'; -export const USAGE = 'Usage'; - -/** - * The initial state. - */ -export const INITIAL_STATE = []; - -/** - * Get the comparator for properties. - * - * @param {Integer} order - The order. - * - * @returns {Function} The comparator function. - */ -const _propertiesComparator = (order) => { - return function (a, b) { - const aValue = - a.cardinality === 'compound' ? 'compound' : a.properties[0] || ''; - const bValue = - b.cardinality === 'compound' ? 'compound' : b.properties[0] || ''; - if (aValue > bValue) { - return order; - } - if (aValue < bValue) { - return -order; - } - return 0; - }; -}; - -/** - * Get a comparator function for the sort. - * - * @param {String} field - The field to sort on. - * @param {String} odr - The order. - * - * @returns {Function} The function. - */ -const _comparator = (field, odr) => { - const order = odr === ASC ? 1 : -1; - if (field === 'properties') { - return _propertiesComparator(order); - } - return function (a, b) { - if (a[field] > b[field]) { - return order; - } - if (a[field] < b[field]) { - return -order; - } - return 0; - }; -}; - -/** - * Get the name of the field to sort on based on the column header. - * - * @param {String} f - The field. - * @returns {String} The field. - */ -const _field = (f) => { - if (f === DEFAULT) { - return 'name'; - } else if (f === USAGE) { - return 'usageCount'; - } - return f.toLowerCase(); -}; - -/** - * Converts the raw index data to Index models and does calculations. - * - * @param {Array} indexes - The indexes. - * - * @returns {Array} The index models. - */ -const _convertToModels = (indexes) => { - const maxSize = max( - indexes.map((index) => { - return index.size; - }) - ); - return map(indexes, (index) => { - const model = new IndexModel(new IndexModel().parse(index)); - model.relativeSize = (model.size / maxSize) * 100; - return model; - }); -}; - -export const modelAndSort = (indexes, sortColumn, sortOrder) => { - return _convertToModels(indexes).sort( - _comparator(_field(sortColumn), sortOrder) - ); -}; - -/** - * Data Service attaches string message property for some errors, but not all - * that can happen during index creation/dropping. Check first for data service - * custom error, then node driver errmsg, lastly use default error message. - * - * @param {Object} err - The error to parse a message from - * - * @returns {string} - The found error message, or the default message. - */ -export const parseErrorMsg = (err) => { - if (typeof err.message === 'string') { - return err.message; - } else if (typeof err.errmsg === 'string') { - return err.errmsg; - } - return 'Unknown error'; -}; - -/** - * Reducer function for handle state changes to indexes. - * - * @param {Array} state - The indexes state. - * @param {Object} action - The action. - * - * @returns {Array} The new state. - */ -export default function reducer(state = INITIAL_STATE, action) { - if (action.type === SORT_INDEXES) { - return [...action.indexes].sort( - _comparator(_field(action.column), action.order) - ); - } else if (action.type === LOAD_INDEXES) { - return action.indexes; - } - return state; -} - -/** - * Action creator for load indexes events. - * - * @param {Array} indexes - The raw indexes list. - * - * @returns {Object} The load indexes action. - */ -export const loadIndexes = (indexes) => ({ - type: LOAD_INDEXES, - indexes: indexes, -}); - -export const sortIndexes = (column, order) => { - return (dispatch, getState) => { - const { indexes } = getState(); - return dispatch({ - type: SORT_INDEXES, - indexes, - column, - order, - }); - }; -}; - -/** - * Load indexes from DB. - * - * @param {String} ns - The namespace. - * - * @returns {Function} The thunk function. - */ -export const loadIndexesFromDb = () => { - return (dispatch, getState) => { - const state = getState(); - if (state.isReadonly) { - dispatch(loadIndexes([])); - dispatch(localAppRegistryEmit('indexes-changed', [])); - } else if (state.dataService && state.dataService.isConnected()) { - const ns = state.namespace; - state.dataService.indexes(state.namespace, {}, (err, indexes) => { - if (err) { - dispatch(handleError(parseErrorMsg(err))); - dispatch(loadIndexes([])); - dispatch(localAppRegistryEmit('indexes-changed', [])); - } else { - // Set the `ns` field manually as it is not returned from the server - // since version 4.4. - for (const index of indexes) { - index.ns = ns; - } - const ixs = modelAndSort(indexes, state.sortColumn, state.sortOrder); - dispatch(loadIndexes(ixs)); - dispatch(localAppRegistryEmit('indexes-changed', ixs)); - } - }); - } else if (state.dataService && !state.dataService.isConnected()) { - debug( - 'warning: trying to load indexes but dataService is disconnected', - state.dataService - ); - } - }; -}; diff --git a/packages/compass-indexes/src/modules/indexes.spec.js b/packages/compass-indexes/src/modules/indexes.spec.ts similarity index 55% rename from packages/compass-indexes/src/modules/indexes.spec.js rename to packages/compass-indexes/src/modules/indexes.spec.ts index 60d6fd1baf9..eebfac3c0dd 100644 --- a/packages/compass-indexes/src/modules/indexes.spec.js +++ b/packages/compass-indexes/src/modules/indexes.spec.ts @@ -1,224 +1,188 @@ -/* eslint-disable no-use-before-define */ import { expect } from 'chai'; -import sinon from 'sinon'; - +import type { Store } from 'redux'; +import type { DataService } from 'mongodb-data-service'; +import { spy } from 'sinon'; import reducer, { - loadIndexesFromDb, + ActionTypes as IndexesActionTypes, + fetchIndexes, loadIndexes, sortIndexes, - LOAD_INDEXES, - SORT_INDEXES, - ASC, - DESC, - DEFAULT, - USAGE, } from './indexes'; - -import { HANDLE_ERROR } from './error'; +import { ActionTypes as IsRefreshingActionTypes } from './is-refreshing'; +import { dataServiceConnected } from './data-service'; +import type { RootState } from '.'; +import configureStore from '../stores/store'; describe('indexes module', function () { + let store: Store; + beforeEach(function () { + store = configureStore({ namespace: 'citibike.trips' }); + }); describe('#reducer', function () { - context('when an action is provided', function () { - context('when the action is LOAD_INDEXES', function () { - it('returns the default sorted', function () { - expect(reducer(undefined, loadIndexes(defaultSort))).to.deep.equal( - defaultSort - ); - }); + describe('when loading indexes', function () { + it('uses default sort order and column when user has not selected any', function () { + expect( + reducer(undefined, loadIndexes(defaultSort as any)) + ).to.deep.equal(defaultSort); }); + }); - context('when the action is SORT_INDEXES', function () { - context('when the column is Usage', function () { - context('when sorting asc', function () { - it('returns the sorted indexes list', function () { - const dispatch = (args) => args; - const getState = () => { - return { - indexes: defaultSort, - }; - }; - const result = reducer( - undefined, - sortIndexes(USAGE, ASC)(dispatch, getState) - ); - expect(result).to.deep.equal(usageSort); - }); - }); - - context('when sorting desc', function () { - it('returns the sorted indexes list', function () { - const dispatch = (args) => args; - const getState = () => { - return { - indexes: defaultSort, - }; - }; - const result = reducer( - undefined, - sortIndexes(USAGE, DESC)(dispatch, getState) - ); - expect(result).to.deep.equal(usageSortDesc); - }); - }); + describe('sorting indexes', function () { + describe('Usage column', function () { + it('asc sort', function () { + store.dispatch(loadIndexes(defaultSort as any)); + store.dispatch(sortIndexes('Usage', 'asc') as any); + const state = store.getState(); + expect(state.indexes).to.deep.equal(usageSort); }); - - context('when the column is Name and Definition', function () { - context('when sorting asc', function () { - it('returns the sorted indexes list', function () { - const dispatch = (args) => args; - const getState = () => { - return { - indexes: usageSort, - }; - }; - const result = reducer( - undefined, - sortIndexes(DEFAULT, ASC)(dispatch, getState) - ); - expect(result).to.deep.equal(defaultSort); - }); - }); - - context('when sorting desc', function () { - it('returns the sorted indexes list', function () { - const dispatch = (args) => args; - const getState = () => { - return { - indexes: usageSort, - }; - }; - const result = reducer( - undefined, - sortIndexes(DEFAULT, DESC)(dispatch, getState) - ); - expect(result).to.deep.equal(defaultSortDesc); - }); - }); + it('desc sort', function () { + store.dispatch(loadIndexes(defaultSort as any)); + store.dispatch(sortIndexes('Usage', 'desc') as any); + const state = store.getState(); + expect(state.indexes).to.deep.equal(usageSortDesc); }); }); - context('when an action is not provided', function () { - it('returns the default state', function () { - expect(reducer(undefined, {})).to.deep.equal([]); + describe('Name and Definition column', function () { + it('asc sort', function () { + store.dispatch(loadIndexes(usageSort as any)); + store.dispatch(sortIndexes('Name and Definition', 'asc') as any); + const state = store.getState(); + expect(state.indexes).to.deep.equal(defaultSort); + }); + it('desc sort', function () { + store.dispatch(loadIndexes(usageSort as any)); + store.dispatch(sortIndexes('Name and Definition', 'desc') as any); + const state = store.getState(); + expect(state.indexes).to.deep.equal(defaultSortDesc); }); }); }); - }); - describe('#loadIndexes', function () { - it('returns the action', function () { - expect(loadIndexes([])).to.deep.equal({ - type: LOAD_INDEXES, - indexes: [], + context('when no action is provided', function () { + it('returns the default state', function () { + expect(reducer(undefined, {} as any)).to.deep.equal([]); }); }); }); - describe('#sortIndexes', function () { - it('returns the action', function () { - const dispatch = (x) => x; - const getState = () => ({ indexes: [] }); - expect( - sortIndexes('Database Name', DESC)(dispatch, getState) - ).to.deep.equal({ - type: SORT_INDEXES, + it('#loadIndexes action', function () { + store.dispatch(loadIndexes([])); + expect(store.getState().indexes).to.deep.equal([]); + + store.dispatch(loadIndexes(usageSort as any)); + expect(JSON.stringify(store.getState().indexes)).to.equal( + JSON.stringify(usageSort) + ); + }); + + it('#sortIndexes action', function () { + store.dispatch(loadIndexes(defaultSort as any)); + store.dispatch(sortIndexes('Name and Definition', 'desc') as any); + const state = store.getState(); + expect(state.sortColumn).to.equal('Name and Definition'); + expect(state.sortOrder).to.equal('desc'); + expect(JSON.stringify(state.indexes)).to.equal( + JSON.stringify(defaultSortDesc) + ); + }); + + describe('#fetchIndexes', function () { + it('sets indexes to empty array for readonly', function () { + const dispatchSpy = spy(); + const dispatch = (x: any) => { + dispatchSpy(x); + return x; + }; + const getState = () => ({ isReadonly: true } as any as RootState); + + fetchIndexes()(dispatch, getState); + + expect(dispatchSpy.callCount).to.equal(3); + + expect(dispatchSpy.getCall(0).args[0]).to.deep.equal({ + type: IndexesActionTypes.LoadIndexes, indexes: [], - column: 'Database Name', - order: DESC, }); + expect(dispatchSpy.getCall(1).args[0]).to.deep.equal({ + type: IsRefreshingActionTypes.RefreshFinished, + }); + expect(typeof dispatchSpy.getCall(2).args[0] === 'function').to.true; }); - }); - describe('#loadIndexesFromDb', function () { - let actionSpy; - let emitSpy; - beforeEach(function () { - actionSpy = sinon.spy(); - emitSpy = sinon.spy(); - }); - afterEach(function () { - actionSpy = null; - emitSpy = null; - }); - it('returns loadIndexes action with empty list for readonly', function () { - const dispatch = (res) => { - if (typeof res !== 'function') { - expect(res).to.deep.equal({ type: LOAD_INDEXES, indexes: [] }); - actionSpy(); - } - }; - const state = () => ({ - appRegistry: { - emit: emitSpy, - }, - isReadonly: true, - namespace: 'citibike.trips', + + it('when dataService is not connected, sets refreshing to false', function () { + store.dispatch({ + type: IndexesActionTypes.LoadIndexes, + indexes: defaultSort, }); - loadIndexesFromDb()(dispatch, state); - expect(actionSpy.calledOnce).to.equal(true); + // Mock dataService.indexes + store.dispatch( + dataServiceConnected( + new (class { + isConnected() { + return false; + } + })() as DataService + ) + ); + + store.dispatch(fetchIndexes() as any); + + const state = store.getState(); + expect(state.indexes).to.deep.equal(defaultSort); + expect(state.isRefreshing).to.equal(false); }); - it('returns loadIndexes action with error for error state', function () { - const dispatch = (res) => { - if (typeof res !== 'function') { - if (res.type === LOAD_INDEXES) { - expect(res).to.deep.equal({ type: LOAD_INDEXES, indexes: [] }); - actionSpy(); - } else if (res.type === HANDLE_ERROR) { - expect(res).to.deep.equal({ - type: HANDLE_ERROR, - error: 'error message!', - }); - actionSpy(); - } else { - expect(true, 'unknown action called').to.be.false(); - } - } - }; - const state = () => ({ - appRegistry: { - emit: emitSpy, - }, - isReadonly: false, - dataService: { - indexes: (ns, opts, cb) => { - cb({ message: 'error message!' }); - }, - isConnected: () => true, - }, - namespace: 'citibike.trips', + it('sets indexes to empty array when there is an error', function () { + const error = new Error('failed to connect to server'); + // Set some data to validate the empty array condition + store.dispatch({ + type: IndexesActionTypes.LoadIndexes, + indexes: defaultSort, }); - loadIndexesFromDb()(dispatch, state); - expect(actionSpy.calledTwice).to.equal(true); + // Mock dataService.indexes + store.dispatch( + dataServiceConnected({ + isConnected() { + return true; + }, + indexes: (ns: any, opts: any, cb: any) => { + cb(error); + }, + } as DataService) + ); + + store.dispatch(fetchIndexes() as any); + + const state = store.getState(); + expect(state.indexes).to.deep.equal([]); + expect(state.error).to.equal(error.message); + expect(state.isRefreshing).to.equal(false); }); - it('returns loadIndexes action with sorted and modelled indexes', function () { - const dispatch = (res) => { - if (typeof res !== 'function') { - expect(Object.keys(res)).to.deep.equal(['type', 'indexes']); - expect(res.type).to.equal(LOAD_INDEXES); - expect(JSON.stringify(res.indexes, null, '\n')).to.equal( - JSON.stringify(defaultSort, null, '\n') - ); - actionSpy(); - } - }; - const state = () => ({ - appRegistry: { - emit: emitSpy, - }, - isReadonly: false, - dataService: { - indexes: (ns, opts, cb) => { + it('sets indexes when fetched successfully', function () { + // Set indexes to empty + store.dispatch(loadIndexes([])); + store.dispatch(sortIndexes('Name and Definition', 'asc') as any); + store.dispatch( + dataServiceConnected({ + isConnected() { + return true; + }, + indexes: (_ns: any, _opts: any, cb: any) => { cb(null, fromDB); }, - isConnected: () => true, - }, - sortColumn: DEFAULT, - sortOrder: ASC, - namespace: 'citibike.trips', - }); - loadIndexesFromDb()(dispatch, state); - expect(actionSpy.calledOnce).to.equal(true); + } as DataService) + ); + + store.dispatch(fetchIndexes() as any); + + const state = store.getState(); + expect(JSON.stringify(state.indexes)).to.equal( + JSON.stringify(defaultSort) + ); + expect(state.error).to.be.null; + expect(state.isRefreshing).to.equal(false); }); }); }); diff --git a/packages/compass-indexes/src/modules/indexes.ts b/packages/compass-indexes/src/modules/indexes.ts new file mode 100644 index 00000000000..7c3700f4920 --- /dev/null +++ b/packages/compass-indexes/src/modules/indexes.ts @@ -0,0 +1,209 @@ +import IndexModel from 'mongodb-index-model'; +import type { Document } from 'mongodb'; +import { localAppRegistryEmit } from '@mongodb-js/mongodb-redux-common/app-registry'; +import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; +import _debug from 'debug'; + +import type { RootState } from './index'; +import { handleError } from './error'; +import type { HandleErrorAction, IndexesError } from './error'; +import { ActionTypes as RefreshActionTypes } from './is-refreshing'; +import type { RefreshFinishedAction } from './is-refreshing'; + +const debug = _debug('mongodb-compass:modules:indexes'); + +type SortField = keyof Pick< + IndexDefinition, + 'name' | 'type' | 'size' | 'usageCount' | 'properties' +>; +export type SortColumn = keyof typeof sortColumnToProps; +export type SortDirection = 'asc' | 'desc'; +const sortColumnToProps = { + 'Name and Definition': 'name', + Type: 'type', + Size: 'size', + Usage: 'usageCount', + Properties: 'properties', +} as const; + +export type IndexDefinition = { + name: string; + fields: { + serialize: () => { field: string; value: number | string }[]; + }; + type: 'geo' | 'hashed' | 'text' | 'wildcard' | 'clustered' | 'columnstore'; + cardinality: 'single' | 'compound'; + properties: ('unique' | 'sparse' | 'partial' | 'ttl' | 'collation')[]; + extra: Record>; + size: number; + relativeSize: number; + usageCount: number; + usageSince?: Date; +}; + +export enum ActionTypes { + LoadIndexes = 'indexes/indexes/LOAD_INDEXES', + SortIndexes = 'indexes/indexes/SORT_INDEXES', +} + +type LoadIndexesAction = { + type: ActionTypes.LoadIndexes; + indexes: IndexDefinition[]; +}; + +export type SortIndexesAction = { + type: ActionTypes.SortIndexes; + indexes: IndexDefinition[]; + column: SortColumn; + order: SortDirection; +}; + +type Actions = LoadIndexesAction | SortIndexesAction; +type State = IndexDefinition[]; + +export const INITIAL_STATE: State = []; + +export default function reducer(state: State = INITIAL_STATE, action: Actions) { + if (action.type === ActionTypes.SortIndexes) { + return [...action.indexes].sort( + _getSortFunction(_mapColumnToProp(action.column), action.order) + ); + } + if (action.type === ActionTypes.LoadIndexes) { + return action.indexes; + } + return state; +} + +export const loadIndexes = (indexes: IndexDefinition[]): LoadIndexesAction => ({ + type: ActionTypes.LoadIndexes, + indexes, +}); + +export const sortIndexes = ( + column: SortColumn, + order: SortDirection +): ThunkAction => { + return (dispatch, getState) => { + const { indexes } = getState(); + dispatch({ + type: ActionTypes.SortIndexes, + indexes, + column, + order, + }); + }; +}; + +const _handleIndexesChanged = ( + dispatch: ThunkDispatch< + RootState, + void, + RefreshFinishedAction | LoadIndexesAction + >, + indexes: IndexDefinition[] +) => { + dispatch(loadIndexes(indexes)); + dispatch({ type: RefreshActionTypes.RefreshFinished }); + dispatch(localAppRegistryEmit('indexes-changed', indexes)); +}; + +export const fetchIndexes = (): ThunkAction< + void, + RootState, + void, + LoadIndexesAction | RefreshFinishedAction | HandleErrorAction +> => { + return (dispatch, getState) => { + const { isReadonly, dataService, namespace, sortColumn, sortOrder } = + getState(); + + if (isReadonly) { + return _handleIndexesChanged(dispatch, []); + } + + if (!dataService || !dataService.isConnected()) { + dispatch({ type: RefreshActionTypes.RefreshFinished }); + debug( + 'warning: trying to load indexes but dataService is disconnected', + dataService + ); + return; + } + + dataService.indexes(namespace, {}, (err: any, indexes: Document[]) => { + if (err) { + dispatch(handleError(err as IndexesError)); + return _handleIndexesChanged(dispatch, []); + } + // Set the `ns` field manually as it is not returned from the server + // since version 4.4. + for (const index of indexes) { + index.ns = namespace; + } + const ixs = _mapAndSort(indexes, sortColumn, sortOrder); + return _handleIndexesChanged(dispatch, ixs); + }); + }; +}; + +const _getSortFunctionForProperties = (order: 1 | -1) => { + return function (a: IndexDefinition, b: IndexDefinition) { + const aValue = + a.cardinality === 'compound' ? 'compound' : a.properties[0] || ''; + const bValue = + b.cardinality === 'compound' ? 'compound' : b.properties[0] || ''; + if (aValue > bValue) { + return order; + } + if (aValue < bValue) { + return -order; + } + return 0; + }; +}; + +const _getSortFunction = (field: SortField, order: SortDirection) => { + const _order = order === 'asc' ? 1 : -1; + if (field === 'properties') { + return _getSortFunctionForProperties(_order); + } + return function (a: IndexDefinition, b: IndexDefinition) { + if (a[field] > b[field]) { + return _order; + } + if (a[field] < b[field]) { + return -_order; + } + return 0; + }; +}; + +const _mapColumnToProp = (column: SortColumn): SortField => { + return sortColumnToProps[column]; +}; + +/** + * Converts the raw index data (from ampersand) to + * Index models (IndexDefinition) and adds computed props. + */ +const _convertToModels = (indexes: Document[]): IndexDefinition[] => { + const sizes = indexes.map((index) => index.size); + const maxSize = Math.max(...sizes); + + return indexes.map((index) => { + const model = new IndexModel(new IndexModel().parse(index)); + model.relativeSize = (model.size / maxSize) * 100; + return model as IndexDefinition; + }); +}; + +const _mapAndSort = ( + indexes: Document[], + sortColumn: SortColumn, + sortOrder: SortDirection +) => { + return _convertToModels(indexes).sort( + _getSortFunction(_mapColumnToProp(sortColumn), sortOrder) + ); +}; diff --git a/packages/compass-indexes/src/modules/is-readonly.js b/packages/compass-indexes/src/modules/is-readonly.js index 11f8cbf86ee..486c93e65a2 100644 --- a/packages/compass-indexes/src/modules/is-readonly.js +++ b/packages/compass-indexes/src/modules/is-readonly.js @@ -6,10 +6,6 @@ export const INITIAL_STATE = /** * Reducer function doesn't do anything since we're based on process. - * - * @param {Array} state - The state. - * - * @returns {Array} The state. */ export default function reducer(state = INITIAL_STATE) { return state; diff --git a/packages/compass-indexes/src/modules/is-refreshing.ts b/packages/compass-indexes/src/modules/is-refreshing.ts new file mode 100644 index 00000000000..2d0f9b60088 --- /dev/null +++ b/packages/compass-indexes/src/modules/is-refreshing.ts @@ -0,0 +1,46 @@ +import type { ThunkAction } from 'redux-thunk'; +import type { RootState } from './index'; +import { fetchIndexes } from './indexes'; + +export enum ActionTypes { + RefreshStarted = 'indexes/is-refreshing/RefreshStarted', + RefreshFinished = 'indexes/is-refreshing/RefreshFinished', +} + +type RefreshStartedAction = { + type: ActionTypes.RefreshStarted; +}; + +export type RefreshFinishedAction = { + type: ActionTypes.RefreshFinished; +}; + +type Actions = RefreshStartedAction | RefreshFinishedAction; + +type State = boolean; + +export const INITIAL_STATE: State = false; + +export default function reducer(state = INITIAL_STATE, action: Actions) { + if (action.type === ActionTypes.RefreshStarted) { + return true; + } + if (action.type === ActionTypes.RefreshFinished) { + return false; + } + return state; +} + +export const refreshIndexes = (): ThunkAction< + void, + RootState, + void, + Actions +> => { + return (dispatch) => { + dispatch(fetchIndexes()); + dispatch({ + type: ActionTypes.RefreshStarted, + }); + }; +}; diff --git a/packages/compass-indexes/src/modules/sort-column.js b/packages/compass-indexes/src/modules/sort-column.js deleted file mode 100644 index 71a22c32edd..00000000000 --- a/packages/compass-indexes/src/modules/sort-column.js +++ /dev/null @@ -1,23 +0,0 @@ -import { SORT_INDEXES } from './indexes'; - -/** - * The initial state of the sort column attribute. - */ -export const INITIAL_STATE = 'Name and Definition'; - -/** - * Reducer function for handle state changes to sortColumn. - * - * @param {Boolean} state - The status state. - * @param {Object} action - The action. - * - * @returns {Boolean} The new state. - */ -const reducer = (state = INITIAL_STATE, action) => { - if (action.type === SORT_INDEXES) { - return action.column; - } - return state; -}; - -export default reducer; diff --git a/packages/compass-indexes/src/modules/sort-column.spec.js b/packages/compass-indexes/src/modules/sort-column.spec.js deleted file mode 100644 index 6c5ad7e5541..00000000000 --- a/packages/compass-indexes/src/modules/sort-column.spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { expect } from 'chai'; - -import reducer, { INITIAL_STATE } from './sort-column'; -import { sortIndexes } from './indexes'; - -describe('sort column module', function () { - describe('#reducer', function () { - context('when an action is provided', function () { - it('returns the new column', function () { - const dispatch = (x) => x; - const getState = () => ({}); - expect( - reducer(undefined, sortIndexes('Size', '')(dispatch, getState)) - ).to.equal('Size'); - }); - }); - - context('when an action is not provided', function () { - it('returns the default state', function () { - expect(reducer(undefined, {})).to.equal(INITIAL_STATE); - }); - }); - }); -}); diff --git a/packages/compass-indexes/src/modules/sort-column.spec.ts b/packages/compass-indexes/src/modules/sort-column.spec.ts new file mode 100644 index 00000000000..e9b80631913 --- /dev/null +++ b/packages/compass-indexes/src/modules/sort-column.spec.ts @@ -0,0 +1,24 @@ +import { expect } from 'chai'; + +import reducer, { INITIAL_STATE } from './sort-column'; +import { ActionTypes as IndexesActionTypes } from './indexes'; + +describe('sort column reducer', function () { + it('when action is provied with column', function () { + expect( + reducer(undefined, { + type: IndexesActionTypes.SortIndexes, + column: 'Size', + }) + ).to.equal('Size'); + expect( + reducer('Size', { type: IndexesActionTypes.SortIndexes, column: 'Type' }) + ).to.equal('Type'); + expect( + reducer('Type', { type: IndexesActionTypes.SortIndexes, column: 'Usage' }) + ).to.equal('Usage'); + }); + it('when action is not provided, it returns default state', function () { + expect(reducer(undefined, {} as any)).to.equal(INITIAL_STATE); + }); +}); diff --git a/packages/compass-indexes/src/modules/sort-column.ts b/packages/compass-indexes/src/modules/sort-column.ts new file mode 100644 index 00000000000..1ebe4e2e13d --- /dev/null +++ b/packages/compass-indexes/src/modules/sort-column.ts @@ -0,0 +1,16 @@ +import { ActionTypes as IndexesActionTypes } from './indexes'; +import type { SortColumn, SortIndexesAction } from './indexes'; + +type State = SortColumn; + +export const INITIAL_STATE: State = 'Name and Definition'; + +export default function reducer( + state = INITIAL_STATE, + action: Pick +) { + if (action.type === IndexesActionTypes.SortIndexes) { + return action.column; + } + return state; +} diff --git a/packages/compass-indexes/src/modules/sort-order.js b/packages/compass-indexes/src/modules/sort-order.js deleted file mode 100644 index 62397248256..00000000000 --- a/packages/compass-indexes/src/modules/sort-order.js +++ /dev/null @@ -1,23 +0,0 @@ -import { SORT_INDEXES, ASC } from './indexes'; - -/** - * The initial state of the sort order attribute. - */ -export const INITIAL_STATE = ASC; - -/** - * Reducer function for handle state changes to sortOrder. - * - * @param {Boolean} state - The status state. - * @param {Object} action - The action. - * - * @returns {Boolean} The new state. - */ -const reducer = (state = INITIAL_STATE, action) => { - if (action.type === SORT_INDEXES) { - return action.order; - } - return state; -}; - -export default reducer; diff --git a/packages/compass-indexes/src/modules/sort-order.spec.js b/packages/compass-indexes/src/modules/sort-order.spec.js deleted file mode 100644 index 32dcec67430..00000000000 --- a/packages/compass-indexes/src/modules/sort-order.spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { expect } from 'chai'; - -import reducer, { INITIAL_STATE } from './sort-order'; -import { sortIndexes } from './indexes'; - -describe('sort order module', function () { - describe('#reducer', function () { - context('when an action is provided', function () { - it('returns the new order', function () { - const dispatch = (x) => x; - const getState = () => ({}); - expect( - reducer(undefined, sortIndexes('', 'desc')(dispatch, getState)) - ).to.equal('desc'); - }); - }); - - context('when an action is not provided', function () { - it('returns the default state', function () { - expect(reducer(undefined, {})).to.equal(INITIAL_STATE); - }); - }); - }); -}); diff --git a/packages/compass-indexes/src/modules/sort-order.spec.ts b/packages/compass-indexes/src/modules/sort-order.spec.ts new file mode 100644 index 00000000000..e5afee789fb --- /dev/null +++ b/packages/compass-indexes/src/modules/sort-order.spec.ts @@ -0,0 +1,33 @@ +import { expect } from 'chai'; + +import reducer, { INITIAL_STATE } from './sort-order'; +import { ActionTypes as IndexesActionTypes } from './indexes'; + +describe('sort order reducer', function () { + it('when action is provided', function () { + expect( + reducer('desc', { + type: IndexesActionTypes.SortIndexes, + order: 'asc', + }) + ).to.equal('asc'); + + expect( + reducer('asc', { + type: IndexesActionTypes.SortIndexes, + order: 'desc', + }) + ).to.equal('desc'); + + expect( + reducer(undefined, { + type: IndexesActionTypes.SortIndexes, + order: 'desc', + }) + ).to.equal('desc'); + }); + + it('when action is not provided, it returns the default state', function () { + expect(reducer(undefined, {} as any)).to.equal(INITIAL_STATE); + }); +}); diff --git a/packages/compass-indexes/src/modules/sort-order.ts b/packages/compass-indexes/src/modules/sort-order.ts new file mode 100644 index 00000000000..c93e387a767 --- /dev/null +++ b/packages/compass-indexes/src/modules/sort-order.ts @@ -0,0 +1,16 @@ +import { ActionTypes as IndexesActionTypes } from './indexes'; +import type { SortDirection, SortIndexesAction } from './indexes'; + +type State = SortDirection; + +export const INITIAL_STATE: State = 'asc'; + +export default function reducer( + state = INITIAL_STATE, + action: Pick +) { + if (action.type === IndexesActionTypes.SortIndexes) { + return action.order; + } + return state; +} diff --git a/packages/compass-indexes/src/stores/create-index.js b/packages/compass-indexes/src/stores/create-index.js index 8d396dfb83c..15578804409 100644 --- a/packages/compass-indexes/src/stores/create-index.js +++ b/packages/compass-indexes/src/stores/create-index.js @@ -8,7 +8,6 @@ import { } from '@mongodb-js/mongodb-redux-common/app-registry'; import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; import { changeSchemaFields } from '../modules/create-index/schema-fields'; -import { parseErrorMsg } from '../modules/indexes'; import { handleError } from '../modules/error'; import { toggleIsVisible } from '../modules/is-visible'; import { namespaceChanged } from '../modules/namespace'; @@ -25,7 +24,7 @@ const { track } = createLoggerAndTelemetry('COMPASS-INDEXES-UI'); */ export const setDataProvider = (store, error, provider) => { if (error !== null) { - store.dispatch(handleError(parseErrorMsg(error))); + store.dispatch(handleError(error)); } else { store.dispatch(dataServiceConnected(provider)); } diff --git a/packages/compass-indexes/src/stores/drop-index.js b/packages/compass-indexes/src/stores/drop-index.js index 08c9db4bc22..eebe6532e2b 100644 --- a/packages/compass-indexes/src/stores/drop-index.js +++ b/packages/compass-indexes/src/stores/drop-index.js @@ -6,7 +6,6 @@ import { localAppRegistryActivated, globalAppRegistryActivated, } from '@mongodb-js/mongodb-redux-common/app-registry'; -import { parseErrorMsg } from '../modules/indexes'; import { handleError } from '../modules/error'; import { toggleIsVisible } from '../modules/is-visible'; import { nameChanged } from '../modules/drop-index/name'; @@ -21,7 +20,7 @@ import { namespaceChanged } from '../modules/namespace'; */ export const setDataProvider = (store, error, provider) => { if (error !== null) { - store.dispatch(handleError(parseErrorMsg(error))); + store.dispatch(handleError(error)); } else { store.dispatch(dataServiceConnected(provider)); } diff --git a/packages/compass-indexes/src/stores/store.js b/packages/compass-indexes/src/stores/store.js index e9db5a7e4be..5e1626fe67c 100644 --- a/packages/compass-indexes/src/stores/store.js +++ b/packages/compass-indexes/src/stores/store.js @@ -9,7 +9,7 @@ import { writeStateChanged } from '../modules/is-writable'; import { readonlyViewChanged } from '../modules/is-readonly-view'; import { getDescription } from '../modules/description'; import { dataServiceConnected } from '../modules/data-service'; -import { loadIndexesFromDb, parseErrorMsg } from '../modules/indexes'; +import { fetchIndexes } from '../modules/indexes'; import { handleError } from '../modules/error'; import { namespaceChanged } from '../modules/namespace'; import { serverVersionChanged } from '../modules/server-version'; @@ -23,10 +23,10 @@ import { serverVersionChanged } from '../modules/server-version'; */ export const setDataProvider = (store, error, provider) => { if (error !== null) { - store.dispatch(handleError(parseErrorMsg(error))); + store.dispatch(handleError(error)); } else { store.dispatch(dataServiceConnected(provider)); - store.dispatch(loadIndexesFromDb()); + store.dispatch(fetchIndexes()); } }; @@ -39,7 +39,7 @@ const configureStore = (options = {}) => { store.dispatch(localAppRegistryActivated(localAppRegistry)); localAppRegistry.on('refresh-data', () => { - store.dispatch(loadIndexesFromDb()); + store.dispatch(fetchIndexes()); }); // TODO: could save the version to check for wildcard indexes } @@ -64,7 +64,7 @@ const configureStore = (options = {}) => { }); globalAppRegistry.on('refresh-data', () => { - store.dispatch(loadIndexesFromDb()); + store.dispatch(fetchIndexes()); }); } diff --git a/packages/compass-indexes/src/typings.d.ts b/packages/compass-indexes/src/typings.d.ts index d9688b91af8..c76448ce1e0 100644 --- a/packages/compass-indexes/src/typings.d.ts +++ b/packages/compass-indexes/src/typings.d.ts @@ -10,3 +10,4 @@ declare module 'hadron-react-components'; declare module 'hadron-app'; declare module '@mongodb-js/mongodb-redux-common/app-registry'; declare module 'lodash.contains'; +declare module 'mongodb-index-model'; diff --git a/packages/data-service/src/data-service.ts b/packages/data-service/src/data-service.ts index 735855cd12d..fed167e6c28 100644 --- a/packages/data-service/src/data-service.ts +++ b/packages/data-service/src/data-service.ts @@ -539,7 +539,7 @@ export interface DataService { * @param options - The options (unused). * @param callback - The callback. */ - indexes(ns: string, options: unknown, callback: Callback): void; + indexes(ns: string, options: unknown, callback: Callback): void; /** * Get the current instance details. @@ -1655,7 +1655,7 @@ export class DataServiceImpl extends EventEmitter implements DataService { ); } - indexes(ns: string, options: unknown, callback: Callback): void { + indexes(ns: string, options: unknown, callback: Callback): void { const logop = this._startLogOp( mongoLogId(1_001_000_047), 'Listing indexes',