From 62b5ae36f4073d049d121cfedd70e9d230b0f059 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Thu, 2 Jul 2026 09:05:01 -0700 Subject: [PATCH 1/7] refactor(crud): crud store to redux from reflux COMPASS-6844 --- package-lock.json | 4 +- .../src/modules/collection-tab.ts | 4 + packages/compass-crud/README.md | 21 - packages/compass-crud/package.json | 4 +- packages/compass-crud/src/actions/index.ts | 30 +- .../src/components/bulk-delete-modal.spec.tsx | 2 +- .../src/components/bulk-delete-modal.tsx | 29 +- .../src/components/bulk-update-modal.spec.tsx | 2 +- .../src/components/bulk-update-modal.tsx | 35 +- .../components/change-view/change-view.tsx | 6 +- .../src/components/crud-toolbar.tsx | 4 +- .../src/components/document-list.tsx | 231 +- .../src/components/editable-document.tsx | 15 +- .../insert-document-dialog.spec.tsx | 132 +- .../src/components/insert-document-dialog.tsx | 39 +- .../src/components/json-editor.tsx | 15 +- .../table-view/cell-editor.spec.tsx | 12 +- .../src/components/table-view/cell-editor.tsx | 9 +- .../components/table-view/cell-renderer.tsx | 7 +- .../table-view/document-table-view.spec.tsx | 4 +- .../table-view/document-table-view.tsx | 37 +- .../table-view/full-width-cell-renderer.tsx | 8 +- packages/compass-crud/src/index.ts | 35 +- packages/compass-crud/src/plugin-title.tsx | 20 +- .../compass-crud/src/stores/bulk-delete.ts | 177 ++ .../compass-crud/src/stores/bulk-update.ts | 366 +++ .../src/stores/collection-meta.ts | 105 + .../src/stores/crud-store.spec.ts | 809 +++--- .../compass-crud/src/stores/crud-store.ts | 2347 +---------------- packages/compass-crud/src/stores/documents.ts | 1168 ++++++++ .../src/stores/fetch-documents.ts | 150 ++ .../src/stores/grid-store-context.ts | 19 + .../compass-crud/src/stores/insert.spec.ts | 135 + packages/compass-crud/src/stores/insert.ts | 440 +++ .../src/stores/parse-shell-bson.ts | 17 + packages/compass-crud/src/stores/reducer.ts | 117 + packages/compass-crud/src/stores/util.ts | 9 + packages/compass-crud/src/stores/view.ts | 122 + 38 files changed, 3812 insertions(+), 2874 deletions(-) create mode 100644 packages/compass-crud/src/stores/bulk-delete.ts create mode 100644 packages/compass-crud/src/stores/bulk-update.ts create mode 100644 packages/compass-crud/src/stores/collection-meta.ts create mode 100644 packages/compass-crud/src/stores/documents.ts create mode 100644 packages/compass-crud/src/stores/fetch-documents.ts create mode 100644 packages/compass-crud/src/stores/grid-store-context.ts create mode 100644 packages/compass-crud/src/stores/insert.spec.ts create mode 100644 packages/compass-crud/src/stores/insert.ts create mode 100644 packages/compass-crud/src/stores/parse-shell-bson.ts create mode 100644 packages/compass-crud/src/stores/reducer.ts create mode 100644 packages/compass-crud/src/stores/util.ts create mode 100644 packages/compass-crud/src/stores/view.ts diff --git a/package-lock.json b/package-lock.json index 7b5396b771b..1a31641e278 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51518,7 +51518,6 @@ "@mongodb-js/compass-workspaces": "^1.1.0", "@mongodb-js/explain-plan-helper": "^1.5.12", "@mongodb-js/my-queries-storage": "^1.1.0", - "@mongodb-js/reflux-state-mixin": "^1.2.36", "@mongodb-js/shell-bson-parser": "^1.5.13", "ag-grid-community": "^20.2.0", "ag-grid-react": "^20.2.0", @@ -51533,6 +51532,9 @@ "mongodb-ns": "^3.2.0", "mongodb-query-parser": "^4.7.14", "react": "^17.0.2", + "react-redux": "^8.1.3", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", "reflux": "^0.4.1", "semver": "^7.6.3" }, diff --git a/packages/compass-collection/src/modules/collection-tab.ts b/packages/compass-collection/src/modules/collection-tab.ts index 04e84e519a3..1bcb6f37685 100644 --- a/packages/compass-collection/src/modules/collection-tab.ts +++ b/packages/compass-collection/src/modules/collection-tab.ts @@ -993,6 +993,10 @@ export type CollectionTabPluginMetadata = CollectionMetadata & { * Subtab that is currently open */ subTab?: CollectionSubtab; + /** + * Mock data generator feature enablement. + */ + isMockDataGeneratorEnabled?: boolean; }; export default reducer; diff --git a/packages/compass-crud/README.md b/packages/compass-crud/README.md index 3076f9a137b..6a22420036e 100644 --- a/packages/compass-crud/README.md +++ b/packages/compass-crud/README.md @@ -65,27 +65,6 @@ class MyComponent extends React.Component { } ``` -Listen to the various CRUD actions. - -```javascript -const app = require('hadron-app'); -const CrudActions = app.appRegistry.getAction('CRUD.Actions'); - -CrudActions.documentRemoved.listen((id) => { - console.log(`Document with _id ${id} removed.`); -}); - -CrudActions.openInsertDocumentDialog((doc, clone) => { - if (clone) { - console.log('Opening insert dialog with cloned document'); - } -}); - -CrudActions.insertDocument((doc) => { - console.log('Inserting document into db'); -}); -``` - ### App Registry Events Emmitted Various actions within this plugin will emit events for other parts of the diff --git a/packages/compass-crud/package.json b/packages/compass-crud/package.json index 036fc9399c9..1a49bab16a4 100644 --- a/packages/compass-crud/package.json +++ b/packages/compass-crud/package.json @@ -82,7 +82,6 @@ "@mongodb-js/compass-workspaces": "^1.1.0", "@mongodb-js/explain-plan-helper": "^1.5.12", "@mongodb-js/my-queries-storage": "^1.1.0", - "@mongodb-js/reflux-state-mixin": "^1.2.36", "@mongodb-js/shell-bson-parser": "^1.5.13", "ag-grid-community": "^20.2.0", "ag-grid-react": "^20.2.0", @@ -97,6 +96,9 @@ "mongodb-ns": "^3.2.0", "mongodb-query-parser": "^4.7.14", "react": "^17.0.2", + "react-redux": "^8.1.3", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", "reflux": "^0.4.1", "semver": "^7.6.3" }, diff --git a/packages/compass-crud/src/actions/index.ts b/packages/compass-crud/src/actions/index.ts index 5600c4c103c..bc359fb88ab 100644 --- a/packages/compass-crud/src/actions/index.ts +++ b/packages/compass-crud/src/actions/index.ts @@ -1,43 +1,21 @@ import Reflux from 'reflux'; +/** + * Reflux actions used by the grid-store. The main crud store is + * Redux-based, these actions are only for the still-Reflux grid store. + */ const configureActions = () => { const actions = Reflux.createActions([ 'addColumn', 'cleanCols', - 'closeInsertDocumentDialog', - 'closeBulkUpdateModal', - 'copyToClipboard', - 'documentRemoved', - 'drillDown', 'elementAdded', 'elementMarkRemoved', 'elementRemoved', 'elementTypeChanged', - 'getPage', - 'insertDocument', - 'insertMany', - 'toggleInsertDocumentView', - 'toggleInsertDocument', - 'openInsertDocumentDialog', - 'openBulkUpdateModal', - 'updateBulkUpdatePreview', - 'runBulkUpdate', - 'openExportFileDialog', - 'openImportFileDialog', - 'pathChanged', - 'refreshDocuments', - 'cancelOperation', - 'removeDocument', 'removeColumn', 'renameColumn', 'replaceDoc', - 'replaceDocument', 'resetColumns', - 'updateDocument', - 'updateJsonDoc', - 'viewChanged', - 'updateComment', - 'updateMaxDocumentsPerPage', ]); return actions; diff --git a/packages/compass-crud/src/components/bulk-delete-modal.spec.tsx b/packages/compass-crud/src/components/bulk-delete-modal.spec.tsx index c6fdedfec0b..b9486ba077c 100644 --- a/packages/compass-crud/src/components/bulk-delete-modal.spec.tsx +++ b/packages/compass-crud/src/components/bulk-delete-modal.spec.tsx @@ -7,7 +7,7 @@ import { cleanup, userEvent, } from '@mongodb-js/testing-library-compass'; -import BulkDeleteModal from './bulk-delete-modal'; +import { BulkDeleteModal } from './bulk-delete-modal'; function renderBulkDeleteModal( props?: Partial> diff --git a/packages/compass-crud/src/components/bulk-delete-modal.tsx b/packages/compass-crud/src/components/bulk-delete-modal.tsx index 163ca88969a..3b85f13e942 100644 --- a/packages/compass-crud/src/components/bulk-delete-modal.tsx +++ b/packages/compass-crud/src/components/bulk-delete-modal.tsx @@ -12,11 +12,15 @@ import { spacing, useId, } from '@mongodb-js/compass-components'; -import type { BSONObject } from '../stores/crud-store'; +import { connect } from 'react-redux'; +import type { BSONObject, CrudState } from '../stores/crud-store'; import { toJSString } from 'mongodb-query-parser'; import { ReadonlyFilter } from './readonly-filter'; import ReadonlyDocument from './readonly-document'; import type { Document } from 'bson'; +import { useLastAppliedQuery } from '@mongodb-js/compass-query-bar'; +import { closeBulkDeleteDialog, runBulkDelete } from '../stores/bulk-delete'; +import { openDeleteQueryExportToLanguageDialog } from '../stores/documents'; const footerStyles = css({ display: 'flex', @@ -61,7 +65,7 @@ const exportToLanguageButtonStyles = css({ alignSelf: 'end', }); -type BulkDeleteModalProps = { +export type BulkDeleteModalProps = { open: boolean; documentCount?: number; filter: BSONObject; @@ -72,7 +76,7 @@ type BulkDeleteModalProps = { onExportToLanguage: () => void; }; -const BulkDeleteModal: React.FunctionComponent = ({ +export const BulkDeleteModal: React.FunctionComponent = ({ open, documentCount, filter, @@ -155,4 +159,21 @@ const BulkDeleteModal: React.FunctionComponent = ({ ); }; -export default BulkDeleteModal; +function ConnectedBulkDeleteModal(props: Omit) { + const { filter } = useLastAppliedQuery('crud'); + return ; +} + +export default connect( + (state: CrudState) => ({ + open: state.bulkDelete.status === 'open', + documentCount: state.bulkDelete.affected, + namespace: state.documents.ns, + sampleDocuments: state.bulkDelete.previews, + }), + { + onCancel: closeBulkDeleteDialog, + onConfirmDeletion: runBulkDelete, + onExportToLanguage: openDeleteQueryExportToLanguageDialog, + } +)(ConnectedBulkDeleteModal); diff --git a/packages/compass-crud/src/components/bulk-update-modal.spec.tsx b/packages/compass-crud/src/components/bulk-update-modal.spec.tsx index bb2b1ae547d..0b52e39853c 100644 --- a/packages/compass-crud/src/components/bulk-update-modal.spec.tsx +++ b/packages/compass-crud/src/components/bulk-update-modal.spec.tsx @@ -8,7 +8,7 @@ import { waitFor, userEvent, } from '@mongodb-js/testing-library-compass'; -import BulkUpdateModal from './bulk-update-modal'; +import { BulkUpdateModal } from './bulk-update-modal'; import { FavoriteQueryStorageProvider } from '@mongodb-js/my-queries-storage/provider'; import { createElectronFavoriteQueryStorage } from '@mongodb-js/my-queries-storage'; diff --git a/packages/compass-crud/src/components/bulk-update-modal.tsx b/packages/compass-crud/src/components/bulk-update-modal.tsx index bc071bd5bda..e19e4f13489 100644 --- a/packages/compass-crud/src/components/bulk-update-modal.tsx +++ b/packages/compass-crud/src/components/bulk-update-modal.tsx @@ -29,9 +29,18 @@ import { import type { Annotation } from '@mongodb-js/compass-editor'; import { CodemirrorMultilineEditor } from '@mongodb-js/compass-editor'; +import { connect } from 'react-redux'; +import type { CrudState } from '../stores/crud-store'; import type { BSONObject } from '../stores/crud-store'; import { ChangeView } from './change-view'; import { ReadonlyFilter } from './readonly-filter'; +import { useLastAppliedQuery } from '@mongodb-js/compass-query-bar'; +import { + closeBulkUpdateModal, + updateBulkUpdatePreview, + runBulkUpdate, + saveUpdateQuery, +} from '../stores/bulk-update'; import { useFavoriteQueryStorageAccess } from '@mongodb-js/my-queries-storage/provider'; @@ -334,7 +343,7 @@ export type BulkUpdateModalProps = { saveUpdateQuery: (name: string) => void; }; -export default function BulkUpdateModal({ +export function BulkUpdateModal({ isOpen, ns, filter, @@ -499,6 +508,30 @@ export default function BulkUpdateModal({ ); } +function ConnectedBulkUpdateModal(props: Omit) { + const { filter } = useLastAppliedQuery('crud'); + return ; +} + +export default connect( + (state: CrudState) => ({ + isOpen: state.bulkUpdate.isOpen, + ns: state.documents.ns, + count: state.documents.count ?? undefined, + updateText: state.bulkUpdate.updateText, + preview: state.bulkUpdate.preview, + syntaxError: state.bulkUpdate.syntaxError, + serverError: state.bulkUpdate.serverError, + enablePreview: state.collectionMeta.isUpdatePreviewSupported, + }), + { + closeBulkUpdateModal, + updateBulkUpdatePreview, + runBulkUpdate, + saveUpdateQuery, + } +)(ConnectedBulkUpdateModal); + const previewCardStyles = css({ padding: spacing[400], }); diff --git a/packages/compass-crud/src/components/change-view/change-view.tsx b/packages/compass-crud/src/components/change-view/change-view.tsx index 24bcb9e08d6..bdf569d5d13 100644 --- a/packages/compass-crud/src/components/change-view/change-view.tsx +++ b/packages/compass-crud/src/components/change-view/change-view.tsx @@ -220,7 +220,7 @@ function ChangeArrayItem({ item }: { item: ItemBranch }) { const shape = getValueShape(value); if (shape === 'array') { // array summary followed by array items if expanded - return ; + return ; } else if (shape === 'object') { // object summary followed by object properties if expanded return ; @@ -401,9 +401,7 @@ function ChangeObjectProperty({ property }: { property: PropertyBranch }) { const shape = getValueShape(value); if (shape === 'array') { // array summary followed by array items if expanded - return ( - - ); + return ; } else if (shape === 'object') { // object summary followed by object properties if expanded return ( diff --git a/packages/compass-crud/src/components/crud-toolbar.tsx b/packages/compass-crud/src/components/crud-toolbar.tsx index dad3f7cad23..b94ee4b2ace 100644 --- a/packages/compass-crud/src/components/crud-toolbar.tsx +++ b/packages/compass-crud/src/components/crud-toolbar.tsx @@ -136,7 +136,7 @@ function isOperationTimedOutError(err: ErrorWithPossibleCode) { export type CrudToolbarProps = { activeDocumentView: DocumentView; - count?: number; + count?: number | null; end: number; error?: ErrorWithPossibleCode | null; getPage: (page: number) => void; @@ -159,7 +159,7 @@ export type CrudToolbarProps = { page: number; readonly: boolean; refreshDocuments: () => void; - resultId: string; + resultId: number; start: number; viewSwitchHandler: (view: DocumentView) => void; insights?: Signal; diff --git a/packages/compass-crud/src/components/document-list.tsx b/packages/compass-crud/src/components/document-list.tsx index 35739e4fe79..628f2193835 100644 --- a/packages/compass-crud/src/components/document-list.tsx +++ b/packages/compass-crud/src/components/document-list.tsx @@ -11,9 +11,7 @@ import { withDarkMode, useCurrentValueRef, } from '@mongodb-js/compass-components'; -import type { InsertDocumentDialogProps } from './insert-document-dialog'; import InsertDocumentDialog from './insert-document-dialog'; -import type { BulkUpdateModalProps } from './bulk-update-modal'; import BulkUpdateModal from './bulk-update-modal'; import type { DocumentListViewProps } from './document-list-view'; import VirtualizedDocumentListView from './virtualized-document-list-view'; @@ -30,7 +28,27 @@ import { DOCUMENTS_STATUS_FETCHING, DOCUMENTS_STATUS_FETCHED_INITIAL, } from '../constants/documents-statuses'; -import type { CrudStore, BSONObject, DocumentView } from '../stores/crud-store'; +import { connect } from 'react-redux'; +import type { BSONObject, CrudState, DocumentView } from '../stores/crud-store'; +import { + refreshDocuments, + cancelOperation, + copyToClipboard, + removeDocument, + updateDocument, + replaceDocument, + openImportFileDialog, + openExportFileDialog, + openCreateIndexModal, + openCreateSearchIndexModal, + openQueryExportToLanguageDialog, + getPage, + updateMaxDocumentsPerPage, +} from '../stores/documents'; +import { drillDown, pathChanged, viewChanged } from '../stores/view'; +import { openInsertDocumentDialog } from '../stores/insert'; +import { openBulkUpdateModal } from '../stores/bulk-update'; +import { openBulkDeleteDialog } from '../stores/bulk-delete'; import { getToolbarSignal } from '../utils/toolbar-signal'; import BulkDeleteModal from './bulk-delete-modal'; import { useTabState } from '@mongodb-js/compass-workspaces/provider'; @@ -66,58 +84,28 @@ const loaderContainerStyles = css({ }); export type DocumentListProps = { - store: CrudStore; - openInsertDocumentDialog?: (doc: BSONObject, cloned: boolean) => void; - openBulkUpdateModal: () => void; - updateBulkUpdatePreview: (updateText: string) => void; - runBulkUpdate: () => void; - saveUpdateQuery: (name: string) => void; - openImportFileDialog?: (origin: 'empty-state' | 'crud-toolbar') => void; + isDataLake: boolean; + isReadonly: boolean; + openBulkUpdateModal: (updateText?: string) => void; + cancelOperation: () => void; + openBulkDeleteDialog: () => void; + refreshDocuments: (onApply?: boolean) => void; + openCreateIndexModal: () => void; + openCreateSearchIndexModal: () => void; + openQueryExportToLanguageDialog: () => void; docs: Document[]; view: DocumentView; - insert: Partial & - Required< - Pick< - InsertDocumentDialogProps, - | 'doc' - | 'csfleState' - | 'isOpen' - | 'error' - | 'mode' - | 'jsonDoc' - | 'isCommentNeeded' - > - >; - bulkUpdate: Partial & - Required< - Pick< - BulkUpdateModalProps, - 'isOpen' | 'syntaxError' | 'serverError' | 'preview' | 'updateText' - > - >; status: DOCUMENTS_STATUSES; debouncingLoad?: boolean; viewChanged: CrudToolbarProps['viewSwitchHandler']; darkMode?: boolean; isCollectionScan?: boolean; isSearchIndexesSupported: boolean; - isUpdatePreviewSupported: boolean; + openInsertDocumentDialog?: (doc: BSONObject, cloned: boolean) => void; + openImportFileDialog?: (origin: 'empty-state' | 'crud-toolbar') => void; } & Omit & Omit & Omit & - Pick< - InsertDocumentDialogProps, - | 'closeInsertDocumentDialog' - | 'insertDocument' - | 'insertMany' - | 'updateJsonDoc' - | 'toggleInsertDocument' - | 'toggleInsertDocumentView' - | 'version' - | 'ns' - | 'updateComment' - > & - Pick & Pick< CrudToolbarProps, | 'error' @@ -133,7 +121,6 @@ export type DocumentListProps = { | 'isWritable' | 'isMockDataGeneratorEligibleAndSchemaReady' | 'instanceDescription' - | 'refreshDocuments' | 'resultId' | 'docsPerPage' | 'updateMaxDocumentsPerPage' @@ -293,13 +280,19 @@ const DocumentList: React.FunctionComponent = (props) => { end, page, getPage, - store, + isDataLake, + isReadonly, openExportFileDialog, viewChanged, isWritable, isMockDataGeneratorEligibleAndSchemaReady, instanceDescription, refreshDocuments, + cancelOperation, + openBulkDeleteDialog, + openQueryExportToLanguageDialog, + openCreateIndexModal, + openCreateSearchIndexModal, resultId, isCollectionScan, isSearchIndexesSupported, @@ -309,21 +302,6 @@ const DocumentList: React.FunctionComponent = (props) => { docs, status, debouncingLoad, - closeInsertDocumentDialog, - insertDocument, - insertMany, - updateJsonDoc, - toggleInsertDocument, - toggleInsertDocumentView, - version, - ns, - updateComment, - insert, - bulkUpdate, - isUpdatePreviewSupported, - closeBulkUpdateModal, - updateBulkUpdatePreview, - runBulkUpdate, docsPerPage, updateMaxDocumentsPerPage, } = props; @@ -340,43 +318,24 @@ const DocumentList: React.FunctionComponent = (props) => { ); const onApplyClicked = useCallback(() => { - void store.refreshDocuments(true); - }, [store]); + refreshDocuments(true); + }, [refreshDocuments]); const onResetClicked = useCallback(() => { - void store.refreshDocuments(); - }, [store]); + refreshDocuments(); + }, [refreshDocuments]); const onCancelClicked = useCallback(() => { - void store.cancelOperation(); - }, [store]); + cancelOperation(); + }, [cancelOperation]); const onUpdateButtonClicked = useCallback(() => { openBulkUpdateModal(); }, [openBulkUpdateModal]); const onDeleteButtonClicked = useCallback(() => { - store.openBulkDeleteDialog(); - }, [store]); - - const onSaveUpdateQuery = useCallback( - (name: string) => { - void store.saveUpdateQuery(name); - }, - [store] - ); - - const onCancelBulkDeleteDialog = useCallback(() => { - store.closeBulkDeleteDialog(); - }, [store]); - - const onConfirmBulkDeleteDialog = useCallback(() => { - void store.runBulkDelete(); - }, [store]); - - const onExportToLanguageDeleteQuery = useCallback(() => { - store.openDeleteQueryExportToLanguageDialog(); - }, [store]); + openBulkDeleteDialog(); + }, [openBulkDeleteDialog]); const query = useLastAppliedQuery('crud'); const outdated = useIsLastAppliedQueryOutdated('crud'); @@ -395,8 +354,8 @@ const DocumentList: React.FunctionComponent = (props) => { const isEditable = !preferencesReadOnly && - !store.state.isDataLake && - !store.state.isReadonly && + !isDataLake && + !isReadonly && Object.keys(query.project ?? {}).length === 0; const isEmpty = docs.length === 0; @@ -591,9 +550,7 @@ const DocumentList: React.FunctionComponent = (props) => { onExpandAllClicked={onExpandAllClicked} onCollapseAllClicked={onCollapseAllClicked} openExportFileDialog={openExportFileDialog} - onOpenExportToLanguage={store.openQueryExportToLanguageDialog.bind( - store - )} + onOpenExportToLanguage={openQueryExportToLanguageDialog} outdated={outdated} readonly={!isEditable} viewSwitchHandler={handleViewChanged} @@ -608,8 +565,8 @@ const DocumentList: React.FunctionComponent = (props) => { isCollectionScan: Boolean(isCollectionScan), isSearchIndexesSupported, canCreateIndexes: !preferencesReadWrite, - onCreateIndex: store.openCreateIndexModal.bind(store), - onCreateSearchIndex: store.openCreateSearchIndexModal.bind(store), + onCreateIndex: openCreateIndexModal, + onCreateSearchIndex: openCreateSearchIndexModal, onAssistantButtonClick: tellMoreAboutInsight ? () => tellMoreAboutInsight({ @@ -628,44 +585,58 @@ const DocumentList: React.FunctionComponent = (props) => { {isEditable && ( <> - - - + + + )} ); }; -export default withDarkMode(DocumentList); +export default connect( + (state: CrudState) => ({ + isDataLake: state.collectionMeta.isDataLake, + isReadonly: state.collectionMeta.isReadonly, + isWritable: state.collectionMeta.isWritable, + instanceDescription: state.collectionMeta.instanceDescription, + isSearchIndexesSupported: state.collectionMeta.isSearchIndexesSupported, + view: state.view.view, + table: state.view.table, + docs: state.documents.docs ?? [], + start: state.documents.start, + end: state.documents.end, + page: state.documents.page, + count: state.documents.count, + status: state.documents.status, + error: state.documents.error, + resultId: state.documents.resultId, + isCollectionScan: state.documents.isCollectionScan, + loadingCount: state.documents.loadingCount, + lastCountRunMaxTimeMS: state.documents.lastCountRunMaxTimeMS, + debouncingLoad: state.documents.debouncingLoad, + docsPerPage: state.documents.docsPerPage, + }), + { + refreshDocuments, + cancelOperation, + copyToClipboard, + drillDown, + removeDocument, + updateDocument, + replaceDocument, + pathChanged, + viewChanged, + openImportFileDialog: (_origin: 'empty-state' | 'crud-toolbar') => + openImportFileDialog(), + openExportFileDialog, + openCreateIndexModal, + openCreateSearchIndexModal, + openQueryExportToLanguageDialog, + getPage, + updateMaxDocumentsPerPage, + openInsertDocumentDialog, + openBulkUpdateModal, + openBulkDeleteDialog, + } +)(withDarkMode(DocumentList)); diff --git a/packages/compass-crud/src/components/editable-document.tsx b/packages/compass-crud/src/components/editable-document.tsx index 914fd365098..bb91bd0a530 100644 --- a/packages/compass-crud/src/components/editable-document.tsx +++ b/packages/compass-crud/src/components/editable-document.tsx @@ -6,7 +6,7 @@ import { withPreferences } from 'compass-preferences-model/provider'; import { documentStyles, documentContentStyles } from './readonly-document'; import { getInsightsForDocument } from '../utils'; -import type { CrudActions } from '../stores/crud-store'; +import type { BSONObject } from '../stores/crud-store'; const documentElementsContainerStyles = css({ position: 'relative', @@ -14,11 +14,14 @@ const documentElementsContainerStyles = css({ export type EditableDocumentProps = { doc: Document; - removeDocument?: CrudActions['removeDocument']; - replaceDocument?: CrudActions['replaceDocument']; - updateDocument?: CrudActions['updateDocument']; - openInsertDocumentDialog?: CrudActions['openInsertDocumentDialog']; - copyToClipboard?: CrudActions['copyToClipboard']; + removeDocument?: (doc: Document) => Promise; + replaceDocument?: (doc: Document) => Promise; + updateDocument?: (doc: Document) => Promise; + openInsertDocumentDialog?: ( + doc: BSONObject, + cloned: boolean + ) => Promise; + copyToClipboard?: (doc: Document) => void; showInsights?: boolean; onUpdateQuery?: (field: string, value: unknown) => void; query?: Record; diff --git a/packages/compass-crud/src/components/insert-document-dialog.spec.tsx b/packages/compass-crud/src/components/insert-document-dialog.spec.tsx index 0b75e156263..b806851be25 100644 --- a/packages/compass-crud/src/components/insert-document-dialog.spec.tsx +++ b/packages/compass-crud/src/components/insert-document-dialog.spec.tsx @@ -1,59 +1,111 @@ -import React, { type ComponentProps } from 'react'; +import React from 'react'; import { expect } from 'chai'; import { render, screen } from '@mongodb-js/testing-library-compass'; +import { Provider } from 'react-redux'; +import AppRegistry, { + createActivateHelpers, +} from '@mongodb-js/compass-app-registry'; +import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; +import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider'; +import { + ConnectionScopedAppRegistryImpl, + type ConnectionInfoRef, +} from '@mongodb-js/compass-connections/provider'; +import type { FieldStoreService } from '@mongodb-js/compass-field-store'; +import { createDefaultConnectionInfo } from '@mongodb-js/testing-library-compass'; import InsertDocumentDialog from './insert-document-dialog'; -import HadronDocument from 'hadron-document'; import { setCodemirrorEditorValue } from '@mongodb-js/compass-editor'; +import { activateDocumentsPlugin } from '../stores/crud-store'; +import { openInsertDocumentDialog } from '../stores/insert'; -const noop = () => {}; -const defaultProps = { - closeInsertDocumentDialog: noop, - insertDocument: noop, - insertMany: noop, - toggleInsertDocument: noop, - toggleInsertDocumentView: noop, - isCommentNeeded: false, - csfleState: { state: 'none' }, - isOpen: true, - ns: 'airbnb.listings', - updateComment: noop, - error: null, -} as unknown as ComponentProps; +const TEST_CONNECTION_INFO = createDefaultConnectionInfo(); + +const mockFieldStoreService = { + updateFieldsFromDocuments() {}, + updateFieldsFromSchema() {}, + getSchemaFieldsForNamespace() { + return undefined; + }, +} as unknown as FieldStoreService; + +function createActivatedStore(ns = 'airbnb.listings') { + const localAppRegistry = new AppRegistry(); + const globalAppRegistry = new AppRegistry(); + const connectionInfoRef = { + current: TEST_CONNECTION_INFO, + } as ConnectionInfoRef; + const connectionScopedAppRegistry = new ConnectionScopedAppRegistryImpl( + globalAppRegistry.emit.bind(globalAppRegistry), + connectionInfoRef + ); + + return activateDocumentsPlugin( + { + namespace: ns, + isReadonly: false, + isTimeSeries: false, + isSearchIndexesSupported: false, + noRefreshOnConfigure: true, + }, + { + dataService: {} as any, + instance: { + build: { version: '7.0.0' }, + dataLake: { isDataLake: false }, + isWritable: true, + description: 'Standalone', + topologyDescription: { type: 'Standalone' }, + on: () => {}, + removeListener: () => {}, + } as any, + localAppRegistry, + globalAppRegistry, + preferences: {} as any, + logger: createNoopLogger(), + track: createNoopTrack(), + fieldStoreService: mockFieldStoreService, + connectionInfoRef, + connectionScopedAppRegistry, + queryBar: { + getLastAppliedQuery: () => ({}), + getCurrentQuery: () => ({}), + changeQuery: () => {}, + }, + collection: { + toJSON: () => ({ + avg_document_size: 0, + document_count: 0, + free_storage_size: 0, + storage_size: 0, + }), + on: () => {}, + removeListener: () => {}, + } as any, + }, + createActivateHelpers() + ); +} describe('InsertDocumentDialog', function () { it('show error message for invalid EJSON', async function () { - let jsonDoc = '{}'; - const doc = new HadronDocument({}); - doc.editing = true; - function updateJsonDoc(value: string | null) { - doc.setModifiedEJSONString(value); - jsonDoc = value ?? '{}'; - } - const { rerender } = render( - + const { store, deactivate } = createActivatedStore(); + await store.dispatch(openInsertDocumentDialog({})); + + render( + + + ); + await setCodemirrorEditorValue( screen.getByTestId('insert-document-json-editor'), '{ "invalid_long": { "$numberLong": "1234567234324812317654321" } } ' ); - rerender( - - ); + const errorMessage = await screen.findByText( /numberLong string is too long/i ); expect(errorMessage).to.exist; + deactivate(); }); }); diff --git a/packages/compass-crud/src/components/insert-document-dialog.tsx b/packages/compass-crud/src/components/insert-document-dialog.tsx index 72364b8a2db..b39d2ac08b6 100644 --- a/packages/compass-crud/src/components/insert-document-dialog.tsx +++ b/packages/compass-crud/src/components/insert-document-dialog.tsx @@ -24,6 +24,17 @@ import type { Logger } from '@mongodb-js/compass-logging/provider'; import { withLogger } from '@mongodb-js/compass-logging/provider'; import type { TrackFunction } from '@mongodb-js/compass-telemetry'; import type { WriteError } from '../stores/crud-store'; +import { connect } from 'react-redux'; +import type { CrudState } from '../stores/crud-store'; +import { + insertDocument, + insertMany, + toggleInsertDocument, + toggleInsertDocumentView, + closeInsertDocumentDialog, + updateJsonDoc, + updateComment, +} from '../stores/insert'; /** * The insert invalid message. @@ -51,16 +62,14 @@ const errorDetailsBtnStyles = css({ float: 'right', }); -export type InsertDocumentDialogProps = InsertCSFLEWarningBannerProps & { +type InsertDocumentDialogProps = InsertCSFLEWarningBannerProps & { closeInsertDocumentDialog: () => void; toggleInsertDocumentView: (view: 'JSON' | 'List') => void; toggleInsertDocument: (view: 'JSON' | 'List') => void; insertDocument: () => void; insertMany: () => void; isOpen: boolean; - error: WriteError; - mode: 'modifying' | 'error'; - version: string; + error?: WriteError; updateJsonDoc: (value: string | null) => void; jsonDoc: string; jsonView: boolean; @@ -367,4 +376,24 @@ const InsertDocumentDialog: React.FC = ({ ); }; -export default withLogger(InsertDocumentDialog, 'COMPASS-CRUD-UI'); +export default connect( + (state: CrudState) => ({ + isOpen: state.insert.isOpen, + error: state.insert.error, + jsonDoc: state.insert.jsonDoc ?? '', + jsonView: state.insert.jsonView, + doc: state.insert.doc, + ns: state.documents.ns, + isCommentNeeded: state.insert.isCommentNeeded, + csfleState: state.insert.csfleState, + }), + { + insertDocument, + insertMany, + toggleInsertDocument, + toggleInsertDocumentView, + closeInsertDocumentDialog, + updateJsonDoc, + updateComment, + } +)(withLogger(InsertDocumentDialog, 'COMPASS-CRUD-UI')); diff --git a/packages/compass-crud/src/components/json-editor.tsx b/packages/compass-crud/src/components/json-editor.tsx index 3ec53603b82..3b43b2db3f5 100644 --- a/packages/compass-crud/src/components/json-editor.tsx +++ b/packages/compass-crud/src/components/json-editor.tsx @@ -21,8 +21,8 @@ import { CodemirrorMultilineEditor, } from '@mongodb-js/compass-editor'; import type { EditorRef, Action } from '@mongodb-js/compass-editor'; -import type { CrudActions } from '../stores/crud-store'; import { useAutocompleteFields } from '@mongodb-js/compass-field-store'; +import type { BSONObject } from '../stores/insert'; const editorStyles = css({ minHeight: spacing[800] + spacing[400], @@ -54,11 +54,14 @@ export type JSONEditorProps = { doc: Document; editable: boolean; isTimeSeries?: boolean; - removeDocument?: CrudActions['removeDocument']; - replaceDocument?: CrudActions['replaceDocument']; - updateDocument?: CrudActions['updateDocument']; - copyToClipboard?: CrudActions['copyToClipboard']; - openInsertDocumentDialog?: CrudActions['openInsertDocumentDialog']; + removeDocument?: (doc: Document) => Promise; + replaceDocument?: (doc: Document) => Promise; + updateDocument?: (doc: Document) => Promise; + copyToClipboard?: (doc: Document) => void; + openInsertDocumentDialog?: ( + doc: BSONObject, + cloned: boolean + ) => Promise; }; const JSONEditor: React.FunctionComponent = ({ diff --git a/packages/compass-crud/src/components/table-view/cell-editor.spec.tsx b/packages/compass-crud/src/components/table-view/cell-editor.spec.tsx index ea04d06e2d6..dc75d01c755 100644 --- a/packages/compass-crud/src/components/table-view/cell-editor.spec.tsx +++ b/packages/compass-crud/src/components/table-view/cell-editor.spec.tsx @@ -341,9 +341,7 @@ describe('', function () { /> ); - const input = screen.getByTestId( - 'table-view-cell-editor-value-input' - ) as HTMLInputElement; + const input = screen.getByTestId('table-view-cell-editor-value-input'); expect(input).to.exist; userEvent.clear(input); userEvent.type(input, 'new input'); @@ -527,7 +525,7 @@ describe('', function () { const fieldInput = screen.getByTestId( 'table-view-cell-editor-fieldname-input' - ) as HTMLInputElement; + ); expect(fieldInput).to.exist; userEvent.clear(fieldInput); userEvent.type(fieldInput, 'fieldname'); @@ -574,7 +572,7 @@ describe('', function () { const fieldInput = screen.getByTestId( 'table-view-cell-editor-fieldname-input' - ) as HTMLInputElement; + ); expect(fieldInput).to.exist; userEvent.clear(fieldInput); userEvent.type(fieldInput, 'fieldname'); @@ -697,9 +695,7 @@ describe('', function () { /> ); - const input = screen.getByTestId( - 'table-view-cell-editor-value-input' - ) as HTMLInputElement; + const input = screen.getByTestId('table-view-cell-editor-value-input'); expect(input).to.exist; userEvent.clear(input); userEvent.type(input, 'new input'); diff --git a/packages/compass-crud/src/components/table-view/cell-editor.tsx b/packages/compass-crud/src/components/table-view/cell-editor.tsx index c658a124c20..ffc6c14b41b 100644 --- a/packages/compass-crud/src/components/table-view/cell-editor.tsx +++ b/packages/compass-crud/src/components/table-view/cell-editor.tsx @@ -27,7 +27,6 @@ import type { } from 'ag-grid-community'; import type { ICellEditorReactComp } from 'ag-grid-react'; import type { GridActions, TableHeaderType } from '../../stores/grid-store'; -import type { CrudActions } from '../../stores/crud-store'; import type { GridContext } from './document-table-view'; /** @@ -74,7 +73,11 @@ export type CellEditorProps = Omit & { elementRemoved: GridActions['elementRemoved']; elementTypeChanged: GridActions['elementTypeChanged']; elementMarkRemoved: GridActions['elementMarkRemoved']; - drillDown: CrudActions['drillDown']; + drillDown: ( + doc: Document, + element: Element, + editParams?: { colId: string; rowIndex: number } + ) => void; tz: string; darkMode?: boolean; legacyUUIDDisplayEncoding?: string; @@ -269,7 +272,7 @@ class CellEditor */ } this.props.api.refreshCells({ - rowNodes: [this.props.node as RowNode], + rowNodes: [this.props.node], force: true, }); return false; diff --git a/packages/compass-crud/src/components/table-view/cell-renderer.tsx b/packages/compass-crud/src/components/table-view/cell-renderer.tsx index 33e0b07d4e5..d59677a33bb 100644 --- a/packages/compass-crud/src/components/table-view/cell-renderer.tsx +++ b/packages/compass-crud/src/components/table-view/cell-renderer.tsx @@ -14,7 +14,6 @@ import { Element } from 'hadron-document'; import type { ICellRendererReactComp } from 'ag-grid-react'; import type { ICellRendererParams } from 'ag-grid-community'; import type { GridActions, TableHeaderType } from '../../stores/grid-store'; -import type { CrudActions } from '../../stores/crud-store'; import type { GridContext } from './document-table-view'; /** @@ -88,7 +87,11 @@ export type CellRendererProps = Omit & { elementAdded: GridActions['elementAdded']; elementRemoved: GridActions['elementRemoved']; elementTypeChanged: GridActions['elementTypeChanged']; - drillDown: CrudActions['drillDown']; + drillDown: ( + doc: Document, + element: Element, + editParams?: { colId: string; rowIndex: number } + ) => void; tz: string; darkMode?: boolean; legacyUUIDDisplayEncoding?: DocumentListTypes.LegacyUUIDDisplay; diff --git a/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx b/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx index 0ae9c864745..9d6bd0d43dd 100644 --- a/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx +++ b/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx @@ -60,10 +60,10 @@ describe('', function () { // - the '_id' column should not share that explicit width (keeps default) const nameHeader = document.querySelector( '.ag-header-cell[col-id="name"]' - ) as HTMLElement | null; + ); const idHeader = document.querySelector( '.ag-header-cell[col-id="_id"]' - ) as HTMLElement | null; + ); expect(nameHeader, 'name column header should exist').to.exist; expect(idHeader, '_id column header should exist').to.exist; if (nameHeader) { diff --git a/packages/compass-crud/src/components/table-view/document-table-view.tsx b/packages/compass-crud/src/components/table-view/document-table-view.tsx index e971b601a58..dfc3c5dc8cf 100644 --- a/packages/compass-crud/src/components/table-view/document-table-view.tsx +++ b/packages/compass-crud/src/components/table-view/document-table-view.tsx @@ -17,17 +17,14 @@ import CellEditor from './cell-editor'; import './document-table-view.less'; import './ag-grid-dist.css'; import { cx, spacing, withDarkMode } from '@mongodb-js/compass-components'; -import type { - BSONObject, - CrudActions, - CrudStore, - TableState, -} from '../../stores/crud-store'; +import type { BSONObject, TableState } from '../../stores/crud-store'; import type { GridActions, + GridStore, GridStoreTriggerParams, TableHeaderType, } from '../../stores/grid-store'; +import { GridStoreContext } from '../../stores/grid-store-context'; import type { CellDoubleClickedEvent, ColDef, @@ -35,7 +32,6 @@ import type { GridApi, GridCellDef, GridReadyEvent, - RowNode, ValueGetterParams, ColumnResizedEvent, } from 'ag-grid-community'; @@ -46,7 +42,11 @@ export type DocumentTableViewProps = { addColumn: GridActions['addColumn']; cleanCols: GridActions['cleanCols']; docs: Document[]; - drillDown: CrudActions['drillDown']; + drillDown: ( + doc: Document, + element: Element, + editParams?: { colId: string; rowIndex: number } + ) => void; elementAdded: GridActions['elementAdded']; elementMarkRemoved: GridActions['elementMarkRemoved']; elementRemoved: GridActions['elementRemoved']; @@ -55,18 +55,20 @@ export type DocumentTableViewProps = { isEditable: boolean; ns: string; version: string; - openInsertDocumentDialog?: CrudActions['openInsertDocumentDialog']; + openInsertDocumentDialog?: ( + doc: BSONObject, + cloned: boolean + ) => Promise; pathChanged: (path: (string | number)[], types: TableHeaderType[]) => void; removeColumn: GridActions['removeColumn']; copyToClipboard: (doc: Document) => void; renameColumn: GridActions['renameColumn']; replaceDoc: GridActions['replaceDoc']; resetColumns: GridActions['resetColumns']; - removeDocument: CrudActions['removeDocument']; - replaceDocument: CrudActions['replaceDocument']; - updateDocument: CrudActions['updateDocument']; + removeDocument: (doc: Document) => Promise; + replaceDocument: (doc: Document) => Promise; + updateDocument: (doc: Document) => Promise; start: number; - store: CrudStore; table: TableState; tz: string; className?: string; @@ -93,6 +95,8 @@ export type GridContext = { * Represents the table view of the documents tab. */ export class DocumentTableView extends React.Component { + static contextType = GridStoreContext; + declare context: GridStore | null; AGGrid: React.ReactElement; collection: string; topLevel: boolean; @@ -162,10 +166,7 @@ export class DocumentTableView extends React.Component { } componentDidMount() { - this.unsubscribeGridStore = this.props.store.gridStore.listen( - this.modifyColumns, - this - ); + this.unsubscribeGridStore = this.context?.listen(this.modifyColumns, this); } componentWillUnmount() { @@ -256,7 +257,7 @@ export class DocumentTableView extends React.Component { node.data.hasFooter = true; node.data.state = state; this.gridApi?.refreshCells({ - rowNodes: [node as RowNode], + rowNodes: [node], columns: ['$rowActions'], force: true, }); diff --git a/packages/compass-crud/src/components/table-view/full-width-cell-renderer.tsx b/packages/compass-crud/src/components/table-view/full-width-cell-renderer.tsx index e3b1a824d2d..2ad7e1fb54c 100644 --- a/packages/compass-crud/src/components/table-view/full-width-cell-renderer.tsx +++ b/packages/compass-crud/src/components/table-view/full-width-cell-renderer.tsx @@ -7,7 +7,7 @@ import type Document from 'hadron-document'; import type { CellEditorProps } from './cell-editor'; import type { GridActions } from '../../stores/grid-store'; import { DocumentEvents, type Element } from 'hadron-document'; -import type { BSONObject, CrudActions } from '../../stores/crud-store'; +import type { BSONObject } from '../../stores/crud-store'; export type FullWidthCellRendererProps = Pick< CellEditorProps, @@ -16,9 +16,9 @@ export type FullWidthCellRendererProps = Pick< data: CellEditorProps['node']['data']; cleanCols: GridActions['cleanCols']; replaceDoc: GridActions['replaceDoc']; - replaceDocument: CrudActions['replaceDocument']; - removeDocument: CrudActions['removeDocument']; - updateDocument: CrudActions['updateDocument']; + replaceDocument: (doc: Document) => Promise; + removeDocument: (doc: Document) => Promise; + updateDocument: (doc: Document) => Promise; darkMode?: boolean; }; diff --git a/packages/compass-crud/src/index.ts b/packages/compass-crud/src/index.ts index 36ca93f22da..73300f5ea69 100644 --- a/packages/compass-crud/src/index.ts +++ b/packages/compass-crud/src/index.ts @@ -1,4 +1,5 @@ import React from 'react'; +import { useStore } from 'react-redux'; import type { DocumentProps } from './components/document'; import Document from './components/document'; import type { DocumentListProps } from './components/document-list'; @@ -7,17 +8,15 @@ import InsertDocumentDialog from './components/insert-document-dialog'; import { type EmittedAppRegistryEvents, activateDocumentsPlugin, + type CrudReduxStore, } from './stores/crud-store'; +import { GridStoreContext } from './stores/grid-store-context'; import { connectionInfoRefLocator, connectionScopedAppRegistryLocator, - dataServiceLocator, type DataServiceLocator, + dataServiceLocator, } from '@mongodb-js/compass-connections/provider'; -import type { - OptionalDataServiceProps, - RequiredDataServiceProps, -} from './utils/data-service'; import { collectionModelLocator, mongoDBInstanceLocator, @@ -32,24 +31,30 @@ import { import { fieldStoreServiceLocator } from '@mongodb-js/compass-field-store'; import { queryBarServiceLocator } from '@mongodb-js/compass-query-bar'; import { telemetryLocator } from '@mongodb-js/compass-telemetry/provider'; -import { CrudTabTitle } from './plugin-title'; +import CrudTabTitle from './plugin-title'; +import type { + RequiredDataServiceProps, + OptionalDataServiceProps, +} from './utils/data-service'; const CompassDocumentsPluginProvider = registerCompassPlugin( { name: 'CompassDocuments', - component: function CrudProvider({ children, ...props }) { + // The redux store carries a side-channel reference to the still-Reflux + // grid store, we surface it via a dedicated React context so components can + // subscribe to grid events without holding a reference to the store object. + component: function CrudProvider({ children }) { + const reduxStore = useStore() as unknown as CrudReduxStore; return React.createElement( - React.Fragment, - null, - // Cloning children with props is a workaround for reflux store. - React.isValidElement(children) - ? React.cloneElement(children, props) - : null + GridStoreContext.Provider, + { value: reduxStore.gridStore }, + children ); }, activate: activateDocumentsPlugin, }, { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion dataService: dataServiceLocator as DataServiceLocator< RequiredDataServiceProps, OptionalDataServiceProps @@ -72,8 +77,8 @@ const CompassDocumentsPluginProvider = registerCompassPlugin( export const CompassDocumentsPlugin = { name: 'Documents' as const, provider: CompassDocumentsPluginProvider, - content: DocumentList as any, // as any because of reflux store - header: CrudTabTitle as any, // as any because of reflux store + content: DocumentList, + header: CrudTabTitle, }; export default DocumentList; diff --git a/packages/compass-crud/src/plugin-title.tsx b/packages/compass-crud/src/plugin-title.tsx index 91bba381fa0..70a512a2d54 100644 --- a/packages/compass-crud/src/plugin-title.tsx +++ b/packages/compass-crud/src/plugin-title.tsx @@ -7,7 +7,9 @@ import { compactBytes, compactNumber, } from '@mongodb-js/compass-components'; -import type { CrudStore } from './stores/crud-store'; +import { connect } from 'react-redux'; +import type { CrudState } from './stores/crud-store'; +import type { CollectionStats } from './stores/collection-meta'; import { usePreference } from 'compass-preferences-model/provider'; const tooltipContentStyles = css({ @@ -77,12 +79,12 @@ const CollectionStats: React.FunctionComponent = ({ ); }; -export const CrudTabTitle = ({ - store: { - state: { collectionStats }, - }, -}: { - store: CrudStore; +type CrudTabTitleProps = { + collectionStats: CollectionStats | null; +}; + +const CrudTabTitle: React.FunctionComponent = ({ + collectionStats, }) => { const { documentCount, storageSize, avgDocumentSize } = useMemo(() => { const { @@ -114,3 +116,7 @@ export const CrudTabTitle = ({ ); }; + +export default connect((state: CrudState) => ({ + collectionStats: state.collectionMeta.collectionStats, +}))(CrudTabTitle); diff --git a/packages/compass-crud/src/stores/bulk-delete.ts b/packages/compass-crud/src/stores/bulk-delete.ts new file mode 100644 index 00000000000..ce166baeaac --- /dev/null +++ b/packages/compass-crud/src/stores/bulk-delete.ts @@ -0,0 +1,177 @@ +import type { Reducer } from 'redux'; +import { Document } from 'hadron-document'; +import { mongoLogId } from '@mongodb-js/compass-logging/provider'; +import { showConfirmation } from '@mongodb-js/compass-components'; +import { isAction } from './util'; +import type { CrudThunkAction } from './reducer'; +import { + openBulkDeleteFailureToast, + openBulkDeleteProgressToast, + openBulkDeleteSuccessToast, +} from '../components/bulk-actions-toasts'; +import { refreshDocuments } from './documents'; + +export type BulkDeleteState = { + previews: Document[]; + status: 'open' | 'closed' | 'in-progress'; + affected?: number; +}; + +export const INITIAL_BULK_DELETE_STATE: BulkDeleteState = { + previews: [], + status: 'closed', + affected: 0, +}; + +export const BulkDeleteActionTypes = { + OPEN_DIALOG: 'crud/bulk-delete/OPEN_DIALOG', + CLOSE_DIALOG: 'crud/bulk-delete/CLOSE_DIALOG', + IN_PROGRESS: 'crud/bulk-delete/IN_PROGRESS', +} as const; + +export type OpenBulkDeleteDialogAction = { + type: typeof BulkDeleteActionTypes.OPEN_DIALOG; + previews: Document[]; + affected: number | undefined; +}; + +export type CloseBulkDeleteDialogAction = { + type: typeof BulkDeleteActionTypes.CLOSE_DIALOG; +}; + +export type BulkDeleteInProgressAction = { + type: typeof BulkDeleteActionTypes.IN_PROGRESS; +}; + +export type BulkDeleteActions = + | OpenBulkDeleteDialogAction + | CloseBulkDeleteDialogAction + | BulkDeleteInProgressAction; + +export const bulkDeleteReducer: Reducer = ( + state = INITIAL_BULK_DELETE_STATE, + action +) => { + if (isAction(action, BulkDeleteActionTypes.OPEN_DIALOG)) { + return { + previews: action.previews, + status: 'open', + affected: action.affected, + }; + } + if (isAction(action, BulkDeleteActionTypes.CLOSE_DIALOG)) { + return { ...state, status: 'closed' }; + } + if (isAction(action, BulkDeleteActionTypes.IN_PROGRESS)) { + return { ...state, status: 'in-progress' }; + } + return state; +}; + +const PREVIEW_DOCS = 5; + +export function openBulkDeleteDialog(): CrudThunkAction< + void, + OpenBulkDeleteDialogAction +> { + return (dispatch, getState, { track, connectionInfoRef }) => { + track('Bulk Delete Opened', {}, connectionInfoRef.current); + + const state = getState(); + const previews = (state.documents.docs?.slice(0, PREVIEW_DOCS) ?? []).map( + (doc) => { + // Break the link with the docs in the list so that expanding/collapsing + // docs in the modal doesn't modify the ones in the list. + return Document.FromEJSON(doc.toEJSON()); + } + ); + dispatch({ + type: BulkDeleteActionTypes.OPEN_DIALOG, + previews, + affected: state.documents.count ?? undefined, + }); + }; +} + +export function closeBulkDeleteDialog(): CloseBulkDeleteDialogAction { + return { type: BulkDeleteActionTypes.CLOSE_DIALOG }; +} + +export function runBulkDelete(): CrudThunkAction< + Promise, + BulkDeleteActions +> { + return async ( + dispatch, + getState, + { + dataService, + queryBar, + logger, + localAppRegistry, + connectionScopedAppRegistry, + track, + connectionInfoRef, + } + ) => { + const query = queryBar.getLastAppliedQuery('crud'); + + const { affected } = getState().bulkDelete; + dispatch(closeBulkDeleteDialog()); + + const confirmation = await showConfirmation({ + title: 'Are you absolutely sure?', + buttonText: `Delete ${affected ? `${affected} ` : ''} document${ + affected !== 1 ? 's' : '' + }`, + description: `This action can not be undone. This will permanently delete ${ + affected ?? 'an unknown number of' + } document${affected !== 1 ? 's' : ''}.`, + warning: + 'The document list and count may not always reflect the latest updates in real time. This action will apply to all relevant documents, including those not currently visible, so please ensure they are handled safely.', + variant: 'danger', + }); + + if (!confirmation) { + return; + } + + dispatch({ type: BulkDeleteActionTypes.IN_PROGRESS }); + openBulkDeleteProgressToast({ + affectedDocuments: affected, + }); + + const ns = getState().documents.ns; + const view = getState().view.view; + const { filter = {} } = query; + try { + await dataService.deleteMany(ns, filter); + track( + 'Bulk Delete Executed', + { + has_filter: Object.keys(query.filter ?? {}).length > 0, + }, + connectionInfoRef.current + ); + openBulkDeleteSuccessToast({ + affectedDocuments: affected, + onRefresh: () => void dispatch(refreshDocuments()), + }); + // Emit both events so all listeners update (fixes bulk delete document count not updating) + const payload = { view, ns }; + localAppRegistry.emit('documents-deleted', payload); + connectionScopedAppRegistry.emit('documents-deleted', payload); + } catch (ex) { + openBulkDeleteFailureToast({ + affectedDocuments: affected, + error: ex as Error, + }); + logger.log.error( + mongoLogId(1_001_000_268), + 'Bulk Delete Documents', + `Delete operation failed: ${(ex as Error).message}`, + ex + ); + } + }; +} diff --git a/packages/compass-crud/src/stores/bulk-update.ts b/packages/compass-crud/src/stores/bulk-update.ts new file mode 100644 index 00000000000..533fb9854c2 --- /dev/null +++ b/packages/compass-crud/src/stores/bulk-update.ts @@ -0,0 +1,366 @@ +import type { Reducer } from 'redux'; +import type { UpdatePreview } from 'mongodb-data-service'; +import { mongoLogId } from '@mongodb-js/compass-logging/provider'; +import { openToast } from '@mongodb-js/compass-components'; +import { isAction } from './util'; +import type { CrudThunkAction } from './reducer'; +import { parseShellBSON } from './parse-shell-bson'; +import { + openBulkUpdateFailureToast, + openBulkUpdateProgressToast, + openBulkUpdateSuccessToast, +} from '../components/bulk-actions-toasts'; +import type { BSONObject } from './insert'; +import { refreshDocuments } from './documents'; + +export const INITIAL_BULK_UPDATE_TEXT = `{ + $set: { + + }, +}`; + +export type BulkUpdateState = { + isOpen: boolean; + updateText: string; + preview: UpdatePreview; + syntaxError?: Error; + serverError?: Error; + previewAbortController?: AbortController; + affected?: number; +}; + +export const INITIAL_BULK_UPDATE_STATE: BulkUpdateState = { + isOpen: false, + updateText: INITIAL_BULK_UPDATE_TEXT, + preview: { + changes: [], + }, + syntaxError: undefined, + serverError: undefined, +}; + +export const BulkUpdateActionTypes = { + OPEN_MODAL: 'crud/bulk-update/OPEN_MODAL', + CLOSE_MODAL: 'crud/bulk-update/CLOSE_MODAL', + PREVIEW_STARTED: 'crud/bulk-update/PREVIEW_STARTED', + PREVIEW_UPDATED: 'crud/bulk-update/PREVIEW_UPDATED', + PREVIEW_SYNTAX_ERROR: 'crud/bulk-update/PREVIEW_SYNTAX_ERROR', + PREVIEW_SERVER_ERROR: 'crud/bulk-update/PREVIEW_SERVER_ERROR', + RUN_AFFECTED_LATCHED: 'crud/bulk-update/RUN_AFFECTED_LATCHED', +} as const; + +export type OpenBulkUpdateModalAction = { + type: typeof BulkUpdateActionTypes.OPEN_MODAL; +}; + +export type CloseBulkUpdateModalAction = { + type: typeof BulkUpdateActionTypes.CLOSE_MODAL; +}; + +export type PreviewStartedAction = { + type: typeof BulkUpdateActionTypes.PREVIEW_STARTED; + abortController: AbortController; +}; + +export type PreviewUpdatedAction = { + type: typeof BulkUpdateActionTypes.PREVIEW_UPDATED; + updateText: string; + preview: UpdatePreview; +}; + +export type PreviewSyntaxErrorAction = { + type: typeof BulkUpdateActionTypes.PREVIEW_SYNTAX_ERROR; + updateText: string; + syntaxError: Error; +}; + +export type PreviewServerErrorAction = { + type: typeof BulkUpdateActionTypes.PREVIEW_SERVER_ERROR; + updateText: string; + serverError: Error; +}; + +export type RunAffectedLatchedAction = { + type: typeof BulkUpdateActionTypes.RUN_AFFECTED_LATCHED; + affected: number | undefined; +}; + +export type BulkUpdateActions = + | OpenBulkUpdateModalAction + | CloseBulkUpdateModalAction + | PreviewStartedAction + | PreviewUpdatedAction + | PreviewSyntaxErrorAction + | PreviewServerErrorAction + | RunAffectedLatchedAction; + +export const bulkUpdateReducer: Reducer = ( + state = INITIAL_BULK_UPDATE_STATE, + action +) => { + if (isAction(action, BulkUpdateActionTypes.OPEN_MODAL)) { + return { ...state, isOpen: true }; + } + if (isAction(action, BulkUpdateActionTypes.CLOSE_MODAL)) { + return { ...state, isOpen: false }; + } + if (isAction(action, BulkUpdateActionTypes.PREVIEW_STARTED)) { + return { ...state, previewAbortController: action.abortController }; + } + if (isAction(action, BulkUpdateActionTypes.PREVIEW_UPDATED)) { + return { + ...state, + updateText: action.updateText, + preview: action.preview, + serverError: undefined, + syntaxError: undefined, + previewAbortController: undefined, + }; + } + if (isAction(action, BulkUpdateActionTypes.PREVIEW_SYNTAX_ERROR)) { + return { + ...state, + updateText: action.updateText, + preview: { changes: [] }, + serverError: undefined, + syntaxError: action.syntaxError, + previewAbortController: undefined, + }; + } + if (isAction(action, BulkUpdateActionTypes.PREVIEW_SERVER_ERROR)) { + return { + ...state, + updateText: action.updateText, + preview: { changes: [] }, + serverError: action.serverError, + syntaxError: undefined, + previewAbortController: undefined, + }; + } + if (isAction(action, BulkUpdateActionTypes.RUN_AFFECTED_LATCHED)) { + return { ...state, affected: action.affected }; + } + return state; +}; + +export function closeBulkUpdateModal(): CloseBulkUpdateModalAction { + return { type: BulkUpdateActionTypes.CLOSE_MODAL }; +} + +export function updateBulkUpdatePreview( + updateText: string +): CrudThunkAction, BulkUpdateActions> { + return async (dispatch, getState, { dataService, queryBar }) => { + const state = getState(); + state.bulkUpdate.previewAbortController?.abort(); + + // If preview is not supported, just verify the update parses. + if (!state.collectionMeta.isUpdatePreviewSupported) { + try { + parseShellBSON(updateText); + } catch (err: any) { + dispatch({ + type: BulkUpdateActionTypes.PREVIEW_SYNTAX_ERROR, + updateText, + syntaxError: err, + }); + return; + } + dispatch({ + type: BulkUpdateActionTypes.PREVIEW_UPDATED, + updateText, + preview: { changes: [] }, + }); + return; + } + + const abortController = new AbortController(); + dispatch({ + type: BulkUpdateActionTypes.PREVIEW_STARTED, + abortController, + }); + + let update: BSONObject | BSONObject[]; + try { + update = parseShellBSON(updateText); + } catch (err: any) { + if (abortController.signal.aborted) return; + dispatch({ + type: BulkUpdateActionTypes.PREVIEW_SYNTAX_ERROR, + updateText, + syntaxError: err, + }); + return; + } + + if (abortController.signal.aborted) return; + + const ns = getState().documents.ns; + const { filter = {} } = queryBar.getLastAppliedQuery('crud'); + + let preview; + try { + preview = await dataService.previewUpdate(ns, filter, update, { + sample: 3, + abortSignal: abortController.signal, + }); + } catch (err: any) { + if (abortController.signal.aborted) return; + dispatch({ + type: BulkUpdateActionTypes.PREVIEW_SERVER_ERROR, + updateText, + serverError: err, + }); + return; + } + + if (abortController.signal.aborted) return; + + dispatch({ + type: BulkUpdateActionTypes.PREVIEW_UPDATED, + updateText, + preview, + }); + }; +} + +export function openBulkUpdateModal( + updateText?: string +): CrudThunkAction, BulkUpdateActions> { + return async (dispatch, getState, { track, connectionInfoRef }) => { + track( + 'Bulk Update Opened', + { + isUpdatePreviewSupported: + getState().collectionMeta.isUpdatePreviewSupported, + }, + connectionInfoRef.current + ); + + await dispatch( + updateBulkUpdatePreview(updateText ?? INITIAL_BULK_UPDATE_TEXT) + ); + dispatch({ type: BulkUpdateActionTypes.OPEN_MODAL }); + }; +} + +export function runBulkUpdate(): CrudThunkAction< + Promise, + BulkUpdateActions +> { + return async ( + dispatch, + getState, + { + dataService, + queryBar, + recentQueriesStorage, + logger, + track, + connectionInfoRef, + } + ) => { + const query = queryBar.getLastAppliedQuery('crud'); + track( + 'Bulk Update Executed', + { + isUpdatePreviewSupported: + getState().collectionMeta.isUpdatePreviewSupported, + has_filter: Object.keys(query.filter ?? {}).length > 0, + }, + connectionInfoRef.current + ); + + dispatch(closeBulkUpdateModal()); + + // Latch the affected count for the duration of the toast. + const affected = getState().documents.count ?? undefined; + dispatch({ + type: BulkUpdateActionTypes.RUN_AFFECTED_LATCHED, + affected, + }); + + const ns = getState().documents.ns; + const { filter = {} } = query; + let update; + try { + update = parseShellBSON(getState().bulkUpdate.updateText); + } catch { + // If this couldn't parse then the update button should have been + // disabled. So if we get here it is a race condition. + return; + } + + await recentQueriesStorage?.saveQuery({ + _ns: ns, + filter, + update, + }); + + openBulkUpdateProgressToast({ + affectedDocuments: affected, + }); + + try { + await dataService.updateMany(ns, filter, update); + + openBulkUpdateSuccessToast({ + affectedDocuments: affected, + onRefresh: () => void dispatch(refreshDocuments()), + }); + } catch (err: any) { + openBulkUpdateFailureToast({ + affectedDocuments: affected, + error: err as Error, + }); + + logger.log.error( + mongoLogId(1_001_000_269), + 'Bulk Update Documents', + `Update operation failed: ${err.message}`, + err + ); + } + }; +} + +export function saveUpdateQuery( + name: string +): CrudThunkAction, never> { + return async ( + dispatch, + getState, + { favoriteQueriesStorage, queryBar, track, connectionInfoRef } + ) => { + track( + 'Bulk Update Favorited', + { + isUpdatePreviewSupported: + getState().collectionMeta.isUpdatePreviewSupported, + }, + connectionInfoRef.current + ); + + const { filter } = queryBar.getLastAppliedQuery('crud'); + let update; + try { + update = parseShellBSON(getState().bulkUpdate.updateText); + } catch { + return; + } + + await favoriteQueriesStorage?.saveQuery({ + _name: name, + _ns: getState().documents.ns, + filter, + update, + }); + openToast('saved-favorite-update-query', { + title: '', + variant: 'success', + dismissible: true, + timeout: 6_000, + description: `${name} added to "My Queries".`, + }); + }; +} diff --git a/packages/compass-crud/src/stores/collection-meta.ts b/packages/compass-crud/src/stores/collection-meta.ts new file mode 100644 index 00000000000..c99025af74e --- /dev/null +++ b/packages/compass-crud/src/stores/collection-meta.ts @@ -0,0 +1,105 @@ +import type { Reducer } from 'redux'; +import type { Collection } from '@mongodb-js/compass-app-stores/provider'; +import { isAction } from './util'; + +export type CollectionStats = Pick< + Collection, + 'document_count' | 'storage_size' | 'free_storage_size' | 'avg_document_size' +>; + +export function extractCollectionStats( + collection: Collection +): CollectionStats { + const coll = collection.toJSON(); + return { + document_count: coll.document_count, + storage_size: coll.storage_size, + free_storage_size: coll.free_storage_size, + avg_document_size: coll.avg_document_size, + }; +} + +export type CollectionMetaState = { + version: string; + isDataLake: boolean; + isReadonly: boolean; + isTimeSeries: boolean; + isWritable: boolean; + instanceDescription: string; + isSearchIndexesSupported: boolean; + isUpdatePreviewSupported: boolean; + collectionStats: CollectionStats | null; +}; + +export const CollectionMetaActionTypes = { + IS_WRITABLE_CHANGED: 'crud/collection-meta/IS_WRITABLE_CHANGED', + INSTANCE_DESCRIPTION_CHANGED: + 'crud/collection-meta/INSTANCE_DESCRIPTION_CHANGED', + COLLECTION_STATS_FETCHED: 'crud/collection-meta/COLLECTION_STATS_FETCHED', +} as const; + +export type IsWritableChangedAction = { + type: typeof CollectionMetaActionTypes.IS_WRITABLE_CHANGED; + isWritable: boolean; +}; + +export type InstanceDescriptionChangedAction = { + type: typeof CollectionMetaActionTypes.INSTANCE_DESCRIPTION_CHANGED; + instanceDescription: string; +}; + +export type CollectionStatsFetchedAction = { + type: typeof CollectionMetaActionTypes.COLLECTION_STATS_FETCHED; + collectionStats: CollectionStats; +}; + +export type CollectionMetaActions = + | IsWritableChangedAction + | InstanceDescriptionChangedAction + | CollectionStatsFetchedAction; + +export function createCollectionMetaReducer( + initialState: CollectionMetaState +): Reducer { + return (state = initialState, action) => { + if (isAction(action, CollectionMetaActionTypes.IS_WRITABLE_CHANGED)) { + return { ...state, isWritable: action.isWritable }; + } + if ( + isAction(action, CollectionMetaActionTypes.INSTANCE_DESCRIPTION_CHANGED) + ) { + return { ...state, instanceDescription: action.instanceDescription }; + } + if (isAction(action, CollectionMetaActionTypes.COLLECTION_STATS_FETCHED)) { + return { ...state, collectionStats: action.collectionStats }; + } + return state; + }; +} + +export function isWritableChanged( + isWritable: boolean +): IsWritableChangedAction { + return { + type: CollectionMetaActionTypes.IS_WRITABLE_CHANGED, + isWritable, + }; +} + +export function instanceDescriptionChanged( + instanceDescription: string +): InstanceDescriptionChangedAction { + return { + type: CollectionMetaActionTypes.INSTANCE_DESCRIPTION_CHANGED, + instanceDescription, + }; +} + +export function collectionStatsFetched( + collection: Collection +): CollectionStatsFetchedAction { + return { + type: CollectionMetaActionTypes.COLLECTION_STATS_FETCHED, + collectionStats: extractCollectionStats(collection), + }; +} diff --git a/packages/compass-crud/src/stores/crud-store.spec.ts b/packages/compass-crud/src/stores/crud-store.spec.ts index 9a4175d8473..dd8a0804fd0 100644 --- a/packages/compass-crud/src/stores/crud-store.spec.ts +++ b/packages/compass-crud/src/stores/crud-store.spec.ts @@ -16,7 +16,8 @@ import sinon from 'sinon'; import chai, { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import type { - CrudStore, + CrudReduxStore, + CrudState, CrudStoreOptions, DocumentsPluginServices, } from './crud-store'; @@ -26,6 +27,34 @@ import { activateDocumentsPlugin as _activate, MAX_DOCS_PER_PAGE_STORAGE_KEY, } from './crud-store'; +import { + cancelOperation, + copyToClipboard, + getPage, + openDeleteQueryExportToLanguageDialog, + refreshDocuments, + removeDocument, + replaceDocument, + seedDocumentsTestState, + updateDocument, + updateMaxDocumentsPerPage, +} from './documents'; +import { + insertDocument, + insertMany, + openInsertDocumentDialog, + toggleInsertDocument, + updateJsonDoc, +} from './insert'; +import { + closeBulkUpdateModal, + openBulkUpdateModal, + runBulkUpdate, + saveUpdateQuery, + updateBulkUpdatePreview, +} from './bulk-update'; +import { closeBulkDeleteDialog, openBulkDeleteDialog } from './bulk-delete'; +import { drillDown, pathChanged, viewChanged } from './view'; import { Int32 } from 'bson'; import { mochaTestServer } from '@mongodb-js/compass-test-server'; import { @@ -68,14 +97,21 @@ chai.use(chaiAsPromised); const delay = util.promisify(setTimeout); -function waitForStates(store, cbs, timeout = 2000) { +type StateAssertion = (state: CrudState, index: number) => void; + +function waitForStates( + store: CrudReduxStore, + cbs: StateAssertion[], + timeout = 2000 +) { let numMatches = 0; - const states: any[] = []; + const states: CrudState[] = []; const errors: Error[] = []; let unsubscribe: () => void; const waiter = new Promise((resolve, reject) => { - unsubscribe = store.listen((state) => { + unsubscribe = store.subscribe(() => { + const state = store.getState(); states.push(state); try { // eslint-disable-next-line callback-return @@ -127,7 +163,11 @@ function waitForStates(store, cbs, timeout = 2000) { }); } -function waitForState(store, cb, timeout?: number) { +function waitForState( + store: CrudReduxStore, + cb: StateAssertion, + timeout?: number +) { return waitForStates(store, [cb], timeout); } @@ -137,7 +177,7 @@ function onceDocumentEvent( ): Promise { // The once function was not meant for strongly typed events, so we need to // do some additional type casting. - return once(doc as unknown as EventEmitter, event as string); + return once(doc as unknown as EventEmitter, event); } const mockFieldStoreService = { @@ -299,7 +339,7 @@ describe('store', function () { }); describe('#getInitialState', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin(); @@ -308,16 +348,13 @@ describe('store', function () { }); it('sets the initial state', function () { - expect(store.state.resultId).to.be.a('number'); - delete (store.state as any).resultId; // always different + const state = store.getState(); + expect(state.documents.resultId).to.be.a('number'); + const documents: Partial = { ...state.documents }; + delete documents.resultId; // always different - expect(store.state).to.deep.equal({ + expect(documents).to.deep.equal({ abortController: null, - bulkDelete: { - affected: 0, - previews: [], - status: 'closed', - }, debouncingLoad: false, lastCountRunMaxTimeMS: 5000, loadingCount: false, @@ -327,24 +364,14 @@ describe('store', function () { docsPerPage: 25, end: 0, error: null, - insert: { - doc: null, - isCommentNeeded: true, - isOpen: false, - jsonDoc: null, - jsonView: false, - csfleState: { state: 'none' }, - mode: 'modifying', - }, - bulkUpdate: { - isOpen: false, - preview: { - changes: [], - }, - serverError: undefined, - syntaxError: undefined, - updateText: '{\n $set: {\n\n },\n}', - }, + ns: 'compass-crud.test', + page: 0, + shardKeys: null, + start: 0, + status: 'initial', + isCollectionScan: false, + }); + expect(state.collectionMeta).to.deep.equal({ instanceDescription: 'Topology type: Unknown is not writable', isDataLake: false, isReadonly: false, @@ -352,20 +379,7 @@ describe('store', function () { isTimeSeries: false, isUpdatePreviewSupported: true, isWritable: false, - ns: 'compass-crud.test', - page: 0, - shardKeys: null, - start: 0, - status: 'initial', - table: { - doc: null, - editParams: null, - path: [], - types: [], - }, - isCollectionScan: false, version: '6.0.0', - view: 'List', collectionStats: { avg_document_size: 1, document_count: 10, @@ -373,11 +387,43 @@ describe('store', function () { storage_size: 20, }, }); + expect(state.view).to.deep.equal({ + view: 'List', + table: { + doc: null, + editParams: null, + path: [], + types: [], + }, + }); + expect(state.insert).to.deep.equal({ + doc: null, + isCommentNeeded: true, + isOpen: false, + jsonDoc: null, + jsonView: false, + csfleState: { state: 'none' }, + mode: 'modifying', + }); + expect(state.bulkUpdate).to.deep.equal({ + isOpen: false, + preview: { + changes: [], + }, + serverError: undefined, + syntaxError: undefined, + updateText: '{\n $set: {\n\n },\n}', + }); + expect(state.bulkDelete).to.deep.equal({ + affected: 0, + previews: [], + status: 'closed', + }); }); }); describe('#copyToClipboard', function () { - let store: CrudStore; + let store: CrudReduxStore; let mockCopyToClipboard: any; beforeEach(function () { @@ -409,7 +455,7 @@ describe('store', function () { const doc = { _id: 'testing', name: 'heart 5' }; const hadronDoc = new HadronDocument(doc); - store.copyToClipboard(hadronDoc); + store.dispatch(copyToClipboard(hadronDoc)); expect(mockCopyToClipboard).to.have.been.calledOnceWithExactly( '{\n "_id": "testing",\n "name": "heart 5"\n}' ); @@ -417,14 +463,14 @@ describe('store', function () { }); describe('#toggleInsertDocument', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(async function () { const plugin = activatePlugin(); store = plugin.store; deactivate = () => plugin.deactivate(); - await store.openInsertDocumentDialog({ foo: 1 }); + await store.dispatch(openInsertDocumentDialog({ foo: 1 })); }); it('switches between JSON and Document view', async function () { @@ -434,7 +480,7 @@ describe('store', function () { expect(state).to.have.nested.property('insert.jsonView', false); }); - store.toggleInsertDocument('List'); + store.dispatch(toggleInsertDocument('List')); await listener; @@ -442,14 +488,14 @@ describe('store', function () { expect(state).to.have.nested.property('insert.jsonView', true); }); - store.toggleInsertDocument('JSON'); + store.dispatch(toggleInsertDocument('JSON')); await listener; }); }); describe('#removeDocument', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin(); @@ -462,19 +508,23 @@ describe('store', function () { const hadronDoc = new HadronDocument(doc); beforeEach(function () { - store.state.docs = [hadronDoc]; - store.state.count = 1; - store.state.end = 1; + store.dispatch( + seedDocumentsTestState({ + docs: [hadronDoc], + count: 1, + end: 1, + }) + ); }); it('deletes the document from the collection', async function () { const listener = waitForState(store, (state) => { - expect(state.docs.length).to.equal(0); - expect(state.count).to.equal(0); - expect(state.end).to.equal(0); + expect(state.documents.docs.length).to.equal(0); + expect(state.documents.count).to.equal(0); + expect(state.documents.end).to.equal(0); }); - void store.removeDocument(hadronDoc); + void store.dispatch(removeDocument(hadronDoc)); await listener; }); @@ -485,19 +535,23 @@ describe('store', function () { const hadronDoc = new HadronDocument(doc); beforeEach(function () { - store.state.docs = [hadronDoc]; - store.state.count = 1; - store.state.end = 1; + store.dispatch( + seedDocumentsTestState({ + docs: [hadronDoc], + count: 1, + end: 1, + }) + ); }); it('deletes the document from the collection', async function () { const listener = waitForState(store, (state) => { - expect(state.docs.length).to.equal(0); - expect(state.count).to.equal(0); - expect(state.end).to.equal(0); + expect(state.documents.docs.length).to.equal(0); + expect(state.documents.count).to.equal(0); + expect(state.documents.end).to.equal(0); }); - void store.removeDocument(hadronDoc); + void store.dispatch(removeDocument(hadronDoc)); await listener; }); @@ -508,19 +562,23 @@ describe('store', function () { const hadronDoc = new HadronDocument(doc); beforeEach(function () { - store.state.docs = [hadronDoc]; - store.state.count = null; - store.state.end = 1; + store.dispatch( + seedDocumentsTestState({ + docs: [hadronDoc], + count: null, + end: 1, + }) + ); }); it('keeps the count as null after the delete', async function () { const listener = waitForState(store, (state) => { - expect(state.docs.length).to.equal(0); - expect(state.count).to.equal(null); - expect(state.end).to.equal(0); + expect(state.documents.docs.length).to.equal(0); + expect(state.documents.count).to.equal(null); + expect(state.documents.end).to.equal(0); }); - void store.removeDocument(hadronDoc); + void store.dispatch(removeDocument(hadronDoc)); await listener; }); @@ -542,13 +600,13 @@ describe('store', function () { done(); }); - void store.removeDocument(hadronDoc); + void store.dispatch(removeDocument(hadronDoc)); }); }); }); describe('#updateDocument', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(async function () { const plugin = activatePlugin(); @@ -569,17 +627,18 @@ describe('store', function () { const hadronDoc = new HadronDocument(doc); beforeEach(function () { - store.state.docs = [hadronDoc]; + store.dispatch(seedDocumentsTestState({ docs: [hadronDoc] })); hadronDoc.elements.at(1)?.rename('new name'); }); it('replaces the document in the list', function (done) { - const unsubscribe = store.listen((state) => { - expect(state.docs[0]).to.not.equal(hadronDoc); - expect(state.docs[0].elements.at(1).key === 'new name'); + const unsubscribe = store.subscribe(() => { + const state = store.getState(); + expect(state.documents.docs![0]).to.not.equal(hadronDoc); + expect(state.documents.docs![0].elements.at(1)!.key === 'new name'); unsubscribe(); done(); - }, store); + }); hadronDoc.on(DocumentEvents.UpdateBlocked, () => { done(new Error("Didn't expect update to be blocked.")); @@ -593,7 +652,7 @@ describe('store', function () { ); }); - void store.updateDocument(hadronDoc); + void store.dispatch(updateDocument(hadronDoc)); }); }); @@ -602,7 +661,7 @@ describe('store', function () { const hadronDoc = new HadronDocument(doc); beforeEach(function () { - store.state.docs = [hadronDoc]; + store.dispatch(seedDocumentsTestState({ docs: [hadronDoc] })); hadronDoc.insertAfter( hadronDoc.elements.at(1)!, 'new field', @@ -611,14 +670,17 @@ describe('store', function () { }); it('updates the document in the list', function (done) { - const unsubscribe = store.listen((state) => { - expect(state.docs[0]).to.not.equal(hadronDoc); - expect(state.docs[0]).to.have.property('elements'); - expect(state.docs[0].elements.at(2).key).to.equal('new field'); + const unsubscribe = store.subscribe(() => { + const state = store.getState(); + expect(state.documents.docs![0]).to.not.equal(hadronDoc); + expect(state.documents.docs![0]).to.have.property('elements'); + expect(state.documents.docs![0].elements.at(2)!.key).to.equal( + 'new field' + ); unsubscribe(); // Ensure we have enough time for update-blocked or update-error to be called. setTimeout(() => done(), 100); - }, store); + }); hadronDoc.on(DocumentEvents.UpdateBlocked, () => { done(new Error("Didn't expect update to be blocked.")); @@ -632,7 +694,7 @@ describe('store', function () { ); }); - void store.updateDocument(hadronDoc); + void store.dispatch(updateDocument(hadronDoc)); }); }); @@ -654,7 +716,7 @@ describe('store', function () { done(); }); - void store.updateDocument(hadronDoc); + void store.dispatch(updateDocument(hadronDoc)); }); }); @@ -675,7 +737,7 @@ describe('store', function () { done(); }); - void store.updateDocument(hadronDoc); + void store.dispatch(updateDocument(hadronDoc)); }); }); @@ -693,7 +755,7 @@ describe('store', function () { done(); }); - void store.updateDocument(hadronDoc); + void store.dispatch(updateDocument(hadronDoc)); }); }); @@ -708,7 +770,7 @@ describe('store', function () { }); it('has the original value for the edited value in the query', async function () { - await store.updateDocument(hadronDoc); + await store.dispatch(updateDocument(hadronDoc)); expect(stub.getCall(0).args[1]).to.deep.equal({ _id: 'testing', @@ -730,17 +792,17 @@ describe('store', function () { let stub; beforeEach(function () { - store.state.shardKeys = { yes: 1 }; + store.dispatch(seedDocumentsTestState({ shardKeys: { yes: 1 } })); hadronDoc.get('name')?.edit('Desert Sand'); stub = sinon.stub(dataService, 'findOneAndUpdate').resolves({}); }); afterEach(function () { - store.state.shardKeys = null; + store.dispatch(seedDocumentsTestState({ shardKeys: null })); }); it('has the shard key in the query', async function () { - await store.updateDocument(hadronDoc); + await store.dispatch(updateDocument(hadronDoc)); expect(stub.getCall(0).args[1]).to.deep.equal({ _id: 'testing', @@ -770,7 +832,7 @@ describe('store', function () { done(); }); - void store.updateDocument(invalidHadronDoc); + void store.dispatch(updateDocument(invalidHadronDoc)); }); }); @@ -804,7 +866,7 @@ describe('store', function () { DocumentEvents.UpdateError ); - await store.updateDocument(hadronDoc); + await store.dispatch(updateDocument(hadronDoc)); expect((await updateErrorEvent)[0]).to.match(/Update blocked/); expect(findOneAndReplaceStub).to.not.have.been.called; @@ -819,7 +881,7 @@ describe('store', function () { }); describe('#bulkUpdateModal', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(async function () { const plugin = activatePlugin(); @@ -833,13 +895,13 @@ describe('store', function () { }); it('opens the bulk update dialog with a proper initialised state', async function () { - void store.openBulkUpdateModal(); + void store.dispatch(openBulkUpdateModal()); await waitForState(store, (state) => { expect(state.bulkUpdate.previewAbortController).to.not.exist; }); - const bulkUpdate = store.state.bulkUpdate; + const bulkUpdate = store.getState().bulkUpdate; delete bulkUpdate.preview.changes[0].before._id; delete bulkUpdate.preview.changes[0].after._id; @@ -866,16 +928,16 @@ describe('store', function () { }); it('closes the bulk dialog keeping previous state', async function () { - void store.openBulkUpdateModal(); - void store.openBulkUpdateModal(); + void store.dispatch(openBulkUpdateModal()); + void store.dispatch(openBulkUpdateModal()); await waitForState(store, (state) => { expect(state.bulkUpdate.previewAbortController).to.not.exist; }); - store.closeBulkUpdateModal(); + store.dispatch(closeBulkUpdateModal()); - const bulkUpdate = store.state.bulkUpdate; + const bulkUpdate = store.getState().bulkUpdate; delete bulkUpdate.preview.changes[0].before._id; delete bulkUpdate.preview.changes[0].after._id; @@ -903,7 +965,7 @@ describe('store', function () { }); describe('favourited bulk update queries coming from My Queries', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin({ @@ -932,7 +994,7 @@ describe('store', function () { }); describe('#bulkDeleteDialog', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin(); @@ -942,18 +1004,22 @@ describe('store', function () { it('opens the bulk dialog with a proper initialised state', function () { const hadronDoc = new HadronDocument({ a: 1 }); - store.state.docs = [hadronDoc]; - store.state.count = 1; + store.dispatch( + seedDocumentsTestState({ + docs: [hadronDoc], + count: 1, + }) + ); - store.openBulkDeleteDialog(); + store.dispatch(openBulkDeleteDialog()); - const previews = store.state.bulkDelete.previews; + const previews = store.getState().bulkDelete.previews; // because we make a copy of the previews what comes out will not be the // same as what goes in so just check the previews separately expect(previews[0].doc.a).to.deep.equal(new Int32(1)); - expect(store.state.bulkDelete).to.deep.equal({ + expect(store.getState().bulkDelete).to.deep.equal({ previews, status: 'open', affected: 1, @@ -962,18 +1028,22 @@ describe('store', function () { it('closes the bulk dialog keeping previous state', function () { const hadronDoc = new HadronDocument({ a: 1 }); - store.state.docs = [hadronDoc]; - store.state.count = 1; + store.dispatch( + seedDocumentsTestState({ + docs: [hadronDoc], + count: 1, + }) + ); - store.openBulkDeleteDialog(); - store.closeBulkDeleteDialog(); + store.dispatch(openBulkDeleteDialog()); + store.dispatch(closeBulkDeleteDialog()); - const previews = store.state.bulkDelete.previews; + const previews = store.getState().bulkDelete.previews; // same comment as above expect(previews[0].doc.a).to.deep.equal(new Int32(1)); - expect(store.state.bulkDelete).to.deep.equal({ + expect(store.getState().bulkDelete).to.deep.equal({ previews, status: 'closed', affected: 1, @@ -982,7 +1052,7 @@ describe('store', function () { it('triggers code export', function (done) { mockQueryBar.getLastAppliedQuery.returns({ filter: { query: 1 } }); - store.localAppRegistry.on( + localAppRegistry.on( 'open-query-export-to-language', (options, exportMode) => { expect(exportMode).to.equal('Delete Query'); @@ -992,12 +1062,12 @@ describe('store', function () { } ); - store.openDeleteQueryExportToLanguageDialog(); + store.dispatch(openDeleteQueryExportToLanguageDialog()); }); }); describe('#replaceDocument', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin(); @@ -1010,15 +1080,15 @@ describe('store', function () { const hadronDoc = new HadronDocument(doc); beforeEach(function () { - store.state.docs = [hadronDoc]; + store.dispatch(seedDocumentsTestState({ docs: [hadronDoc] })); }); it('replaces the document in the list', async function () { const listener = waitForState(store, (state) => { - expect(state.docs[0]).to.not.equal(hadronDoc); + expect(state.documents.docs[0]).to.not.equal(hadronDoc); }); - void store.replaceDocument(hadronDoc); + void store.dispatch(replaceDocument(hadronDoc)); await listener; }); @@ -1040,7 +1110,7 @@ describe('store', function () { done(); }); - void store.replaceDocument(hadronDoc); + void store.dispatch(replaceDocument(hadronDoc)); }); }); @@ -1055,7 +1125,7 @@ describe('store', function () { }); it('has the original value for the edited value in the query', async function () { - await store.replaceDocument(hadronDoc); + await store.dispatch(replaceDocument(hadronDoc)); expect(stub.getCall(0).args[2]).to.deep.equal({ _id: 'testing', @@ -1072,17 +1142,17 @@ describe('store', function () { let stub; beforeEach(function () { - store.state.shardKeys = { yes: 1 }; + store.dispatch(seedDocumentsTestState({ shardKeys: { yes: 1 } })); hadronDoc.get('name')?.edit('Desert Sand'); stub = sinon.stub(dataService, 'findOneAndReplace').resolves({}); }); afterEach(function () { - store.state.shardKeys = null; + store.dispatch(seedDocumentsTestState({ shardKeys: null })); }); it('has the shard key in the query', async function () { - await store.replaceDocument(hadronDoc); + await store.dispatch(replaceDocument(hadronDoc)); expect(stub.getCall(0).args[1]).to.deep.equal({ _id: 'testing', @@ -1127,7 +1197,7 @@ describe('store', function () { DocumentEvents.UpdateError ); - await store.replaceDocument(hadronDoc); + await store.dispatch(replaceDocument(hadronDoc)); expect((await updateErrorEvent)[0]).to.match(/Update blocked/); expect(findOneAndReplaceStub).to.not.have.been.called; @@ -1142,7 +1212,7 @@ describe('store', function () { }); describe('#insertOneDocument', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin(); @@ -1156,13 +1226,11 @@ describe('store', function () { }); context('when the document matches the filter', function () { - const doc = new HadronDocument({ name: 'testing' }); - it('inserts the document', async function () { const listener = waitForState(store, (state) => { - expect(state.docs.length).to.equal(1); - expect(state.count).to.equal(1); - expect(state.end).to.equal(1); + expect(state.documents.docs.length).to.equal(1); + expect(state.documents.count).to.equal(1); + expect(state.documents.end).to.equal(1); expect(state.insert.doc).to.equal(null); expect(state.insert.jsonDoc).to.equal(null); expect(state.insert.isOpen).to.equal(false); @@ -1170,18 +1238,16 @@ describe('store', function () { expect(state.insert.error).to.equal(undefined); }); - store.state.insert.doc = doc; - void store.insertDocument(); + await store.dispatch(openInsertDocumentDialog({ name: 'testing' })); + void store.dispatch(insertDocument()); await listener; }); }); context('when the document does not match the filter', function () { - const doc = new HadronDocument({ name: 'testing' }); - - beforeEach(function () { - store.state.insert.doc = doc; + beforeEach(async function () { + await store.dispatch(openInsertDocumentDialog({ name: 'testing' })); mockQueryBar.getLastAppliedQuery.returns({ filter: { name: 'something' }, }); @@ -1189,8 +1255,8 @@ describe('store', function () { it('inserts the document but does not add to the list', async function () { const listener = waitForState(store, (state) => { - expect(state.docs.length).to.equal(0); - expect(state.count).to.equal(0); + expect(state.documents.docs.length).to.equal(0); + expect(state.documents.count).to.equal(0); expect(state.insert.doc).to.equal(null); expect(state.insert.jsonDoc).to.equal(null); expect(state.insert.isOpen).to.equal(false); @@ -1198,7 +1264,7 @@ describe('store', function () { expect(state.insert.error).to.equal(undefined); }); - void store.insertDocument(); + void store.dispatch(insertDocument()); await listener; }); @@ -1207,20 +1273,18 @@ describe('store', function () { context('when the document has invalid bson', function () { // this is invalid ObjectId const jsonDoc = '{"_id": {"$oid": ""}}'; - const hadronDoc = new HadronDocument({}); - beforeEach(function () { - store.state.insert.jsonView = true; - store.state.insert.doc = hadronDoc; - store.state.insert.jsonDoc = jsonDoc; - store.state.count = 0; + beforeEach(async function () { + await store.dispatch(openInsertDocumentDialog({})); + store.dispatch(updateJsonDoc(jsonDoc)); + store.dispatch(seedDocumentsTestState({ count: 0 })); }); it('does not insert the document and sets the error', async function () { const listener = waitForState(store, (state) => { - expect(state.docs.length).to.equal(0); - expect(state.count).to.equal(0); - expect(state.insert.doc).to.deep.equal(hadronDoc); + expect(state.documents.docs.length).to.equal(0); + expect(state.documents.count).to.equal(0); + expect(state.insert.doc?.generateObject()).to.deep.equal({}); expect(state.insert.jsonDoc).to.equal(jsonDoc); expect(state.insert.isOpen).to.equal(true); expect(state.insert.jsonView).to.equal(true); @@ -1229,7 +1293,7 @@ describe('store', function () { expect(state.insert.mode).to.equal('error'); }); - void store.insertDocument(); + void store.dispatch(insertDocument()); await listener; }); @@ -1238,15 +1302,13 @@ describe('store', function () { context('when there is an error', function () { context('when it is a json mode', function () { - const hadronDoc = new HadronDocument({}); // this should be invalid according to the validation rules const jsonDoc = '{ "status": "testing" }'; - beforeEach(function () { - store.state.insert.jsonView = true; - store.state.insert.doc = hadronDoc; - store.state.insert.jsonDoc = jsonDoc; - store.state.count = 0; + beforeEach(async function () { + await store.dispatch(openInsertDocumentDialog({})); + store.dispatch(updateJsonDoc(jsonDoc)); + store.dispatch(seedDocumentsTestState({ count: 0 })); }); afterEach(function () { @@ -1255,9 +1317,9 @@ describe('store', function () { it('does not insert the document', async function () { const listener = waitForState(store, (state) => { - expect(state.docs.length).to.equal(0); - expect(state.count).to.equal(0); - expect(state.insert.doc).to.deep.equal(hadronDoc); + expect(state.documents.docs.length).to.equal(0); + expect(state.documents.count).to.equal(0); + expect(state.insert.doc?.generateObject()).to.deep.equal({}); expect(state.insert.jsonDoc).to.equal(jsonDoc); expect(state.insert.isOpen).to.equal(true); expect(state.insert.jsonView).to.equal(true); @@ -1265,20 +1327,17 @@ describe('store', function () { expect(state.insert.error.message).to.not.be.empty; }); - void store.insertDocument(); + void store.dispatch(insertDocument()); await listener; }); }); context('when it is not a json mode', function () { - const doc = new HadronDocument({ status: 'testing' }); - const jsonDoc = ''; - - beforeEach(function () { - store.state.insert.doc = doc; - store.state.insert.jsonDoc = jsonDoc; - store.state.count = 0; + beforeEach(async function () { + await store.dispatch(openInsertDocumentDialog({ status: 'testing' })); + store.dispatch(toggleInsertDocument('List')); + store.dispatch(seedDocumentsTestState({ count: 0 })); }); afterEach(function () { @@ -1286,9 +1345,11 @@ describe('store', function () { }); it('does not insert the document', async function () { + const { doc, jsonDoc } = store.getState().insert; + const listener = waitForState(store, (state) => { - expect(state.docs.length).to.equal(0); - expect(state.count).to.equal(0); + expect(state.documents.docs.length).to.equal(0); + expect(state.documents.count).to.equal(0); expect(state.insert.doc).to.equal(doc); expect(state.insert.jsonDoc).to.equal(jsonDoc); expect(state.insert.isOpen).to.equal(true); @@ -1297,23 +1358,20 @@ describe('store', function () { expect(state.insert.error.message).to.not.be.empty; }); - store.state.insert.doc = doc; - void store.insertDocument(); + void store.dispatch(insertDocument()); await listener; }); }); context('when it is a validation error', function () { - const hadronDoc = new HadronDocument({}); // this should be invalid according to the validation rules const jsonDoc = '{ "status": "testing" }'; - beforeEach(function () { - store.state.insert.jsonView = true; - store.state.insert.doc = hadronDoc; - store.state.insert.jsonDoc = jsonDoc; - store.state.count = 0; + beforeEach(async function () { + await store.dispatch(openInsertDocumentDialog({})); + store.dispatch(updateJsonDoc(jsonDoc)); + store.dispatch(seedDocumentsTestState({ count: 0 })); }); afterEach(async function () { @@ -1322,9 +1380,9 @@ describe('store', function () { it('does not insert the document', async function () { const listener = waitForState(store, (state) => { - expect(state.docs.length).to.equal(0); - expect(state.count).to.equal(0); - expect(state.insert.doc).to.deep.equal(hadronDoc); + expect(state.documents.docs.length).to.equal(0); + expect(state.documents.count).to.equal(0); + expect(state.insert.doc?.generateObject()).to.deep.equal({}); expect(state.insert.jsonDoc).to.equal(jsonDoc); expect(state.insert.isOpen).to.equal(true); expect(state.insert.jsonView).to.equal(true); @@ -1333,7 +1391,7 @@ describe('store', function () { expect(state.insert.error.info).not.to.be.empty; }); - void store.insertDocument(); + void store.dispatch(insertDocument()); await listener; }); @@ -1342,7 +1400,7 @@ describe('store', function () { }); describe('#insertManyDocuments', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin(); @@ -1360,7 +1418,7 @@ describe('store', function () { '[ { "name": "Chashu", "type": "Norwegian Forest" }, { "name": "Rey", "type": "Viszla" } ]'; it('inserts the document', async function () { - const resultId = store.state.resultId; + const resultId = store.getState().documents.resultId; const listener = waitForStates(store, [ (state) => { @@ -1372,40 +1430,41 @@ describe('store', function () { expect(state.insert.jsonView).to.equal(false); expect(state.insert.error).to.equal(undefined); - expect(state.status).to.equal('fetching'); - expect(state.abortController).to.not.be.null; - expect(state.error).to.be.null; + expect(state.documents.status).to.equal('fetching'); + expect(state.documents.abortController).to.not.be.null; + expect(state.documents.error).to.be.null; }, (state) => { // after it refreshed the documents it will update the store again - expect(state.error).to.equal(null); - expect(state.docs.length).to.equal(2); - expect(state.count).to.equal(2); - expect(state.end).to.equal(2); + expect(state.documents.error).to.equal(null); + expect(state.documents.docs.length).to.equal(2); + expect(state.documents.count).to.equal(2); + expect(state.documents.end).to.equal(2); // this is fetchedInitial because there's no filter/projection/collation - expect(state.status).to.equal('fetchedInitial'); - expect(state.error).to.be.null; - expect(state.docs).to.have.lengthOf(2); - expect(state.count).to.equal(2); - expect(state.page).to.equal(0); - expect(state.start).to.equal(1); - expect(state.end).to.equal(2); - expect(state.table).to.deep.equal({ + expect(state.documents.status).to.equal('fetchedInitial'); + expect(state.documents.error).to.be.null; + expect(state.documents.docs).to.have.lengthOf(2); + expect(state.documents.count).to.equal(2); + expect(state.documents.page).to.equal(0); + expect(state.documents.start).to.equal(1); + expect(state.documents.end).to.equal(2); + expect(state.view.table).to.deep.equal({ doc: null, editParams: null, path: [], types: [], }); - expect(state.shardKeys).to.deep.equal({}); + expect(state.documents.shardKeys).to.deep.equal({}); - expect(state.abortController).to.be.null; - expect(state.resultId).to.not.equal(resultId); + expect(state.documents.abortController).to.be.null; + expect(state.documents.resultId).to.not.equal(resultId); }, ]); - store.state.insert.jsonDoc = docs; - void store.insertMany(); + await store.dispatch(openInsertDocumentDialog({})); + store.dispatch(updateJsonDoc(docs)); + void store.dispatch(insertMany()); await listener; }); @@ -1423,9 +1482,9 @@ describe('store', function () { it('inserts both documents but does not add to the list', async function () { const listener = waitForState(store, (state) => { - expect(state.docs.length).to.equal(0); - expect(state.count).to.equal(0); - expect(state.end).to.equal(0); + expect(state.documents.docs.length).to.equal(0); + expect(state.documents.count).to.equal(0); + expect(state.documents.end).to.equal(0); expect(state.insert.doc).to.equal(null); expect(state.insert.jsonDoc).to.equal(null); expect(state.insert.isOpen).to.equal(false); @@ -1433,8 +1492,9 @@ describe('store', function () { expect(state.insert.error).to.equal(undefined); }); - store.state.insert.jsonDoc = docs; - void store.insertMany(); + await store.dispatch(openInsertDocumentDialog({})); + store.dispatch(updateJsonDoc(docs)); + void store.dispatch(insertMany()); await listener; }); @@ -1450,16 +1510,17 @@ describe('store', function () { it('inserts both documents but only adds the matching one to the list', async function () { const listener = waitForState(store, (state) => { - expect(state.error).to.be.null; - expect(state.docs).to.have.lengthOf(1); - expect(state.count).to.equal(1); - expect(state.page).to.equal(0); - expect(state.start).to.equal(1); - expect(state.end).to.equal(1); + expect(state.documents.error).to.be.null; + expect(state.documents.docs).to.have.lengthOf(1); + expect(state.documents.count).to.equal(1); + expect(state.documents.page).to.equal(0); + expect(state.documents.start).to.equal(1); + expect(state.documents.end).to.equal(1); }); - store.state.insert.jsonDoc = docs; - void store.insertMany(); + await store.dispatch(openInsertDocumentDialog({})); + store.dispatch(updateJsonDoc(docs)); + void store.dispatch(insertMany()); await listener; }); @@ -1471,8 +1532,7 @@ describe('store', function () { '[ { "name": "Chashu", "type": "Norwegian Forest", "status": "invalid" }, { "name": "Rey", "type": "Viszla" } ]'; beforeEach(function () { - store.state.insert.jsonDoc = JSON.stringify(docs); - store.state.count = 0; + store.dispatch(seedDocumentsTestState({ count: 0 })); }); afterEach(function () { @@ -1481,8 +1541,8 @@ describe('store', function () { it('does not insert the document', async function () { const listener = waitForState(store, (state) => { - expect(state.docs.length).to.equal(0); - expect(state.count).to.equal(0); + expect(state.documents.docs.length).to.equal(0); + expect(state.documents.count).to.equal(0); expect(state.insert.doc?.generateObject()).to.deep.equal({}); expect(state.insert.jsonDoc).to.deep.equal(docs); expect(state.insert.isOpen).to.equal(true); @@ -1493,8 +1553,9 @@ describe('store', function () { ); }); - store.state.insert.jsonDoc = docs; - void store.insertMany(); + await store.dispatch(openInsertDocumentDialog({})); + store.dispatch(updateJsonDoc(docs)); + void store.dispatch(insertMany()); await listener; }); @@ -1503,7 +1564,7 @@ describe('store', function () { describe('#openInsertDocumentDialog', function () { const doc = { _id: 1, name: 'test' }; - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin(); @@ -1517,7 +1578,7 @@ describe('store', function () { expect(state.insert.doc.elements.at(0).key).to.equal('name'); }); - void store.openInsertDocumentDialog(doc, true); + void store.dispatch(openInsertDocumentDialog(doc, true)); await listener; }); @@ -1529,7 +1590,7 @@ describe('store', function () { expect(state.insert.doc.elements.at(0).key).to.equal('_id'); }); - void store.openInsertDocumentDialog(doc, false); + void store.dispatch(openInsertDocumentDialog(doc, false)); await listener; }); @@ -1561,7 +1622,7 @@ describe('store', function () { getCSFLEMode.returns('unavailable'); - void store.openInsertDocumentDialog(doc, false); + void store.dispatch(openInsertDocumentDialog(doc, false)); await listener; @@ -1583,7 +1644,7 @@ describe('store', function () { encryptedFields: { encryptedFields: [] }, }); - void store.openInsertDocumentDialog(doc, false); + void store.dispatch(openInsertDocumentDialog(doc, false)); await listener; @@ -1609,7 +1670,7 @@ describe('store', function () { }); isUpdateAllowed.resolves(false); - void store.openInsertDocumentDialog(doc, false); + void store.dispatch(openInsertDocumentDialog(doc, false)); await listener; @@ -1635,7 +1696,7 @@ describe('store', function () { }); isUpdateAllowed.resolves(true); - void store.openInsertDocumentDialog(doc, false); + void store.dispatch(openInsertDocumentDialog(doc, false)); await listener; @@ -1660,7 +1721,7 @@ describe('store', function () { }); isUpdateAllowed.resolves(true); - void store.openInsertDocumentDialog(doc, false); + void store.dispatch(openInsertDocumentDialog(doc, false)); await listener; @@ -1672,7 +1733,7 @@ describe('store', function () { }); describe('#drillDown', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin(); @@ -1686,20 +1747,20 @@ describe('store', function () { it('sets the drill down state', async function () { const listener = waitForState(store, (state) => { - expect(state.table.doc).to.deep.equal(doc); - expect(state.table.path).to.deep.equal(['field3']); - expect(state.table.types).to.deep.equal(['String']); - expect(state.table.editParams).to.deep.equal(editParams); + expect(state.view.table.doc).to.deep.equal(doc); + expect(state.view.table.path).to.deep.equal(['field3']); + expect(state.view.table.types).to.deep.equal(['String']); + expect(state.view.table.editParams).to.deep.equal(editParams); }); - store.drillDown(doc, element, editParams); + store.dispatch(drillDown(doc, element, editParams)); await listener; }); }); describe('#pathChanged', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin(); @@ -1712,18 +1773,18 @@ describe('store', function () { it('sets the path and types state', async function () { const listener = waitForState(store, (state) => { - expect(state.table.path).to.deep.equal(path); - expect(state.table.types).to.deep.equal(types); + expect(state.view.table.path).to.deep.equal(path); + expect(state.view.table.types).to.deep.equal(types); }); - store.pathChanged(path, types); + store.dispatch(pathChanged(path, types)); await listener; }); }); describe('#viewChanged', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin(); @@ -1733,10 +1794,10 @@ describe('store', function () { it('sets the view', async function () { const listener = waitForState(store, (state) => { - expect(state.view).to.equal('Table'); + expect(state.view.view).to.equal('Table'); }); - store.viewChanged('Table'); + store.dispatch(viewChanged('Table')); await listener; }); @@ -1744,7 +1805,7 @@ describe('store', function () { describe('#refreshDocuments', function () { context('when there is no shard key', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(async function () { const plugin = activatePlugin(); @@ -1762,22 +1823,22 @@ describe('store', function () { it('resets the documents to the first page', async function () { const listener = waitForStates(store, [ (state) => { - expect(state.debouncingLoad).to.equal(true); - expect(state.count).to.equal(null); + expect(state.documents.debouncingLoad).to.equal(true); + expect(state.documents.count).to.equal(null); }, (state) => { - expect(state.error).to.equal(null); - expect(state.docs).to.have.length(2); - expect(state.docs[0].doc.name).to.equal('testing1'); - expect(state.debouncingLoad).to.equal(false); - expect(state.count).to.equal(2); - expect(state.start).to.equal(1); - expect(state.shardKeys).to.deep.equal({}); + expect(state.documents.error).to.equal(null); + expect(state.documents.docs).to.have.length(2); + expect(state.documents.docs[0].doc.name).to.equal('testing1'); + expect(state.documents.debouncingLoad).to.equal(false); + expect(state.documents.count).to.equal(2); + expect(state.documents.start).to.equal(1); + expect(state.documents.shardKeys).to.deep.equal({}); }, ]); - void store.refreshDocuments(); + void store.dispatch(refreshDocuments()); await listener; }); @@ -1788,22 +1849,22 @@ describe('store', function () { }); const listener = waitForStates(store, [ (state) => { - expect(state.debouncingLoad).to.equal(true); - expect(state.count).to.equal(null); + expect(state.documents.debouncingLoad).to.equal(true); + expect(state.documents.count).to.equal(null); }, (state) => { - expect(state.error).to.equal(null); - expect(state.docs).to.have.length(2); - expect(state.docs[0].doc.name).to.equal('testing2'); - expect(state.debouncingLoad).to.equal(false); - expect(state.count).to.equal(2); - expect(state.start).to.equal(1); - expect(state.shardKeys).to.deep.equal({}); + expect(state.documents.error).to.equal(null); + expect(state.documents.docs).to.have.length(2); + expect(state.documents.docs[0].doc.name).to.equal('testing2'); + expect(state.documents.debouncingLoad).to.equal(false); + expect(state.documents.count).to.equal(2); + expect(state.documents.start).to.equal(1); + expect(state.documents.shardKeys).to.deep.equal({}); }, ]); - void store.refreshDocuments(); + void store.dispatch(refreshDocuments()); await listener; }); @@ -1820,11 +1881,11 @@ describe('store', function () { ); const listener = waitForState(store, (state) => { - expect(state.docs).to.have.length(2); - expect(state.count).to.equal(2); + expect(state.documents.docs).to.have.length(2); + expect(state.documents.count).to.equal(2); }); - void store.refreshDocuments(); + void store.dispatch(refreshDocuments()); await listener; @@ -1848,13 +1909,13 @@ describe('store', function () { it('resets the documents to the first page', async function () { const listener = waitForState(store, (state) => { - expect(state.error).to.not.equal(null); - expect(state.docs).to.have.length(0); - expect(state.count).to.equal(null); - expect(state.start).to.equal(0); + expect(state.documents.error).to.not.equal(null); + expect(state.documents.docs).to.have.length(0); + expect(state.documents.count).to.equal(null); + expect(state.documents.start).to.equal(0); }); - void store.refreshDocuments(); + void store.dispatch(refreshDocuments()); await listener; }); @@ -1862,7 +1923,7 @@ describe('store', function () { }); context('when there is a shard key', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(async function () { const plugin = activatePlugin(); store = plugin.store; @@ -1881,18 +1942,18 @@ describe('store', function () { it('looks up the shard keys', async function () { const listener = waitForState(store, (state) => { - expect(state.error).to.equal(null); - expect(state.shardKeys).to.deep.equal({ a: 1 }); + expect(state.documents.error).to.equal(null); + expect(state.documents.shardKeys).to.deep.equal({ a: 1 }); }); - void store.refreshDocuments(); + void store.dispatch(refreshDocuments()); await listener; }); }); context('when the collection is a timeseries', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(async function () { if (!satisfies(cluster().serverVersion, '>= 5.0.0')) { @@ -1916,10 +1977,10 @@ describe('store', function () { it('does not specify the _id_ index as hint', async function () { const spy = sinon.spy(dataService, 'aggregate'); const listener = waitForState(store, (state) => { - expect(state.count).to.equal(0); + expect(state.documents.count).to.equal(0); }); - void store.refreshDocuments(); + void store.dispatch(refreshDocuments()); await listener; @@ -1931,7 +1992,7 @@ describe('store', function () { }); context('when cancelling the operation', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { const plugin = activatePlugin(); @@ -1945,30 +2006,32 @@ describe('store', function () { const listener = waitForStates(store, [ (state) => { // cancel the operation as soon as the query starts - expect(state.status).to.equal('fetching'); - expect(state.count).to.be.null; - expect(state.loadingCount).to.be.true; // initially count is still loading - expect(state.error).to.be.null; - expect(state.abortController).to.not.be.null; + expect(state.documents.status).to.equal('fetching'); + expect(state.documents.count).to.be.null; + expect(state.documents.loadingCount).to.be.true; // initially count is still loading + expect(state.documents.error).to.be.null; + expect(state.documents.abortController).to.not.be.null; - store.cancelOperation(); + store.dispatch(cancelOperation()); }, (state) => { // cancelOperation cleans up abortController - expect(state.abortController).to.be.null; + expect(state.documents.abortController).to.be.null; }, (state) => { // the operation should fail - expect(state.status).to.equal('error'); - expect(state.error.message).to.equal('This operation was aborted'); - expect(state.abortController).to.be.null; - expect(state.loadingCount).to.be.false; // eventually count loads + expect(state.documents.status).to.equal('error'); + expect(state.documents.error.message).to.equal( + 'This operation was aborted' + ); + expect(state.documents.abortController).to.be.null; + expect(state.documents.loadingCount).to.be.false; // eventually count loads }, ]); - void store.refreshDocuments(); + void store.dispatch(refreshDocuments()); await listener; @@ -1981,7 +2044,7 @@ describe('store', function () { }); describe('#getPage', function () { - let store: CrudStore; + let store: CrudReduxStore; let findSpy; beforeEach(async function () { @@ -1989,7 +2052,7 @@ describe('store', function () { store = plugin.store; deactivate = () => plugin.deactivate(); - findSpy = sinon.spy(store.dataService, 'find'); + findSpy = sinon.spy(dataService, 'find'); const docs = [...Array(1000).keys()].map((i) => ({ i })); await dataService.insertMany('compass-crud.test', docs); @@ -2000,25 +2063,25 @@ describe('store', function () { }); it('does nothing for negative page numbers', async function () { - await store.getPage(-1); + await store.dispatch(getPage(-1)); expect(findSpy.called).to.be.false; }); it('does nothing if documents are already being fetched', async function () { - store.state.status = 'fetching'; - await store.getPage(1); + store.dispatch(seedDocumentsTestState({ status: 'fetching' })); + await store.dispatch(getPage(1)); expect(findSpy.called).to.be.false; }); it('does nothing if the page being requested is past the end', async function () { mockQueryBar.getLastAppliedQuery.returns({ limit: 25 }); - await store.getPage(1); // there is only one page of 25 + await store.dispatch(getPage(1)); // there is only one page of 25 expect(findSpy.called).to.be.false; }); it('does not ask for documents past the end', async function () { mockQueryBar.getLastAppliedQuery.returns({ limit: 26 }); - await store.getPage(1); // there is only one page of 25 + await store.dispatch(getPage(1)); // there is only one page of 25 expect(findSpy.called).to.be.true; const opts = findSpy.args[0][2]; // the second page should only have 1 due to the limit @@ -2026,32 +2089,32 @@ describe('store', function () { }); it('sets status fetchedPagination if it succeeds with no filter', async function () { - await store.getPage(1); // there is only one page of 25 + await store.dispatch(getPage(1)); // there is only one page of 25 expect(findSpy.called).to.be.true; - expect(store.state.status).to.equal('fetchedPagination'); + expect(store.getState().documents.status).to.equal('fetchedPagination'); }); it('sets status fetchedPagination if it succeeds with a filter', async function () { mockQueryBar.getLastAppliedQuery.returns({ filter: { i: { $gt: 1 } } }); - await store.getPage(1); // there is only one page of 25 + await store.dispatch(getPage(1)); // there is only one page of 25 expect(findSpy.called).to.be.true; - expect(store.state.status).to.equal('fetchedPagination'); + expect(store.getState().documents.status).to.equal('fetchedPagination'); }); it('sets status error if it fails', async function () { // remove the spy and replace it with a stub findSpy.restore(); const findStub = sinon - .stub(store.dataService, 'find') + .stub(dataService, 'find') .rejects(new Error('This is a fake error.')); - expect(store.state.abortController).to.be.null; + expect(store.getState().documents.abortController).to.be.null; - const promise = store.getPage(1); - expect(store.state.abortController).to.not.be.null; + const promise = store.dispatch(getPage(1)); + expect(store.getState().documents.abortController).to.not.be.null; await promise; - expect(store.state.error).to.have.property( + expect(store.getState().documents.error).to.have.property( 'message', 'This is a fake error.' ); @@ -2060,17 +2123,17 @@ describe('store', function () { }); it('allows the operation to be cancelled', async function () { - expect(store.state.abortController).to.be.null; + expect(store.getState().documents.abortController).to.be.null; - const promise = store.getPage(1); - expect(store.state.abortController).to.not.be.null; + const promise = store.dispatch(getPage(1)); + expect(store.getState().documents.abortController).to.not.be.null; - store.cancelOperation(); - expect(store.state.abortController).to.be.null; - expect(store.state.error).to.be.null; + store.dispatch(cancelOperation()); + expect(store.getState().documents.abortController).to.be.null; + expect(store.getState().documents.error).to.be.null; await promise; - expect(store.state.error).to.have.property( + expect(store.getState().documents.error).to.have.property( 'message', 'This operation was aborted' ); @@ -2080,7 +2143,7 @@ describe('store', function () { }); describe.skip('default query for view with own sort order', function () { - let store: CrudStore; + let store: CrudReduxStore; beforeEach(async function () { const plugin = activatePlugin(); @@ -2107,8 +2170,10 @@ describe('store', function () { it('returns documents in view order', async function () { const listener = waitForState(store, (state) => { - expect(state.docs).to.have.lengthOf(4); - expect(state.docs.map((doc) => doc.generateObject())).to.deep.equal([ + expect(state.documents.docs).to.have.lengthOf(4); + expect( + state.documents.docs.map((doc) => doc.generateObject()) + ).to.deep.equal([ { _id: '003', cat: 'amy' }, { _id: '002', cat: 'chashu' }, { _id: '001', cat: 'nori' }, @@ -2116,7 +2181,7 @@ describe('store', function () { ]); }); - void store.refreshDocuments(); + void store.dispatch(refreshDocuments()); await listener; }); @@ -2158,7 +2223,7 @@ describe('store', function () { const [error, d] = await findAndModifyWithFLEFallback( dataServiceStub, 'compass-crud.test', - { _id: 1234 } as any, + { _id: 1234 }, { name: 'document_12345' }, 'update' ); @@ -2188,7 +2253,7 @@ describe('store', function () { const [error, d] = await findAndModifyWithFLEFallback( dataServiceStub, 'compass-crud.test', - { _id: 1234 } as any, + { _id: 1234 }, { name: 'document_12345' }, 'replace' ); @@ -2219,7 +2284,7 @@ describe('store', function () { const [error, d] = await findAndModifyWithFLEFallback( dataServiceStub, 'compass-crud.test', - { _id: 1234 } as any, + { _id: 1234 }, { name: 'document_12345' }, 'update' ); @@ -2250,7 +2315,7 @@ describe('store', function () { const [error, d] = await findAndModifyWithFLEFallback( dataServiceStub, 'compass-crud.test', - { _id: 1234 } as any, + { _id: 1234 }, { name: 'document_12345' }, 'update' ); @@ -2297,7 +2362,7 @@ describe('store', function () { const [error, d] = await findAndModifyWithFLEFallback( dataServiceStub, 'compass-crud.test', - { _id: 1234 } as any, + { _id: 1234 }, { name: 'document_12345' }, 'update' ); @@ -2320,7 +2385,7 @@ describe('store', function () { const [error, d] = await findAndModifyWithFLEFallback( dataServiceStub, 'compass-crud.test', - { _id: 1234 } as any, + { _id: 1234 }, { name: 'document_12345' }, 'update' ); @@ -2347,7 +2412,7 @@ describe('store', function () { const [error, d] = await findAndModifyWithFLEFallback( dataServiceStub, 'compass-crud.test', - { _id: 1234 } as any, + { _id: 1234 }, { name: 'document_12345' }, 'replace' ); @@ -2541,7 +2606,7 @@ describe('store', function () { describe('saveUpdateQuery', function () { let favoriteQueriesStorage; let saveQueryStub; - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { favoriteQueriesStorage = compassFavoriteQueryStorageAccess.getStorage(); @@ -2565,8 +2630,10 @@ describe('store', function () { it('should save the query once is submitted to save', async function () { mockQueryBar.getLastAppliedQuery.returns({ filter: { field: 1 } }); - await store.updateBulkUpdatePreview('{ $set: { anotherField: 2 } }'); - await store.saveUpdateQuery('my-query'); + await store.dispatch( + updateBulkUpdatePreview('{ $set: { anotherField: 2 } }') + ); + await store.dispatch(saveUpdateQuery('my-query')); expect(saveQueryStub).to.have.been.calledWith({ _name: 'my-query', @@ -2584,7 +2651,7 @@ describe('store', function () { describe('updateBulkUpdatePreview', function () { context('with isUpdatePreviewSupported=false', function () { let previewUpdateStub; - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { previewUpdateStub = sinon.stub().resolves({ @@ -2604,13 +2671,15 @@ describe('store', function () { }); it('never calls dataService.previewUpdate()', async function () { - void store.openBulkUpdateModal(); + void store.dispatch(openBulkUpdateModal()); mockQueryBar.getLastAppliedQuery.returns({ filter: { field: 1 } }); - await store.updateBulkUpdatePreview('{ $set: { anotherField: 2 } }'); + await store.dispatch( + updateBulkUpdatePreview('{ $set: { anotherField: 2 } }') + ); expect(previewUpdateStub.called).to.be.false; - expect(store.state.bulkUpdate).to.deep.equal({ + expect(store.getState().bulkUpdate).to.deep.equal({ isOpen: true, preview: { changes: [], @@ -2623,24 +2692,28 @@ describe('store', function () { }); it('resets syntaxError when there is no syntax error', async function () { - void store.openBulkUpdateModal(); + void store.dispatch(openBulkUpdateModal()); mockQueryBar.getLastAppliedQuery.returns({ filter: { field: 1 } }); - await store.updateBulkUpdatePreview('{ $set: { anotherField: } }'); // syntax error + await store.dispatch( + updateBulkUpdatePreview('{ $set: { anotherField: } }') + ); // syntax error expect(previewUpdateStub.called).to.be.false; - expect(store.state.bulkUpdate.syntaxError?.name).to.equal( + expect(store.getState().bulkUpdate.syntaxError?.name).to.equal( 'SyntaxError' ); - expect(store.state.bulkUpdate.syntaxError?.message).to.equal( + expect(store.getState().bulkUpdate.syntaxError?.message).to.equal( 'Unexpected token (2:25) in (\n{ $set: { anotherField: } }\n)' ); - await store.updateBulkUpdatePreview('{ $set: { anotherField: 2 } }'); + await store.dispatch( + updateBulkUpdatePreview('{ $set: { anotherField: 2 } }') + ); expect(previewUpdateStub.called).to.be.false; - expect(store.state.bulkUpdate).to.deep.equal({ + expect(store.getState().bulkUpdate).to.deep.equal({ isOpen: true, preview: { changes: [], @@ -2655,7 +2728,7 @@ describe('store', function () { context('with isUpdatePreviewSupported=true', function () { let previewUpdateStub; - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { previewUpdateStub = sinon.stub().resolves({ @@ -2674,14 +2747,16 @@ describe('store', function () { }); it('calls dataService.previewUpdate()', async function () { - void store.openBulkUpdateModal(); + void store.dispatch(openBulkUpdateModal()); mockQueryBar.getLastAppliedQuery.returns({ filter: { field: 1 } }); - await store.updateBulkUpdatePreview('{ $set: { anotherField: 2 } }'); + await store.dispatch( + updateBulkUpdatePreview('{ $set: { anotherField: 2 } }') + ); // why two? because it also gets called when the dialog opens expect(previewUpdateStub.callCount).to.equal(2); - expect(store.state.bulkUpdate).to.deep.equal({ + expect(store.getState().bulkUpdate).to.deep.equal({ isOpen: true, preview: { changes: [ @@ -2703,7 +2778,7 @@ describe('store', function () { describe('saveRecentQueryQuery', function () { let recentQueriesStorage; let saveQueryStub; - let store: CrudStore; + let store: CrudReduxStore; beforeEach(function () { recentQueriesStorage = compassRecentQueryStorageAccess.getStorage(); @@ -2726,8 +2801,10 @@ describe('store', function () { it('should save the query once is run', async function () { mockQueryBar.getLastAppliedQuery.returns({ filter: { field: 1 } }); - await store.updateBulkUpdatePreview('{ $set: { anotherField: 2 } }'); - await store.runBulkUpdate(); + await store.dispatch( + updateBulkUpdatePreview('{ $set: { anotherField: 2 } }') + ); + await store.dispatch(runBulkUpdate()); expect(saveQueryStub).to.have.been.calledWith({ _ns: 'compass-crud.testview', @@ -2742,7 +2819,7 @@ describe('store', function () { }); describe('#updateMaxDocumentsPerPage', function () { - let store: CrudStore; + let store: CrudReduxStore; let fakeLocalStorage: sinon.SinonStub; let fakeGetItem: (key: string) => string | null; let fakeSetItem: (key: string, value: string) => void; @@ -2771,27 +2848,39 @@ describe('store', function () { it('should update the number of documents per page in the state and in localStorage', async function () { let listener = waitForState(store, (state) => { - expect(state).to.have.property('docsPerPage', 50); + expect(state.documents.docsPerPage).to.equal(50); expect(fakeGetItem(MAX_DOCS_PER_PAGE_STORAGE_KEY)).to.equal('50'); }); - store.updateMaxDocumentsPerPage(50); + store.dispatch(updateMaxDocumentsPerPage(50)); await listener; listener = waitForState(store, (state) => { - expect(state).to.have.property('docsPerPage', 75); + expect(state.documents.docsPerPage).to.equal(75); expect(fakeGetItem(MAX_DOCS_PER_PAGE_STORAGE_KEY)).to.equal('75'); }); - store.updateMaxDocumentsPerPage(75); + store.dispatch(updateMaxDocumentsPerPage(75)); await listener; }); it('should trigger refresh of documents when documents per page changes', function () { - const refreshSpy = sinon.spy(store, 'refreshDocuments'); - store.updateMaxDocumentsPerPage(50); + // refreshDocuments transitions documents.status into 'fetching'; count + // those edges to confirm only one refresh was kicked off across the two + // dispatches with the same value. + let prevStatus = store.getState().documents.status; + let fetchingTransitions = 0; + const unsubscribe = store.subscribe(() => { + const newStatus = store.getState().documents.status; + if (newStatus !== prevStatus && newStatus === 'fetching') { + fetchingTransitions++; + } + prevStatus = newStatus; + }); + store.dispatch(updateMaxDocumentsPerPage(50)); // calling it twice with the same count but the refresh should be // triggered only once - store.updateMaxDocumentsPerPage(50); - expect(refreshSpy).to.be.calledOnce; + store.dispatch(updateMaxDocumentsPerPage(50)); + unsubscribe(); + expect(fetchingTransitions).to.equal(1); }); }); }); diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index 0c45eb6956b..650f8757ae8 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -1,2097 +1,81 @@ -import type { Listenable, Store } from 'reflux'; -import Reflux from 'reflux'; -import toNS from 'mongodb-ns'; -import { findIndex, isEmpty, isEqual } from 'lodash'; -import semver from 'semver'; -import StateMixin from '@mongodb-js/reflux-state-mixin'; -import type { Element } from 'hadron-document'; -import HadronDocument, { Document } from 'hadron-document'; -import { toJSString, validate } from 'mongodb-query-parser'; -import _parseShellBSON, { ParseMode } from '@mongodb-js/shell-bson-parser'; -import type { PreferencesAccess } from 'compass-preferences-model/provider'; -import { capMaxTimeMSAtPreferenceLimit } from 'compass-preferences-model/provider'; -import type { Stage } from '@mongodb-js/explain-plan-helper'; -import { ExplainPlan } from '@mongodb-js/explain-plan-helper'; -import { EJSON } from 'bson'; -import type { - FavoriteQueryStorage, - FavoriteQueryStorageAccess, - RecentQueryStorage, - RecentQueryStorageAccess, -} from '@mongodb-js/my-queries-storage/provider'; - -import { - countDocuments, - fetchShardingKeys, - objectContainsRegularExpression, -} from '../utils'; - -import type { DOCUMENTS_STATUSES } from '../constants/documents-statuses'; -import { - DOCUMENTS_STATUS_ERROR, - DOCUMENTS_STATUS_FETCHED_CUSTOM, - DOCUMENTS_STATUS_FETCHED_INITIAL, - DOCUMENTS_STATUS_FETCHED_PAGINATION, - DOCUMENTS_STATUS_FETCHING, - DOCUMENTS_STATUS_INITIAL, -} from '../constants/documents-statuses'; - -import type { UpdatePreview } from 'mongodb-data-service'; -import type { GridStore, TableHeaderType } from './grid-store'; -import configureGridStore from './grid-store'; -import type { TypeCastMap } from 'hadron-type-checker'; +import type { Store } from 'redux'; +import { applyMiddleware, createStore } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; +import thunk from 'redux-thunk'; +import { toJSString } from 'mongodb-query-parser'; import type AppRegistry from '@mongodb-js/compass-app-registry'; import type { ActivateHelpers } from '@mongodb-js/compass-app-registry'; -import { BaseRefluxStore } from './base-reflux-store'; -import { openToast, showConfirmation } from '@mongodb-js/compass-components'; -import { - openBulkDeleteFailureToast, - openBulkDeleteProgressToast, - openBulkDeleteSuccessToast, - openBulkUpdateFailureToast, - openBulkUpdateProgressToast, - openBulkUpdateSuccessToast, -} from '../components/bulk-actions-toasts'; -import type { DataService } from '../utils/data-service'; import type { Collection, MongoDBInstance, } from '@mongodb-js/compass-app-stores/provider'; -import configureActions from '../actions'; +import type { PreferencesAccess } from 'compass-preferences-model/provider'; import type { Logger } from '@mongodb-js/compass-logging/provider'; -import { mongoLogId } from '@mongodb-js/compass-logging/provider'; -import type { CollectionTabPluginMetadata } from '@mongodb-js/compass-collection'; +import type { TrackFunction } from '@mongodb-js/compass-telemetry'; import type { FieldStoreService } from '@mongodb-js/compass-field-store'; import type { ConnectionInfoRef, ConnectionScopedAppRegistry, } from '@mongodb-js/compass-connections/provider'; -import type { Query, QueryBarService } from '@mongodb-js/compass-query-bar'; -import type { TrackFunction } from '@mongodb-js/compass-telemetry'; -import type { CollationOptions, MongoServerError } from 'mongodb'; - -export type BSONObject = TypeCastMap['Object']; -export type BSONArray = TypeCastMap['Array']; -type Mutable = { -readonly [P in keyof T]: T[P] }; - -export type EmittedAppRegistryEvents = - | 'open-import' - | 'open-export' - | 'document-deleted' - | 'documents-deleted' //Added new type for handling bulk deletion - | 'document-inserted' - | 'documents-refreshed'; - -export type CrudActions = { - drillDown( - doc: Document, - element: Element, - editParams?: { - colId: string; - rowIndex: number; - } - ): void; - updateDocument(doc: Document): Promise; - removeDocument(doc: Document): Promise; - replaceDocument(doc: Document): Promise; - openInsertDocumentDialog(doc: BSONObject, cloned: boolean): Promise; - copyToClipboard(doc: Document): void; //XXX - openBulkDeleteDialog(): void; - runBulkUpdate(): Promise; - closeBulkDeleteDialog(): void; - runBulkDelete(): Promise; - openQueryExportToLanguageDialog(): void; - openDeleteQueryExportToLanguageDialog(): void; - saveUpdateQuery(name: string): Promise; -}; - -export type DocumentView = 'List' | 'JSON' | 'Table'; - -const INITIAL_BULK_UPDATE_TEXT = `{ - $set: { - - }, -}`; - -export const fetchDocuments: ( - dataService: DataService, - track: TrackFunction, - serverVersion: string, - isDataLake: boolean, - ...args: Parameters -) => Promise = async ( - dataService: DataService, - track: TrackFunction, - serverVersion, - isDataLake, - ns, - filter, - options, - executionOptions -) => { - const canCalculateDocSize = - // $bsonSize is only supported for mongodb >= 4.4.0 - semver.gte(serverVersion, '4.4.0') && - // ADF doesn't support $bsonSize - !isDataLake && - // Accessing $$ROOT is not possible with CSFLE - ['disabled', 'unavailable', undefined].includes( - dataService?.getCSFLEMode?.() - ) && - // User provided their own projection, we can handle this in some cases, but - // it's hard to get right, so we will just skip this case - isEmpty(options?.projection); - - const modifiedOptions = { - ...options, - projection: canCalculateDocSize - ? { _id: 0, __doc: '$$ROOT', __size: { $bsonSize: '$$ROOT' } } - : options?.projection, - }; - - try { - let uuidSubtype3Count = 0; - let uuidSubtype4Count = 0; - const docs = ( - await dataService.find(ns, filter, modifiedOptions, executionOptions) - ).map((doc) => { - const { __doc, __size, ...rest } = doc; - let hadronDoc: HadronDocument; - if (__doc && __size && Object.keys(rest).length === 0) { - hadronDoc = new HadronDocument(__doc); - hadronDoc.size = Number(__size); - } else { - hadronDoc = new HadronDocument(doc); - } - const { subtype3Count, subtype4Count } = hadronDoc.findUUIDs(); - uuidSubtype3Count += subtype3Count; - uuidSubtype4Count += subtype4Count; - return hadronDoc; - }); - if (uuidSubtype3Count > 0) { - track('UUID Encountered', { subtype: 3, count: uuidSubtype3Count }); - } - if (uuidSubtype4Count > 0) { - track('UUID Encountered', { subtype: 4, count: uuidSubtype4Count }); - } - return docs; - } catch (err) { - // We are handling all the cases where the size calculating projection might - // not work, but just in case we run into some other environment or use-case - // that we haven't anticipated, we will try re-running query without the - // modified projection once more before failing again if this didn't work - if (canCalculateDocSize && (err as Error).name === 'MongoServerError') { - return ( - await dataService.find(ns, filter, options, executionOptions) - ).map((doc) => { - return new HadronDocument(doc); - }); - } - - throw err; - } -}; - -type CollectionStats = Pick< - Collection, - 'document_count' | 'storage_size' | 'free_storage_size' | 'avg_document_size' ->; -const extractCollectionStats = (collection: Collection): CollectionStats => { - const coll = collection.toJSON(); - return { - document_count: coll.document_count, - storage_size: coll.storage_size, - free_storage_size: coll.free_storage_size, - avg_document_size: coll.avg_document_size, - }; -}; - -/** - * Default number of docs per page. - */ -const DEFAULT_NUM_PAGE_DOCS = 25; - -/** - * Error constant. - */ -const ERROR = 'error'; - -/** - * Modifying constant. - */ -const MODIFYING = 'modifying'; - -/** - * The list view constant. - */ -const LIST = 'List'; - -/** - * The delete error message. - */ -const DELETE_ERROR = new Error( - 'Cannot delete documents that do not have an _id field.' -); - -/** - * The empty update error message. - */ -const EMPTY_UPDATE_ERROR = new Error( - 'Unable to update, no changes have been made.' -); - -/** - * Default max time ms for the first query which is not getting the value from - * the query bar. - */ -const DEFAULT_INITIAL_MAX_TIME_MS = 60000; - -/** - * A cap for the maxTimeMS used for countDocuments. This value is used - * in place of the query maxTimeMS unless that is smaller. - * - * Due to the limit of 20 documents the batch of data for the query is usually - * ready sooner than the count. - * - * We want to make sure `count` does not hold back the query results for too - * long after docs are returned. - */ -export const COUNT_MAX_TIME_MS_CAP = 5000; - -/** - * The key we use to persist the user selected maximum documents per page for - * other tabs or for the next application start. - * Exported only for test purpose - */ -export const MAX_DOCS_PER_PAGE_STORAGE_KEY = 'compass_crud-max_docs_per_page'; - -export type CrudStoreOptions = Pick< - CollectionTabPluginMetadata, - | 'query' - | 'isReadonly' - | 'namespace' - | 'isTimeSeries' - | 'isSearchIndexesSupported' - | 'sourceName' -> & { - noRefreshOnConfigure?: boolean; -}; - -export type InsertCSFLEState = { - state: - | 'none' - | 'no-known-schema' - | 'incomplete-schema-for-cloned-doc' - | 'has-known-schema' - | 'csfle-disabled'; - encryptedFields?: string[]; -}; - -export type WriteError = { - message: string; - info?: Record; -}; - -type InsertState = { - doc: null | Document; - jsonDoc: null | string; - error?: WriteError; - csfleState: InsertCSFLEState; - mode: 'modifying' | 'error'; - jsonView: boolean; - isOpen: boolean; - isCommentNeeded: boolean; -}; - -type BulkUpdateState = { - isOpen: boolean; - updateText: string; - preview: UpdatePreview; - syntaxError?: Error; - serverError?: Error; - previewAbortController?: AbortController; - affected?: number; -}; - -export type TableState = { - doc: Document | null; - path: (string | number)[]; - types: TableHeaderType[]; - editParams: null | { - colId: string | number; - rowIndex: number; - }; -}; - -export type BulkDeleteState = { - previews: Document[]; - status: 'open' | 'closed' | 'in-progress'; - affected?: number; -}; - -type CrudState = { - ns: string; - collection: string; - abortController: AbortController | null; - error: Error | null; - docs: Document[] | null; - start: number; - end: number; - page: number; - version: string; - view: DocumentView; - count: number | null; - insert: InsertState; - bulkUpdate: BulkUpdateState; - table: TableState; - isDataLake: boolean; - isReadonly: boolean; - isTimeSeries: boolean; - status: DOCUMENTS_STATUSES; - lastCountRunMaxTimeMS: number; - debouncingLoad: boolean; - loadingCount: boolean; - shardKeys: null | BSONObject; - resultId: number; - isWritable: boolean; - instanceDescription: string; - isCollectionScan?: boolean; - isSearchIndexesSupported: boolean; - isUpdatePreviewSupported: boolean; - bulkDelete: BulkDeleteState; - docsPerPage: number; - collectionStats: CollectionStats | null; -}; - -type CrudStoreActionsOptions = { - actions: { - [key in keyof CrudActions]: Listenable; - }; -}; - -class CrudStoreImpl - extends BaseRefluxStore - implements CrudActions -{ - mixins = [StateMixin.store()]; - listenables: unknown[]; - - // Should this be readonly? The existence of setState would imply that... - // readonly state: Readonly - declare state: CrudState; - declare setState: (newState: Partial) => void; - dataService: DataService; - preferences: PreferencesAccess; - localAppRegistry: Pick; - favoriteQueriesStorage?: FavoriteQueryStorage; - recentQueriesStorage?: RecentQueryStorage; - fieldStoreService: FieldStoreService; - logger: Logger; - track: TrackFunction; - connectionInfoRef: ConnectionInfoRef; - instance: MongoDBInstance; - connectionScopedAppRegistry: ConnectionScopedAppRegistry; - queryBar: QueryBarService; - collection: Collection; - - constructor( - options: CrudStoreOptions & CrudStoreActionsOptions, - services: Pick< - DocumentsPluginServices, - | 'instance' - | 'dataService' - | 'localAppRegistry' - | 'preferences' - | 'logger' - | 'track' - | 'connectionInfoRef' - | 'fieldStoreService' - | 'connectionScopedAppRegistry' - | 'queryBar' - | 'collection' - > & { - favoriteQueryStorage?: FavoriteQueryStorage; - recentQueryStorage?: RecentQueryStorage; - } - ) { - super(options); - this.listenables = options.actions as any; // TODO: The types genuinely mismatch here - this.favoriteQueriesStorage = services.favoriteQueryStorage; - this.recentQueriesStorage = services.recentQueryStorage; - this.dataService = services.dataService; - this.localAppRegistry = services.localAppRegistry; - this.preferences = services.preferences; - this.logger = services.logger; - this.track = services.track; - this.connectionInfoRef = services.connectionInfoRef; - this.instance = services.instance; - this.fieldStoreService = services.fieldStoreService; - this.connectionScopedAppRegistry = services.connectionScopedAppRegistry; - this.queryBar = services.queryBar; - this.collection = services.collection; - } - - getInitialState(): CrudState { - const isDataLake = !!this.instance.dataLake.isDataLake; - const isReadonly = !!this.options.isReadonly; - - return { - ns: this.options.namespace, - collection: toNS(this.options.namespace).collection, - abortController: null, - error: null, - docs: [], - start: 0, - version: this.instance.build.version, - end: 0, - page: 0, - view: LIST, - count: null, - insert: this.getInitialInsertState(), - bulkUpdate: this.getInitialBulkUpdateState(), - bulkDelete: this.getInitialBulkDeleteState(), - table: this.getInitialTableState(), - isDataLake, - isReadonly, - isTimeSeries: !!this.options.isTimeSeries, - status: DOCUMENTS_STATUS_INITIAL, - debouncingLoad: false, - loadingCount: false, - lastCountRunMaxTimeMS: COUNT_MAX_TIME_MS_CAP, - shardKeys: null, - resultId: resultId(), - isWritable: this.instance.isWritable, - instanceDescription: this.instance.description, - isCollectionScan: false, - isSearchIndexesSupported: this.options.isSearchIndexesSupported, - isUpdatePreviewSupported: - this.instance.topologyDescription.type !== 'Single', - docsPerPage: this.getInitialDocsPerPage(), - collectionStats: extractCollectionStats(this.collection), - }; - } - - getInitialDocsPerPage(): number { - const lastUsedDocsPerPageString = localStorage.getItem( - MAX_DOCS_PER_PAGE_STORAGE_KEY - ); - const lastUsedDocsPerPage = lastUsedDocsPerPageString - ? parseInt(lastUsedDocsPerPageString) - : null; - return lastUsedDocsPerPage ?? DEFAULT_NUM_PAGE_DOCS; - } - - /** - * Get the initial insert state. - * - * @returns {Object} The initial insert state. - */ - getInitialInsertState(): InsertState { - return { - doc: null, - jsonDoc: null, - csfleState: { state: 'none' }, - mode: MODIFYING, - jsonView: false, - isOpen: false, - isCommentNeeded: true, - }; - } - - getInitialBulkUpdateState(): BulkUpdateState { - return { - isOpen: false, - updateText: INITIAL_BULK_UPDATE_TEXT, - preview: { - changes: [], - }, - syntaxError: undefined, - serverError: undefined, - }; - } - - getInitialBulkDeleteState(): BulkDeleteState { - return { - previews: [], - status: 'closed', - affected: 0, - }; - } - - /** - * Get the initial table state. - * - * @returns {Object} The initial table state. - */ - getInitialTableState(): TableState { - return { - doc: null, - path: [], - types: [], - editParams: null, - }; - } - - /** - * Returns the current view in the format used for telemetry - * ('list', 'json', 'table'). Grouped here so that this is easy - * to update if the labels change at some point. - */ - modeForTelemetry() { - return this.state.view.toLowerCase() as Lowercase; - } - - /** - * Copy the document to the clipboard. - * - * @param {HadronDocument} doc - The document. - * - * @returns {Boolean} If the copy succeeded. - */ - copyToClipboard(doc: Document) { - this.track( - 'Document Copied', - { mode: this.modeForTelemetry() }, - this.connectionInfoRef.current - ); - const documentEJSON = doc.toEJSON(); - // eslint-disable-next-line no-undef - void navigator.clipboard.writeText(documentEJSON); - } - - getWriteError(error: Error): WriteError { - return { - message: error.message, - info: (error as MongoServerError).errInfo, - }; - } - - updateMaxDocumentsPerPage(docsPerPage: number) { - const previousDocsPerPage = this.state.docsPerPage; - localStorage.setItem(MAX_DOCS_PER_PAGE_STORAGE_KEY, String(docsPerPage)); - this.setState({ - docsPerPage, - }); - if (previousDocsPerPage !== docsPerPage) { - void this.refreshDocuments(); - } - } - - /** - * Remove the provided document from the collection. - * - * @param {Document} doc - The hadron document. - */ - async removeDocument(doc: Document) { - this.track( - 'Document Deleted', - { mode: this.modeForTelemetry() }, - this.connectionInfoRef.current - ); - const id = doc.getId(); - if (id !== undefined) { - doc.onRemoveStart(); - try { - await this.dataService.deleteOne(this.state.ns, { _id: id as any }); - // emit on the document(list view) and success state(json view) - doc.onRemoveSuccess(); - const payload = { view: this.state.view, ns: this.state.ns }; - this.localAppRegistry.emit('document-deleted', payload); - this.connectionScopedAppRegistry.emit('document-deleted', payload); - const index = this.findDocumentIndex(doc); - const newDocs = this.state.docs - ? [...this.state.docs] - : this.state.docs; - newDocs?.splice(index, 1); - this.setState({ - docs: newDocs, - count: this.state.count === null ? null : this.state.count - 1, - end: Math.max(this.state.end - 1, 0), - }); - } catch (error) { - // emit on the document(list view) and success state(json view) - doc.onRemoveError(error as Error); - this.trigger(this.state); - } - } else { - doc.onRemoveError(DELETE_ERROR); - this.trigger(this.state); - } - } - - /** - * Ensure that updating the given document is allowed - * (currently only in the sense that for CSFLE-enabled clients, - * there is no risk of writing back unencrypted data). - * If this is not the case, returns false and emit `update-error` - * on the document object. - * - * @param {string} ns The collection namespace - * @param {Document} doc A HadronDocument instance - * @returns {boolean} Whether updating is allowed. - */ - async _verifyUpdateAllowed(ns: string, doc: Document) { - if (this.dataService.getCSFLEMode?.() === 'enabled') { - // Editing the document and then being informed that - // doing so is disallowed might not be great UX, but - // since we are mostly targeting typical FLE2 use cases, - // it's probably not worth spending too much time on this. - const isAllowed = await this.dataService.isUpdateAllowed?.( - ns, - doc.generateOriginalObject() - ); - if (!isAllowed) { - doc.onUpdateError( - new Error( - 'Update blocked as it could unintentionally write unencrypted data due to a missing or incomplete schema.' - ) - ); - return false; - } - } - return true; - } - - /** - * Update the provided document unless the elements being changed were - * changed in the background. If the elements being changed were changed - * in the background, block the update. - * - * @param {Document} doc - The hadron document. - */ - async updateDocument(doc: Document) { - this.track( - 'Document Updated', - { mode: this.modeForTelemetry() }, - this.connectionInfoRef.current - ); - try { - doc.onUpdateStart(); - // We add the shard keys here, if there are any, because that is - // required for updated documents in sharded collections. - const { query, updateDoc } = - doc.generateUpdateUnlessChangedInBackgroundQuery( - // '.' in shard keys means nested doc - { - alwaysIncludeKeys: Object.keys(this.state.shardKeys || {}).map( - (key) => key.split('.') - ), - } - ); - this.logger.debug('Performing findOneAndUpdate', { query, updateDoc }); - - if (Object.keys(updateDoc).length === 0) { - doc.onUpdateError(EMPTY_UPDATE_ERROR); - return; - } - - if (!(await this._verifyUpdateAllowed(this.state.ns, doc))) { - // _verifyUpdateAllowed emitted update-error - return; - } - const [error, d] = await findAndModifyWithFLEFallback( - this.dataService, - this.state.ns, - query, - updateDoc, - 'update' - ); - - if (error) { - if ( - error.codeName === 'InvalidPipelineOperator' && - error.message.match(/\$[gs]etField/) - ) { - const nbsp = '\u00a0'; - error.message += ` (Updating fields whose names contain dots or start with $ require MongoDB${nbsp}5.0 or above.)`; - } - doc.onUpdateError(error as Error); - } else if (d) { - doc.onUpdateSuccess(d); - const index = this.findDocumentIndex(doc); - const newDocs = this.state.docs - ? [...this.state.docs] - : this.state.docs; - newDocs?.splice(index, 1, new HadronDocument(d)); - this.setState({ - docs: newDocs, - }); - } else { - doc.onUpdateBlocked(); - } - } catch (err: any) { - doc.onUpdateError( - new Error( - `An error occured when attempting to update the document: ${String( - err.message - )}` - ) - ); - } - } - - /** - * Replace the document in the database with the provided document. - * - * @param {Document} doc - The hadron document. - */ - async replaceDocument(doc: Document) { - this.track( - 'Document Updated', - { mode: this.modeForTelemetry() }, - this.connectionInfoRef.current - ); - try { - doc.onUpdateStart(); - - if (!(await this._verifyUpdateAllowed(this.state.ns, doc))) { - // _verifyUpdateAllowed emitted update-error - return; - } - - const object = doc.generateObject(); - const queryKeyInclusionOptions: Mutable< - Parameters< - typeof doc.getQueryForOriginalKeysAndValuesForSpecifiedKeys - >[0] - > = { - alwaysIncludeKeys: [ - ['_id'], - // '.' in shard keys means nested doc - ...Object.keys(this.state.shardKeys || {}).map((key) => - key.split('.') - ), - ], - }; - - if (this.dataService.getCSFLEMode?.() === 'enabled') { - const knownSchemaForCollection = - await this.dataService.knownSchemaForCollection(this.state.ns); - - // The find/query portion will typically exclude encrypted fields, - // because those cannot be queried to make sure that the original - // value matches the current one; however, if we know that the - // field is equality-searchable, we can (and should) still include it. - queryKeyInclusionOptions.includableEncryptedKeys = - knownSchemaForCollection.encryptedFields.equalityQueryableEncryptedFields; - - if ( - object.__safeContent__ && - isEqual( - object.__safeContent__, - doc.generateOriginalObject().__safeContent__ - ) && - knownSchemaForCollection.hasSchema - ) { - // SERVER-66662 blocks writes of __safeContent__ for queryable-encryption-enabled - // collections. We remove it unless it was edited, in which case we assume that the - // user really knows what they are doing. - delete object.__safeContent__; - } - } - - const query = doc.getQueryForOriginalKeysAndValuesForSpecifiedKeys( - queryKeyInclusionOptions - ); - this.logger.debug('Performing findOneAndReplace', { query, object }); - - const [error, d] = await findAndModifyWithFLEFallback( - this.dataService, - this.state.ns, - query, - object, - 'replace' - ); - if (error) { - doc.onUpdateError(error as Error); - } else { - doc.onUpdateSuccess(d); - const index = this.findDocumentIndex(doc); - const newDocs = this.state.docs - ? [...this.state.docs] - : this.state.docs; - newDocs?.splice(index, 1, new HadronDocument(d)); - this.setState({ - docs: newDocs, - }); - } - } catch (err: any) { - doc.onUpdateError( - new Error( - `An error occured when attempting to update the document: ${String( - err.message - )}` - ) - ); - } - } - - /** - * Set if the default comment should be displayed. - * - * @param {Boolean} isCommentNeeded - Is a comment needed or not. - */ - updateComment(isCommentNeeded: boolean) { - const insert = { ...this.state.insert, isCommentNeeded }; - this.setState({ insert }); - } - - /** - * Find the index of the document in the list. - * - * @param {Document} doc - The hadron document. - * - * @returns {String} Document Index from the list. - */ - findDocumentIndex(doc: Document) { - return findIndex(this.state.docs, (d) => { - return doc.getStringId() === d.getStringId(); - }); - } - - /** - * When the next page button is clicked, need to load the next 20 documents. - * - * @param {Number} page - The page that is being shown. - */ - async getPage(page: number) { - const { ns, status, docsPerPage } = this.state; - - if (page < 0) { - return; - } - - if (status === DOCUMENTS_STATUS_FETCHING) { - return; - } - - const { - filter, - limit, - sort, - hint, - project: projection, - collation, - maxTimeMS, - skip: _skip = 0, - } = this.queryBar.getLastAppliedQuery('crud'); - - const skip = _skip + page * docsPerPage; - - // nextPageCount will be the number of docs to load - let nextPageCount = docsPerPage; - - // Make sure we don't go past the limit if a limit is set - if (limit) { - const remaining = limit - skip; - if (remaining < 1) { - return; - } - if (remaining < nextPageCount) { - nextPageCount = remaining; - } - } - - const abortController = new AbortController(); - const signal = abortController.signal; - - const opts = { - skip, - limit: nextPageCount, - hint: hint ?? undefined, - sort: sort ?? undefined, - projection: projection ?? undefined, - collation: collation as CollationOptions, - maxTimeMS: capMaxTimeMSAtPreferenceLimit(this.preferences, maxTimeMS), - promoteValues: false, - bsonRegExp: true, - }; - - this.setState({ - status: DOCUMENTS_STATUS_FETCHING, - abortController, - error: null, - }); - - const cancelDebounceLoad = this.debounceLoading(); - - let error: Error | undefined; - let documents: HadronDocument[]; - try { - documents = await fetchDocuments( - this.dataService, - this.track, - this.state.version, - this.state.isDataLake, - ns, - filter ?? {}, - opts, - { - abortSignal: signal, - } - ); - } catch (err: any) { - documents = []; - error = err; - } - - const length = error ? 0 : documents.length; - this.setState({ - error, - status: error - ? DOCUMENTS_STATUS_ERROR - : DOCUMENTS_STATUS_FETCHED_PAGINATION, - docs: documents, - // making sure we don't set start to 1 if length is 0 - start: length === 0 ? 0 : skip + 1, - end: skip + length, - page, - table: this.getInitialTableState(), - resultId: resultId(), - abortController: null, - }); - void this.fieldStoreService.updateFieldsFromDocuments(this.state.ns, [ - documents[0]?.generateObject(), - ]); - - cancelDebounceLoad(); - } - - /** - * Closing the insert document dialog just resets the state to the default. - */ - closeInsertDocumentDialog() { - this.setState({ - insert: this.getInitialInsertState(), - }); - } - - /** - * Closing the bulk update dialog just resets the state to the default. - */ - closeBulkUpdateModal() { - this.setState({ - bulkUpdate: { - ...this.state.bulkUpdate, - isOpen: false, - }, - }); - } - - /** - * Open the insert document dialog. - * - * @param {Object} doc - The document to insert. - * @param {Boolean} clone - Whether this is a clone operation. - */ - async openInsertDocumentDialog(doc: BSONObject, clone = false) { - const hadronDoc = new HadronDocument(doc); - - if (clone) { - this.track( - 'Document Cloned', - { mode: this.modeForTelemetry() }, - this.connectionInfoRef.current - ); - // We need to remove the _id or we will get an duplicate key error on - // insert, and we currently do not allow editing of the _id field. - for (const element of hadronDoc.elements) { - if (element.currentKey === '_id') { - hadronDoc.elements.remove(element); - break; - } - } - } - - const csfleState: InsertState['csfleState'] = { state: 'none' }; - const dataServiceCSFLEMode = this.dataService.getCSFLEMode?.(); - if (dataServiceCSFLEMode === 'enabled') { - // Show a warning if this is a CSFLE-enabled connection but this - // collection does not have a schema. - const { - hasSchema, - encryptedFields: { encryptedFields }, - } = await this.dataService.knownSchemaForCollection(this.state.ns); - if (encryptedFields.length > 0) { - // This is for displaying encrypted fields to the user. We do not really - // need to worry about the distinction between '.' as a nested-field - // indicator and '.' as a literal part of a field name here, esp. since - // automatic Queryable Encryption does not support '.' in field names at all. - csfleState.encryptedFields = encryptedFields.map((field) => - field.join('.') - ); - } - if (!hasSchema) { - csfleState.state = 'no-known-schema'; - } else if ( - !(await this.dataService.isUpdateAllowed?.(this.state.ns, doc)) - ) { - csfleState.state = 'incomplete-schema-for-cloned-doc'; - } else { - csfleState.state = 'has-known-schema'; - } - } else if (dataServiceCSFLEMode === 'disabled') { - csfleState.state = 'csfle-disabled'; - } - - const jsonDoc = hadronDoc.toEJSON(); - - this.setState({ - insert: { - doc: hadronDoc, - jsonDoc: jsonDoc, - jsonView: true, - error: undefined, - csfleState, - mode: MODIFYING, - isOpen: true, - isCommentNeeded: true, - }, - }); - } - - async openBulkUpdateModal(updateText?: string) { - this.track( - 'Bulk Update Opened', - { - isUpdatePreviewSupported: this.state.isUpdatePreviewSupported, - }, - this.connectionInfoRef.current - ); - - await this.updateBulkUpdatePreview(updateText ?? INITIAL_BULK_UPDATE_TEXT); - this.setState({ - bulkUpdate: { - ...this.state.bulkUpdate, - isOpen: true, - }, - }); - } - - async updateBulkUpdatePreview(updateText: string) { - if (this.state.bulkUpdate.previewAbortController) { - this.state.bulkUpdate.previewAbortController.abort(); - } - - // Don't try and calculate the update preview if we know it won't work. Just - // see if the update will parse. - if (!this.state.isUpdatePreviewSupported) { - try { - parseShellBSON(updateText); - } catch (err: any) { - this.setState({ - bulkUpdate: { - ...this.state.bulkUpdate, - updateText, - preview: { - changes: [], - }, - serverError: undefined, - syntaxError: err, - previewAbortController: undefined, - }, - }); - return; - } - - // if there's no syntax error, then just clear it - this.setState({ - bulkUpdate: { - ...this.state.bulkUpdate, - updateText, - preview: { - changes: [], - }, - serverError: undefined, - syntaxError: undefined, - previewAbortController: undefined, - }, - }); - - return; - } - - const abortController = new AbortController(); - - // set the abort controller in the state before we start doing anything so - // that other calls can see it - this.setState({ - bulkUpdate: { - ...this.state.bulkUpdate, - previewAbortController: abortController, - }, - }); - - let update: BSONObject | BSONObject[]; - try { - update = parseShellBSON(updateText); - } catch (err: any) { - if (abortController.signal.aborted) { - // ignore this result because it is stale - return; - } - - this.setState({ - bulkUpdate: { - ...this.state.bulkUpdate, - updateText, - preview: { - changes: [], - }, - serverError: undefined, - syntaxError: err, - previewAbortController: undefined, - }, - }); - - return; - } - - if (abortController.signal.aborted) { - // don't kick off an expensive query if we're already aborted anyway - return; - } - - const { ns } = this.state; - const { filter = {} } = this.queryBar.getLastAppliedQuery('crud'); - - let preview; - try { - preview = await this.dataService.previewUpdate(ns, filter, update, { - sample: 3, - abortSignal: abortController.signal, - }); - } catch (err: any) { - if (abortController.signal.aborted) { - // ignore this result because it is stale - return; - } - - this.setState({ - bulkUpdate: { - ...this.state.bulkUpdate, - updateText, - preview: { - changes: [], - }, - serverError: err, - syntaxError: undefined, - previewAbortController: undefined, - }, - }); - - return; - } - - if (abortController.signal.aborted) { - // ignore this result because it is stale - return; - } - - this.setState({ - bulkUpdate: { - ...this.state.bulkUpdate, - updateText, - preview, - serverError: undefined, - syntaxError: undefined, - previewAbortController: undefined, - }, - }); - } - - async runBulkUpdate() { - const query = this.queryBar.getLastAppliedQuery('crud'); - this.track( - 'Bulk Update Executed', - { - isUpdatePreviewSupported: this.state.isUpdatePreviewSupported, - has_filter: Object.keys(query.filter ?? {}).length > 0, - }, - this.connectionInfoRef.current - ); - - this.closeBulkUpdateModal(); - - // keep the filter count around for the duration of the toast - this.setState({ - bulkUpdate: { - ...this.state.bulkUpdate, - affected: this.state.count ?? undefined, - }, - }); - - const { ns } = this.state; - const { filter = {} } = query; - let update; - try { - update = parseShellBSON(this.state.bulkUpdate.updateText); - } catch { - // If this couldn't parse then the update button should have been - // disabled. So if we get here it is a race condition and ignoring is - // probably OK - the button will soon appear disabled to the user anyway. - return; - } - - await this.recentQueriesStorage?.saveQuery({ - _ns: this.state.ns, - filter, - update, - }); - - openBulkUpdateProgressToast({ - affectedDocuments: this.state.bulkUpdate.affected, - }); - - try { - await this.dataService.updateMany(ns, filter, update); - - openBulkUpdateSuccessToast({ - affectedDocuments: this.state.bulkUpdate.affected, - onRefresh: () => void this.refreshDocuments(), - }); - } catch (err: any) { - openBulkUpdateFailureToast({ - affectedDocuments: this.state.bulkUpdate.affected, - error: err as Error, - }); - - this.logger.log.error( - mongoLogId(1_001_000_269), - 'Bulk Update Documents', - `Update operation failed: ${err.message}`, - err - ); - } - } - - /** - * Open an import file dialog from compass-import-export-plugin. - * Emits a global app registry event the plugin listens to. - */ - openImportFileDialog() { - this.connectionScopedAppRegistry.emit('open-import', { - namespace: this.state.ns, - origin: 'empty-state', - }); - } - - /** - * Open an export file dialog from compass-import-export-plugin. - * Emits a global app registry event the plugin listens to. - */ - openExportFileDialog(exportFullCollection?: boolean) { - const { filter, project, collation, limit, skip, sort } = - this.queryBar.getLastAppliedQuery('crud'); - - this.connectionScopedAppRegistry.emit('open-export', { - namespace: this.state.ns, - query: { filter, project, collation, limit, skip, sort }, - exportFullCollection, - origin: 'crud-toolbar', - }); - } - - /** - * Switch between list and JSON views when inserting a document through Insert Document modal. - * - * Also modifies doc and jsonDoc states to keep accurate data for each view. - * @param {String} view - view we are switching to. - */ - toggleInsertDocument(view: DocumentView) { - if (view === 'JSON') { - const jsonDoc = this.state.insert.doc?.toEJSON(); - - this.setState({ - insert: { - doc: this.state.insert.doc, - jsonView: true, - jsonDoc: jsonDoc ?? null, - error: undefined, - csfleState: this.state.insert.csfleState, - mode: MODIFYING, - isOpen: true, - isCommentNeeded: this.state.insert.isCommentNeeded, - }, - }); - } else { - let hadronDoc; - - if (this.state.insert.jsonDoc === '') { - hadronDoc = this.state.insert.doc; - } else { - hadronDoc = HadronDocument.FromEJSON(this.state.insert.jsonDoc ?? ''); - } - - this.setState({ - insert: { - doc: hadronDoc, - jsonView: false, - jsonDoc: this.state.insert.jsonDoc, - error: undefined, - csfleState: this.state.insert.csfleState, - mode: MODIFYING, - isOpen: true, - isCommentNeeded: this.state.insert.isCommentNeeded, - }, - }); - } - } - - /** - * Toggle just the jsonView insert state. - * - * @param {String} view - view we are switching to. - */ - toggleInsertDocumentView(view: DocumentView) { - const jsonView = view === 'JSON'; - this.setState({ - insert: { - doc: new Document({}), - jsonDoc: this.state.insert.jsonDoc, - jsonView: jsonView, - error: undefined, - csfleState: this.state.insert.csfleState, - mode: MODIFYING, - isOpen: true, - isCommentNeeded: this.state.insert.isCommentNeeded, - }, - }); - } - - /** - * As we are editing a JSON document in Insert Document Dialog, update the - * state with the inputed json data. - * - * @param {String} value - JSON string we are updating. - */ - updateJsonDoc(value: string | null) { - this.setState({ - insert: { - doc: new Document({}), - jsonDoc: value, - jsonView: true, - error: undefined, - csfleState: this.state.insert.csfleState, - mode: MODIFYING, - isOpen: true, - isCommentNeeded: this.state.insert.isCommentNeeded, - }, - }); - } - - /** - * Insert a single document. - */ - async insertMany() { - try { - const schemaFields = this.fieldStoreService.getSchemaFieldsForNamespace( - this.state.ns - ); - const docs = HadronDocument.FromEJSONArray( - this.state.insert.jsonDoc ?? '' - ).map((doc) => { - if (schemaFields) { - doc.preserveTypesFromSchema(schemaFields); - } - return doc.generateObject(); - }); - this.track( - 'Document Inserted', - { - mode: this.state.insert.jsonView ? 'json' : 'field-by-field', - multiple: docs.length > 1, - }, - this.connectionInfoRef.current - ); - - await this.dataService.insertMany(this.state.ns, docs); - // track mode for analytics events - const payload = { - ns: this.state.ns, - view: this.state.view, - mode: this.state.insert.jsonView ? 'json' : 'default', - multiple: true, - docs, - }; - void this.fieldStoreService.updateFieldsFromDocuments( - this.state.ns, - docs - ); - // TODO(COMPASS-7815): Remove this event and use AppStoreService - this.connectionScopedAppRegistry.emit('document-inserted', payload); - - this.state.insert = this.getInitialInsertState(); - } catch (error) { - this.setState({ - insert: { - doc: new Document({}), - jsonDoc: this.state.insert.jsonDoc, - jsonView: true, - error: this.getWriteError(error as Error), - csfleState: this.state.insert.csfleState, - mode: ERROR, - isOpen: true, - isCommentNeeded: this.state.insert.isCommentNeeded, - }, - }); - } - - // Since we are inserting a bunch of documents and we need to rerun all - // the queries and counts for them, let's just refresh the whole set of - // documents. - void this.refreshDocuments(); - } - - /** - * Insert the document given the document in current state. - * Parse document from Json Insert View Modal or generate object from hadron document - * view to insert. - */ - async insertDocument() { - this.track( - 'Document Inserted', - { - mode: this.state.insert.jsonView ? 'json' : 'field-by-field', - multiple: false, - }, - this.connectionInfoRef.current - ); - - let doc: BSONObject; - - try { - const schemaFields = this.fieldStoreService.getSchemaFieldsForNamespace( - this.state.ns - ); - if (this.state.insert.jsonView) { - const hadronDoc = HadronDocument.FromEJSON( - this.state.insert.jsonDoc ?? '' - ); - if (schemaFields) { - hadronDoc.preserveTypesFromSchema(schemaFields); - } - doc = hadronDoc.generateObject(); - } else { - // Create a fresh document from the current state to avoid mutating - // the insert dialog's document in place (which would be a side - // effect visible on retry if the insert fails). - const hadronDoc = new HadronDocument( - this.state.insert.doc!.generateObject() - ); - if (schemaFields) { - hadronDoc.preserveTypesFromSchema(schemaFields); - } - doc = hadronDoc.generateObject(); - } - await this.dataService.insertOne(this.state.ns, doc); - - const payload = { - ns: this.state.ns, - view: this.state.view, - mode: this.state.insert.jsonView ? 'json' : 'default', - multiple: false, - docs: [doc], - }; - void this.fieldStoreService.updateFieldsFromDocuments(this.state.ns, [ - doc, - ]); - // TODO(COMPASS-7815): Remove this event and use AppStoreService - this.connectionScopedAppRegistry.emit('document-inserted', payload); - - this.state.insert = this.getInitialInsertState(); - } catch (error) { - this.setState({ - insert: { - doc: this.state.insert.doc, - jsonDoc: this.state.insert.jsonDoc, - jsonView: this.state.insert.jsonView, - error: this.getWriteError(error as Error), - csfleState: this.state.insert.csfleState, - mode: ERROR, - isOpen: true, - isCommentNeeded: this.state.insert.isCommentNeeded, - }, - }); - return; - } - - void this.refreshDocuments(); - } - - /** - * The user has drilled down into a new element. - * - * @param {HadronDocument} doc - The parent document. - * @param {Element} element - The element being drilled into. - * @param {Object} editParams - If we need to open a cell for editing, the coordinates. - */ - drillDown( - doc: Document, - element: Element, - editParams: TableState['editParams'] = null - ) { - this.setState({ - table: { - path: this.state.table.path.concat([element.currentKey]), - types: this.state.table.types.concat([element.currentType]), - doc, - editParams, - }, - }); - } - - /** - * The path of the table view has changed. - * - * @param {Array} path - A list of fieldnames and indexes. - * @param {Array} types - A list of the types of each path segment. - */ - pathChanged(path: (string | number)[], types: TableHeaderType[]) { - this.setState({ - table: { - doc: this.state.table.doc, - editParams: this.state.table.editParams, - path: path, - types: types, - }, - }); - } - - /** - * The view has changed. - * - * @param {String} view - The new view. - */ - viewChanged(view: CrudState['view']) { - this.setState({ view: view }); - } - - /** - * Detect if it is safe to perform the count query optimisation where we - * specify the _id_ index as the hint. - */ - isCountHintSafe(query: { filter?: unknown }) { - const { isTimeSeries } = this.state; - - if (isTimeSeries) { - // timeseries collections don't have the _id_ filter, so we can't use the hint speedup - return false; - } - - if (query.filter && Object.keys(query.filter).length) { - // we can't safely use the hint speedup if there's a filter - return false; - } - - return true; - } - - /** - * Checks if the initial query was not modified. - * - * @param {Object} query - The query to check. - * - * @returns {Boolean} - */ - isInitialQuery(query: Query = {}): boolean { - return ( - isEmpty(query.filter) && - isEmpty(query.project) && - isEmpty(query.collation) - ); - } - - collectionStatsFetched(model: Collection) { - this.setState({ - collectionStats: extractCollectionStats(model), - }); - } - - /** - * This function is called when the collection filter changes. - */ - async refreshDocuments(onApply = false) { - if (this.dataService && !this.dataService.isConnected()) { - this.logger.log.warn( - mongoLogId(1_001_000_072), - 'Documents', - 'Trying to refresh documents but dataService is disconnected' - ); - return; - } - - const { ns, status, docsPerPage } = this.state; - const query = this.queryBar.getLastAppliedQuery('crud'); - - if (status === DOCUMENTS_STATUS_FETCHING) { - return; - } - - if (onApply) { - const { isTimeSeries, isReadonly } = this.state; - const { defaultSortOrder } = this.preferences.getPreferences(); - this.track( - 'Query Executed', - { - has_filter: !!query.filter && Object.keys(query.filter).length > 0, - has_projection: - !!query.project && Object.keys(query.project).length > 0, - has_skip: (query.skip ?? 0) > 0, - has_sort: !!query.sort && Object.keys(query.sort).length > 0, - default_sort: !defaultSortOrder - ? 'none' - : /_id/.test(defaultSortOrder) - ? '_id' - : 'natural', - has_limit: (query.limit ?? 0) > 0, - has_collation: !!query.collation, - changed_maxtimems: query.maxTimeMS !== DEFAULT_INITIAL_MAX_TIME_MS, - collection_type: isTimeSeries - ? 'time-series' - : isReadonly - ? 'readonly' - : 'collection', - used_regex: objectContainsRegularExpression(query.filter ?? {}), - mode: this.modeForTelemetry(), - }, - this.connectionInfoRef.current - ); - } - - // pass the signal so that the queries can close their own cursors and - // reject their promises - const abortController = new AbortController(); - const signal = abortController.signal; - - const fetchShardingKeysOptions = { - maxTimeMS: capMaxTimeMSAtPreferenceLimit( - this.preferences, - query.maxTimeMS - ), - signal, - }; - - const countOptions: Parameters[4] = { - skip: query.skip, - maxTimeMS: capMaxTimeMSAtPreferenceLimit( - this.preferences, - (query.maxTimeMS ?? 0) > COUNT_MAX_TIME_MS_CAP - ? COUNT_MAX_TIME_MS_CAP - : query.maxTimeMS - ), - signal, - ...(query.hint - ? { - hint: query.hint, - } - : {}), - }; - - if (!countOptions.hint && this.isCountHintSafe(query)) { - countOptions.hint = '_id_'; - } - - const isView = this.options.isReadonly && this.options.sourceName; - // Default sort options that we allow to choose from in settings will have a - // massive negative effect on the query performance for views and view-like - // collections in all cases. To avoid that, we're not applying default sort - // for those - const allowDefaultSort = !isView && !this.options.isTimeSeries; - - const { defaultSortOrder } = this.preferences.getPreferences(); - - let sort = query.sort; - - if (!sort && allowDefaultSort && defaultSortOrder) { - sort = validate('sort', defaultSortOrder); - } - - const findOptions = { - sort: sort ?? undefined, - projection: query.project ?? undefined, - skip: query.skip, - limit: docsPerPage, - collation: query.collation as CollationOptions, - hint: query.hint ?? undefined, - maxTimeMS: capMaxTimeMSAtPreferenceLimit( - this.preferences, - query.maxTimeMS - ), - promoteValues: false, - bsonRegExp: true, - }; - - // only set limit if it's > 0, read-only views cannot handle 0 limit. - if (query.limit && query.limit > 0) { - countOptions.limit = query.limit; - findOptions.limit = Math.min(docsPerPage, query.limit); - } - - this.logger.log.info( - mongoLogId(1_001_000_073), - 'Documents', - 'Refreshing documents', - { - ns, - withFilter: !isEmpty(query.filter), - findOptions, - countOptions, - } - ); - - // Only check if index was used if query filter or sort is not empty - if (!isEmpty(query.filter) || !isEmpty(query.sort)) { - void this.dataService - .explainFind(ns, query.filter ?? {}, findOptions, { - explainVerbosity: 'queryPlanner', - abortSignal: signal, - }) - .then((rawExplainPlan) => { - const explainPlan = new ExplainPlan(rawExplainPlan as Stage); - this.setState({ - isCollectionScan: explainPlan.isCollectionScan, - }); - }) - .catch(() => { - // We are only fetching this to get information about index usage for - // insight badge, if this fails for any reason, server, cancel, or - // error parsing explan, we don't care and ignore it - }); - } else { - this.setState({ isCollectionScan: false }); - } - - // Don't wait for the count to finish. Set the result asynchronously. - countDocuments( - this.dataService, - this.preferences, - ns, - query.filter ?? {}, - countOptions, - (err: any) => { - this.logger.log.warn( - mongoLogId(1_001_000_288), - 'Documents', - 'Failed to count documents', - err - ); - } - ) - .then((count) => this.setState({ count, loadingCount: false })) - .catch((err) => { - // countDocuments already swallows all db errors and returns null. The - // only known error it can throw is AbortError. If - // something new does appear we probably shouldn't swallow it. - if (!this.dataService.isCancelError(err)) { - throw err; - } - this.setState({ loadingCount: false }); - }); - - const promises = [ - fetchShardingKeys( - this.dataService, - ns, - fetchShardingKeysOptions, - (err) => { - this.logger.log.warn( - mongoLogId(1_001_000_075), - 'Documents', - 'Failed to fetch sharding keys', - err - ); - } - ), - fetchDocuments( - this.dataService, - this.track, - this.state.version, - this.state.isDataLake, - ns, - query.filter ?? {}, - findOptions, - { - abortSignal: signal, - } - ), - ] as const; - - // This is so that the UI can update to show that we're fetching - this.setState({ - lastCountRunMaxTimeMS: countOptions.maxTimeMS, - status: DOCUMENTS_STATUS_FETCHING, - abortController, - error: null, - count: null, // we don't know the new count yet - loadingCount: true, - }); - - // don't start showing the loading indicator and cancel button immediately - const cancelDebounceLoad = this.debounceLoading(); - - const stateChanges = {}; - - try { - const [shardKeys, docs] = await Promise.all(promises); - - Object.assign(stateChanges, { - status: this.isInitialQuery(query) - ? DOCUMENTS_STATUS_FETCHED_INITIAL - : DOCUMENTS_STATUS_FETCHED_CUSTOM, - error: null, - docs: docs, - page: 0, - start: docs.length > 0 ? 1 : 0, - end: docs.length, - table: this.getInitialTableState(), - shardKeys, - }); - - void this.fieldStoreService.updateFieldsFromDocuments(this.state.ns, [ - docs[0]?.generateObject(), - ]); - - // Notify the instance store to refresh collection stats so the tab - // header count stays in sync with the pagination count. - this.connectionScopedAppRegistry.emit('documents-refreshed', { ns }); - } catch (error) { - this.logger.log.error( - mongoLogId(1_001_000_074), - 'Documents', - 'Failed to refresh documents', - error - ); - Object.assign(stateChanges, { - error, - status: DOCUMENTS_STATUS_ERROR, - }); - } - - // cancel the debouncing status if we load before the timer fires - cancelDebounceLoad(); - - Object.assign(stateChanges, { - abortController: null, - resultId: resultId(), - }); - - // Trigger all the accumulated changes once at the end - this.setState(stateChanges); - } - - cancelOperation() { - // As we use same controller for all operations - // (find, count and shardingKeys), aborting will stop all. - this.state.abortController?.abort(new Error('This operation was aborted')); - this.setState({ abortController: null }); - } - - debounceLoading() { - this.setState({ debouncingLoad: true }); - - const debouncePromise = new Promise((resolve) => { - setTimeout(resolve, 200); // 200ms should feel about instant - }); - - let cancelDebounceLoad: () => void; - const loadPromise = new Promise((resolve) => { - cancelDebounceLoad = resolve; - }); - - void Promise.race([debouncePromise, loadPromise]).then(() => { - this.setState({ debouncingLoad: false }); - }); - - return cancelDebounceLoad!; - } - - hasProjection(query: BSONObject) { - return !!(query.project && Object.keys(query.project).length > 0); - } - - openCreateIndexModal() { - this.localAppRegistry.emit('open-create-index-modal', { - query: EJSON.serialize(this.queryBar.getLastAppliedQuery('crud')?.filter), - }); - } - - openCreateSearchIndexModal() { - this.localAppRegistry.emit('open-create-search-index-modal'); - } - - openBulkDeleteDialog() { - this.track('Bulk Delete Opened', {}, this.connectionInfoRef.current); - - const PREVIEW_DOCS = 5; - - const previews = (this.state.docs?.slice(0, PREVIEW_DOCS) || []).map( - (doc) => { - // The idea is just to break the link with the docs in the list so that - // expanding/collapsing docs in the modal doesn't modify the ones in the - // list. - return Document.FromEJSON(doc.toEJSON()); - } - ); - - this.setState({ - bulkDelete: { - previews, - status: 'open', - affected: this.state.count ?? undefined, - }, - }); - } - - bulkDeleteInProgress() { - this.setState({ - bulkDelete: { - ...this.state.bulkDelete, - status: 'in-progress', - }, - }); - - openBulkDeleteProgressToast({ - affectedDocuments: this.state.bulkDelete.affected, - }); - } - - bulkDeleteFailed(ex: Error) { - openBulkDeleteFailureToast({ - affectedDocuments: this.state.bulkDelete.affected, - error: ex, - }); - - this.logger.log.error( - mongoLogId(1_001_000_268), - 'Bulk Delete Documents', - `Delete operation failed: ${ex.message}`, - ex - ); - } - - bulkDeleteSuccess() { - openBulkDeleteSuccessToast({ - affectedDocuments: this.state.bulkDelete.affected, - onRefresh: () => void this.refreshDocuments(), - }); - } - - closeBulkDeleteDialog() { - this.setState({ - bulkDelete: { - ...this.state.bulkDelete, - status: 'closed', - }, - }); - } - - async runBulkDelete() { - const query = this.queryBar.getLastAppliedQuery('crud'); - - const { affected } = this.state.bulkDelete; - this.closeBulkDeleteDialog(); - - const confirmation = await showConfirmation({ - title: 'Are you absolutely sure?', - buttonText: `Delete ${affected ? `${affected} ` : ''} document${ - affected !== 1 ? 's' : '' - }`, - description: `This action can not be undone. This will permanently delete ${ - affected ?? 'an unknown number of' - } document${affected !== 1 ? 's' : ''}.`, - warning: - 'The document list and count may not always reflect the latest updates in real time. This action will apply to all relevant documents, including those not currently visible, so please ensure they are handled safely.', - variant: 'danger', - }); - - if (confirmation) { - this.bulkDeleteInProgress(); - const { filter = {} } = query; - try { - await this.dataService.deleteMany(this.state.ns, filter); - this.track( - 'Bulk Delete Executed', - { - has_filter: Object.keys(query.filter ?? {}).length > 0, - }, - this.connectionInfoRef.current - ); - this.bulkDeleteSuccess(); - // Emit both events so all listeners update (fixes bulk delete document count not updating) - const payload = { view: this.state.view, ns: this.state.ns }; - this.localAppRegistry.emit('documents-deleted', payload); - this.connectionScopedAppRegistry.emit('documents-deleted', payload); - } catch (ex) { - this.bulkDeleteFailed(ex as Error); - } - } - } - - openQueryExportToLanguageDialog(): void { - const query = this.queryBar.getLastAppliedQuery('crud'); - this.localAppRegistry.emit( - 'open-query-export-to-language', - { - filter: toJSString(query.filter) || '{}', - project: query.project ? toJSString(query.project) : undefined, - sort: query.sort ? toJSString(query.sort) : undefined, - collation: query.collation ? toJSString(query.collation) : undefined, - skip: query.skip ? String(query.skip) : undefined, - limit: query.limit ? String(query.limit) : undefined, - }, - 'Query' - ); - } - - openDeleteQueryExportToLanguageDialog(): void { - const { filter = {} } = this.queryBar.getLastAppliedQuery('crud'); - this.localAppRegistry.emit( - 'open-query-export-to-language', - { - filter: toJSString(filter) || '{}', - }, - 'Delete Query' - ); - } - - async saveUpdateQuery(name: string): Promise { - this.track( - 'Bulk Update Favorited', - { - isUpdatePreviewSupported: this.state.isUpdatePreviewSupported, - }, - this.connectionInfoRef.current - ); - - const { filter } = this.queryBar.getLastAppliedQuery('crud'); - let update; - try { - update = parseShellBSON(this.state.bulkUpdate.updateText); - } catch { - // If this couldn't parse then the update button should have been - // disabled. So if we get here it is a race condition and ignoring is - // probably OK - the button will soon appear disabled to the user anyway. - return; - } +import type { QueryBarService } from '@mongodb-js/compass-query-bar'; +import type { + FavoriteQueryStorageAccess, + RecentQueryStorageAccess, +} from '@mongodb-js/my-queries-storage/provider'; - await this.favoriteQueriesStorage?.saveQuery({ - _name: name, - _ns: this.state.ns, - filter, - update, - }); - openToast('saved-favorite-update-query', { - title: '', - variant: 'success', - dismissible: true, - timeout: 6_000, - description: `${name} added to "My Queries".`, - }); - } -} +import type { DataService } from '../utils/data-service'; +import type { GridStore } from './grid-store'; +import configureGridStore from './grid-store'; +import configureActions from '../actions'; +import { + getInitialDocumentsState, + refreshDocuments, + cancelOperation, +} from './documents'; +import { extractCollectionStats } from './collection-meta'; +import { + isWritableChanged, + instanceDescriptionChanged, + collectionStatsFetched, +} from './collection-meta'; +import { openBulkUpdateModal, INITIAL_BULK_UPDATE_TEXT } from './bulk-update'; +import { + createRootReducer, + type CrudExtraArgs, + type CrudReduxActions, + type CrudState, + type CrudStoreOptions, + type EmittedAppRegistryEvents, +} from './reducer'; + +export type { + CrudStoreOptions, + CrudState, + CrudReduxActions, + CrudActionTypes, + CrudThunkAction, + EmittedAppRegistryEvents, +} from './reducer'; + +export type { + BSONObject, + BSONArray, + InsertCSFLEState, + WriteError, +} from './insert'; +export type { TableState, DocumentView } from './view'; +export type { BulkDeleteState } from './bulk-delete'; + +export { + fetchDocuments, + findAndModifyWithFLEFallback, +} from './fetch-documents'; +export { parseShellBSON } from './parse-shell-bson'; +export { + MAX_DOCS_PER_PAGE_STORAGE_KEY, + COUNT_MAX_TIME_MS_CAP, +} from './documents'; -export type CrudStore = Store & CrudStoreImpl & { gridStore: GridStore }; export type DocumentsPluginServices = { dataService: DataService; instance: MongoDBInstance; @@ -2109,6 +93,20 @@ export type DocumentsPluginServices = { collection: Collection; }; +export type CrudDispatch = ThunkDispatch< + CrudState, + CrudExtraArgs, + CrudReduxActions +>; + +export type CrudReduxStore = Omit< + Store, + 'dispatch' +> & { + dispatch: CrudDispatch; + gridStore: GridStore; +}; + export function activateDocumentsPlugin( options: CrudStoreOptions, { @@ -2129,51 +127,74 @@ export function activateDocumentsPlugin( }: DocumentsPluginServices, { on, cleanup }: ActivateHelpers ) { + const initialDocumentsState = getInitialDocumentsState(options.namespace); + const initialCollectionMetaState = { + version: instance.build.version, + isDataLake: !!instance.dataLake.isDataLake, + isReadonly: !!options.isReadonly, + isTimeSeries: !!options.isTimeSeries, + isWritable: instance.isWritable, + instanceDescription: instance.description, + isSearchIndexesSupported: options.isSearchIndexesSupported, + isUpdatePreviewSupported: instance.topologyDescription.type !== 'Single', + collectionStats: extractCollectionStats(collection), + }; + + const reducer = createRootReducer({ + documents: initialDocumentsState, + collectionMeta: initialCollectionMetaState, + }); + + const extraArgs: CrudExtraArgs = { + dataService, + localAppRegistry, + globalAppRegistry, + preferences, + logger, + track, + favoriteQueriesStorage: favoriteQueryStorageAccess?.getStorage(), + recentQueriesStorage: recentQueryStorageAccess?.getStorage(), + fieldStoreService, + connectionInfoRef, + connectionScopedAppRegistry, + queryBar, + crudOptions: options, + }; + + const store = createStore( + reducer, + applyMiddleware(thunk.withExtraArgument(extraArgs)) + ) as unknown as CrudReduxStore; + + // The grid store is still Reflux-based, we attach it as a property + // on the redux store so components can subscribe to it. const actions = configureActions(); - const store = Reflux.createStore( - new CrudStoreImpl( - { ...options, actions }, - { - instance, - dataService, - localAppRegistry, - preferences, - logger, - track, - connectionInfoRef, - favoriteQueryStorage: favoriteQueryStorageAccess?.getStorage(), - recentQueryStorage: recentQueryStorageAccess?.getStorage(), - fieldStoreService, - connectionScopedAppRegistry, - queryBar, - collection, - } - ) - ) as CrudStore; + const gridStore = configureGridStore({ actions }); + store.gridStore = gridStore; on( localAppRegistry, 'favorites-open-bulk-update-favorite', - (query: { update: BSONObject }) => { - void store.refreshDocuments(); - void store.openBulkUpdateModal(); - void store.updateBulkUpdatePreview( - toJSString(query.update) || INITIAL_BULK_UPDATE_TEXT + (query: { update: Record }) => { + void store.dispatch(refreshDocuments()); + void store.dispatch( + openBulkUpdateModal( + toJSString(query.update) || INITIAL_BULK_UPDATE_TEXT + ) ); } ); - // these can change later on(instance, 'change:isWritable', () => { - store.setState({ isWritable: instance.isWritable }); + store.dispatch(isWritableChanged(instance.isWritable)); }); on(instance, 'change:description', () => { - store.setState({ instanceDescription: instance.description }); + store.dispatch(instanceDescriptionChanged(instance.description)); }); on(globalAppRegistry, 'refresh-data', () => { - void store.refreshDocuments(); + void store.dispatch(refreshDocuments()); }); on( @@ -2184,128 +205,42 @@ export function activateDocumentsPlugin( { connectionId }: { connectionId?: string } = {} ) => { const { id: currentConnectionId } = connectionInfoRef.current; - if (currentConnectionId === connectionId && ns === store.state.ns) { - void store.refreshDocuments(); + const currentNs = store.getState().documents.ns; + if (currentConnectionId === connectionId && ns === currentNs) { + void store.dispatch(refreshDocuments()); } } ); on(collection, 'change:status', (model: Collection, status: string) => { if (status === 'ready') { - store.collectionStatsFetched(model); + store.dispatch(collectionStatsFetched(model)); } }); if (!options.noRefreshOnConfigure) { queueMicrotask(() => { - void store.refreshDocuments(); + void store.dispatch(refreshDocuments()); }); } if ((options.query as any)?.update) { - // This will be set if the user clicked a bulk update query on the My Queries page - const initialUpdate: BSONObject | undefined = (options.query as any) - ?.update; + // Set when the user clicked a bulk update query on the My Queries page. + const initialUpdate = (options.query as any)?.update as + | Record + | undefined; const updateText = initialUpdate ? toJSString(initialUpdate) : undefined; queueMicrotask(() => { - void store.openBulkUpdateModal(updateText); + void store.dispatch(openBulkUpdateModal(updateText)); }); } - const gridStore = configureGridStore({ actions }); - store.gridStore = gridStore; - return { store, - actions, deactivate() { - store.cancelOperation(); + store.dispatch(cancelOperation()); cleanup(); }, }; } - -function resultId() { - return Math.floor(Math.random() * 2 ** 53); -} - -type ErrorOrResult = - | [ - error: { message: string; code?: number; codeName?: string }, - result: undefined - ] - | [error: undefined | null, result: BSONObject]; - -export async function findAndModifyWithFLEFallback( - ds: DataService, - ns: string, - query: BSONObject, - object: { $set?: BSONObject; $unset?: BSONObject } | BSONObject | BSONArray, - modificationType: 'update' | 'replace' -): Promise { - const findOneAndModifyMethod = - modificationType === 'update' ? 'findOneAndUpdate' : 'findOneAndReplace'; - let error: (Error & { codeName?: string; code?: any }) | undefined; - - try { - return [ - undefined, - await ds[findOneAndModifyMethod](ns, query, object, { - returnDocument: 'after', - promoteValues: false, - }), - ] as ErrorOrResult; - } catch (e) { - error = e as Error; - } - - if ( - error.codeName === 'ShardKeyNotFound' || - +(error?.code ?? 0) === 63714_02 // 6371402 is "'findAndModify with encryption only supports new: false'" - ) { - const modifyOneMethod = - modificationType === 'update' ? 'updateOne' : 'replaceOne'; - - try { - await ds[modifyOneMethod](ns, query, object); - } catch (e) { - // Return the modifyOneMethod error here - // since we already know the original error from findOneAndModifyMethod - // and want to know what went wrong with the fallback method, - // e.g. return the `Found indexed encrypted fields but could not find __safeContent__` error. - return [e, undefined] as ErrorOrResult; - } - - try { - const docs = await ds.find( - ns, - { _id: query._id as any }, - { promoteValues: false } - ); - return [undefined, docs[0]] as ErrorOrResult; - } catch { - /* fallthrough */ - } - } - - // Race condition -- most likely, somebody else - // deleted the document between the findAndModify command - // and the find command. Just return the original error. - return [error, undefined] as ErrorOrResult; -} - -// Copied from packages/compass-aggregations/src/modules/pipeline-builder/pipeline-parser/utils.ts -export function parseShellBSON(source: string): BSONObject | BSONObject[] { - const parsed = _parseShellBSON(source, { - mode: ParseMode.Strict, - allowComments: true, - allowMethods: true, - }); - if (!parsed || typeof parsed !== 'object') { - // XXX(COMPASS-5689): We've hit the condition in - // https://github.com/mongodb-js/ejson-shell-parser/blob/c9c0145ababae52536ccd2244ac2ad01a4bbdef3/src/index.ts#L36 - throw new Error('The provided definition is invalid.'); - } - return parsed as BSONObject | BSONObject[]; -} diff --git a/packages/compass-crud/src/stores/documents.ts b/packages/compass-crud/src/stores/documents.ts new file mode 100644 index 00000000000..cf1d0795ddc --- /dev/null +++ b/packages/compass-crud/src/stores/documents.ts @@ -0,0 +1,1168 @@ +import type { Reducer } from 'redux'; +import toNS from 'mongodb-ns'; +import { findIndex, isEmpty } from 'lodash'; +import HadronDocument from 'hadron-document'; +import type { Document } from 'hadron-document'; +import type { CollationOptions } from 'mongodb'; +import { EJSON } from 'bson'; +import { toJSString, validate } from 'mongodb-query-parser'; +import type { Stage } from '@mongodb-js/explain-plan-helper'; +import { ExplainPlan } from '@mongodb-js/explain-plan-helper'; +import { mongoLogId } from '@mongodb-js/compass-logging/provider'; +import { capMaxTimeMSAtPreferenceLimit } from 'compass-preferences-model/provider'; +import type { Query } from '@mongodb-js/compass-query-bar'; + +import { isAction } from './util'; +import type { CrudThunkAction } from './reducer'; +import type { BSONObject } from './insert'; +import { + fetchDocuments, + findAndModifyWithFLEFallback, +} from './fetch-documents'; +import { + countDocuments, + fetchShardingKeys, + objectContainsRegularExpression, +} from '../utils'; +import type { DOCUMENTS_STATUSES } from '../constants/documents-statuses'; +import { + DOCUMENTS_STATUS_ERROR, + DOCUMENTS_STATUS_FETCHED_CUSTOM, + DOCUMENTS_STATUS_FETCHED_INITIAL, + DOCUMENTS_STATUS_FETCHED_PAGINATION, + DOCUMENTS_STATUS_FETCHING, + DOCUMENTS_STATUS_INITIAL, +} from '../constants/documents-statuses'; + +/** + * Default number of docs per page. + */ +export const DEFAULT_NUM_PAGE_DOCS = 25; + +/** + * Default max time ms for the first query which is not getting the value from + * the query bar. + */ +const DEFAULT_INITIAL_MAX_TIME_MS = 60000; + +/** + * A cap for the maxTimeMS used for countDocuments. This value is used + * in place of the query maxTimeMS unless that is smaller. + * + * Due to the limit of 20 documents the batch of data for the query is usually + * ready sooner than the count. + * + * We want to make sure `count` does not hold back the query results for too + * long after docs are returned. + */ +export const COUNT_MAX_TIME_MS_CAP = 5000; + +/** + * The key we use to persist the user selected maximum documents per page for + * other tabs or for the next application start. + * Exported only for test purpose + */ +export const MAX_DOCS_PER_PAGE_STORAGE_KEY = 'compass_crud-max_docs_per_page'; + +const DELETE_ERROR = new Error( + 'Cannot delete documents that do not have an _id field.' +); +const EMPTY_UPDATE_ERROR = new Error( + 'Unable to update, no changes have been made.' +); + +type Mutable = { -readonly [P in keyof T]: T[P] }; + +export type DocumentsState = { + ns: string; + collection: string; + abortController: AbortController | null; + error: Error | null; + docs: Document[] | null; + start: number; + end: number; + page: number; + count: number | null; + status: DOCUMENTS_STATUSES; + shardKeys: null | BSONObject; + resultId: number; + isCollectionScan?: boolean; + debouncingLoad: boolean; + loadingCount: boolean; + lastCountRunMaxTimeMS: number; + docsPerPage: number; +}; + +function resultId() { + return Math.floor(Math.random() * 2 ** 53); +} + +function getInitialDocsPerPage(): number { + const lastUsedDocsPerPageString = localStorage.getItem( + MAX_DOCS_PER_PAGE_STORAGE_KEY + ); + const lastUsedDocsPerPage = lastUsedDocsPerPageString + ? parseInt(lastUsedDocsPerPageString) + : null; + return lastUsedDocsPerPage ?? DEFAULT_NUM_PAGE_DOCS; +} + +export function getInitialDocumentsState(namespace: string): DocumentsState { + return { + ns: namespace, + collection: toNS(namespace).collection, + abortController: null, + error: null, + docs: [], + start: 0, + end: 0, + page: 0, + count: null, + status: DOCUMENTS_STATUS_INITIAL, + shardKeys: null, + resultId: resultId(), + isCollectionScan: false, + debouncingLoad: false, + loadingCount: false, + lastCountRunMaxTimeMS: COUNT_MAX_TIME_MS_CAP, + docsPerPage: getInitialDocsPerPage(), + }; +} + +export const DocumentsActionTypes = { + REFRESH_STARTED: 'crud/documents/REFRESH_STARTED', + REFRESH_SUCCESS: 'crud/documents/REFRESH_SUCCESS', + REFRESH_ERROR: 'crud/documents/REFRESH_ERROR', + COUNT_FETCHED: 'crud/documents/COUNT_FETCHED', + COUNT_FINISHED: 'crud/documents/COUNT_FINISHED', + COLLECTION_SCAN_RESULT: 'crud/documents/COLLECTION_SCAN_RESULT', + GET_PAGE_STARTED: 'crud/documents/GET_PAGE_STARTED', + GET_PAGE_SUCCESS: 'crud/documents/GET_PAGE_SUCCESS', + GET_PAGE_ERROR: 'crud/documents/GET_PAGE_ERROR', + CANCEL_OPERATION: 'crud/documents/CANCEL_OPERATION', + DEBOUNCING_LOAD_CHANGED: 'crud/documents/DEBOUNCING_LOAD_CHANGED', + DOCUMENT_REMOVED: 'crud/documents/DOCUMENT_REMOVED', + DOCUMENT_REPLACED: 'crud/documents/DOCUMENT_REPLACED', + DOCS_PER_PAGE_CHANGED: 'crud/documents/DOCS_PER_PAGE_CHANGED', + /** @internal Test-only — seed partial state for unit tests. */ + SEED_DOCUMENTS_TEST_STATE: 'crud/documents/SEED_DOCUMENTS_TEST_STATE', +} as const; + +export type RefreshStartedAction = { + type: typeof DocumentsActionTypes.REFRESH_STARTED; + abortController: AbortController; + lastCountRunMaxTimeMS: number; +}; + +export type RefreshSuccessAction = { + type: typeof DocumentsActionTypes.REFRESH_SUCCESS; + docs: Document[]; + shardKeys: BSONObject | null; + status: DOCUMENTS_STATUSES; +}; + +export type RefreshErrorAction = { + type: typeof DocumentsActionTypes.REFRESH_ERROR; + error: Error; +}; + +export type CountFetchedAction = { + type: typeof DocumentsActionTypes.COUNT_FETCHED; + count: number | null; +}; + +export type CountFinishedAction = { + type: typeof DocumentsActionTypes.COUNT_FINISHED; +}; + +export type CollectionScanResultAction = { + type: typeof DocumentsActionTypes.COLLECTION_SCAN_RESULT; + isCollectionScan: boolean; +}; + +export type GetPageStartedAction = { + type: typeof DocumentsActionTypes.GET_PAGE_STARTED; + abortController: AbortController; +}; + +export type GetPageSuccessAction = { + type: typeof DocumentsActionTypes.GET_PAGE_SUCCESS; + docs: Document[]; + start: number; + end: number; + page: number; +}; + +export type GetPageErrorAction = { + type: typeof DocumentsActionTypes.GET_PAGE_ERROR; + error: Error; + page: number; + start: number; + end: number; +}; + +export type CancelOperationAction = { + type: typeof DocumentsActionTypes.CANCEL_OPERATION; +}; + +export type DebouncingLoadChangedAction = { + type: typeof DocumentsActionTypes.DEBOUNCING_LOAD_CHANGED; + debouncingLoad: boolean; +}; + +export type DocumentRemovedAction = { + type: typeof DocumentsActionTypes.DOCUMENT_REMOVED; + index: number; +}; + +export type DocumentReplacedAction = { + type: typeof DocumentsActionTypes.DOCUMENT_REPLACED; + index: number; + doc: Document; +}; + +export type DocsPerPageChangedAction = { + type: typeof DocumentsActionTypes.DOCS_PER_PAGE_CHANGED; + docsPerPage: number; +}; + +export type SeedDocumentsTestStateAction = { + type: typeof DocumentsActionTypes.SEED_DOCUMENTS_TEST_STATE; + state: Partial; +}; + +export type DocumentsActions = + | RefreshStartedAction + | RefreshSuccessAction + | RefreshErrorAction + | CountFetchedAction + | CountFinishedAction + | CollectionScanResultAction + | GetPageStartedAction + | GetPageSuccessAction + | GetPageErrorAction + | CancelOperationAction + | DebouncingLoadChangedAction + | DocumentRemovedAction + | DocumentReplacedAction + | DocsPerPageChangedAction + | SeedDocumentsTestStateAction; + +/** + * Test-only helper to seed partial documents-slice state. Production code + * should never dispatch this — it's a convenience for unit tests that previously + * relied on direct Reflux state mutation. + */ +export function seedDocumentsTestState( + state: Partial +): SeedDocumentsTestStateAction { + return { type: DocumentsActionTypes.SEED_DOCUMENTS_TEST_STATE, state }; +} + +export function createDocumentsReducer( + initialState: DocumentsState +): Reducer { + return (state = initialState, action) => { + if (isAction(action, DocumentsActionTypes.REFRESH_STARTED)) { + return { + ...state, + status: DOCUMENTS_STATUS_FETCHING, + abortController: action.abortController, + error: null, + count: null, + loadingCount: true, + lastCountRunMaxTimeMS: action.lastCountRunMaxTimeMS, + }; + } + if (isAction(action, DocumentsActionTypes.REFRESH_SUCCESS)) { + return { + ...state, + status: action.status, + error: null, + docs: action.docs, + page: 0, + start: action.docs.length > 0 ? 1 : 0, + end: action.docs.length, + shardKeys: action.shardKeys, + abortController: null, + resultId: resultId(), + }; + } + if (isAction(action, DocumentsActionTypes.REFRESH_ERROR)) { + return { + ...state, + status: DOCUMENTS_STATUS_ERROR, + error: action.error, + abortController: null, + resultId: resultId(), + }; + } + if (isAction(action, DocumentsActionTypes.COUNT_FETCHED)) { + return { ...state, count: action.count, loadingCount: false }; + } + if (isAction(action, DocumentsActionTypes.COUNT_FINISHED)) { + return { ...state, loadingCount: false }; + } + if (isAction(action, DocumentsActionTypes.COLLECTION_SCAN_RESULT)) { + return { ...state, isCollectionScan: action.isCollectionScan }; + } + if (isAction(action, DocumentsActionTypes.GET_PAGE_STARTED)) { + return { + ...state, + status: DOCUMENTS_STATUS_FETCHING, + abortController: action.abortController, + error: null, + }; + } + if (isAction(action, DocumentsActionTypes.GET_PAGE_SUCCESS)) { + return { + ...state, + status: DOCUMENTS_STATUS_FETCHED_PAGINATION, + error: null, + docs: action.docs, + start: action.start, + end: action.end, + page: action.page, + resultId: resultId(), + abortController: null, + }; + } + if (isAction(action, DocumentsActionTypes.GET_PAGE_ERROR)) { + return { + ...state, + status: DOCUMENTS_STATUS_ERROR, + error: action.error, + docs: [], + start: action.start, + end: action.end, + page: action.page, + resultId: resultId(), + abortController: null, + }; + } + if (isAction(action, DocumentsActionTypes.CANCEL_OPERATION)) { + return { ...state, abortController: null }; + } + if (isAction(action, DocumentsActionTypes.DEBOUNCING_LOAD_CHANGED)) { + return { ...state, debouncingLoad: action.debouncingLoad }; + } + if (isAction(action, DocumentsActionTypes.DOCUMENT_REMOVED)) { + const newDocs = state.docs ? [...state.docs] : state.docs; + newDocs?.splice(action.index, 1); + return { + ...state, + docs: newDocs, + count: state.count === null ? null : state.count - 1, + end: Math.max(state.end - 1, 0), + }; + } + if (isAction(action, DocumentsActionTypes.DOCUMENT_REPLACED)) { + const newDocs = state.docs ? [...state.docs] : state.docs; + newDocs?.splice(action.index, 1, action.doc); + return { ...state, docs: newDocs }; + } + if (isAction(action, DocumentsActionTypes.DOCS_PER_PAGE_CHANGED)) { + return { ...state, docsPerPage: action.docsPerPage }; + } + if (isAction(action, DocumentsActionTypes.SEED_DOCUMENTS_TEST_STATE)) { + return { ...state, ...action.state }; + } + return state; + }; +} + +function modeForTelemetry(view: string): 'list' | 'json' | 'table' { + return view.toLowerCase() as 'list' | 'json' | 'table'; +} + +function findDocumentIndex(docs: Document[] | null, doc: Document) { + return findIndex(docs ?? [], (d) => doc.getStringId() === d.getStringId()); +} + +/** + * Detect if it is safe to perform the count query optimisation where we + * specify the _id_ index as the hint. + */ +function isCountHintSafe( + query: { filter?: unknown }, + isTimeSeries: boolean +): boolean { + if (isTimeSeries) { + // timeseries collections don't have the _id_ filter, so we can't use the hint speedup + return false; + } + if (query.filter && Object.keys(query.filter).length) { + // we can't safely use the hint speedup if there's a filter + return false; + } + return true; +} + +/** + * Checks if the initial query was not modified. + */ +function isInitialQuery(query: Query = {}): boolean { + return ( + isEmpty(query.filter) && isEmpty(query.project) && isEmpty(query.collation) + ); +} + +function debounceLoading( + dispatch: (action: DebouncingLoadChangedAction) => void +) { + dispatch({ + type: DocumentsActionTypes.DEBOUNCING_LOAD_CHANGED, + debouncingLoad: true, + }); + const debouncePromise = new Promise((resolve) => { + setTimeout(resolve, 200); // 200ms should feel about instant + }); + + let cancelDebounceLoad!: () => void; + const loadPromise = new Promise((resolve) => { + cancelDebounceLoad = resolve; + }); + + void Promise.race([debouncePromise, loadPromise]).then(() => { + dispatch({ + type: DocumentsActionTypes.DEBOUNCING_LOAD_CHANGED, + debouncingLoad: false, + }); + }); + + return cancelDebounceLoad; +} + +async function _verifyUpdateAllowed( + dataService: { + getCSFLEMode?: () => string; + isUpdateAllowed?: (...args: any[]) => Promise; + }, + ns: string, + doc: Document +): Promise { + if (dataService.getCSFLEMode?.() === 'enabled') { + const isAllowed = await dataService.isUpdateAllowed?.( + ns, + doc.generateOriginalObject() + ); + if (!isAllowed) { + doc.onUpdateError( + new Error( + 'Update blocked as it could unintentionally write unencrypted data due to a missing or incomplete schema.' + ) + ); + return false; + } + } + return true; +} + +export function refreshDocuments( + onApply = false +): CrudThunkAction, DocumentsActions> { + return async ( + dispatch, + getState, + { + dataService, + preferences, + logger, + track, + connectionInfoRef, + connectionScopedAppRegistry, + fieldStoreService, + queryBar, + crudOptions, + } + ) => { + if (dataService && !dataService.isConnected()) { + logger.log.warn( + mongoLogId(1_001_000_072), + 'Documents', + 'Trying to refresh documents but dataService is disconnected' + ); + return; + } + + const state = getState(); + const { ns, status, docsPerPage } = state.documents; + const query = queryBar.getLastAppliedQuery('crud'); + + if (status === DOCUMENTS_STATUS_FETCHING) { + return; + } + + if (onApply) { + const { isTimeSeries, isReadonly } = state.collectionMeta; + const { defaultSortOrder } = preferences.getPreferences(); + track( + 'Query Executed', + { + has_filter: !!query.filter && Object.keys(query.filter).length > 0, + has_projection: + !!query.project && Object.keys(query.project).length > 0, + has_skip: (query.skip ?? 0) > 0, + has_sort: !!query.sort && Object.keys(query.sort).length > 0, + default_sort: !defaultSortOrder + ? 'none' + : /_id/.test(defaultSortOrder) + ? '_id' + : 'natural', + has_limit: (query.limit ?? 0) > 0, + has_collation: !!query.collation, + changed_maxtimems: query.maxTimeMS !== DEFAULT_INITIAL_MAX_TIME_MS, + collection_type: isTimeSeries + ? 'time-series' + : isReadonly + ? 'readonly' + : 'collection', + used_regex: objectContainsRegularExpression(query.filter ?? {}), + mode: modeForTelemetry(state.view.view), + }, + connectionInfoRef.current + ); + } + + // pass the signal so that the queries can close their own cursors and + // reject their promises + const abortController = new AbortController(); + const signal = abortController.signal; + + const fetchShardingKeysOptions = { + maxTimeMS: capMaxTimeMSAtPreferenceLimit(preferences, query.maxTimeMS), + signal, + }; + + const countOptions: Parameters[4] = { + skip: query.skip, + maxTimeMS: capMaxTimeMSAtPreferenceLimit( + preferences, + (query.maxTimeMS ?? 0) > COUNT_MAX_TIME_MS_CAP + ? COUNT_MAX_TIME_MS_CAP + : query.maxTimeMS + ), + signal, + ...(query.hint + ? { + hint: query.hint, + } + : {}), + }; + + if ( + !countOptions.hint && + isCountHintSafe(query, state.collectionMeta.isTimeSeries) + ) { + countOptions.hint = '_id_'; + } + + const isView = crudOptions.isReadonly && crudOptions.sourceName; + // Default sort options that we allow to choose from in settings will have a + // massive negative effect on the query performance for views and view-like + // collections in all cases. To avoid that, we're not applying default sort + // for those + const allowDefaultSort = !isView && !crudOptions.isTimeSeries; + + const { defaultSortOrder } = preferences.getPreferences(); + + let sort = query.sort; + + if (!sort && allowDefaultSort && defaultSortOrder) { + sort = validate('sort', defaultSortOrder); + } + + const findOptions = { + sort: sort ?? undefined, + projection: query.project ?? undefined, + skip: query.skip, + limit: docsPerPage, + collation: query.collation as CollationOptions, + hint: query.hint ?? undefined, + maxTimeMS: capMaxTimeMSAtPreferenceLimit(preferences, query.maxTimeMS), + promoteValues: false, + bsonRegExp: true, + }; + + // only set limit if it's > 0, read-only views cannot handle 0 limit. + if (query.limit && query.limit > 0) { + countOptions.limit = query.limit; + findOptions.limit = Math.min(docsPerPage, query.limit); + } + + logger.log.info( + mongoLogId(1_001_000_073), + 'Documents', + 'Refreshing documents', + { + ns, + withFilter: !isEmpty(query.filter), + findOptions, + countOptions, + } + ); + + // Only check if index was used if query filter or sort is not empty + if (!isEmpty(query.filter) || !isEmpty(query.sort)) { + void dataService + .explainFind(ns, query.filter ?? {}, findOptions, { + explainVerbosity: 'queryPlanner', + abortSignal: signal, + }) + .then((rawExplainPlan) => { + const explainPlan = new ExplainPlan(rawExplainPlan as Stage); + dispatch({ + type: DocumentsActionTypes.COLLECTION_SCAN_RESULT, + isCollectionScan: explainPlan.isCollectionScan, + }); + }) + .catch(() => { + // We are only fetching this to get information about index usage for + // insight badge, if this fails for any reason, server, cancel, or + // error parsing explain, we don't care and ignore it + }); + } else { + dispatch({ + type: DocumentsActionTypes.COLLECTION_SCAN_RESULT, + isCollectionScan: false, + }); + } + + // Don't wait for the count to finish. Set the result asynchronously. + void countDocuments( + dataService, + preferences, + ns, + query.filter ?? {}, + countOptions, + (err: any) => { + logger.log.warn( + mongoLogId(1_001_000_288), + 'Documents', + 'Failed to count documents', + err + ); + } + ) + .then((count) => { + dispatch({ + type: DocumentsActionTypes.COUNT_FETCHED, + count, + }); + }) + .catch((err) => { + // countDocuments already swallows all db errors and returns null. The + // only known error it can throw is AbortError. If + // something new does appear we probably shouldn't swallow it. + if (!dataService.isCancelError(err)) { + throw err; + } + dispatch({ type: DocumentsActionTypes.COUNT_FINISHED }); + }); + + const promises = [ + fetchShardingKeys( + dataService, + ns, + fetchShardingKeysOptions, + (err: Error) => { + logger.log.warn( + mongoLogId(1_001_000_075), + 'Documents', + 'Failed to fetch sharding keys', + err + ); + } + ), + fetchDocuments( + dataService, + track, + state.collectionMeta.version, + state.collectionMeta.isDataLake, + ns, + query.filter ?? {}, + findOptions, + { + abortSignal: signal, + } + ), + ] as const; + + dispatch({ + type: DocumentsActionTypes.REFRESH_STARTED, + abortController, + lastCountRunMaxTimeMS: countOptions.maxTimeMS!, + }); + + // don't start showing the loading indicator and cancel button immediately + const cancelDebounceLoad = debounceLoading(dispatch); + + try { + const [shardKeys, docs] = await Promise.all(promises); + dispatch({ + type: DocumentsActionTypes.REFRESH_SUCCESS, + docs, + shardKeys, + status: isInitialQuery(query) + ? DOCUMENTS_STATUS_FETCHED_INITIAL + : DOCUMENTS_STATUS_FETCHED_CUSTOM, + }); + + void fieldStoreService.updateFieldsFromDocuments(ns, [ + docs[0]?.generateObject(), + ]); + + // Notify the instance store to refresh collection stats so the tab + // header count stays in sync with the pagination count. + connectionScopedAppRegistry.emit('documents-refreshed', { ns }); + } catch (error) { + logger.log.error( + mongoLogId(1_001_000_074), + 'Documents', + 'Failed to refresh documents', + error + ); + dispatch({ + type: DocumentsActionTypes.REFRESH_ERROR, + error: error as Error, + }); + } + + cancelDebounceLoad(); + }; +} + +export function getPage( + page: number +): CrudThunkAction, DocumentsActions> { + return async ( + dispatch, + getState, + { dataService, preferences, track, fieldStoreService, queryBar } + ) => { + const state = getState(); + const { ns, status, docsPerPage } = state.documents; + + if (page < 0) return; + if (status === DOCUMENTS_STATUS_FETCHING) return; + + const { + filter, + limit, + sort, + hint, + project: projection, + collation, + maxTimeMS, + skip: _skip = 0, + } = queryBar.getLastAppliedQuery('crud'); + + const skip = _skip + page * docsPerPage; + + let nextPageCount = docsPerPage; + if (limit) { + const remaining = limit - skip; + if (remaining < 1) return; + if (remaining < nextPageCount) nextPageCount = remaining; + } + + const abortController = new AbortController(); + const signal = abortController.signal; + + const opts = { + skip, + limit: nextPageCount, + hint: hint ?? undefined, + sort: sort ?? undefined, + projection: projection ?? undefined, + collation: collation as CollationOptions, + maxTimeMS: capMaxTimeMSAtPreferenceLimit(preferences, maxTimeMS), + promoteValues: false, + bsonRegExp: true, + }; + + dispatch({ + type: DocumentsActionTypes.GET_PAGE_STARTED, + abortController, + }); + + const cancelDebounceLoad = debounceLoading(dispatch); + + let documents: HadronDocument[]; + try { + documents = await fetchDocuments( + dataService, + track, + state.collectionMeta.version, + state.collectionMeta.isDataLake, + ns, + filter ?? {}, + opts, + { abortSignal: signal } + ); + } catch (error) { + dispatch({ + type: DocumentsActionTypes.GET_PAGE_ERROR, + error: error as Error, + page, + start: 0, + end: skip, + }); + cancelDebounceLoad(); + return; + } + + const length = documents.length; + dispatch({ + type: DocumentsActionTypes.GET_PAGE_SUCCESS, + docs: documents, + // making sure we don't set start to 1 if length is 0 + start: length === 0 ? 0 : skip + 1, + end: skip + length, + page, + }); + void fieldStoreService.updateFieldsFromDocuments(ns, [ + documents[0]?.generateObject(), + ]); + + cancelDebounceLoad(); + }; +} + +export function cancelOperation(): CrudThunkAction< + void, + CancelOperationAction +> { + return (dispatch, getState) => { + getState().documents.abortController?.abort( + new Error('This operation was aborted') + ); + dispatch({ type: DocumentsActionTypes.CANCEL_OPERATION }); + }; +} + +export function removeDocument( + doc: Document +): CrudThunkAction, DocumentsActions> { + return async ( + dispatch, + getState, + { + dataService, + localAppRegistry, + connectionScopedAppRegistry, + track, + connectionInfoRef, + } + ) => { + track( + 'Document Deleted', + { mode: modeForTelemetry(getState().view.view) }, + connectionInfoRef.current + ); + const id = doc.getId(); + if (id === undefined) { + doc.onRemoveError(DELETE_ERROR); + return; + } + doc.onRemoveStart(); + try { + const ns = getState().documents.ns; + await dataService.deleteOne(ns, { _id: id as any }); + doc.onRemoveSuccess(); + const payload = { view: getState().view.view, ns }; + localAppRegistry.emit('document-deleted', payload); + connectionScopedAppRegistry.emit('document-deleted', payload); + const index = findDocumentIndex(getState().documents.docs, doc); + dispatch({ type: DocumentsActionTypes.DOCUMENT_REMOVED, index }); + } catch (error) { + doc.onRemoveError(error as Error); + } + }; +} + +export function updateDocument( + doc: Document +): CrudThunkAction, DocumentReplacedAction> { + return async ( + dispatch, + getState, + { dataService, logger, track, connectionInfoRef } + ) => { + track( + 'Document Updated', + { mode: modeForTelemetry(getState().view.view) }, + connectionInfoRef.current + ); + try { + doc.onUpdateStart(); + const { query, updateDoc } = + doc.generateUpdateUnlessChangedInBackgroundQuery({ + alwaysIncludeKeys: Object.keys( + getState().documents.shardKeys || {} + ).map((key) => key.split('.')), + }); + logger.debug('Performing findOneAndUpdate', { query, updateDoc }); + + if (Object.keys(updateDoc).length === 0) { + doc.onUpdateError(EMPTY_UPDATE_ERROR); + return; + } + + const ns = getState().documents.ns; + if (!(await _verifyUpdateAllowed(dataService, ns, doc))) { + return; + } + const [error, d] = await findAndModifyWithFLEFallback( + dataService, + ns, + query, + updateDoc, + 'update' + ); + + if (error) { + if ( + error.codeName === 'InvalidPipelineOperator' && + error.message.match(/\$[gs]etField/) + ) { + const nbsp = ' '; + error.message += ` (Updating fields whose names contain dots or start with $ require MongoDB${nbsp}5.0 or above.)`; + } + doc.onUpdateError(error as Error); + } else if (d) { + doc.onUpdateSuccess(d); + const index = findDocumentIndex(getState().documents.docs, doc); + dispatch({ + type: DocumentsActionTypes.DOCUMENT_REPLACED, + index, + doc: new HadronDocument(d), + }); + } else { + doc.onUpdateBlocked(); + } + } catch (err: any) { + doc.onUpdateError( + new Error( + `An error occured when attempting to update the document: ${String( + err.message + )}` + ) + ); + } + }; +} + +export function replaceDocument( + doc: Document +): CrudThunkAction, DocumentReplacedAction> { + return async ( + dispatch, + getState, + { dataService, logger, track, connectionInfoRef } + ) => { + track( + 'Document Updated', + { mode: modeForTelemetry(getState().view.view) }, + connectionInfoRef.current + ); + try { + doc.onUpdateStart(); + + const ns = getState().documents.ns; + if (!(await _verifyUpdateAllowed(dataService, ns, doc))) { + return; + } + + const object = doc.generateObject(); + const queryKeyInclusionOptions: Mutable< + Parameters< + typeof doc.getQueryForOriginalKeysAndValuesForSpecifiedKeys + >[0] + > = { + alwaysIncludeKeys: [ + ['_id'], + // '.' in shard keys means nested doc + ...Object.keys(getState().documents.shardKeys || {}).map((key) => + key.split('.') + ), + ], + }; + + if (dataService.getCSFLEMode?.() === 'enabled') { + const knownSchemaForCollection = + await dataService.knownSchemaForCollection(ns); + + // The find/query portion will typically exclude encrypted fields, + // because those cannot be queried to make sure that the original + // value matches the current one; however, if we know that the + // field is equality-searchable, we can (and should) still include it. + queryKeyInclusionOptions.includableEncryptedKeys = + knownSchemaForCollection.encryptedFields.equalityQueryableEncryptedFields; + + if ( + object.__safeContent__ && + isEqualSafeContent( + object.__safeContent__, + doc.generateOriginalObject().__safeContent__ + ) && + knownSchemaForCollection.hasSchema + ) { + // SERVER-66662 blocks writes of __safeContent__ for queryable-encryption-enabled + // collections. We remove it unless it was edited, in which case we assume that the + // user really knows what they are doing. + delete object.__safeContent__; + } + } + + const query = doc.getQueryForOriginalKeysAndValuesForSpecifiedKeys( + queryKeyInclusionOptions + ); + logger.debug('Performing findOneAndReplace', { query, object }); + + const [error, d] = await findAndModifyWithFLEFallback( + dataService, + ns, + query, + object, + 'replace' + ); + if (error) { + doc.onUpdateError(error as Error); + } else { + doc.onUpdateSuccess(d); + const index = findDocumentIndex(getState().documents.docs, doc); + dispatch({ + type: DocumentsActionTypes.DOCUMENT_REPLACED, + index, + doc: new HadronDocument(d), + }); + } + } catch (err: any) { + doc.onUpdateError( + new Error( + `An error occured when attempting to update the document: ${String( + err.message + )}` + ) + ); + } + }; +} + +function isEqualSafeContent(a: unknown, b: unknown): boolean { + // Lightweight reference + JSON equality is enough for the safeContent + // comparison used by replaceDocument. Avoids dragging in lodash isEqual + // for that single call site. + if (a === b) return true; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + +export function copyToClipboard(doc: Document): CrudThunkAction { + return (dispatch, getState, { track, connectionInfoRef }) => { + track( + 'Document Copied', + { mode: modeForTelemetry(getState().view.view) }, + connectionInfoRef.current + ); + const documentEJSON = doc.toEJSON(); + // eslint-disable-next-line no-undef + void navigator.clipboard.writeText(documentEJSON); + }; +} + +export function updateMaxDocumentsPerPage( + docsPerPage: number +): CrudThunkAction { + return (dispatch, getState) => { + const previousDocsPerPage = getState().documents.docsPerPage; + localStorage.setItem(MAX_DOCS_PER_PAGE_STORAGE_KEY, String(docsPerPage)); + dispatch({ + type: DocumentsActionTypes.DOCS_PER_PAGE_CHANGED, + docsPerPage, + }); + if (previousDocsPerPage !== docsPerPage) { + void dispatch(refreshDocuments()); + } + }; +} + +export function openCreateIndexModal(): CrudThunkAction { + return (dispatch, getState, { localAppRegistry, queryBar }) => { + localAppRegistry.emit('open-create-index-modal', { + query: EJSON.serialize(queryBar.getLastAppliedQuery('crud')?.filter), + }); + }; +} + +export function openCreateSearchIndexModal(): CrudThunkAction { + return (dispatch, getState, { localAppRegistry }) => { + localAppRegistry.emit('open-create-search-index-modal'); + }; +} + +export function openImportFileDialog(): CrudThunkAction { + return (dispatch, getState, { connectionScopedAppRegistry }) => { + connectionScopedAppRegistry.emit('open-import', { + namespace: getState().documents.ns, + origin: 'empty-state', + }); + }; +} + +export function openExportFileDialog( + exportFullCollection?: boolean +): CrudThunkAction { + return (dispatch, getState, { connectionScopedAppRegistry, queryBar }) => { + const { filter, project, collation, limit, skip, sort } = + queryBar.getLastAppliedQuery('crud'); + + connectionScopedAppRegistry.emit('open-export', { + namespace: getState().documents.ns, + query: { filter, project, collation, limit, skip, sort }, + exportFullCollection, + origin: 'crud-toolbar', + }); + }; +} + +export function openQueryExportToLanguageDialog(): CrudThunkAction< + void, + never +> { + return (dispatch, getState, { localAppRegistry, queryBar }) => { + const query = queryBar.getLastAppliedQuery('crud'); + localAppRegistry.emit( + 'open-query-export-to-language', + { + filter: toJSString(query.filter) || '{}', + project: query.project ? toJSString(query.project) : undefined, + sort: query.sort ? toJSString(query.sort) : undefined, + collation: query.collation ? toJSString(query.collation) : undefined, + skip: query.skip ? String(query.skip) : undefined, + limit: query.limit ? String(query.limit) : undefined, + }, + 'Query' + ); + }; +} + +export function openDeleteQueryExportToLanguageDialog(): CrudThunkAction< + void, + never +> { + return (dispatch, getState, { localAppRegistry, queryBar }) => { + const { filter = {} } = queryBar.getLastAppliedQuery('crud'); + localAppRegistry.emit( + 'open-query-export-to-language', + { + filter: toJSString(filter) || '{}', + }, + 'Delete Query' + ); + }; +} diff --git a/packages/compass-crud/src/stores/fetch-documents.ts b/packages/compass-crud/src/stores/fetch-documents.ts new file mode 100644 index 00000000000..b3c071cfda6 --- /dev/null +++ b/packages/compass-crud/src/stores/fetch-documents.ts @@ -0,0 +1,150 @@ +import semver from 'semver'; +import { isEmpty } from 'lodash'; +import HadronDocument from 'hadron-document'; +import type { TrackFunction } from '@mongodb-js/compass-telemetry'; +import type { DataService } from '../utils/data-service'; +import type { BSONArray, BSONObject } from './insert'; + +export const fetchDocuments: ( + dataService: DataService, + track: TrackFunction, + serverVersion: string, + isDataLake: boolean, + ...args: Parameters +) => Promise = async ( + dataService: DataService, + track: TrackFunction, + serverVersion, + isDataLake, + ns, + filter, + options, + executionOptions +) => { + const canCalculateDocSize = + // $bsonSize is only supported for mongodb >= 4.4.0 + semver.gte(serverVersion, '4.4.0') && + // ADF doesn't support $bsonSize + !isDataLake && + // Accessing $$ROOT is not possible with CSFLE + ['disabled', 'unavailable', undefined].includes( + dataService?.getCSFLEMode?.() + ) && + // User provided their own projection, we can handle this in some cases, but + // it's hard to get right, so we will just skip this case + isEmpty(options?.projection); + + const modifiedOptions = { + ...options, + projection: canCalculateDocSize + ? { _id: 0, __doc: '$$ROOT', __size: { $bsonSize: '$$ROOT' } } + : options?.projection, + }; + + try { + let uuidSubtype3Count = 0; + let uuidSubtype4Count = 0; + const docs = ( + await dataService.find(ns, filter, modifiedOptions, executionOptions) + ).map((doc) => { + const { __doc, __size, ...rest } = doc; + let hadronDoc: HadronDocument; + if (__doc && __size && Object.keys(rest).length === 0) { + hadronDoc = new HadronDocument(__doc); + hadronDoc.size = Number(__size); + } else { + hadronDoc = new HadronDocument(doc); + } + const { subtype3Count, subtype4Count } = hadronDoc.findUUIDs(); + uuidSubtype3Count += subtype3Count; + uuidSubtype4Count += subtype4Count; + return hadronDoc; + }); + if (uuidSubtype3Count > 0) { + track('UUID Encountered', { subtype: 3, count: uuidSubtype3Count }); + } + if (uuidSubtype4Count > 0) { + track('UUID Encountered', { subtype: 4, count: uuidSubtype4Count }); + } + return docs; + } catch (err) { + // We are handling all the cases where the size calculating projection might + // not work, but just in case we run into some other environment or use-case + // that we haven't anticipated, we will try re-running query without the + // modified projection once more before failing again if this didn't work + if (canCalculateDocSize && (err as Error).name === 'MongoServerError') { + return ( + await dataService.find(ns, filter, options, executionOptions) + ).map((doc) => { + return new HadronDocument(doc); + }); + } + + throw err; + } +}; + +type ErrorOrResult = + | [ + error: { message: string; code?: number; codeName?: string }, + result: undefined + ] + | [error: undefined | null, result: BSONObject]; + +export async function findAndModifyWithFLEFallback( + ds: DataService, + ns: string, + query: BSONObject, + object: { $set?: BSONObject; $unset?: BSONObject } | BSONObject | BSONArray, + modificationType: 'update' | 'replace' +): Promise { + const findOneAndModifyMethod = + modificationType === 'update' ? 'findOneAndUpdate' : 'findOneAndReplace'; + let error: (Error & { codeName?: string; code?: any }) | undefined; + + try { + return [ + undefined, + await ds[findOneAndModifyMethod](ns, query, object, { + returnDocument: 'after', + promoteValues: false, + }), + ] as ErrorOrResult; + } catch (e) { + error = e as Error; + } + + if ( + error.codeName === 'ShardKeyNotFound' || + +(error?.code ?? 0) === 63714_02 // 6371402 is "'findAndModify with encryption only supports new: false'" + ) { + const modifyOneMethod = + modificationType === 'update' ? 'updateOne' : 'replaceOne'; + + try { + await ds[modifyOneMethod](ns, query, object); + } catch (e) { + // Return the modifyOneMethod error here + // since we already know the original error from findOneAndModifyMethod + // and want to know what went wrong with the fallback method, + // e.g. return the `Found indexed encrypted fields but could not find __safeContent__` error. + return [e, undefined] as ErrorOrResult; + } + + try { + const docs = await ds.find( + ns, + { _id: query._id as any }, + { promoteValues: false } + ); + return [undefined, docs[0]] as ErrorOrResult; + } catch { + /* fallthrough */ + } + } + + // Race condition -- most likely, somebody else + // deleted the document between the findAndModify command + // and the find command. Just return the original error. + return [error, undefined] as ErrorOrResult; +} diff --git a/packages/compass-crud/src/stores/grid-store-context.ts b/packages/compass-crud/src/stores/grid-store-context.ts new file mode 100644 index 00000000000..aa46a1367ee --- /dev/null +++ b/packages/compass-crud/src/stores/grid-store-context.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; +import type { GridStore } from './grid-store'; + +/** + * The grid store is still backed by Reflux, separately from the redux store. + * It is provided to components via this context so that they don't need to + * receive a reference to the redux store object itself. + */ +export const GridStoreContext = createContext(null); + +export function useGridStore(): GridStore { + const store = useContext(GridStoreContext); + if (!store) { + throw new Error( + 'GridStoreContext is missing — make sure the component is rendered inside the CompassDocuments plugin.' + ); + } + return store; +} diff --git a/packages/compass-crud/src/stores/insert.spec.ts b/packages/compass-crud/src/stores/insert.spec.ts new file mode 100644 index 00000000000..9554f1e19cf --- /dev/null +++ b/packages/compass-crud/src/stores/insert.spec.ts @@ -0,0 +1,135 @@ +import { expect } from 'chai'; +import HadronDocument, { Document } from 'hadron-document'; +import { + INITIAL_INSERT_STATE, + InsertActionTypes, + insertReducer, + type InsertState, +} from './insert'; + +describe('insertReducer', function () { + describe('INSERT_DOCUMENT_ERROR', function () { + it('preserves the existing doc, jsonDoc and jsonView when the action does not override them', function () { + const doc = new HadronDocument({ status: 'testing' }); + const state: InsertState = { + ...INITIAL_INSERT_STATE, + doc, + jsonDoc: '{"status":"testing"}', + jsonView: false, + isOpen: true, + }; + + const nextState = insertReducer(state, { + type: InsertActionTypes.INSERT_DOCUMENT_ERROR, + error: { message: 'failed validation' }, + }); + + expect(nextState.doc).to.equal(doc); + expect(nextState.jsonDoc).to.equal('{"status":"testing"}'); + expect(nextState.jsonView).to.equal(false); + expect(nextState.isOpen).to.equal(true); + expect(nextState.mode).to.equal('error'); + expect(nextState.error).to.deep.equal({ message: 'failed validation' }); + }); + + it('overrides doc, jsonDoc and jsonView when the action provides them', function () { + const state: InsertState = { + ...INITIAL_INSERT_STATE, + doc: new HadronDocument({ status: 'testing' }), + jsonDoc: '{"status":"testing"}', + jsonView: false, + }; + + const overrideDoc = new Document({}); + const nextState = insertReducer(state, { + type: InsertActionTypes.INSERT_DOCUMENT_ERROR, + error: { message: 'invalid bson' }, + doc: overrideDoc, + jsonDoc: '{}', + jsonView: true, + }); + + expect(nextState.doc).to.equal(overrideDoc); + expect(nextState.jsonDoc).to.equal('{}'); + expect(nextState.jsonView).to.equal(true); + }); + }); + + describe('TOGGLE_INSERT_DOCUMENT', function () { + it('switching to List parses the current jsonDoc into a doc', function () { + const state: InsertState = { + ...INITIAL_INSERT_STATE, + doc: new Document({}), + jsonDoc: '{"status":"testing"}', + jsonView: true, + }; + + const nextState = insertReducer(state, { + type: InsertActionTypes.TOGGLE_INSERT_DOCUMENT, + view: 'List', + }); + + expect(nextState.jsonView).to.equal(false); + expect(nextState.doc?.generateObject()).to.deep.equal({ + status: 'testing', + }); + expect(nextState.jsonDoc).to.equal('{"status":"testing"}'); + }); + + it('switching to List keeps the existing doc when jsonDoc is empty', function () { + const doc = new HadronDocument({ status: 'testing' }); + const state: InsertState = { + ...INITIAL_INSERT_STATE, + doc, + jsonDoc: '', + jsonView: true, + }; + + const nextState = insertReducer(state, { + type: InsertActionTypes.TOGGLE_INSERT_DOCUMENT, + view: 'List', + }); + + expect(nextState.doc).to.equal(doc); + expect(nextState.jsonView).to.equal(false); + }); + + it('switching to JSON serializes the current doc', function () { + const doc = new HadronDocument({ status: 'testing' }); + const state: InsertState = { + ...INITIAL_INSERT_STATE, + doc, + jsonDoc: null, + jsonView: false, + }; + + const nextState = insertReducer(state, { + type: InsertActionTypes.TOGGLE_INSERT_DOCUMENT, + view: 'JSON', + }); + + expect(nextState.jsonView).to.equal(true); + expect(nextState.jsonDoc).to.equal(doc.toEJSON()); + }); + }); + + describe('UPDATE_JSON_DOC', function () { + it('sets the jsonDoc and resets doc to an empty document', function () { + const state: InsertState = { + ...INITIAL_INSERT_STATE, + doc: new HadronDocument({ status: 'testing' }), + jsonDoc: null, + jsonView: false, + }; + + const nextState = insertReducer(state, { + type: InsertActionTypes.UPDATE_JSON_DOC, + jsonDoc: '{"foo":"bar"}', + }); + + expect(nextState.jsonDoc).to.equal('{"foo":"bar"}'); + expect(nextState.jsonView).to.equal(true); + expect(nextState.doc?.generateObject()).to.deep.equal({}); + }); + }); +}); diff --git a/packages/compass-crud/src/stores/insert.ts b/packages/compass-crud/src/stores/insert.ts new file mode 100644 index 00000000000..56bb546d801 --- /dev/null +++ b/packages/compass-crud/src/stores/insert.ts @@ -0,0 +1,440 @@ +import type { Reducer } from 'redux'; +import HadronDocument, { Document } from 'hadron-document'; +import type { TypeCastMap } from 'hadron-type-checker'; +import type { MongoServerError } from 'mongodb'; +import { isAction } from './util'; +import type { CrudThunkAction } from './reducer'; +import { refreshDocuments } from './documents'; +import type { DocumentView } from './view'; + +export type BSONObject = TypeCastMap['Object']; +export type BSONArray = TypeCastMap['Array']; + +export type InsertCSFLEState = { + state: + | 'none' + | 'no-known-schema' + | 'incomplete-schema-for-cloned-doc' + | 'has-known-schema' + | 'csfle-disabled'; + encryptedFields?: string[]; +}; + +export type WriteError = { + message: string; + info?: Record; +}; + +export type InsertState = { + doc: null | Document; + jsonDoc: null | string; + error?: WriteError; + csfleState: InsertCSFLEState; + mode: 'modifying' | 'error'; + jsonView: boolean; + isOpen: boolean; + isCommentNeeded: boolean; +}; + +const MODIFYING = 'modifying'; +const ERROR = 'error'; + +export const INITIAL_INSERT_STATE: InsertState = { + doc: null, + jsonDoc: null, + csfleState: { state: 'none' }, + mode: MODIFYING, + jsonView: false, + isOpen: false, + isCommentNeeded: true, +}; + +export const InsertActionTypes = { + OPEN_INSERT_DOCUMENT_DIALOG: 'crud/insert/OPEN_INSERT_DOCUMENT_DIALOG', + CLOSE_INSERT_DOCUMENT_DIALOG: 'crud/insert/CLOSE_INSERT_DOCUMENT_DIALOG', + TOGGLE_INSERT_DOCUMENT: 'crud/insert/TOGGLE_INSERT_DOCUMENT', + TOGGLE_INSERT_DOCUMENT_VIEW: 'crud/insert/TOGGLE_INSERT_DOCUMENT_VIEW', + UPDATE_JSON_DOC: 'crud/insert/UPDATE_JSON_DOC', + UPDATE_COMMENT: 'crud/insert/UPDATE_COMMENT', + INSERT_DOCUMENT_ERROR: 'crud/insert/INSERT_DOCUMENT_ERROR', +} as const; + +export type OpenInsertDocumentDialogAction = { + type: typeof InsertActionTypes.OPEN_INSERT_DOCUMENT_DIALOG; + doc: Document; + jsonDoc: string; + csfleState: InsertCSFLEState; +}; + +export type CloseInsertDocumentDialogAction = { + type: typeof InsertActionTypes.CLOSE_INSERT_DOCUMENT_DIALOG; +}; + +export type ToggleInsertDocumentAction = { + type: typeof InsertActionTypes.TOGGLE_INSERT_DOCUMENT; + view: DocumentView; +}; + +export type ToggleInsertDocumentViewAction = { + type: typeof InsertActionTypes.TOGGLE_INSERT_DOCUMENT_VIEW; + jsonView: boolean; +}; + +export type UpdateJsonDocAction = { + type: typeof InsertActionTypes.UPDATE_JSON_DOC; + jsonDoc: string | null; +}; + +export type UpdateCommentAction = { + type: typeof InsertActionTypes.UPDATE_COMMENT; + isCommentNeeded: boolean; +}; + +export type InsertDocumentErrorAction = { + type: typeof InsertActionTypes.INSERT_DOCUMENT_ERROR; + error: WriteError; + /** Override which doc is shown in the dialog after the error. */ + doc?: Document; + jsonDoc?: string | null; + jsonView?: boolean; +}; + +export type InsertActions = + | OpenInsertDocumentDialogAction + | CloseInsertDocumentDialogAction + | ToggleInsertDocumentAction + | ToggleInsertDocumentViewAction + | UpdateJsonDocAction + | UpdateCommentAction + | InsertDocumentErrorAction; + +export const insertReducer: Reducer = ( + state = INITIAL_INSERT_STATE, + action +) => { + if (isAction(action, InsertActionTypes.OPEN_INSERT_DOCUMENT_DIALOG)) { + return { + doc: action.doc, + jsonDoc: action.jsonDoc, + jsonView: true, + error: undefined, + csfleState: action.csfleState, + mode: MODIFYING, + isOpen: true, + isCommentNeeded: true, + }; + } + if (isAction(action, InsertActionTypes.CLOSE_INSERT_DOCUMENT_DIALOG)) { + return INITIAL_INSERT_STATE; + } + if (isAction(action, InsertActionTypes.TOGGLE_INSERT_DOCUMENT)) { + if (action.view === 'JSON') { + const jsonDoc = state.doc?.toEJSON(); + return { + doc: state.doc, + jsonView: true, + jsonDoc: jsonDoc ?? null, + error: undefined, + csfleState: state.csfleState, + mode: MODIFYING, + isOpen: true, + isCommentNeeded: state.isCommentNeeded, + }; + } + let hadronDoc; + if (state.jsonDoc === '') { + hadronDoc = state.doc; + } else { + hadronDoc = HadronDocument.FromEJSON(state.jsonDoc ?? ''); + } + return { + doc: hadronDoc, + jsonView: false, + jsonDoc: state.jsonDoc, + error: undefined, + csfleState: state.csfleState, + mode: MODIFYING, + isOpen: true, + isCommentNeeded: state.isCommentNeeded, + }; + } + if (isAction(action, InsertActionTypes.TOGGLE_INSERT_DOCUMENT_VIEW)) { + return { + doc: new Document({}), + jsonDoc: state.jsonDoc, + jsonView: action.jsonView, + error: undefined, + csfleState: state.csfleState, + mode: MODIFYING, + isOpen: true, + isCommentNeeded: state.isCommentNeeded, + }; + } + if (isAction(action, InsertActionTypes.UPDATE_JSON_DOC)) { + return { + doc: new Document({}), + jsonDoc: action.jsonDoc, + jsonView: true, + error: undefined, + csfleState: state.csfleState, + mode: MODIFYING, + isOpen: true, + isCommentNeeded: state.isCommentNeeded, + }; + } + if (isAction(action, InsertActionTypes.UPDATE_COMMENT)) { + return { ...state, isCommentNeeded: action.isCommentNeeded }; + } + if (isAction(action, InsertActionTypes.INSERT_DOCUMENT_ERROR)) { + return { + doc: action.doc ?? state.doc, + jsonDoc: action.jsonDoc ?? state.jsonDoc, + jsonView: action.jsonView ?? state.jsonView, + error: action.error, + csfleState: state.csfleState, + mode: ERROR, + isOpen: true, + isCommentNeeded: state.isCommentNeeded, + }; + } + return state; +}; + +export function closeInsertDocumentDialog(): CloseInsertDocumentDialogAction { + return { type: InsertActionTypes.CLOSE_INSERT_DOCUMENT_DIALOG }; +} + +export function toggleInsertDocument( + view: DocumentView +): ToggleInsertDocumentAction { + return { type: InsertActionTypes.TOGGLE_INSERT_DOCUMENT, view }; +} + +export function toggleInsertDocumentView( + view: DocumentView +): ToggleInsertDocumentViewAction { + return { + type: InsertActionTypes.TOGGLE_INSERT_DOCUMENT_VIEW, + jsonView: view === 'JSON', + }; +} + +export function updateJsonDoc(jsonDoc: string | null): UpdateJsonDocAction { + return { type: InsertActionTypes.UPDATE_JSON_DOC, jsonDoc }; +} + +export function updateComment(isCommentNeeded: boolean): UpdateCommentAction { + return { type: InsertActionTypes.UPDATE_COMMENT, isCommentNeeded }; +} + +function getWriteError(error: Error): WriteError { + return { + message: error.message, + info: (error as MongoServerError).errInfo, + }; +} + +export function openInsertDocumentDialog( + doc: BSONObject, + clone = false +): CrudThunkAction, OpenInsertDocumentDialogAction> { + return async ( + dispatch, + getState, + { dataService, track, connectionInfoRef } + ) => { + const hadronDoc = new HadronDocument(doc); + + if (clone) { + track( + 'Document Cloned', + { + mode: getState().view.view.toLowerCase() as 'list' | 'json' | 'table', + }, + connectionInfoRef.current + ); + // We need to remove the _id or we will get a duplicate key error on + // insert, and we currently do not allow editing of the _id field. + for (const element of hadronDoc.elements) { + if (element.currentKey === '_id') { + hadronDoc.elements.remove(element); + break; + } + } + } + + const csfleState: InsertCSFLEState = { state: 'none' }; + const dataServiceCSFLEMode = dataService.getCSFLEMode?.(); + const ns = getState().documents.ns; + if (dataServiceCSFLEMode === 'enabled') { + const { + hasSchema, + encryptedFields: { encryptedFields }, + } = await dataService.knownSchemaForCollection(ns); + if (encryptedFields.length > 0) { + // This is for displaying encrypted fields to the user. We do not really + // need to worry about the distinction between '.' as a nested-field + // indicator and '.' as a literal part of a field name here, esp. since + // automatic Queryable Encryption does not support '.' in field names at all. + csfleState.encryptedFields = encryptedFields.map((field) => + field.join('.') + ); + } + if (!hasSchema) { + csfleState.state = 'no-known-schema'; + } else if (!(await dataService.isUpdateAllowed?.(ns, doc))) { + csfleState.state = 'incomplete-schema-for-cloned-doc'; + } else { + csfleState.state = 'has-known-schema'; + } + } else if (dataServiceCSFLEMode === 'disabled') { + csfleState.state = 'csfle-disabled'; + } + + dispatch({ + type: InsertActionTypes.OPEN_INSERT_DOCUMENT_DIALOG, + doc: hadronDoc, + jsonDoc: hadronDoc.toEJSON(), + csfleState, + }); + }; +} + +export function insertMany(): CrudThunkAction, InsertActions> { + return async ( + dispatch, + getState, + { + dataService, + fieldStoreService, + connectionScopedAppRegistry, + track, + connectionInfoRef, + } + ) => { + const state = getState(); + const ns = state.documents.ns; + const view = state.view.view; + const insertState = state.insert; + try { + const schemaFields = fieldStoreService.getSchemaFieldsForNamespace(ns); + const docs = HadronDocument.FromEJSONArray(insertState.jsonDoc ?? '').map( + (doc) => { + if (schemaFields) { + doc.preserveTypesFromSchema(schemaFields); + } + return doc.generateObject(); + } + ); + track( + 'Document Inserted', + { + mode: insertState.jsonView ? 'json' : 'field-by-field', + multiple: docs.length > 1, + }, + connectionInfoRef.current + ); + + await dataService.insertMany(ns, docs); + const payload = { + ns: ns, + view: view, + mode: insertState.jsonView ? 'json' : 'default', + multiple: true, + docs, + }; + void fieldStoreService.updateFieldsFromDocuments(ns, docs); + // TODO(COMPASS-7815): Remove this event and use AppStoreService + connectionScopedAppRegistry.emit('document-inserted', payload); + + dispatch(closeInsertDocumentDialog()); + } catch (error) { + dispatch({ + type: InsertActionTypes.INSERT_DOCUMENT_ERROR, + error: getWriteError(error as Error), + doc: new Document({}), + jsonDoc: insertState.jsonDoc, + jsonView: true, + }); + } + + // Since we are inserting a bunch of documents and we need to rerun all + // the queries and counts for them, let's just refresh the whole set of + // documents. + await dispatch(refreshDocuments()); + }; +} + +export function insertDocument(): CrudThunkAction< + Promise, + InsertActions +> { + return async ( + dispatch, + getState, + { + dataService, + fieldStoreService, + connectionScopedAppRegistry, + track, + connectionInfoRef, + } + ) => { + const state = getState(); + const ns = state.documents.ns; + const view = state.view.view; + const insertState = state.insert; + + track( + 'Document Inserted', + { + mode: insertState.jsonView ? 'json' : 'field-by-field', + multiple: false, + }, + connectionInfoRef.current + ); + + let doc: BSONObject; + try { + const schemaFields = fieldStoreService.getSchemaFieldsForNamespace(ns); + if (insertState.jsonView) { + const hadronDoc = HadronDocument.FromEJSON(insertState.jsonDoc ?? ''); + if (schemaFields) { + hadronDoc.preserveTypesFromSchema(schemaFields); + } + doc = hadronDoc.generateObject(); + } else { + // Create a fresh document from the current state to avoid mutating + // the insert dialog's document in place (which would be a side + // effect visible on retry if the insert fails). + const hadronDoc = new HadronDocument(insertState.doc!.generateObject()); + if (schemaFields) { + hadronDoc.preserveTypesFromSchema(schemaFields); + } + doc = hadronDoc.generateObject(); + } + await dataService.insertOne(ns, doc); + + const payload = { + ns, + view, + mode: insertState.jsonView ? 'json' : 'default', + multiple: false, + docs: [doc], + }; + void fieldStoreService.updateFieldsFromDocuments(ns, [doc]); + // TODO(COMPASS-7815): Remove this event and use AppStoreService + connectionScopedAppRegistry.emit('document-inserted', payload); + + dispatch(closeInsertDocumentDialog()); + } catch (error) { + dispatch({ + type: InsertActionTypes.INSERT_DOCUMENT_ERROR, + error: getWriteError(error as Error), + }); + return; + } + + await dispatch(refreshDocuments()); + }; +} diff --git a/packages/compass-crud/src/stores/parse-shell-bson.ts b/packages/compass-crud/src/stores/parse-shell-bson.ts new file mode 100644 index 00000000000..38455879a6b --- /dev/null +++ b/packages/compass-crud/src/stores/parse-shell-bson.ts @@ -0,0 +1,17 @@ +import _parseShellBSON, { ParseMode } from '@mongodb-js/shell-bson-parser'; +import type { BSONObject } from './insert'; + +// Copied from packages/compass-aggregations/src/modules/pipeline-builder/pipeline-parser/utils.ts +export function parseShellBSON(source: string): BSONObject | BSONObject[] { + const parsed = _parseShellBSON(source, { + mode: ParseMode.Strict, + allowComments: true, + allowMethods: true, + }); + if (!parsed || typeof parsed !== 'object') { + // XXX(COMPASS-5689): We've hit the condition in + // https://github.com/mongodb-js/ejson-shell-parser/blob/c9c0145ababae52536ccd2244ac2ad01a4bbdef3/src/index.ts#L36 + throw new Error('The provided definition is invalid.'); + } + return parsed as BSONObject | BSONObject[]; +} diff --git a/packages/compass-crud/src/stores/reducer.ts b/packages/compass-crud/src/stores/reducer.ts new file mode 100644 index 00000000000..79774c89265 --- /dev/null +++ b/packages/compass-crud/src/stores/reducer.ts @@ -0,0 +1,117 @@ +import type { AnyAction } from 'redux'; +import { combineReducers } from 'redux'; +import type { ThunkAction } from 'redux-thunk'; +import type AppRegistry from '@mongodb-js/compass-app-registry'; +import type { TrackFunction } from '@mongodb-js/compass-telemetry'; +import type { Logger } from '@mongodb-js/compass-logging/provider'; +import type { PreferencesAccess } from 'compass-preferences-model/provider'; +import type { + ConnectionInfoRef, + ConnectionScopedAppRegistry, +} from '@mongodb-js/compass-connections/provider'; +import type { FieldStoreService } from '@mongodb-js/compass-field-store'; +import type { QueryBarService } from '@mongodb-js/compass-query-bar'; +import type { + FavoriteQueryStorage, + RecentQueryStorage, +} from '@mongodb-js/my-queries-storage/provider'; +import type { CollectionTabPluginMetadata } from '@mongodb-js/compass-collection'; +import type { DataService } from '../utils/data-service'; + +import type { + DocumentsActions, + DocumentsState, + DocumentsActionTypes, +} from './documents'; +import { createDocumentsReducer } from './documents'; +import type { + CollectionMetaActions, + CollectionMetaState, + CollectionMetaActionTypes, +} from './collection-meta'; +import { createCollectionMetaReducer } from './collection-meta'; +import type { ViewActions, ViewActionTypes } from './view'; +import { viewReducer } from './view'; +import type { InsertActions, InsertActionTypes } from './insert'; +import { insertReducer } from './insert'; +import type { BulkUpdateActions, BulkUpdateActionTypes } from './bulk-update'; +import { bulkUpdateReducer } from './bulk-update'; +import type { BulkDeleteActions, BulkDeleteActionTypes } from './bulk-delete'; +import { bulkDeleteReducer } from './bulk-delete'; + +export type EmittedAppRegistryEvents = + | 'open-import' + | 'open-export' + | 'document-deleted' + | 'documents-deleted' + | 'document-inserted' + | 'documents-refreshed'; + +export type CrudStoreOptions = Pick< + CollectionTabPluginMetadata, + | 'query' + | 'isReadonly' + | 'namespace' + | 'isTimeSeries' + | 'isSearchIndexesSupported' + | 'sourceName' + | 'isMockDataGeneratorEnabled' +> & { + noRefreshOnConfigure?: boolean; +}; + +export type CrudExtraArgs = { + dataService: DataService; + localAppRegistry: Pick; + globalAppRegistry: Pick; + preferences: PreferencesAccess; + logger: Logger; + track: TrackFunction; + favoriteQueriesStorage?: FavoriteQueryStorage; + recentQueriesStorage?: RecentQueryStorage; + fieldStoreService: FieldStoreService; + connectionInfoRef: ConnectionInfoRef; + connectionScopedAppRegistry: ConnectionScopedAppRegistry; + queryBar: QueryBarService; + crudOptions: CrudStoreOptions; +}; + +export type CrudReduxActions = + | DocumentsActions + | CollectionMetaActions + | ViewActions + | InsertActions + | BulkUpdateActions + | BulkDeleteActions; + +type _ActionTypes = typeof DocumentsActionTypes & + typeof CollectionMetaActionTypes & + typeof ViewActionTypes & + typeof InsertActionTypes & + typeof BulkUpdateActionTypes & + typeof BulkDeleteActionTypes; + +export type CrudActionTypes = _ActionTypes[keyof _ActionTypes]; + +export function createRootReducer(initial: { + documents: DocumentsState; + collectionMeta: CollectionMetaState; +}) { + return combineReducers({ + documents: createDocumentsReducer(initial.documents), + collectionMeta: createCollectionMetaReducer(initial.collectionMeta), + view: viewReducer, + insert: insertReducer, + bulkUpdate: bulkUpdateReducer, + bulkDelete: bulkDeleteReducer, + }); +} + +export type CrudState = ReturnType>; + +export type CrudThunkAction = ThunkAction< + R, + CrudState, + CrudExtraArgs, + A +>; diff --git a/packages/compass-crud/src/stores/util.ts b/packages/compass-crud/src/stores/util.ts new file mode 100644 index 00000000000..76b3a16158e --- /dev/null +++ b/packages/compass-crud/src/stores/util.ts @@ -0,0 +1,9 @@ +import type { AnyAction } from 'redux'; +import type { CrudActionTypes, CrudReduxActions } from './reducer'; + +export function isAction( + action: AnyAction, + type: T +): action is Extract { + return action.type === type; +} diff --git a/packages/compass-crud/src/stores/view.ts b/packages/compass-crud/src/stores/view.ts new file mode 100644 index 00000000000..9b090e73ae1 --- /dev/null +++ b/packages/compass-crud/src/stores/view.ts @@ -0,0 +1,122 @@ +import type { Reducer } from 'redux'; +import type { Document } from 'hadron-document'; +import type { Element } from 'hadron-document'; +import type { TableHeaderType } from './grid-store'; +import { isAction } from './util'; +import { DocumentsActionTypes } from './documents'; + +export type DocumentView = 'List' | 'JSON' | 'Table'; + +export type TableState = { + doc: Document | null; + path: (string | number)[]; + types: TableHeaderType[]; + editParams: null | { + colId: string | number; + rowIndex: number; + }; +}; + +export type ViewState = { + view: DocumentView; + table: TableState; +}; + +export const INITIAL_TABLE_STATE: TableState = { + doc: null, + path: [], + types: [], + editParams: null, +}; + +export const INITIAL_VIEW_STATE: ViewState = { + view: 'List', + table: INITIAL_TABLE_STATE, +}; + +export const ViewActionTypes = { + VIEW_CHANGED: 'crud/view/VIEW_CHANGED', + DRILL_DOWN: 'crud/view/DRILL_DOWN', + PATH_CHANGED: 'crud/view/PATH_CHANGED', +} as const; + +export type ViewChangedAction = { + type: typeof ViewActionTypes.VIEW_CHANGED; + view: DocumentView; +}; + +export type DrillDownAction = { + type: typeof ViewActionTypes.DRILL_DOWN; + doc: Document; + element: Element; + editParams: TableState['editParams']; +}; + +export type PathChangedAction = { + type: typeof ViewActionTypes.PATH_CHANGED; + path: (string | number)[]; + types: TableHeaderType[]; +}; + +export type ViewActions = + | ViewChangedAction + | DrillDownAction + | PathChangedAction; + +export const viewReducer: Reducer = ( + state = INITIAL_VIEW_STATE, + action +) => { + if (isAction(action, ViewActionTypes.VIEW_CHANGED)) { + return { ...state, view: action.view }; + } + if (isAction(action, ViewActionTypes.DRILL_DOWN)) { + return { + ...state, + table: { + path: state.table.path.concat([action.element.currentKey]), + types: state.table.types.concat([action.element.currentType]), + doc: action.doc, + editParams: action.editParams, + }, + }; + } + if (isAction(action, ViewActionTypes.PATH_CHANGED)) { + return { + ...state, + table: { + doc: state.table.doc, + editParams: state.table.editParams, + path: action.path, + types: action.types, + }, + }; + } + // When a fresh page or refresh lands, reset the table. + if ( + isAction(action, DocumentsActionTypes.GET_PAGE_SUCCESS) || + isAction(action, DocumentsActionTypes.REFRESH_SUCCESS) + ) { + return { ...state, table: INITIAL_TABLE_STATE }; + } + return state; +}; + +export function viewChanged(view: DocumentView): ViewChangedAction { + return { type: ViewActionTypes.VIEW_CHANGED, view }; +} + +export function drillDown( + doc: Document, + element: Element, + editParams: TableState['editParams'] = null +): DrillDownAction { + return { type: ViewActionTypes.DRILL_DOWN, doc, element, editParams }; +} + +export function pathChanged( + path: (string | number)[], + types: TableHeaderType[] +): PathChangedAction { + return { type: ViewActionTypes.PATH_CHANGED, path, types }; +} From 5723d02213e683779a56f9d14f607fcbf6ce9c43 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Fri, 3 Jul 2026 08:38:42 -0700 Subject: [PATCH 2/7] fixup: grid store typing and arg passing --- .../src/components/document-list.tsx | 44 +++++++++++++------ .../table-view/document-table-view.tsx | 2 +- .../src/stores/grid-store-context.ts | 8 +++- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/compass-crud/src/components/document-list.tsx b/packages/compass-crud/src/components/document-list.tsx index 628f2193835..1167bb4ef5c 100644 --- a/packages/compass-crud/src/components/document-list.tsx +++ b/packages/compass-crud/src/components/document-list.tsx @@ -49,6 +49,8 @@ import { drillDown, pathChanged, viewChanged } from '../stores/view'; import { openInsertDocumentDialog } from '../stores/insert'; import { openBulkUpdateModal } from '../stores/bulk-update'; import { openBulkDeleteDialog } from '../stores/bulk-delete'; +import type { GridActions } from '../stores/grid-store'; +import { useGridActions } from '../stores/grid-store-context'; import { getToolbarSignal } from '../utils/toolbar-signal'; import BulkDeleteModal from './bulk-delete-modal'; import { useTabState } from '@mongodb-js/compass-workspaces/provider'; @@ -100,12 +102,20 @@ export type DocumentListProps = { viewChanged: CrudToolbarProps['viewSwitchHandler']; darkMode?: boolean; isCollectionScan?: boolean; + isTimeSeries: boolean; isSearchIndexesSupported: boolean; openInsertDocumentDialog?: (doc: BSONObject, cloned: boolean) => void; openImportFileDialog?: (origin: 'empty-state' | 'crud-toolbar') => void; -} & Omit & - Omit & - Omit & +} & Omit & + Omit< + DocumentTableViewProps, + | 'className' + | 'isEditable' + | 'columnWidths' + | 'onColumnWidthChange' + | keyof GridActions + > & + Omit & Pick< CrudToolbarProps, | 'error' @@ -116,7 +126,6 @@ export type DocumentListProps = { | 'end' | 'page' | 'getPage' - | 'insertDataHandler' | 'openExportFileDialog' | 'isWritable' | 'isMockDataGeneratorEligibleAndSchemaReady' @@ -127,16 +136,17 @@ export type DocumentListProps = { >; const DocumentViewComponent: React.FunctionComponent< - DocumentListProps & { - isEditable: boolean; - outdated: boolean; - query: unknown; - initialScrollTop?: number; - scrollTriggerRef?: React.Ref; - scrollableContainerRef?: React.Ref; - columnWidths: Record; - onColumnWidthChange: (newColumnWidths: Record) => void; - } + DocumentListProps & + GridActions & { + isEditable: boolean; + outdated: boolean; + query: unknown; + initialScrollTop?: number; + scrollTriggerRef?: React.Ref; + scrollableContainerRef?: React.Ref; + columnWidths: Record; + onColumnWidthChange: (newColumnWidths: Record) => void; + } > = ({ initialScrollTop, scrollTriggerRef, @@ -306,6 +316,8 @@ const DocumentList: React.FunctionComponent = (props) => { updateMaxDocumentsPerPage, } = props; + const gridActions = useGridActions(); + const onOpenInsert = useCallback( (key: 'insert-document' | 'import-file') => { if (key === 'insert-document') { @@ -467,6 +479,7 @@ const DocumentList: React.FunctionComponent = (props) => { content = ( = (props) => { isEditable, openImportFileDialog, props, + gridActions, outdated, query, scrollRef, @@ -601,8 +615,10 @@ export default connect( isWritable: state.collectionMeta.isWritable, instanceDescription: state.collectionMeta.instanceDescription, isSearchIndexesSupported: state.collectionMeta.isSearchIndexesSupported, + version: state.collectionMeta.version, view: state.view.view, table: state.view.table, + ns: state.documents.ns, docs: state.documents.docs ?? [], start: state.documents.start, end: state.documents.end, diff --git a/packages/compass-crud/src/components/table-view/document-table-view.tsx b/packages/compass-crud/src/components/table-view/document-table-view.tsx index dfc3c5dc8cf..1690f84214e 100644 --- a/packages/compass-crud/src/components/table-view/document-table-view.tsx +++ b/packages/compass-crud/src/components/table-view/document-table-view.tsx @@ -70,7 +70,7 @@ export type DocumentTableViewProps = { updateDocument: (doc: Document) => Promise; start: number; table: TableState; - tz: string; + tz?: string; className?: string; darkMode?: boolean; legacyUUIDDisplayEncoding?: string; diff --git a/packages/compass-crud/src/stores/grid-store-context.ts b/packages/compass-crud/src/stores/grid-store-context.ts index aa46a1367ee..6f3720d6bce 100644 --- a/packages/compass-crud/src/stores/grid-store-context.ts +++ b/packages/compass-crud/src/stores/grid-store-context.ts @@ -1,5 +1,5 @@ import { createContext, useContext } from 'react'; -import type { GridStore } from './grid-store'; +import type { GridActions, GridStore } from './grid-store'; /** * The grid store is still backed by Reflux, separately from the redux store. @@ -17,3 +17,9 @@ export function useGridStore(): GridStore { } return store; } + +export function useGridActions(): GridActions { + // Reflux actions are callable and match the GridActions signatures, but are + // typed as `Listenable` on the store options, hence the cast. + return useGridStore().options.actions as unknown as GridActions; +} From 7d77638d5a60c3defb609f8aebb4161241d4e507 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Fri, 3 Jul 2026 08:48:12 -0700 Subject: [PATCH 3/7] fixup: move files to utils folder out of stores --- packages/compass-crud/src/stores/bulk-delete.ts | 2 +- packages/compass-crud/src/stores/bulk-update.ts | 4 ++-- packages/compass-crud/src/stores/collection-meta.ts | 2 +- packages/compass-crud/src/stores/crud-store.ts | 4 ++-- packages/compass-crud/src/stores/documents.ts | 4 ++-- packages/compass-crud/src/stores/insert.ts | 2 +- packages/compass-crud/src/stores/view.ts | 2 +- .../compass-crud/src/{stores => utils}/fetch-documents.ts | 4 ++-- .../compass-crud/src/{stores/util.ts => utils/is-action.ts} | 2 +- .../compass-crud/src/{stores => utils}/parse-shell-bson.ts | 2 +- 10 files changed, 14 insertions(+), 14 deletions(-) rename packages/compass-crud/src/{stores => utils}/fetch-documents.ts (97%) rename packages/compass-crud/src/{stores/util.ts => utils/is-action.ts} (73%) rename packages/compass-crud/src/{stores => utils}/parse-shell-bson.ts (93%) diff --git a/packages/compass-crud/src/stores/bulk-delete.ts b/packages/compass-crud/src/stores/bulk-delete.ts index ce166baeaac..2a1470126cb 100644 --- a/packages/compass-crud/src/stores/bulk-delete.ts +++ b/packages/compass-crud/src/stores/bulk-delete.ts @@ -2,7 +2,7 @@ import type { Reducer } from 'redux'; import { Document } from 'hadron-document'; import { mongoLogId } from '@mongodb-js/compass-logging/provider'; import { showConfirmation } from '@mongodb-js/compass-components'; -import { isAction } from './util'; +import { isAction } from '../utils/is-action'; import type { CrudThunkAction } from './reducer'; import { openBulkDeleteFailureToast, diff --git a/packages/compass-crud/src/stores/bulk-update.ts b/packages/compass-crud/src/stores/bulk-update.ts index 533fb9854c2..44d3ef7f7cf 100644 --- a/packages/compass-crud/src/stores/bulk-update.ts +++ b/packages/compass-crud/src/stores/bulk-update.ts @@ -2,9 +2,9 @@ import type { Reducer } from 'redux'; import type { UpdatePreview } from 'mongodb-data-service'; import { mongoLogId } from '@mongodb-js/compass-logging/provider'; import { openToast } from '@mongodb-js/compass-components'; -import { isAction } from './util'; +import { isAction } from '../utils/is-action'; import type { CrudThunkAction } from './reducer'; -import { parseShellBSON } from './parse-shell-bson'; +import { parseShellBSON } from '../utils/parse-shell-bson'; import { openBulkUpdateFailureToast, openBulkUpdateProgressToast, diff --git a/packages/compass-crud/src/stores/collection-meta.ts b/packages/compass-crud/src/stores/collection-meta.ts index c99025af74e..53f673c6af4 100644 --- a/packages/compass-crud/src/stores/collection-meta.ts +++ b/packages/compass-crud/src/stores/collection-meta.ts @@ -1,6 +1,6 @@ import type { Reducer } from 'redux'; import type { Collection } from '@mongodb-js/compass-app-stores/provider'; -import { isAction } from './util'; +import { isAction } from '../utils/is-action'; export type CollectionStats = Pick< Collection, diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index 650f8757ae8..08ee1a85f82 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -69,8 +69,8 @@ export type { BulkDeleteState } from './bulk-delete'; export { fetchDocuments, findAndModifyWithFLEFallback, -} from './fetch-documents'; -export { parseShellBSON } from './parse-shell-bson'; +} from '../utils/fetch-documents'; +export { parseShellBSON } from '../utils/parse-shell-bson'; export { MAX_DOCS_PER_PAGE_STORAGE_KEY, COUNT_MAX_TIME_MS_CAP, diff --git a/packages/compass-crud/src/stores/documents.ts b/packages/compass-crud/src/stores/documents.ts index cf1d0795ddc..d07d68df751 100644 --- a/packages/compass-crud/src/stores/documents.ts +++ b/packages/compass-crud/src/stores/documents.ts @@ -12,13 +12,13 @@ import { mongoLogId } from '@mongodb-js/compass-logging/provider'; import { capMaxTimeMSAtPreferenceLimit } from 'compass-preferences-model/provider'; import type { Query } from '@mongodb-js/compass-query-bar'; -import { isAction } from './util'; +import { isAction } from '../utils/is-action'; import type { CrudThunkAction } from './reducer'; import type { BSONObject } from './insert'; import { fetchDocuments, findAndModifyWithFLEFallback, -} from './fetch-documents'; +} from '../utils/fetch-documents'; import { countDocuments, fetchShardingKeys, diff --git a/packages/compass-crud/src/stores/insert.ts b/packages/compass-crud/src/stores/insert.ts index 56bb546d801..32323b0a299 100644 --- a/packages/compass-crud/src/stores/insert.ts +++ b/packages/compass-crud/src/stores/insert.ts @@ -2,7 +2,7 @@ import type { Reducer } from 'redux'; import HadronDocument, { Document } from 'hadron-document'; import type { TypeCastMap } from 'hadron-type-checker'; import type { MongoServerError } from 'mongodb'; -import { isAction } from './util'; +import { isAction } from '../utils/is-action'; import type { CrudThunkAction } from './reducer'; import { refreshDocuments } from './documents'; import type { DocumentView } from './view'; diff --git a/packages/compass-crud/src/stores/view.ts b/packages/compass-crud/src/stores/view.ts index 9b090e73ae1..9258ad02af3 100644 --- a/packages/compass-crud/src/stores/view.ts +++ b/packages/compass-crud/src/stores/view.ts @@ -2,7 +2,7 @@ import type { Reducer } from 'redux'; import type { Document } from 'hadron-document'; import type { Element } from 'hadron-document'; import type { TableHeaderType } from './grid-store'; -import { isAction } from './util'; +import { isAction } from '../utils/is-action'; import { DocumentsActionTypes } from './documents'; export type DocumentView = 'List' | 'JSON' | 'Table'; diff --git a/packages/compass-crud/src/stores/fetch-documents.ts b/packages/compass-crud/src/utils/fetch-documents.ts similarity index 97% rename from packages/compass-crud/src/stores/fetch-documents.ts rename to packages/compass-crud/src/utils/fetch-documents.ts index b3c071cfda6..411cbbb63b0 100644 --- a/packages/compass-crud/src/stores/fetch-documents.ts +++ b/packages/compass-crud/src/utils/fetch-documents.ts @@ -2,8 +2,8 @@ import semver from 'semver'; import { isEmpty } from 'lodash'; import HadronDocument from 'hadron-document'; import type { TrackFunction } from '@mongodb-js/compass-telemetry'; -import type { DataService } from '../utils/data-service'; -import type { BSONArray, BSONObject } from './insert'; +import type { DataService } from './data-service'; +import type { BSONArray, BSONObject } from '../stores/insert'; export const fetchDocuments: ( dataService: DataService, diff --git a/packages/compass-crud/src/stores/util.ts b/packages/compass-crud/src/utils/is-action.ts similarity index 73% rename from packages/compass-crud/src/stores/util.ts rename to packages/compass-crud/src/utils/is-action.ts index 76b3a16158e..b6fcb719add 100644 --- a/packages/compass-crud/src/stores/util.ts +++ b/packages/compass-crud/src/utils/is-action.ts @@ -1,5 +1,5 @@ import type { AnyAction } from 'redux'; -import type { CrudActionTypes, CrudReduxActions } from './reducer'; +import type { CrudActionTypes, CrudReduxActions } from '../stores/reducer'; export function isAction( action: AnyAction, diff --git a/packages/compass-crud/src/stores/parse-shell-bson.ts b/packages/compass-crud/src/utils/parse-shell-bson.ts similarity index 93% rename from packages/compass-crud/src/stores/parse-shell-bson.ts rename to packages/compass-crud/src/utils/parse-shell-bson.ts index 38455879a6b..d97db2a9469 100644 --- a/packages/compass-crud/src/stores/parse-shell-bson.ts +++ b/packages/compass-crud/src/utils/parse-shell-bson.ts @@ -1,5 +1,5 @@ import _parseShellBSON, { ParseMode } from '@mongodb-js/shell-bson-parser'; -import type { BSONObject } from './insert'; +import type { BSONObject } from '../stores/insert'; // Copied from packages/compass-aggregations/src/modules/pipeline-builder/pipeline-parser/utils.ts export function parseShellBSON(source: string): BSONObject | BSONObject[] { From 252b1080e75a6eab570f5186d90570a38d59da77 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Mon, 6 Jul 2026 11:04:02 -0700 Subject: [PATCH 4/7] fixup: remove unnecessary count store, update action names, remove extra event emit, typos --- .../src/modules/collection-tab.ts | 4 - .../src/hooks/use-confirmation.spec.tsx | 6 +- packages/compass-crud/README.md | 1 - .../compass-crud/src/stores/bulk-delete.ts | 31 ++++---- .../compass-crud/src/stores/bulk-update.ts | 78 ++++++++----------- .../src/stores/crud-store.spec.ts | 2 +- packages/compass-crud/src/stores/documents.ts | 4 +- packages/compass-crud/src/stores/reducer.ts | 1 - .../src/store/diagram.ts | 2 +- .../src/stores/delete-item.ts | 2 +- 10 files changed, 53 insertions(+), 78 deletions(-) diff --git a/packages/compass-collection/src/modules/collection-tab.ts b/packages/compass-collection/src/modules/collection-tab.ts index 1bcb6f37685..04e84e519a3 100644 --- a/packages/compass-collection/src/modules/collection-tab.ts +++ b/packages/compass-collection/src/modules/collection-tab.ts @@ -993,10 +993,6 @@ export type CollectionTabPluginMetadata = CollectionMetadata & { * Subtab that is currently open */ subTab?: CollectionSubtab; - /** - * Mock data generator feature enablement. - */ - isMockDataGeneratorEnabled?: boolean; }; export default reducer; diff --git a/packages/compass-components/src/hooks/use-confirmation.spec.tsx b/packages/compass-components/src/hooks/use-confirmation.spec.tsx index 86e7a0bd5e8..0585ec94b72 100644 --- a/packages/compass-components/src/hooks/use-confirmation.spec.tsx +++ b/packages/compass-components/src/hooks/use-confirmation.spec.tsx @@ -22,7 +22,7 @@ describe('use-confirmation', function () { onClick={() => { response = showConfirmation({ title: 'Are you sure?', - description: 'This action can not be undone.', + description: 'This action cannot be undone.', buttonText: 'Yes', }); }} @@ -39,9 +39,7 @@ describe('use-confirmation', function () { it('renders modal contents', function () { expect(within(modal).getByText('Are you sure?')).to.exist; - expect( - within(modal).getByText('This action can not be undone.') - ).to.exist; + expect(within(modal).getByText('This action cannot be undone.')).to.exist; expect(within(modal).getByText('Yes')).to.exist; const cancelElement = within(modal).getByText('Cancel'); expect(cancelElement).to.exist; diff --git a/packages/compass-crud/README.md b/packages/compass-crud/README.md index 6a22420036e..e38c21233f4 100644 --- a/packages/compass-crud/README.md +++ b/packages/compass-crud/README.md @@ -90,7 +90,6 @@ application can be listened to via [compass-app-registry][compass-app-registry]. `insertMany` and `insertOne` complete. - **'document-updated'**: indicates a document was updated. - **'document-deleted'**: indicates a document was deleted. -- **'documents-deleted'**: indicates multiple documents were deleted. ### App Registry Events Received diff --git a/packages/compass-crud/src/stores/bulk-delete.ts b/packages/compass-crud/src/stores/bulk-delete.ts index 2a1470126cb..178a8935ff6 100644 --- a/packages/compass-crud/src/stores/bulk-delete.ts +++ b/packages/compass-crud/src/stores/bulk-delete.ts @@ -24,23 +24,23 @@ export const INITIAL_BULK_DELETE_STATE: BulkDeleteState = { }; export const BulkDeleteActionTypes = { - OPEN_DIALOG: 'crud/bulk-delete/OPEN_DIALOG', - CLOSE_DIALOG: 'crud/bulk-delete/CLOSE_DIALOG', - IN_PROGRESS: 'crud/bulk-delete/IN_PROGRESS', + OPEN_BULK_DELETE: 'crud/bulk-delete/OPEN_BULK_DELETE', + CLOSE_BULK_DELETE: 'crud/bulk-delete/CLOSE_BULK_DELETE', + BULK_DELETE_STARTED: 'crud/bulk-delete/BULK_DELETE_STARTED', } as const; export type OpenBulkDeleteDialogAction = { - type: typeof BulkDeleteActionTypes.OPEN_DIALOG; + type: typeof BulkDeleteActionTypes.OPEN_BULK_DELETE; previews: Document[]; affected: number | undefined; }; export type CloseBulkDeleteDialogAction = { - type: typeof BulkDeleteActionTypes.CLOSE_DIALOG; + type: typeof BulkDeleteActionTypes.CLOSE_BULK_DELETE; }; export type BulkDeleteInProgressAction = { - type: typeof BulkDeleteActionTypes.IN_PROGRESS; + type: typeof BulkDeleteActionTypes.BULK_DELETE_STARTED; }; export type BulkDeleteActions = @@ -52,17 +52,17 @@ export const bulkDeleteReducer: Reducer = ( state = INITIAL_BULK_DELETE_STATE, action ) => { - if (isAction(action, BulkDeleteActionTypes.OPEN_DIALOG)) { + if (isAction(action, BulkDeleteActionTypes.OPEN_BULK_DELETE)) { return { previews: action.previews, status: 'open', affected: action.affected, }; } - if (isAction(action, BulkDeleteActionTypes.CLOSE_DIALOG)) { + if (isAction(action, BulkDeleteActionTypes.CLOSE_BULK_DELETE)) { return { ...state, status: 'closed' }; } - if (isAction(action, BulkDeleteActionTypes.IN_PROGRESS)) { + if (isAction(action, BulkDeleteActionTypes.BULK_DELETE_STARTED)) { return { ...state, status: 'in-progress' }; } return state; @@ -86,7 +86,7 @@ export function openBulkDeleteDialog(): CrudThunkAction< } ); dispatch({ - type: BulkDeleteActionTypes.OPEN_DIALOG, + type: BulkDeleteActionTypes.OPEN_BULK_DELETE, previews, affected: state.documents.count ?? undefined, }); @@ -94,7 +94,7 @@ export function openBulkDeleteDialog(): CrudThunkAction< } export function closeBulkDeleteDialog(): CloseBulkDeleteDialogAction { - return { type: BulkDeleteActionTypes.CLOSE_DIALOG }; + return { type: BulkDeleteActionTypes.CLOSE_BULK_DELETE }; } export function runBulkDelete(): CrudThunkAction< @@ -108,7 +108,6 @@ export function runBulkDelete(): CrudThunkAction< dataService, queryBar, logger, - localAppRegistry, connectionScopedAppRegistry, track, connectionInfoRef, @@ -124,8 +123,8 @@ export function runBulkDelete(): CrudThunkAction< buttonText: `Delete ${affected ? `${affected} ` : ''} document${ affected !== 1 ? 's' : '' }`, - description: `This action can not be undone. This will permanently delete ${ - affected ?? 'an unknown number of' + description: `This action cannot be undone. This will permanently delete ${ + affected ?? 'an unknown nudocuments-deletember of' } document${affected !== 1 ? 's' : ''}.`, warning: 'The document list and count may not always reflect the latest updates in real time. This action will apply to all relevant documents, including those not currently visible, so please ensure they are handled safely.', @@ -136,7 +135,7 @@ export function runBulkDelete(): CrudThunkAction< return; } - dispatch({ type: BulkDeleteActionTypes.IN_PROGRESS }); + dispatch({ type: BulkDeleteActionTypes.BULK_DELETE_STARTED }); openBulkDeleteProgressToast({ affectedDocuments: affected, }); @@ -157,9 +156,7 @@ export function runBulkDelete(): CrudThunkAction< affectedDocuments: affected, onRefresh: () => void dispatch(refreshDocuments()), }); - // Emit both events so all listeners update (fixes bulk delete document count not updating) const payload = { view, ns }; - localAppRegistry.emit('documents-deleted', payload); connectionScopedAppRegistry.emit('documents-deleted', payload); } catch (ex) { openBulkDeleteFailureToast({ diff --git a/packages/compass-crud/src/stores/bulk-update.ts b/packages/compass-crud/src/stores/bulk-update.ts index 44d3ef7f7cf..66895866dfb 100644 --- a/packages/compass-crud/src/stores/bulk-update.ts +++ b/packages/compass-crud/src/stores/bulk-update.ts @@ -40,74 +40,67 @@ export const INITIAL_BULK_UPDATE_STATE: BulkUpdateState = { }; export const BulkUpdateActionTypes = { - OPEN_MODAL: 'crud/bulk-update/OPEN_MODAL', - CLOSE_MODAL: 'crud/bulk-update/CLOSE_MODAL', - PREVIEW_STARTED: 'crud/bulk-update/PREVIEW_STARTED', - PREVIEW_UPDATED: 'crud/bulk-update/PREVIEW_UPDATED', - PREVIEW_SYNTAX_ERROR: 'crud/bulk-update/PREVIEW_SYNTAX_ERROR', - PREVIEW_SERVER_ERROR: 'crud/bulk-update/PREVIEW_SERVER_ERROR', - RUN_AFFECTED_LATCHED: 'crud/bulk-update/RUN_AFFECTED_LATCHED', + OPEN_BULK_UPDATE: 'crud/bulk-update/OPEN_BULK_UPDATE', + CLOSE_BULK_UPDATE: 'crud/bulk-update/CLOSE_BULK_UPDATE', + FETCH_PREVIEW_STARTED: 'crud/bulk-update/FETCH_PREVIEW_STARTED', + FETCH_PREVIEW_FINISHED: 'crud/bulk-update/FETCH_PREVIEW_FINISHED', + FETCH_PREVIEW_SYNTAX_ERRORED: 'crud/bulk-update/FETCH_PREVIEW_SYNTAX_ERRORED', + FETCH_PREVIEW_SERVER_ERRORED: 'crud/bulk-update/FETCH_PREVIEW_SERVER_ERRORED', } as const; -export type OpenBulkUpdateModalAction = { - type: typeof BulkUpdateActionTypes.OPEN_MODAL; +export type OpenBulkUpdateAction = { + type: typeof BulkUpdateActionTypes.OPEN_BULK_UPDATE; }; -export type CloseBulkUpdateModalAction = { - type: typeof BulkUpdateActionTypes.CLOSE_MODAL; +export type CloseBulkUpdateAction = { + type: typeof BulkUpdateActionTypes.CLOSE_BULK_UPDATE; }; export type PreviewStartedAction = { - type: typeof BulkUpdateActionTypes.PREVIEW_STARTED; + type: typeof BulkUpdateActionTypes.FETCH_PREVIEW_STARTED; abortController: AbortController; }; export type PreviewUpdatedAction = { - type: typeof BulkUpdateActionTypes.PREVIEW_UPDATED; + type: typeof BulkUpdateActionTypes.FETCH_PREVIEW_FINISHED; updateText: string; preview: UpdatePreview; }; export type PreviewSyntaxErrorAction = { - type: typeof BulkUpdateActionTypes.PREVIEW_SYNTAX_ERROR; + type: typeof BulkUpdateActionTypes.FETCH_PREVIEW_SYNTAX_ERRORED; updateText: string; syntaxError: Error; }; export type PreviewServerErrorAction = { - type: typeof BulkUpdateActionTypes.PREVIEW_SERVER_ERROR; + type: typeof BulkUpdateActionTypes.FETCH_PREVIEW_SERVER_ERRORED; updateText: string; serverError: Error; }; -export type RunAffectedLatchedAction = { - type: typeof BulkUpdateActionTypes.RUN_AFFECTED_LATCHED; - affected: number | undefined; -}; - export type BulkUpdateActions = - | OpenBulkUpdateModalAction - | CloseBulkUpdateModalAction + | OpenBulkUpdateAction + | CloseBulkUpdateAction | PreviewStartedAction | PreviewUpdatedAction | PreviewSyntaxErrorAction - | PreviewServerErrorAction - | RunAffectedLatchedAction; + | PreviewServerErrorAction; export const bulkUpdateReducer: Reducer = ( state = INITIAL_BULK_UPDATE_STATE, action ) => { - if (isAction(action, BulkUpdateActionTypes.OPEN_MODAL)) { + if (isAction(action, BulkUpdateActionTypes.OPEN_BULK_UPDATE)) { return { ...state, isOpen: true }; } - if (isAction(action, BulkUpdateActionTypes.CLOSE_MODAL)) { + if (isAction(action, BulkUpdateActionTypes.CLOSE_BULK_UPDATE)) { return { ...state, isOpen: false }; } - if (isAction(action, BulkUpdateActionTypes.PREVIEW_STARTED)) { + if (isAction(action, BulkUpdateActionTypes.FETCH_PREVIEW_STARTED)) { return { ...state, previewAbortController: action.abortController }; } - if (isAction(action, BulkUpdateActionTypes.PREVIEW_UPDATED)) { + if (isAction(action, BulkUpdateActionTypes.FETCH_PREVIEW_FINISHED)) { return { ...state, updateText: action.updateText, @@ -117,7 +110,7 @@ export const bulkUpdateReducer: Reducer = ( previewAbortController: undefined, }; } - if (isAction(action, BulkUpdateActionTypes.PREVIEW_SYNTAX_ERROR)) { + if (isAction(action, BulkUpdateActionTypes.FETCH_PREVIEW_SYNTAX_ERRORED)) { return { ...state, updateText: action.updateText, @@ -127,7 +120,7 @@ export const bulkUpdateReducer: Reducer = ( previewAbortController: undefined, }; } - if (isAction(action, BulkUpdateActionTypes.PREVIEW_SERVER_ERROR)) { + if (isAction(action, BulkUpdateActionTypes.FETCH_PREVIEW_SERVER_ERRORED)) { return { ...state, updateText: action.updateText, @@ -137,14 +130,11 @@ export const bulkUpdateReducer: Reducer = ( previewAbortController: undefined, }; } - if (isAction(action, BulkUpdateActionTypes.RUN_AFFECTED_LATCHED)) { - return { ...state, affected: action.affected }; - } return state; }; -export function closeBulkUpdateModal(): CloseBulkUpdateModalAction { - return { type: BulkUpdateActionTypes.CLOSE_MODAL }; +export function closeBulkUpdateModal(): CloseBulkUpdateAction { + return { type: BulkUpdateActionTypes.CLOSE_BULK_UPDATE }; } export function updateBulkUpdatePreview( @@ -160,14 +150,14 @@ export function updateBulkUpdatePreview( parseShellBSON(updateText); } catch (err: any) { dispatch({ - type: BulkUpdateActionTypes.PREVIEW_SYNTAX_ERROR, + type: BulkUpdateActionTypes.FETCH_PREVIEW_SYNTAX_ERRORED, updateText, syntaxError: err, }); return; } dispatch({ - type: BulkUpdateActionTypes.PREVIEW_UPDATED, + type: BulkUpdateActionTypes.FETCH_PREVIEW_FINISHED, updateText, preview: { changes: [] }, }); @@ -176,7 +166,7 @@ export function updateBulkUpdatePreview( const abortController = new AbortController(); dispatch({ - type: BulkUpdateActionTypes.PREVIEW_STARTED, + type: BulkUpdateActionTypes.FETCH_PREVIEW_STARTED, abortController, }); @@ -186,7 +176,7 @@ export function updateBulkUpdatePreview( } catch (err: any) { if (abortController.signal.aborted) return; dispatch({ - type: BulkUpdateActionTypes.PREVIEW_SYNTAX_ERROR, + type: BulkUpdateActionTypes.FETCH_PREVIEW_SYNTAX_ERRORED, updateText, syntaxError: err, }); @@ -207,7 +197,7 @@ export function updateBulkUpdatePreview( } catch (err: any) { if (abortController.signal.aborted) return; dispatch({ - type: BulkUpdateActionTypes.PREVIEW_SERVER_ERROR, + type: BulkUpdateActionTypes.FETCH_PREVIEW_SERVER_ERRORED, updateText, serverError: err, }); @@ -217,7 +207,7 @@ export function updateBulkUpdatePreview( if (abortController.signal.aborted) return; dispatch({ - type: BulkUpdateActionTypes.PREVIEW_UPDATED, + type: BulkUpdateActionTypes.FETCH_PREVIEW_FINISHED, updateText, preview, }); @@ -240,7 +230,7 @@ export function openBulkUpdateModal( await dispatch( updateBulkUpdatePreview(updateText ?? INITIAL_BULK_UPDATE_TEXT) ); - dispatch({ type: BulkUpdateActionTypes.OPEN_MODAL }); + dispatch({ type: BulkUpdateActionTypes.OPEN_BULK_UPDATE }); }; } @@ -275,10 +265,6 @@ export function runBulkUpdate(): CrudThunkAction< // Latch the affected count for the duration of the toast. const affected = getState().documents.count ?? undefined; - dispatch({ - type: BulkUpdateActionTypes.RUN_AFFECTED_LATCHED, - affected, - }); const ns = getState().documents.ns; const { filter = {} } = query; diff --git a/packages/compass-crud/src/stores/crud-store.spec.ts b/packages/compass-crud/src/stores/crud-store.spec.ts index dd8a0804fd0..08b8b3f7c65 100644 --- a/packages/compass-crud/src/stores/crud-store.spec.ts +++ b/packages/compass-crud/src/stores/crud-store.spec.ts @@ -826,7 +826,7 @@ describe('store', function () { invalidHadronDoc.on(DocumentEvents.UpdateError, ({ message }) => { expect(message).to.equal( - 'An error occured when attempting to update the document: this.getId is not a function' + 'An error occurred when attempting to update the document: this.getId is not a function' ); done(); diff --git a/packages/compass-crud/src/stores/documents.ts b/packages/compass-crud/src/stores/documents.ts index d07d68df751..a82b86d8aa2 100644 --- a/packages/compass-crud/src/stores/documents.ts +++ b/packages/compass-crud/src/stores/documents.ts @@ -944,7 +944,7 @@ export function updateDocument( } catch (err: any) { doc.onUpdateError( new Error( - `An error occured when attempting to update the document: ${String( + `An error occurred when attempting to update the document: ${String( err.message )}` ) @@ -1041,7 +1041,7 @@ export function replaceDocument( } catch (err: any) { doc.onUpdateError( new Error( - `An error occured when attempting to update the document: ${String( + `An error occurred when attempting to update the document: ${String( err.message )}` ) diff --git a/packages/compass-crud/src/stores/reducer.ts b/packages/compass-crud/src/stores/reducer.ts index 79774c89265..f8b5d9dcf97 100644 --- a/packages/compass-crud/src/stores/reducer.ts +++ b/packages/compass-crud/src/stores/reducer.ts @@ -55,7 +55,6 @@ export type CrudStoreOptions = Pick< | 'isTimeSeries' | 'isSearchIndexesSupported' | 'sourceName' - | 'isMockDataGeneratorEnabled' > & { noRefreshOnConfigure?: boolean; }; diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index 967f1a4cdb4..594bfefb68a 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -692,7 +692,7 @@ export function deleteDiagram( return async (dispatch, getState, { dataModelStorage }) => { const confirmed = await showConfirmation({ title: 'Are you sure you want to delete this diagram?', - description: 'This action can not be undone.', + description: 'This action cannot be undone.', variant: 'danger', }); if (!confirmed) { diff --git a/packages/compass-saved-aggregations-queries/src/stores/delete-item.ts b/packages/compass-saved-aggregations-queries/src/stores/delete-item.ts index 26ff1cdd3dd..0fa15ff5b92 100644 --- a/packages/compass-saved-aggregations-queries/src/stores/delete-item.ts +++ b/packages/compass-saved-aggregations-queries/src/stores/delete-item.ts @@ -36,7 +36,7 @@ export const confirmDeleteItem = ( }?`; const confirmed = await showConfirmation({ title, - description: 'This action can not be undone.', + description: 'This action cannot be undone.', variant: ConfirmationModalVariant.Danger, buttonText: 'Delete', }); From 071afaa4edc516eb8d6aa575d823da9a69d2df4b Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Mon, 6 Jul 2026 13:20:11 -0700 Subject: [PATCH 5/7] fixup: throw on no table store, pass import telemetry --- .../src/components/document-list.tsx | 3 +- .../table-view/document-table-view.spec.tsx | 117 +++++++++--------- .../table-view/document-table-view.tsx | 10 +- packages/compass-crud/src/stores/documents.ts | 6 +- packages/compass-crud/src/stores/insert.ts | 3 +- 5 files changed, 74 insertions(+), 65 deletions(-) diff --git a/packages/compass-crud/src/components/document-list.tsx b/packages/compass-crud/src/components/document-list.tsx index 1167bb4ef5c..7d371df75b0 100644 --- a/packages/compass-crud/src/components/document-list.tsx +++ b/packages/compass-crud/src/components/document-list.tsx @@ -643,8 +643,7 @@ export default connect( replaceDocument, pathChanged, viewChanged, - openImportFileDialog: (_origin: 'empty-state' | 'crud-toolbar') => - openImportFileDialog(), + openImportFileDialog, openExportFileDialog, openCreateIndexModal, openCreateSearchIndexModal, diff --git a/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx b/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx index 9d6bd0d43dd..195d034471e 100644 --- a/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx +++ b/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx @@ -10,6 +10,56 @@ import { } from '@mongodb-js/testing-library-compass'; import DocumentTableView from './document-table-view'; +import { GridStoreContext } from '../../stores/grid-store-context'; +import type { GridStore } from '../../stores/grid-store'; + +function renderDocumentTableView({ + columnWidths = {}, + gridStore = { listen: sinon.stub().returns(sinon.spy()) }, +}: { + columnWidths?: Record; + gridStore?: Pick; +} = {}) { + const docs = [{ _id: '6909a21e9e548506e786c1e5', name: 'test-1' }]; + const hadronDocs = docs.map((doc) => new HadronDocument(doc)); + + render( + + + + ); +} describe('', function () { afterEach(cleanup); @@ -17,38 +67,18 @@ describe('', function () { describe('#render', function () { context('when the documents have objects for ids', function () { it('renders the document table view with AG-Grid', async function () { - const docs = [{ _id: '6909a21e9e548506e786c1e5', name: 'test-1' }]; - const hadronDocs = docs.map((doc) => new HadronDocument(doc)); - - render( - - ); + const gridStore = { listen: sinon.stub().returns(sinon.spy()) }; + renderDocumentTableView({ + columnWidths: { name: 1337 }, + gridStore, + }); // The AG-Grid wrapper should be rendered expect(document.querySelector('.ag-root-wrapper')).to.exist; + // The component should subscribe to the grid store from context + expect(gridStore.listen).to.have.been.calledOnce; + // Wait for AG-Grid to fully render and display the data await waitFor(() => { // Check that the grid body is present @@ -58,10 +88,10 @@ describe('', function () { // Validate that the columnWidths prop was applied: // - the 'name' column should have the configured width // - the '_id' column should not share that explicit width (keeps default) - const nameHeader = document.querySelector( + const nameHeader = document.querySelector( '.ag-header-cell[col-id="name"]' ); - const idHeader = document.querySelector( + const idHeader = document.querySelector( '.ag-header-cell[col-id="_id"]' ); expect(nameHeader, 'name column header should exist').to.exist; @@ -75,32 +105,7 @@ describe('', function () { }); it('renders the breadcrumb component', function () { - const docs = [{ _id: '6909a21e9e548506e786c1e5', name: 'test-1' }]; - const hadronDocs = docs.map((doc) => new HadronDocument(doc)); - - render( - - ); + renderDocumentTableView(); // The breadcrumb should show the collection name expect(screen.getByText('collection')).to.exist; diff --git a/packages/compass-crud/src/components/table-view/document-table-view.tsx b/packages/compass-crud/src/components/table-view/document-table-view.tsx index 1690f84214e..66eec9f202d 100644 --- a/packages/compass-crud/src/components/table-view/document-table-view.tsx +++ b/packages/compass-crud/src/components/table-view/document-table-view.tsx @@ -20,7 +20,6 @@ import { cx, spacing, withDarkMode } from '@mongodb-js/compass-components'; import type { BSONObject, TableState } from '../../stores/crud-store'; import type { GridActions, - GridStore, GridStoreTriggerParams, TableHeaderType, } from '../../stores/grid-store'; @@ -96,7 +95,7 @@ export type GridContext = { */ export class DocumentTableView extends React.Component { static contextType = GridStoreContext; - declare context: GridStore | null; + declare context: React.ContextType; AGGrid: React.ReactElement; collection: string; topLevel: boolean; @@ -166,7 +165,12 @@ export class DocumentTableView extends React.Component { } componentDidMount() { - this.unsubscribeGridStore = this.context?.listen(this.modifyColumns, this); + if (!this.context) { + throw new Error( + 'GridStoreContext is missing — make sure the component is rendered inside the CompassDocuments plugin.' + ); + } + this.unsubscribeGridStore = this.context.listen(this.modifyColumns, this); } componentWillUnmount() { diff --git a/packages/compass-crud/src/stores/documents.ts b/packages/compass-crud/src/stores/documents.ts index a82b86d8aa2..55dfcce2458 100644 --- a/packages/compass-crud/src/stores/documents.ts +++ b/packages/compass-crud/src/stores/documents.ts @@ -1105,11 +1105,13 @@ export function openCreateSearchIndexModal(): CrudThunkAction { }; } -export function openImportFileDialog(): CrudThunkAction { +export function openImportFileDialog( + origin: 'empty-state' | 'crud-toolbar' +): CrudThunkAction { return (dispatch, getState, { connectionScopedAppRegistry }) => { connectionScopedAppRegistry.emit('open-import', { namespace: getState().documents.ns, - origin: 'empty-state', + origin: origin, }); }; } diff --git a/packages/compass-crud/src/stores/insert.ts b/packages/compass-crud/src/stores/insert.ts index 32323b0a299..862aa70a47d 100644 --- a/packages/compass-crud/src/stores/insert.ts +++ b/packages/compass-crud/src/stores/insert.ts @@ -129,11 +129,10 @@ export const insertReducer: Reducer = ( } if (isAction(action, InsertActionTypes.TOGGLE_INSERT_DOCUMENT)) { if (action.view === 'JSON') { - const jsonDoc = state.doc?.toEJSON(); return { doc: state.doc, jsonView: true, - jsonDoc: jsonDoc ?? null, + jsonDoc: state.doc?.toEJSON() ?? null, error: undefined, csfleState: state.csfleState, mode: MODIFYING, From 3dc71d5e74338c1d409857047f187856d09c539a Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Wed, 8 Jul 2026 21:11:42 -0700 Subject: [PATCH 6/7] fixup: less setters, more actions --- .../table-view/document-table-view.spec.tsx | 9 +-- packages/compass-crud/src/stores/documents.ts | 36 +++++----- .../compass-crud/src/stores/insert.spec.ts | 12 ++-- packages/compass-crud/src/stores/insert.ts | 72 ++++++++----------- 4 files changed, 57 insertions(+), 72 deletions(-) diff --git a/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx b/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx index 195d034471e..fab560625eb 100644 --- a/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx +++ b/packages/compass-crud/src/components/table-view/document-table-view.spec.tsx @@ -2,12 +2,7 @@ import React from 'react'; import HadronDocument from 'hadron-document'; import { expect } from 'chai'; import sinon from 'sinon'; -import { - render, - cleanup, - screen, - waitFor, -} from '@mongodb-js/testing-library-compass'; +import { render, screen, waitFor } from '@mongodb-js/testing-library-compass'; import DocumentTableView from './document-table-view'; import { GridStoreContext } from '../../stores/grid-store-context'; @@ -62,8 +57,6 @@ function renderDocumentTableView({ } describe('', function () { - afterEach(cleanup); - describe('#render', function () { context('when the documents have objects for ids', function () { it('renders the document table view with AG-Grid', async function () { diff --git a/packages/compass-crud/src/stores/documents.ts b/packages/compass-crud/src/stores/documents.ts index 55dfcce2458..65005b2551b 100644 --- a/packages/compass-crud/src/stores/documents.ts +++ b/packages/compass-crud/src/stores/documents.ts @@ -140,7 +140,8 @@ export const DocumentsActionTypes = { GET_PAGE_SUCCESS: 'crud/documents/GET_PAGE_SUCCESS', GET_PAGE_ERROR: 'crud/documents/GET_PAGE_ERROR', CANCEL_OPERATION: 'crud/documents/CANCEL_OPERATION', - DEBOUNCING_LOAD_CHANGED: 'crud/documents/DEBOUNCING_LOAD_CHANGED', + LOAD_DEBOUNCE_STARTED: 'crud/documents/LOAD_DEBOUNCE_STARTED', + LOAD_DEBOUNCE_FINISHED: 'crud/documents/LOAD_DEBOUNCE_FINISHED', DOCUMENT_REMOVED: 'crud/documents/DOCUMENT_REMOVED', DOCUMENT_REPLACED: 'crud/documents/DOCUMENT_REPLACED', DOCS_PER_PAGE_CHANGED: 'crud/documents/DOCS_PER_PAGE_CHANGED', @@ -205,9 +206,12 @@ export type CancelOperationAction = { type: typeof DocumentsActionTypes.CANCEL_OPERATION; }; -export type DebouncingLoadChangedAction = { - type: typeof DocumentsActionTypes.DEBOUNCING_LOAD_CHANGED; - debouncingLoad: boolean; +export type LoadDebounceStartedAction = { + type: typeof DocumentsActionTypes.LOAD_DEBOUNCE_STARTED; +}; + +export type LoadDebounceFinishedAction = { + type: typeof DocumentsActionTypes.LOAD_DEBOUNCE_FINISHED; }; export type DocumentRemovedAction = { @@ -242,7 +246,8 @@ export type DocumentsActions = | GetPageSuccessAction | GetPageErrorAction | CancelOperationAction - | DebouncingLoadChangedAction + | LoadDebounceStartedAction + | LoadDebounceFinishedAction | DocumentRemovedAction | DocumentReplacedAction | DocsPerPageChangedAction @@ -343,8 +348,11 @@ export function createDocumentsReducer( if (isAction(action, DocumentsActionTypes.CANCEL_OPERATION)) { return { ...state, abortController: null }; } - if (isAction(action, DocumentsActionTypes.DEBOUNCING_LOAD_CHANGED)) { - return { ...state, debouncingLoad: action.debouncingLoad }; + if (isAction(action, DocumentsActionTypes.LOAD_DEBOUNCE_STARTED)) { + return { ...state, debouncingLoad: true }; + } + if (isAction(action, DocumentsActionTypes.LOAD_DEBOUNCE_FINISHED)) { + return { ...state, debouncingLoad: false }; } if (isAction(action, DocumentsActionTypes.DOCUMENT_REMOVED)) { const newDocs = state.docs ? [...state.docs] : state.docs; @@ -408,12 +416,11 @@ function isInitialQuery(query: Query = {}): boolean { } function debounceLoading( - dispatch: (action: DebouncingLoadChangedAction) => void + dispatch: ( + action: LoadDebounceStartedAction | LoadDebounceFinishedAction + ) => void ) { - dispatch({ - type: DocumentsActionTypes.DEBOUNCING_LOAD_CHANGED, - debouncingLoad: true, - }); + dispatch({ type: DocumentsActionTypes.LOAD_DEBOUNCE_STARTED }); const debouncePromise = new Promise((resolve) => { setTimeout(resolve, 200); // 200ms should feel about instant }); @@ -424,10 +431,7 @@ function debounceLoading( }); void Promise.race([debouncePromise, loadPromise]).then(() => { - dispatch({ - type: DocumentsActionTypes.DEBOUNCING_LOAD_CHANGED, - debouncingLoad: false, - }); + dispatch({ type: DocumentsActionTypes.LOAD_DEBOUNCE_FINISHED }); }); return cancelDebounceLoad; diff --git a/packages/compass-crud/src/stores/insert.spec.ts b/packages/compass-crud/src/stores/insert.spec.ts index 9554f1e19cf..bbb0a8d5ae1 100644 --- a/packages/compass-crud/src/stores/insert.spec.ts +++ b/packages/compass-crud/src/stores/insert.spec.ts @@ -55,7 +55,7 @@ describe('insertReducer', function () { }); }); - describe('TOGGLE_INSERT_DOCUMENT', function () { + describe('INSERT_DOCUMENT_VIEW_TOGGLED', function () { it('switching to List parses the current jsonDoc into a doc', function () { const state: InsertState = { ...INITIAL_INSERT_STATE, @@ -65,7 +65,7 @@ describe('insertReducer', function () { }; const nextState = insertReducer(state, { - type: InsertActionTypes.TOGGLE_INSERT_DOCUMENT, + type: InsertActionTypes.INSERT_DOCUMENT_VIEW_TOGGLED, view: 'List', }); @@ -86,7 +86,7 @@ describe('insertReducer', function () { }; const nextState = insertReducer(state, { - type: InsertActionTypes.TOGGLE_INSERT_DOCUMENT, + type: InsertActionTypes.INSERT_DOCUMENT_VIEW_TOGGLED, view: 'List', }); @@ -104,7 +104,7 @@ describe('insertReducer', function () { }; const nextState = insertReducer(state, { - type: InsertActionTypes.TOGGLE_INSERT_DOCUMENT, + type: InsertActionTypes.INSERT_DOCUMENT_VIEW_TOGGLED, view: 'JSON', }); @@ -113,7 +113,7 @@ describe('insertReducer', function () { }); }); - describe('UPDATE_JSON_DOC', function () { + describe('JSON_DOC_EDITED', function () { it('sets the jsonDoc and resets doc to an empty document', function () { const state: InsertState = { ...INITIAL_INSERT_STATE, @@ -123,7 +123,7 @@ describe('insertReducer', function () { }; const nextState = insertReducer(state, { - type: InsertActionTypes.UPDATE_JSON_DOC, + type: InsertActionTypes.JSON_DOC_EDITED, jsonDoc: '{"foo":"bar"}', }); diff --git a/packages/compass-crud/src/stores/insert.ts b/packages/compass-crud/src/stores/insert.ts index 862aa70a47d..f81661c3c49 100644 --- a/packages/compass-crud/src/stores/insert.ts +++ b/packages/compass-crud/src/stores/insert.ts @@ -52,9 +52,10 @@ export const INITIAL_INSERT_STATE: InsertState = { export const InsertActionTypes = { OPEN_INSERT_DOCUMENT_DIALOG: 'crud/insert/OPEN_INSERT_DOCUMENT_DIALOG', CLOSE_INSERT_DOCUMENT_DIALOG: 'crud/insert/CLOSE_INSERT_DOCUMENT_DIALOG', - TOGGLE_INSERT_DOCUMENT: 'crud/insert/TOGGLE_INSERT_DOCUMENT', - TOGGLE_INSERT_DOCUMENT_VIEW: 'crud/insert/TOGGLE_INSERT_DOCUMENT_VIEW', - UPDATE_JSON_DOC: 'crud/insert/UPDATE_JSON_DOC', + INSERT_DOCUMENT_VIEW_TOGGLED: 'crud/insert/INSERT_DOCUMENT_VIEW_TOGGLED', + INSERT_MANY_DOCUMENTS_VIEW_TOGGLED: + 'crud/insert/INSERT_MANY_DOCUMENTS_VIEW_TOGGLED', + JSON_DOC_EDITED: 'crud/insert/JSON_DOC_EDITED', UPDATE_COMMENT: 'crud/insert/UPDATE_COMMENT', INSERT_DOCUMENT_ERROR: 'crud/insert/INSERT_DOCUMENT_ERROR', } as const; @@ -70,18 +71,18 @@ export type CloseInsertDocumentDialogAction = { type: typeof InsertActionTypes.CLOSE_INSERT_DOCUMENT_DIALOG; }; -export type ToggleInsertDocumentAction = { - type: typeof InsertActionTypes.TOGGLE_INSERT_DOCUMENT; +export type InsertDocumentViewToggledAction = { + type: typeof InsertActionTypes.INSERT_DOCUMENT_VIEW_TOGGLED; view: DocumentView; }; -export type ToggleInsertDocumentViewAction = { - type: typeof InsertActionTypes.TOGGLE_INSERT_DOCUMENT_VIEW; +export type InsertManyDocumentsViewToggledAction = { + type: typeof InsertActionTypes.INSERT_MANY_DOCUMENTS_VIEW_TOGGLED; jsonView: boolean; }; -export type UpdateJsonDocAction = { - type: typeof InsertActionTypes.UPDATE_JSON_DOC; +export type JsonDocEditedAction = { + type: typeof InsertActionTypes.JSON_DOC_EDITED; jsonDoc: string | null; }; @@ -102,9 +103,9 @@ export type InsertDocumentErrorAction = { export type InsertActions = | OpenInsertDocumentDialogAction | CloseInsertDocumentDialogAction - | ToggleInsertDocumentAction - | ToggleInsertDocumentViewAction - | UpdateJsonDocAction + | InsertDocumentViewToggledAction + | InsertManyDocumentsViewToggledAction + | JsonDocEditedAction | UpdateCommentAction | InsertDocumentErrorAction; @@ -127,58 +128,45 @@ export const insertReducer: Reducer = ( if (isAction(action, InsertActionTypes.CLOSE_INSERT_DOCUMENT_DIALOG)) { return INITIAL_INSERT_STATE; } - if (isAction(action, InsertActionTypes.TOGGLE_INSERT_DOCUMENT)) { + if (isAction(action, InsertActionTypes.INSERT_DOCUMENT_VIEW_TOGGLED)) { if (action.view === 'JSON') { return { - doc: state.doc, + ...state, jsonView: true, jsonDoc: state.doc?.toEJSON() ?? null, error: undefined, - csfleState: state.csfleState, mode: MODIFYING, - isOpen: true, - isCommentNeeded: state.isCommentNeeded, }; } - let hadronDoc; - if (state.jsonDoc === '') { - hadronDoc = state.doc; - } else { - hadronDoc = HadronDocument.FromEJSON(state.jsonDoc ?? ''); - } + const hadronDoc = + state.jsonDoc === '' + ? state.doc + : HadronDocument.FromEJSON(state.jsonDoc ?? ''); return { + ...state, doc: hadronDoc, jsonView: false, - jsonDoc: state.jsonDoc, error: undefined, - csfleState: state.csfleState, mode: MODIFYING, - isOpen: true, - isCommentNeeded: state.isCommentNeeded, }; } - if (isAction(action, InsertActionTypes.TOGGLE_INSERT_DOCUMENT_VIEW)) { + if (isAction(action, InsertActionTypes.INSERT_MANY_DOCUMENTS_VIEW_TOGGLED)) { return { + ...state, doc: new Document({}), - jsonDoc: state.jsonDoc, jsonView: action.jsonView, error: undefined, - csfleState: state.csfleState, mode: MODIFYING, - isOpen: true, - isCommentNeeded: state.isCommentNeeded, }; } - if (isAction(action, InsertActionTypes.UPDATE_JSON_DOC)) { + if (isAction(action, InsertActionTypes.JSON_DOC_EDITED)) { return { + ...state, doc: new Document({}), jsonDoc: action.jsonDoc, jsonView: true, error: undefined, - csfleState: state.csfleState, mode: MODIFYING, - isOpen: true, - isCommentNeeded: state.isCommentNeeded, }; } if (isAction(action, InsertActionTypes.UPDATE_COMMENT)) { @@ -205,21 +193,21 @@ export function closeInsertDocumentDialog(): CloseInsertDocumentDialogAction { export function toggleInsertDocument( view: DocumentView -): ToggleInsertDocumentAction { - return { type: InsertActionTypes.TOGGLE_INSERT_DOCUMENT, view }; +): InsertDocumentViewToggledAction { + return { type: InsertActionTypes.INSERT_DOCUMENT_VIEW_TOGGLED, view }; } export function toggleInsertDocumentView( view: DocumentView -): ToggleInsertDocumentViewAction { +): InsertManyDocumentsViewToggledAction { return { - type: InsertActionTypes.TOGGLE_INSERT_DOCUMENT_VIEW, + type: InsertActionTypes.INSERT_MANY_DOCUMENTS_VIEW_TOGGLED, jsonView: view === 'JSON', }; } -export function updateJsonDoc(jsonDoc: string | null): UpdateJsonDocAction { - return { type: InsertActionTypes.UPDATE_JSON_DOC, jsonDoc }; +export function updateJsonDoc(jsonDoc: string | null): JsonDocEditedAction { + return { type: InsertActionTypes.JSON_DOC_EDITED, jsonDoc }; } export function updateComment(isCommentNeeded: boolean): UpdateCommentAction { From a9387e2442897220e2fbe5e0860d6025a97cc570 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Thu, 9 Jul 2026 22:38:21 -0700 Subject: [PATCH 7/7] fixup: move non-serializable state out of store --- .../src/components/bulk-delete-modal.spec.tsx | 2 +- .../src/components/bulk-delete-modal.tsx | 18 +++++---- .../compass-crud/src/stores/bulk-delete.ts | 38 +++++++++---------- .../compass-crud/src/stores/bulk-update.ts | 35 +++++++++-------- .../src/stores/crud-store.spec.ts | 38 +++++++++---------- .../compass-crud/src/stores/crud-store.ts | 1 + packages/compass-crud/src/stores/reducer.ts | 3 ++ 7 files changed, 69 insertions(+), 66 deletions(-) diff --git a/packages/compass-crud/src/components/bulk-delete-modal.spec.tsx b/packages/compass-crud/src/components/bulk-delete-modal.spec.tsx index b9486ba077c..52ca4a38297 100644 --- a/packages/compass-crud/src/components/bulk-delete-modal.spec.tsx +++ b/packages/compass-crud/src/components/bulk-delete-modal.spec.tsx @@ -18,7 +18,7 @@ function renderBulkDeleteModal( documentCount={0} filter={{ a: 1 }} namespace="mydb.mycoll" - sampleDocuments={[]} + sampleDocumentsEJSON={[]} onCancel={() => {}} onConfirmDeletion={() => {}} onExportToLanguage={() => {}} diff --git a/packages/compass-crud/src/components/bulk-delete-modal.tsx b/packages/compass-crud/src/components/bulk-delete-modal.tsx index 3b85f13e942..7b15bc90729 100644 --- a/packages/compass-crud/src/components/bulk-delete-modal.tsx +++ b/packages/compass-crud/src/components/bulk-delete-modal.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useMemo } from 'react'; +import { Document as HadronDocument } from 'hadron-document'; import { Modal, ModalHeader, @@ -17,7 +18,6 @@ import type { BSONObject, CrudState } from '../stores/crud-store'; import { toJSString } from 'mongodb-query-parser'; import { ReadonlyFilter } from './readonly-filter'; import ReadonlyDocument from './readonly-document'; -import type { Document } from 'bson'; import { useLastAppliedQuery } from '@mongodb-js/compass-query-bar'; import { closeBulkDeleteDialog, runBulkDelete } from '../stores/bulk-delete'; import { openDeleteQueryExportToLanguageDialog } from '../stores/documents'; @@ -70,7 +70,7 @@ export type BulkDeleteModalProps = { documentCount?: number; filter: BSONObject; namespace: string; - sampleDocuments: Document[]; + sampleDocumentsEJSON: string[]; onCancel: () => void; onConfirmDeletion: () => void; onExportToLanguage: () => void; @@ -81,17 +81,21 @@ export const BulkDeleteModal: React.FunctionComponent = ({ documentCount, filter, namespace, - sampleDocuments, + sampleDocumentsEJSON, onCancel, onConfirmDeletion, onExportToLanguage, }) => { + const sampleDocuments = useMemo( + () => sampleDocumentsEJSON.map((ejson) => HadronDocument.FromEJSON(ejson)), + [sampleDocumentsEJSON] + ); const preview = (
{sampleDocuments.map((doc, i) => { return ( - + ); })} @@ -166,10 +170,10 @@ function ConnectedBulkDeleteModal(props: Omit) { export default connect( (state: CrudState) => ({ - open: state.bulkDelete.status === 'open', + open: state.bulkDelete.isOpen, documentCount: state.bulkDelete.affected, namespace: state.documents.ns, - sampleDocuments: state.bulkDelete.previews, + sampleDocumentsEJSON: state.bulkDelete.previewDocumentsEJSON, }), { onCancel: closeBulkDeleteDialog, diff --git a/packages/compass-crud/src/stores/bulk-delete.ts b/packages/compass-crud/src/stores/bulk-delete.ts index 178a8935ff6..828442fb1cb 100644 --- a/packages/compass-crud/src/stores/bulk-delete.ts +++ b/packages/compass-crud/src/stores/bulk-delete.ts @@ -1,5 +1,4 @@ import type { Reducer } from 'redux'; -import { Document } from 'hadron-document'; import { mongoLogId } from '@mongodb-js/compass-logging/provider'; import { showConfirmation } from '@mongodb-js/compass-components'; import { isAction } from '../utils/is-action'; @@ -12,14 +11,14 @@ import { import { refreshDocuments } from './documents'; export type BulkDeleteState = { - previews: Document[]; - status: 'open' | 'closed' | 'in-progress'; + previewDocumentsEJSON: string[]; + isOpen: boolean; affected?: number; }; export const INITIAL_BULK_DELETE_STATE: BulkDeleteState = { - previews: [], - status: 'closed', + previewDocumentsEJSON: [], + isOpen: false, affected: 0, }; @@ -31,7 +30,7 @@ export const BulkDeleteActionTypes = { export type OpenBulkDeleteDialogAction = { type: typeof BulkDeleteActionTypes.OPEN_BULK_DELETE; - previews: Document[]; + previewDocumentsEJSON: string[]; affected: number | undefined; }; @@ -39,14 +38,14 @@ export type CloseBulkDeleteDialogAction = { type: typeof BulkDeleteActionTypes.CLOSE_BULK_DELETE; }; -export type BulkDeleteInProgressAction = { +export type BulkDeleteStartedAction = { type: typeof BulkDeleteActionTypes.BULK_DELETE_STARTED; }; export type BulkDeleteActions = | OpenBulkDeleteDialogAction | CloseBulkDeleteDialogAction - | BulkDeleteInProgressAction; + | BulkDeleteStartedAction; export const bulkDeleteReducer: Reducer = ( state = INITIAL_BULK_DELETE_STATE, @@ -54,16 +53,16 @@ export const bulkDeleteReducer: Reducer = ( ) => { if (isAction(action, BulkDeleteActionTypes.OPEN_BULK_DELETE)) { return { - previews: action.previews, - status: 'open', + previewDocumentsEJSON: action.previewDocumentsEJSON, + isOpen: true, affected: action.affected, }; } if (isAction(action, BulkDeleteActionTypes.CLOSE_BULK_DELETE)) { - return { ...state, status: 'closed' }; + return { ...state, isOpen: false }; } if (isAction(action, BulkDeleteActionTypes.BULK_DELETE_STARTED)) { - return { ...state, status: 'in-progress' }; + return { ...state, isOpen: false }; } return state; }; @@ -78,16 +77,15 @@ export function openBulkDeleteDialog(): CrudThunkAction< track('Bulk Delete Opened', {}, connectionInfoRef.current); const state = getState(); - const previews = (state.documents.docs?.slice(0, PREVIEW_DOCS) ?? []).map( - (doc) => { - // Break the link with the docs in the list so that expanding/collapsing - // docs in the modal doesn't modify the ones in the list. - return Document.FromEJSON(doc.toEJSON()); - } - ); + // Store the serialized EJSON rather than Document instances so that the + // state stays serializable and expanding/collapsing docs in the modal + // doesn't modify the ones in the list. + const previewDocumentsEJSON = ( + state.documents.docs?.slice(0, PREVIEW_DOCS) ?? [] + ).map((doc) => doc.toEJSON()); dispatch({ type: BulkDeleteActionTypes.OPEN_BULK_DELETE, - previews, + previewDocumentsEJSON, affected: state.documents.count ?? undefined, }); }; diff --git a/packages/compass-crud/src/stores/bulk-update.ts b/packages/compass-crud/src/stores/bulk-update.ts index 66895866dfb..5a3ddf3c160 100644 --- a/packages/compass-crud/src/stores/bulk-update.ts +++ b/packages/compass-crud/src/stores/bulk-update.ts @@ -25,7 +25,6 @@ export type BulkUpdateState = { preview: UpdatePreview; syntaxError?: Error; serverError?: Error; - previewAbortController?: AbortController; affected?: number; }; @@ -42,7 +41,7 @@ export const INITIAL_BULK_UPDATE_STATE: BulkUpdateState = { export const BulkUpdateActionTypes = { OPEN_BULK_UPDATE: 'crud/bulk-update/OPEN_BULK_UPDATE', CLOSE_BULK_UPDATE: 'crud/bulk-update/CLOSE_BULK_UPDATE', - FETCH_PREVIEW_STARTED: 'crud/bulk-update/FETCH_PREVIEW_STARTED', + BULK_UPDATE_STARTED: 'crud/bulk-update/BULK_UPDATE_STARTED', FETCH_PREVIEW_FINISHED: 'crud/bulk-update/FETCH_PREVIEW_FINISHED', FETCH_PREVIEW_SYNTAX_ERRORED: 'crud/bulk-update/FETCH_PREVIEW_SYNTAX_ERRORED', FETCH_PREVIEW_SERVER_ERRORED: 'crud/bulk-update/FETCH_PREVIEW_SERVER_ERRORED', @@ -56,9 +55,8 @@ export type CloseBulkUpdateAction = { type: typeof BulkUpdateActionTypes.CLOSE_BULK_UPDATE; }; -export type PreviewStartedAction = { - type: typeof BulkUpdateActionTypes.FETCH_PREVIEW_STARTED; - abortController: AbortController; +export type BulkUpdateStartedAction = { + type: typeof BulkUpdateActionTypes.BULK_UPDATE_STARTED; }; export type PreviewUpdatedAction = { @@ -82,7 +80,7 @@ export type PreviewServerErrorAction = { export type BulkUpdateActions = | OpenBulkUpdateAction | CloseBulkUpdateAction - | PreviewStartedAction + | BulkUpdateStartedAction | PreviewUpdatedAction | PreviewSyntaxErrorAction | PreviewServerErrorAction; @@ -97,8 +95,8 @@ export const bulkUpdateReducer: Reducer = ( if (isAction(action, BulkUpdateActionTypes.CLOSE_BULK_UPDATE)) { return { ...state, isOpen: false }; } - if (isAction(action, BulkUpdateActionTypes.FETCH_PREVIEW_STARTED)) { - return { ...state, previewAbortController: action.abortController }; + if (isAction(action, BulkUpdateActionTypes.BULK_UPDATE_STARTED)) { + return { ...state, isOpen: false }; } if (isAction(action, BulkUpdateActionTypes.FETCH_PREVIEW_FINISHED)) { return { @@ -107,7 +105,6 @@ export const bulkUpdateReducer: Reducer = ( preview: action.preview, serverError: undefined, syntaxError: undefined, - previewAbortController: undefined, }; } if (isAction(action, BulkUpdateActionTypes.FETCH_PREVIEW_SYNTAX_ERRORED)) { @@ -117,7 +114,6 @@ export const bulkUpdateReducer: Reducer = ( preview: { changes: [] }, serverError: undefined, syntaxError: action.syntaxError, - previewAbortController: undefined, }; } if (isAction(action, BulkUpdateActionTypes.FETCH_PREVIEW_SERVER_ERRORED)) { @@ -127,7 +123,6 @@ export const bulkUpdateReducer: Reducer = ( preview: { changes: [] }, serverError: action.serverError, syntaxError: undefined, - previewAbortController: undefined, }; } return state; @@ -140,9 +135,13 @@ export function closeBulkUpdateModal(): CloseBulkUpdateAction { export function updateBulkUpdatePreview( updateText: string ): CrudThunkAction, BulkUpdateActions> { - return async (dispatch, getState, { dataService, queryBar }) => { + return async ( + dispatch, + getState, + { dataService, queryBar, bulkUpdatePreviewAbortControllerRef } + ) => { const state = getState(); - state.bulkUpdate.previewAbortController?.abort(); + bulkUpdatePreviewAbortControllerRef.current?.abort(); // If preview is not supported, just verify the update parses. if (!state.collectionMeta.isUpdatePreviewSupported) { @@ -165,16 +164,14 @@ export function updateBulkUpdatePreview( } const abortController = new AbortController(); - dispatch({ - type: BulkUpdateActionTypes.FETCH_PREVIEW_STARTED, - abortController, - }); + bulkUpdatePreviewAbortControllerRef.current = abortController; let update: BSONObject | BSONObject[]; try { update = parseShellBSON(updateText); } catch (err: any) { if (abortController.signal.aborted) return; + bulkUpdatePreviewAbortControllerRef.current = undefined; dispatch({ type: BulkUpdateActionTypes.FETCH_PREVIEW_SYNTAX_ERRORED, updateText, @@ -196,6 +193,7 @@ export function updateBulkUpdatePreview( }); } catch (err: any) { if (abortController.signal.aborted) return; + bulkUpdatePreviewAbortControllerRef.current = undefined; dispatch({ type: BulkUpdateActionTypes.FETCH_PREVIEW_SERVER_ERRORED, updateText, @@ -206,6 +204,7 @@ export function updateBulkUpdatePreview( if (abortController.signal.aborted) return; + bulkUpdatePreviewAbortControllerRef.current = undefined; dispatch({ type: BulkUpdateActionTypes.FETCH_PREVIEW_FINISHED, updateText, @@ -261,7 +260,7 @@ export function runBulkUpdate(): CrudThunkAction< connectionInfoRef.current ); - dispatch(closeBulkUpdateModal()); + dispatch({ type: BulkUpdateActionTypes.BULK_UPDATE_STARTED }); // Latch the affected count for the duration of the toast. const affected = getState().documents.count ?? undefined; diff --git a/packages/compass-crud/src/stores/crud-store.spec.ts b/packages/compass-crud/src/stores/crud-store.spec.ts index 08b8b3f7c65..2ca8bbf56cd 100644 --- a/packages/compass-crud/src/stores/crud-store.spec.ts +++ b/packages/compass-crud/src/stores/crud-store.spec.ts @@ -416,8 +416,8 @@ describe('store', function () { }); expect(state.bulkDelete).to.deep.equal({ affected: 0, - previews: [], - status: 'closed', + previewDocumentsEJSON: [], + isOpen: false, }); }); }); @@ -898,7 +898,7 @@ describe('store', function () { void store.dispatch(openBulkUpdateModal()); await waitForState(store, (state) => { - expect(state.bulkUpdate.previewAbortController).to.not.exist; + expect(state.bulkUpdate.preview.changes.length).to.equal(1); }); const bulkUpdate = store.getState().bulkUpdate; @@ -920,7 +920,6 @@ describe('store', function () { }, ], }, - previewAbortController: undefined, serverError: undefined, syntaxError: undefined, updateText: '{\n $set: {\n\n },\n}', @@ -932,7 +931,7 @@ describe('store', function () { void store.dispatch(openBulkUpdateModal()); await waitForState(store, (state) => { - expect(state.bulkUpdate.previewAbortController).to.not.exist; + expect(state.bulkUpdate.preview.changes.length).to.equal(1); }); store.dispatch(closeBulkUpdateModal()); @@ -956,7 +955,6 @@ describe('store', function () { }, ], }, - previewAbortController: undefined, serverError: undefined, syntaxError: undefined, updateText: '{\n $set: {\n\n },\n}', @@ -1013,15 +1011,16 @@ describe('store', function () { store.dispatch(openBulkDeleteDialog()); - const previews = store.getState().bulkDelete.previews; + const previewDocumentsEJSON = + store.getState().bulkDelete.previewDocumentsEJSON; - // because we make a copy of the previews what comes out will not be the - // same as what goes in so just check the previews separately - expect(previews[0].doc.a).to.deep.equal(new Int32(1)); + expect( + HadronDocument.FromEJSON(previewDocumentsEJSON[0]).get('a')?.value + ).to.deep.equal(new Int32(1)); expect(store.getState().bulkDelete).to.deep.equal({ - previews, - status: 'open', + previewDocumentsEJSON, + isOpen: true, affected: 1, }); }); @@ -1038,14 +1037,16 @@ describe('store', function () { store.dispatch(openBulkDeleteDialog()); store.dispatch(closeBulkDeleteDialog()); - const previews = store.getState().bulkDelete.previews; + const previewDocumentsEJSON = + store.getState().bulkDelete.previewDocumentsEJSON; - // same comment as above - expect(previews[0].doc.a).to.deep.equal(new Int32(1)); + expect( + HadronDocument.FromEJSON(previewDocumentsEJSON[0]).get('a')?.value + ).to.deep.equal(new Int32(1)); expect(store.getState().bulkDelete).to.deep.equal({ - previews, - status: 'closed', + previewDocumentsEJSON, + isOpen: false, affected: 1, }); }); @@ -2684,7 +2685,6 @@ describe('store', function () { preview: { changes: [], }, - previewAbortController: undefined, serverError: undefined, syntaxError: undefined, updateText: '{ $set: { anotherField: 2 } }', @@ -2718,7 +2718,6 @@ describe('store', function () { preview: { changes: [], }, - previewAbortController: undefined, serverError: undefined, syntaxError: undefined, updateText: '{ $set: { anotherField: 2 } }', @@ -2766,7 +2765,6 @@ describe('store', function () { }, ], }, - previewAbortController: undefined, serverError: undefined, syntaxError: undefined, updateText: '{ $set: { anotherField: 2 } }', diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index 08ee1a85f82..e17c0a8b85a 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -159,6 +159,7 @@ export function activateDocumentsPlugin( connectionScopedAppRegistry, queryBar, crudOptions: options, + bulkUpdatePreviewAbortControllerRef: { current: undefined }, }; const store = createStore( diff --git a/packages/compass-crud/src/stores/reducer.ts b/packages/compass-crud/src/stores/reducer.ts index f8b5d9dcf97..e54882db9de 100644 --- a/packages/compass-crud/src/stores/reducer.ts +++ b/packages/compass-crud/src/stores/reducer.ts @@ -73,6 +73,9 @@ export type CrudExtraArgs = { connectionScopedAppRegistry: ConnectionScopedAppRegistry; queryBar: QueryBarService; crudOptions: CrudStoreOptions; + bulkUpdatePreviewAbortControllerRef: { + current: AbortController | undefined; + }; }; export type CrudReduxActions =