diff --git a/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination-count.spec.tsx b/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination-count.spec.tsx new file mode 100644 index 00000000000..bff678094de --- /dev/null +++ b/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination-count.spec.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import userEvent from '@testing-library/user-event'; + +import { PipelinePaginationCount } from './pipeline-pagination-count'; + +describe('PipelinePagination', function () { + it('renders count button by default', function () { + render( + {}} + /> + ); + + const container = screen.getByTestId('pipeline-pagination-count'); + expect(within(container).getByTestId('pipeline-pagination-count-action')).to + .exist; + }); + it('calls onCount when clicked', function () { + const onCountSpy = spy(); + render( + + ); + + expect(onCountSpy.calledOnce).to.be.false; + const container = screen.getByTestId('pipeline-pagination-count'); + userEvent.click( + within(container).getByTestId('pipeline-pagination-count-action') + ); + expect(onCountSpy.calledOnce).to.be.true; + }); + it('renders count of results', function () { + render( + {}} /> + ); + + const container = screen.getByTestId('pipeline-pagination-count'); + expect(within(container).getByText('of 20')).to.exist; + }); +}); diff --git a/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination-count.tsx b/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination-count.tsx new file mode 100644 index 00000000000..6d6a85406c2 --- /dev/null +++ b/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination-count.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { + css, + spacing, + Body, + Link, + uiColors, + Tooltip, + SpinLoader, +} from '@mongodb-js/compass-components'; + +import type { RootState } from '../../modules'; +import { countDocuments } from '../../modules/count-documents'; + +type PipelinePaginationCountProps = { + loading: boolean; + count?: number; + onCount: () => void; +}; + +const countButtonStyles = css({ + backgroundColor: 'transparent', + border: 'none', + display: 'inline', + height: spacing[4] + spacing[1], + ':focus': { + outline: `${spacing[1]}px auto ${uiColors.focus}`, + }, +}); + +export const PipelinePaginationCount: React.FunctionComponent = + ({ loading, count, onCount }) => { + const countDefinition = ` + In order to have the final count of documents we need to run the + aggregation again. This will be the equivalent of adding a + $count as the last stage of the pipeline. + `; + + const testId = 'pipeline-pagination-count'; + + if (loading) { + return ( +
+ +
+ ); + } + + if (count !== undefined) { + return ( +
+ of {count} +
+ ); + } + + return ( +
+ ( + onCount()} + > + {children} + count results + + )} + > + {countDefinition} + +
+ ); + }; + +const mapState = ({ countDocuments: { loading, count } }: RootState) => ({ + loading, + count, +}); + +const mapDispatch = { + onCount: countDocuments, +}; + +export default connect(mapState, mapDispatch)(PipelinePaginationCount); diff --git a/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination.spec.tsx b/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination.spec.tsx index bb9121f3993..002c23f4045 100644 --- a/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination.spec.tsx +++ b/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination.spec.tsx @@ -3,6 +3,9 @@ import { render, screen, within } from '@testing-library/react'; import { expect } from 'chai'; import { spy } from 'sinon'; import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; + +import configureStore from '../../stores/store'; import { PipelinePagination, @@ -12,16 +15,18 @@ import { const renderPipelinePagination = (props: Record = {}) => { render( - {}} - onNext={() => {}} - {...props} - /> + + {}} + onNext={() => {}} + {...props} + /> + ); }; @@ -33,6 +38,8 @@ describe('PipelinePagination', function () { expect( within(container).getByTestId('pipeline-pagination-desc').textContent ).to.equal('Showing 1 – 20'); + expect(within(container).getByTestId('pipeline-pagination-count')).to + .exist; expect(within(container).getByTestId('pipeline-pagination-prev-action')) .to.exist; expect(within(container).getByTestId('pipeline-pagination-next-action')) diff --git a/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination.tsx b/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination.tsx index 80004bdb593..4567780fd83 100644 --- a/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination.tsx +++ b/packages/compass-aggregations/src/components/pipeline-results-workspace/pipeline-pagination.tsx @@ -11,6 +11,8 @@ import { import type { RootState } from '../../modules'; import { fetchNextPage, fetchPrevPage } from '../../modules/aggregation'; +import PipelinePaginationCount from './pipeline-pagination-count'; + type PipelinePaginationProps = { showingFrom: number; showingTo: number; @@ -27,6 +29,12 @@ const containerStyles = css({ gap: spacing[2], }); +const paginationStyles = css({ + display: 'flex', + gap: spacing[1], + alignItems: 'center', +}); + export const PipelinePagination: React.FunctionComponent = ({ showingFrom, @@ -40,9 +48,12 @@ export const PipelinePagination: React.FunctionComponent {!isCountDisabled && ( - - Showing {showingFrom} – {showingTo} - +
+ + Showing {showingFrom} – {showingTo} + + +
)}
({ - showingFrom: calculateShowingFrom({ limit, page }), - showingTo: calculateShowingTo({ + countDocuments: { count }, +}: RootState) => { + const showingFrom = calculateShowingFrom({ limit, page }); + const showingTo = calculateShowingTo({ limit, page, documentCount: documents.length, - }), - isCountDisabled: Boolean(error), - isPrevDisabled: page <= 1 || loading || Boolean(error), - isNextDisabled: isLast || loading || Boolean(error), -}); + }); + return { + showingFrom, + showingTo, + isCountDisabled: Boolean(error), + isPrevDisabled: page <= 1 || loading || Boolean(error), + isNextDisabled: isLast || loading || Boolean(error) || count === showingTo, + }; +}; const mapDispatch = { onPrev: fetchPrevPage, diff --git a/packages/compass-aggregations/src/modules/aggregation.spec.ts b/packages/compass-aggregations/src/modules/aggregation.spec.ts index 9faaa4bad31..715e095762e 100644 --- a/packages/compass-aggregations/src/modules/aggregation.spec.ts +++ b/packages/compass-aggregations/src/modules/aggregation.spec.ts @@ -97,6 +97,7 @@ describe('aggregation module', function () { loading: false, error: undefined, abortController: undefined, + previousPageData: undefined, }); }); @@ -152,6 +153,7 @@ describe('aggregation module', function () { loading: false, error: undefined, abortController: undefined, + previousPageData: undefined, }); }); @@ -205,6 +207,7 @@ describe('aggregation module', function () { loading: false, error: undefined, abortController: undefined, + previousPageData: undefined, }); }); it('nextPage -> on last page', async function () { @@ -277,6 +280,7 @@ describe('aggregation module', function () { loading: false, error: undefined, abortController: undefined, + previousPageData: undefined, }); }); it('prevPage -> on first page', async function () { diff --git a/packages/compass-aggregations/src/modules/aggregation.ts b/packages/compass-aggregations/src/modules/aggregation.ts index 0fd152d7bfb..9cec40c3a47 100644 --- a/packages/compass-aggregations/src/modules/aggregation.ts +++ b/packages/compass-aggregations/src/modules/aggregation.ts @@ -19,9 +19,16 @@ export enum ActionTypes { AggregationStarted = 'compass-aggregations/aggregationStarted', AggregationFinished = 'compass-aggregations/aggregationFinished', AggregationFailed = 'compass-aggregations/aggregationFailed', + AggregationCancelled = 'compass-aggregations/aggregationCancelled', LastPageReached = 'compass-aggregations/lastPageReached', } +type PreviousPageData = { + page: number; + isLast: boolean; + documents: Document[]; +}; + type AggregationStartedAction = { type: ActionTypes.AggregationStarted; abortController: AbortController; @@ -40,6 +47,10 @@ type AggregationFailedAction = { page: number; }; +type AggregationCancelledAction = { + type: ActionTypes.AggregationCancelled; +}; + type LastPageReachedAction = { type: ActionTypes.LastPageReached; page: number; @@ -49,6 +60,7 @@ export type Actions = | AggregationStartedAction | AggregationFinishedAction | AggregationFailedAction + | AggregationCancelledAction | LastPageReachedAction; export type State = { @@ -59,6 +71,7 @@ export type State = { loading: boolean; abortController?: AbortController; error?: string; + previousPageData?: PreviousPageData; }; export const INITIAL_STATE: State = { @@ -72,11 +85,7 @@ export const INITIAL_STATE: State = { const reducer: Reducer = (state = INITIAL_STATE, action) => { switch (action.type) { case WorkspaceActionTypes.WorkspaceChanged: - return { - ...INITIAL_STATE, - page: 1, - limit: 20, - }; + return INITIAL_STATE; case ActionTypes.AggregationStarted: return { ...state, @@ -84,6 +93,11 @@ const reducer: Reducer = (state = INITIAL_STA error: undefined, documents: [], abortController: action.abortController, + previousPageData: { + page: state.page, + documents: state.documents, + isLast: state.isLast, + }, }; case ActionTypes.AggregationFinished: return { @@ -94,6 +108,7 @@ const reducer: Reducer = (state = INITIAL_STA loading: false, abortController: undefined, error: undefined, + previousPageData: undefined, }; case ActionTypes.AggregationFailed: return { @@ -103,6 +118,17 @@ const reducer: Reducer = (state = INITIAL_STA abortController: undefined, error: action.error, page: action.page, + previousPageData: undefined, + }; + case ActionTypes.AggregationCancelled: + return { + ...state, + loading: false, + abortController: undefined, + documents: state.previousPageData?.documents || [], + page: state.previousPageData?.page || 1, + isLast: state.previousPageData?.isLast || false, + previousPageData: undefined, }; case ActionTypes.LastPageReached: return { @@ -182,15 +208,24 @@ export const cancelAggregation = (): ThunkAction< void, Actions > => { - return (_dispatch, getState) => { + return (dispatch, getState) => { track('Aggregation Canceled'); const { aggregation: { abortController } } = getState(); - abortController?.abort(); + _abortAggregation(abortController); + // In order to avoid the race condition between user cancel and cancel triggered + // in fetchAggregationData, we dispatch ActionTypes.AggregationCancelled here. + dispatch({ + type: ActionTypes.AggregationCancelled, + }); }; }; +const _abortAggregation = (controller?: AbortController): void => { + controller?.abort(); +}; + const fetchAggregationData = (page: number): ThunkAction< Promise, RootState, @@ -204,13 +239,19 @@ const fetchAggregationData = (page: number): ThunkAction< maxTimeMS, collation, dataService: { dataService }, - aggregation: { limit, documents: _documents, page: _page, isLast: _isLast }, + aggregation: { + limit, + abortController: _abortController, + }, } = getState(); if (!dataService) { return; } + // Cancel the existing aggregate + _abortAggregation(_abortController); + try { const abortController = new AbortController(); const signal = abortController.signal; @@ -247,15 +288,8 @@ const fetchAggregationData = (page: number): ThunkAction< }); } } catch (e) { - // On cancel, we show the previous state - if ((e as Error).name === PROMISE_CANCELLED_ERROR) { - dispatch({ - type: ActionTypes.AggregationFinished, - documents: _documents, - page: _page, - isLast: _isLast, - }); - } else { + // User cancel is handled in cancelAggregation + if ((e as Error).name !== PROMISE_CANCELLED_ERROR) { dispatch({ type: ActionTypes.AggregationFailed, error: (e as Error).message, @@ -282,6 +316,7 @@ export const exportAggregationResults = (): ThunkAction< namespace, maxTimeMS, collation, + countDocuments: { count } } = getState(); const stages = pipeline @@ -298,7 +333,8 @@ export const exportAggregationResults = (): ThunkAction< aggregation: { stages, options - } + }, + count, }) ); return; diff --git a/packages/compass-aggregations/src/modules/count-documents.ts b/packages/compass-aggregations/src/modules/count-documents.ts new file mode 100644 index 00000000000..3dfca997c1f --- /dev/null +++ b/packages/compass-aggregations/src/modules/count-documents.ts @@ -0,0 +1,136 @@ +import type { Reducer } from 'redux'; +import type { AggregateOptions } from 'mongodb'; +import type { ThunkAction } from 'redux-thunk'; +import type { RootState } from '.'; +import { DEFAULT_MAX_TIME_MS } from '../constants'; +import { generateStage } from './stage'; +import { aggregatePipeline } from '../utils/cancellable-aggregation'; +import type { Actions as WorkspaceActions } from './workspace'; +import { ActionTypes as WorkspaceActionTypes } from './workspace'; + + +export enum ActionTypes { + CountStarted = 'compass-aggregations/countStarted', + CountFinished = 'compass-aggregations/countFinished', + CountFailed = 'compass-aggregations/countFailed', +}; + +type CountStartedAction = { + type: ActionTypes.CountStarted; + abortController: AbortController; +}; + +type CountFinishedAction = { + type: ActionTypes.CountFinished; + count: number; +}; + +type CountFailedAction = { + type: ActionTypes.CountFailed; +}; + +export type Actions = + | CountStartedAction + | CountFinishedAction + | CountFailedAction; + +export type State = { + count?: number; + loading: boolean; + abortController?: AbortController; +}; + +export const INITIAL_STATE: State = { + loading: false, +}; + +const reducer: Reducer = (state = INITIAL_STATE, action) => { + switch (action.type) { + case WorkspaceActionTypes.WorkspaceChanged: + return INITIAL_STATE; + case ActionTypes.CountStarted: + return { + loading: true, + abortController: action.abortController, + }; + case ActionTypes.CountFinished: + return { + loading: false, + abortController: undefined, + count: action.count, + }; + case ActionTypes.CountFailed: + return { + ...state, + loading: false, + abortController: undefined, + }; + default: + return state; + } +}; + +export const cancelCount = (): ThunkAction => { + return (_dispatch, getState) => { + const { + countDocuments: { abortController } + } = getState(); + abortController?.abort(); + }; +}; + +export const countDocuments = (): ThunkAction< + void, + RootState, + void, + Actions +> => { + return async (dispatch, getState) => { + const { + pipeline, + namespace, + maxTimeMS, + collation, + dataService: { dataService } + } = getState(); + + if (!dataService) { + return; + } + + try { + const abortController = new AbortController(); + const signal = abortController.signal; + dispatch({ + type: ActionTypes.CountStarted, + abortController, + }); + + const stages = pipeline.map(generateStage).filter(x => Object.keys(x).length > 0); + const options: AggregateOptions = { + maxTimeMS: maxTimeMS || DEFAULT_MAX_TIME_MS, + allowDiskUse: true, + collation: collation || undefined, + }; + + const documents = await aggregatePipeline( + dataService, + signal, + namespace, + [...stages, { $count: 'count' }], + options, + 0, + 1, + ); + dispatch({ + type: ActionTypes.CountFinished, + count: documents[0]?.count ?? 0, + }); + } catch (e) { + dispatch({ + type: ActionTypes.CountFailed, + }); + } + } +} +export default reducer; \ No newline at end of file diff --git a/packages/compass-aggregations/src/modules/index.ts b/packages/compass-aggregations/src/modules/index.ts index 7bd460a7027..ee3e088bc72 100644 --- a/packages/compass-aggregations/src/modules/index.ts +++ b/packages/compass-aggregations/src/modules/index.ts @@ -119,6 +119,10 @@ import aggregation, { INITIAL_STATE as AGGREGATION_INITIAL_STATE } from './aggregation'; +import countDocuments, { + INITIAL_STATE as COUNT_INITIAL_STATE +} from './count-documents'; + import workspace, { INITIAL_STATE as WORKSPACE_INITIAL_STATE } from './workspace'; @@ -174,6 +178,7 @@ export const INITIAL_STATE = { updateViewError: UPDATE_VIEW_ERROR_INITIAL_STATE, aggregation: AGGREGATION_INITIAL_STATE, workspace: WORKSPACE_INITIAL_STATE, + countDocuments: COUNT_INITIAL_STATE, }; export type RootState = typeof INITIAL_STATE; @@ -254,6 +259,7 @@ const appReducer = combineReducers({ updateViewError, aggregation, workspace, + countDocuments, }); /** diff --git a/packages/compass-aggregations/src/modules/workspace.ts b/packages/compass-aggregations/src/modules/workspace.ts index 26486d6fcf0..a397c7656ee 100644 --- a/packages/compass-aggregations/src/modules/workspace.ts +++ b/packages/compass-aggregations/src/modules/workspace.ts @@ -1,7 +1,10 @@ import type { Reducer } from 'redux'; -import { ActionTypes as AggregationActionTypes } from './aggregation'; +import { ActionTypes as AggregationActionTypes, cancelAggregation } from './aggregation'; import type { Actions as AggregationActions } from './aggregation'; +import type { ThunkAction } from 'redux-thunk'; +import type { RootState } from '.'; +import { cancelCount } from './count-documents'; export type Workspace = 'builder' | 'results'; @@ -30,9 +33,17 @@ const reducer: Reducer = (state = INITIAL_S } }; -export const changeWorkspace = (view: Workspace): WorkspaceChangedAction => ({ - type: ActionTypes.WorkspaceChanged, - view, -}); - +export const changeWorkspace = (view: Workspace): ThunkAction => { + return (dispatch) => { + // As user switches to builder view, we cancel running ops + if (view === 'builder') { + dispatch(cancelAggregation()); + dispatch(cancelCount()); + } + dispatch({ + type: ActionTypes.WorkspaceChanged, + view, + }); + }; +}; export default reducer; diff --git a/packages/compass-aggregations/src/stores/store.spec.js b/packages/compass-aggregations/src/stores/store.spec.js index 862d7adab0e..4cafcf709c6 100644 --- a/packages/compass-aggregations/src/stores/store.spec.js +++ b/packages/compass-aggregations/src/stores/store.spec.js @@ -259,6 +259,7 @@ describe('Aggregation Store', function() { updateViewError: INITIAL_STATE.updateViewError, aggregation: INITIAL_STATE.aggregation, workspace: INITIAL_STATE.workspace, + countDocuments: INITIAL_STATE.countDocuments, }); }); }); diff --git a/packages/compass-import-export/src/components/export-select-output/export-select-output.jsx b/packages/compass-import-export/src/components/export-select-output/export-select-output.jsx index b336b648294..4e21de1eb1e 100644 --- a/packages/compass-import-export/src/components/export-select-output/export-select-output.jsx +++ b/packages/compass-import-export/src/components/export-select-output/export-select-output.jsx @@ -112,7 +112,6 @@ class ExportSelectOutput extends PureComponent { cancel={this.props.cancelExport} message={MESSAGES[this.props.status]} docsWritten={this.props.exportedDocsCount} - isAggregation={this.props.isAggregation} />
); diff --git a/packages/compass-import-export/src/components/progress-bar/progress-bar.jsx b/packages/compass-import-export/src/components/progress-bar/progress-bar.jsx index 11125137f47..e590688c20c 100644 --- a/packages/compass-import-export/src/components/progress-bar/progress-bar.jsx +++ b/packages/compass-import-export/src/components/progress-bar/progress-bar.jsx @@ -37,21 +37,20 @@ class ProgressBar extends PureComponent { progressLabel: PropTypes.func, progressTitle: PropTypes.func, withErrors: PropTypes.bool, - isAggregation: PropTypes.bool, }; static defaultProps = { - progressLabel(formattedWritten, formattedTotal, isAggregation) { - if (isAggregation) { - return formattedWritten; + progressLabel(written, total) { + if (!total) { + return formatNumber(written); } - return `${formattedWritten}\u00A0/\u00A0${formattedTotal}`; + return `${formatNumber(written)}\u00A0/\u00A0${formatNumber(total)}`; }, - progressTitle(formattedWritten, formattedTotal, isAggregation) { - if (isAggregation) { - return `${formattedWritten} documents`; + progressTitle(written, total) { + if (!total) { + return `${formatNumber(written)} documents`; } - return `${formattedWritten} documents out of ${formattedTotal}`; + return `${formatNumber(written)} documents out of ${formatNumber(total)}`; }, }; @@ -109,23 +108,12 @@ class ProgressBar extends PureComponent { }; renderStats() { - const { - docsTotal, - docsWritten, - progressLabel, - progressTitle, - isAggregation, - } = this.props; - - const formattedWritten = formatNumber(docsWritten); - const formattedTotal = formatNumber(docsTotal); - + const { docsTotal, docsWritten, progressLabel, progressTitle } = this.props; + const title = progressTitle(docsWritten, docsTotal); + const label = progressLabel(docsWritten, docsTotal); return ( -

- {progressLabel(formattedWritten, formattedTotal, isAggregation)} +

+ {label}

); } @@ -136,14 +124,8 @@ class ProgressBar extends PureComponent { * @returns {React.Component} The component. */ render() { - const { - message, - status, - docsProcessed, - docsTotal, - docsWritten, - isAggregation, - } = this.props; + const { message, status, docsProcessed, docsTotal, docsWritten } = + this.props; if (status === UNSPECIFIED) { return null; @@ -151,7 +133,7 @@ class ProgressBar extends PureComponent { return (
- {!isAggregation && ( + {docsTotal && (
{ const getExportSource = (dataService, ns, exportData) => { if (exportData.aggregation) { const { stages, options } = exportData.aggregation; - return getAggregationExportSource(dataService, ns, stages, options); + return { + columns: true, + source: dataService.aggregate(ns, stages, options), + numDocsToExport: exportData.count, + }; } return getQueryExportSource( dataService, @@ -550,14 +555,6 @@ const getExportSource = (dataService, ns, exportData) => { ); }; -const getAggregationExportSource = (dataService, ns, stages, options) => { - return { - columns: true, - source: dataService.aggregate(ns, stages, options), - numDocsToExport: 0, - }; -}; - const getQueryExportSource = async ( dataService, ns,