From b296a117dc116b4da02aa24ed9e87e554e48af59 Mon Sep 17 00:00:00 2001 From: Kevin Mas Ruiz Date: Fri, 20 Oct 2023 13:36:59 +0200 Subject: [PATCH] feat: delete bulk operation modal COMPASS-7326 (#5005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: e2e flow * chore: add bulk state in a new substate * chore: add todo markers * chore: styling * chore: more styling 💅 * chore: linting * chore: add dependency to query-parser * chore: more styling * chore: fix height of preview * chore: linting * chore: fix package-lock.json * chore: fix package-lock * chore: fix tests * chore: test modal * chore: oops i did it again, remove the .only * chore: remove unused imports * chore: test bulk dialog * chore: last minute design changes * chore: add ts-ignore and clean up --- package-lock.json | 2 + packages/compass-crud/package.json | 1 + .../src/components/bulk-delete-modal.spec.tsx | 75 +++++++++ .../src/components/bulk-delete-modal.tsx | 159 ++++++++++++++++++ .../src/components/document-list.tsx | 43 ++++- .../src/stores/crud-store.spec.ts | 53 ++++++ .../compass-crud/src/stores/crud-store.ts | 81 +++++++++ 7 files changed, 405 insertions(+), 9 deletions(-) create mode 100644 packages/compass-crud/src/components/bulk-delete-modal.spec.tsx create mode 100644 packages/compass-crud/src/components/bulk-delete-modal.tsx diff --git a/package-lock.json b/package-lock.json index 20e72168f1a..5bc7f67ffe7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44827,6 +44827,7 @@ "mongodb-data-service": "^22.12.2", "mongodb-instance-model": "^12.12.2", "mongodb-ns": "^2.4.0", + "mongodb-query-parser": "^3.1.3", "nyc": "^15.1.0", "prop-types": "^15.7.2", "react": "^17.0.2", @@ -58712,6 +58713,7 @@ "mongodb-data-service": "^22.12.2", "mongodb-instance-model": "^12.12.2", "mongodb-ns": "^2.4.0", + "mongodb-query-parser": "^3.1.3", "nyc": "^15.1.0", "prop-types": "^15.7.2", "react": "^17.0.2", diff --git a/packages/compass-crud/package.json b/packages/compass-crud/package.json index 2047844d06e..800b89b92c1 100644 --- a/packages/compass-crud/package.json +++ b/packages/compass-crud/package.json @@ -91,6 +91,7 @@ "mocha": "^10.2.0", "mongodb-data-service": "^22.12.2", "mongodb-instance-model": "^12.12.2", + "mongodb-query-parser": "^3.1.3", "mongodb-ns": "^2.4.0", "nyc": "^15.1.0", "prop-types": "^15.7.2", diff --git a/packages/compass-crud/src/components/bulk-delete-modal.spec.tsx b/packages/compass-crud/src/components/bulk-delete-modal.spec.tsx new file mode 100644 index 00000000000..350d77794e6 --- /dev/null +++ b/packages/compass-crud/src/components/bulk-delete-modal.spec.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { render, screen, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import BulkDeleteModal from './bulk-delete-modal'; + +function renderBulkDeleteModal( + props?: Partial> +) { + return render( + {}} + onConfirmDeletion={() => {}} + {...props} + /> + ); +} + +describe('BulkDeleteModal Component', function () { + afterEach(function () { + cleanup(); + }); + + it('does not render if closed', function () { + renderBulkDeleteModal({ open: false }); + expect(screen.queryByText(/Delete/)).to.not.exist; + }); + + it('does render if open', function () { + renderBulkDeleteModal(); + expect(screen.queryAllByText(/Delete/)).to.not.be.empty; + }); + + it('shows the number of documents that will be deleted', function () { + renderBulkDeleteModal({ documentCount: 42 }); + expect(screen.queryAllByText('Delete 42 documents')[0]).to.be.visible; + }); + + it('shows the affected collection', function () { + renderBulkDeleteModal({ namespace: 'mydb.mycoll' }); + expect(screen.queryByText('mydb.mycoll')).to.be.visible; + }); + + it('shows the provided query', function () { + renderBulkDeleteModal({ filterQuery: '{ a: 1 }' }); + expect(screen.queryByDisplayValue('{ a: 1 }')).to.be.visible; + }); + + it('closes the modal when cancelled', function () { + const onCloseSpy = sinon.spy(); + renderBulkDeleteModal({ onCancel: onCloseSpy }); + + userEvent.click(screen.getByText('Close').closest('button')!); + expect(onCloseSpy).to.have.been.calledOnce; + }); + + it('confirms deletion when clicked on the Delete documents button', function () { + const onConfirmDeletionSpy = sinon.spy(); + renderBulkDeleteModal({ + documentCount: 10, + onConfirmDeletion: onConfirmDeletionSpy, + }); + + userEvent.click( + screen.getAllByText('Delete 10 documents')[1].closest('button')! + ); + expect(onConfirmDeletionSpy).to.have.been.calledOnce; + }); +}); diff --git a/packages/compass-crud/src/components/bulk-delete-modal.tsx b/packages/compass-crud/src/components/bulk-delete-modal.tsx new file mode 100644 index 00000000000..649345773dc --- /dev/null +++ b/packages/compass-crud/src/components/bulk-delete-modal.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Button, + TextInput, + KeylineCard, + css, + cx, + spacing, + InfoSprinkle, + Label, +} from '@mongodb-js/compass-components'; +import ReadonlyDocument from './readonly-document'; + +const modalFooterSpacingStyles = css({ + gap: spacing[2], +}); + +const documentHorizontalWrapper = css({ + display: 'flex', + flexDirection: 'row', + flex: 'none', + flexShrink: 0, + overflow: 'auto', + marginBottom: spacing[2], + gap: spacing[2], + maxWidth: '100%', +}); + +const documentContainerStyles = css({ + display: 'flex', + flexDirection: 'column', + flex: 'none', + flexShrink: 0, + marginBottom: spacing[2], + width: '100%', +}); + +const documentStyles = css({ + flexBasis: '164px', + flexGrow: 1, + flexShrink: 0, + overflow: 'auto', + padding: 0, + width: '100%', +}); + +const modalBodySpacingStyles = css({ + marginTop: spacing[3], + paddingLeft: spacing[5], + display: 'flex', + flexDirection: 'column', + gap: spacing[3], +}); + +const previewStyles = css({ + minHeight: '200px', +}); + +type QueryLabelProps = { + tooltip: string; + label: string; +}; + +const queryLabelStyles = css({ + display: 'flex', + gap: spacing[2], + alignItems: 'center', +}); + +const QueryLabel: React.FunctionComponent = ({ + tooltip, + label, +}) => { + return ( +
+ + {tooltip} +
+ ); +}; + +type BulkDeleteModalProps = { + open: boolean; + documentCount: number; + filterQuery: string; + namespace: string; + sampleDocuments: Document[]; + onCancel: () => void; + onConfirmDeletion: () => void; +}; + +const BulkDeleteModal: React.FunctionComponent = ({ + open, + documentCount, + filterQuery, + namespace, + sampleDocuments, + onCancel, + onConfirmDeletion, +}) => { + const preview = ( +
+ {sampleDocuments.map((doc, i) => { + return ( + +
+ +
+
+ ); + })} +
+ ); + + return ( + + + + + } + disabled={true} + value={filterQuery} + /> +
+ Preview (sample of {sampleDocuments.length} documents) + {preview} +
+
+ + + + +
+ ); +}; + +export default BulkDeleteModal; diff --git a/packages/compass-crud/src/components/document-list.tsx b/packages/compass-crud/src/components/document-list.tsx index ef45e13ab5f..bea484ba65f 100644 --- a/packages/compass-crud/src/components/document-list.tsx +++ b/packages/compass-crud/src/components/document-list.tsx @@ -21,6 +21,7 @@ import type { DocumentTableViewProps } from './table-view/document-table-view'; import DocumentTableView from './table-view/document-table-view'; import type { CrudToolbarProps } from './crud-toolbar'; import { CrudToolbar } from './crud-toolbar'; +import { toJSString } from 'mongodb-query-parser'; import type { DOCUMENTS_STATUSES } from '../constants/documents-statuses'; import { @@ -37,8 +38,8 @@ import type { DocumentView, QueryState, } from '../stores/crud-store'; -import type Document from 'hadron-document'; import { getToolbarSignal } from '../utils/toolbar-signal'; +import BulkDeleteModal from './bulk-delete-modal'; const listAndJsonStyles = css({ padding: spacing[3], @@ -147,13 +148,6 @@ class DocumentList extends React.Component { } } - /** - * Handle opening the delete bulk dialog. - */ - handleDeleteButton() { - return; - } - /** * Render the views for the document list. * @@ -242,6 +236,36 @@ class DocumentList extends React.Component { } } + onOpenBulkDeleteDialog() { + this.props.store.openBulkDeleteDialog(); + } + + onCancelBulkDeleteDialog() { + this.props.store.closeBulkDeleteDialog(); + } + + onConfirmBulkDeleteDialog() { + void this.props.store.runBulkDelete(); + } + + /** + * Render the bulk deletion modal + */ + renderDeletionModal() { + return ( + + ); + } /** * Render EmptyContent view when no documents are present. * @@ -322,7 +346,7 @@ class DocumentList extends React.Component { isExportable={this.props.isExportable} onApplyClicked={this.onApplyClicked.bind(this)} onResetClicked={this.onResetClicked.bind(this)} - onDeleteButtonClicked={this.handleDeleteButton.bind(this)} + onDeleteButtonClicked={this.onOpenBulkDeleteDialog.bind(this)} openExportFileDialog={this.props.openExportFileDialog} outdated={this.props.outdated} readonly={!this.props.isEditable} @@ -348,6 +372,7 @@ class DocumentList extends React.Component { {this.renderZeroState()} {this.renderContent()} {this.renderInsertModal()} + {this.renderDeletionModal()} ); diff --git a/packages/compass-crud/src/stores/crud-store.spec.ts b/packages/compass-crud/src/stores/crud-store.spec.ts index e9f75d56d7f..087277875cb 100644 --- a/packages/compass-crud/src/stores/crud-store.spec.ts +++ b/packages/compass-crud/src/stores/crud-store.spec.ts @@ -185,6 +185,11 @@ describe('store', function () { expect(store.state).to.deep.equal({ abortController: null, + bulkDelete: { + affected: 0, + previews: [], + status: 'closed', + }, debouncingLoad: false, loadingCount: false, collection: '', @@ -823,6 +828,54 @@ describe('store', function () { ); }); + describe('#bulkDeleteDialog', function () { + let store; + let actions; + + beforeEach(function () { + actions = configureActions(); + store = configureStore({ + localAppRegistry: localAppRegistry, + globalAppRegistry: globalAppRegistry, + dataProvider: { + error: null, + dataProvider: dataService, + }, + actions: actions, + namespace: 'compass-crud.test', + }); + }); + + 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.openBulkDeleteDialog(); + + expect(store.state.bulkDelete).to.deep.equal({ + previews: [hadronDoc], + status: 'open', + affected: 1, + }); + }); + + it('closes the bulk dialog keeping previous state', function () { + const hadronDoc = new HadronDocument({ a: 1 }); + store.state.docs = [hadronDoc]; + store.state.count = 1; + + store.openBulkDeleteDialog(); + store.closeBulkDeleteDialog(); + + expect(store.state.bulkDelete).to.deep.equal({ + previews: [hadronDoc], + status: 'closed', + affected: 1, + }); + }); + }); + describe('#replaceDocument', function () { let store; let actions; diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index a953ff55caf..d635ba21070 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -39,6 +39,7 @@ import configureGridStore from './grid-store'; import type { TypeCastMap } from 'hadron-type-checker'; import type AppRegistry from 'hadron-app-registry'; import { BaseRefluxStore } from './base-reflux-store'; +import { showConfirmation } from '@mongodb-js/compass-components'; export type BSONObject = TypeCastMap['Object']; export type BSONArray = TypeCastMap['Array']; type Mutable = { -readonly [P in keyof T]: T[P] }; @@ -57,6 +58,9 @@ export type CrudActions = { replaceDocument(doc: Document): void; openInsertDocumentDialog(doc: BSONObject, cloned: boolean): void; copyToClipboard(doc: Document): void; //XXX + openBulkDeleteDialog(): void; + closeBulkDeleteDialog(): void; + runBulkDelete(): void; }; export type DocumentView = 'List' | 'JSON' | 'Table'; @@ -366,6 +370,12 @@ export type QueryState = { collation: null | BSONObject; }; +export type BulkDeleteState = { + previews: Document[]; + status: 'open' | 'closed' | 'in-progress'; + affected?: number; +}; + type CrudState = { ns: string; collection: string; @@ -396,6 +406,7 @@ type CrudState = { fields: string[]; isCollectionScan?: boolean; isSearchIndexesSupported: boolean; + bulkDelete: BulkDeleteState; }; class CrudStoreImpl @@ -455,6 +466,11 @@ class CrudStoreImpl fields: [], isCollectionScan: false, isSearchIndexesSupported: false, + bulkDelete: { + previews: [], + status: 'closed', + affected: 0, + }, }; } @@ -1584,6 +1600,71 @@ class CrudStoreImpl openCreateSearchIndexModal() { this.localAppRegistry.emit('open-create-search-index-modal'); } + + openBulkDeleteDialog() { + const PREVIEW_DOCS = 5; + + this.setState({ + bulkDelete: { + previews: this.state.docs?.slice(0, PREVIEW_DOCS) || [], + status: 'open', + affected: this.state.count || 0, + }, + }); + } + + bulkDeleteInProgress() { + this.setState({ + bulkDelete: { + ...this.state.bulkDelete, + status: 'in-progress', + }, + }); // TODO: COMPASS-7328 + } + + bulkDeleteFailed(ex: Error) { + return ex; // TODO: COMPASS-7328 + } + + bulkDeleteSuccess() { + // TODO: COMPASS-7328 + } + + closeBulkDeleteDialog() { + this.setState({ + bulkDelete: { + ...this.state.bulkDelete, + status: 'closed', + }, + }); + } + + async runBulkDelete() { + const { affected } = this.state.bulkDelete; + this.closeBulkDeleteDialog(); + + const confirmation = await showConfirmation({ + title: 'Are you absolutely sure?', + buttonText: 'Delete', + description: `This action can not be undone. This will permanently delete ${ + affected || 0 + } documents.`, + variant: 'danger', + }); + + if (confirmation) { + this.bulkDeleteInProgress(); + try { + await this.dataService.deleteMany( + this.state.ns, + this.state.query.filter + ); + this.bulkDeleteSuccess(); + } catch (ex) { + this.bulkDeleteFailed(ex as Error); + } + } + } } export type CrudStore = Store & CrudStoreImpl & { gridStore: GridStore };