diff --git a/package-lock.json b/package-lock.json index e2e009f3e9e..ed6417a6115 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44437,6 +44437,7 @@ "hadron-type-checker": "^7.4.6", "jsondiffpatch": "^0.5.0", "lodash": "^4.17.21", + "mongodb": "^6.12.0", "mongodb-data-service": "^22.25.6", "mongodb-ns": "^2.4.2", "mongodb-query-parser": "^4.3.0", @@ -49469,7 +49470,8 @@ "bson": "^6.10.3", "eventemitter3": "^4.0.0", "hadron-type-checker": "^7.4.6", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "mongodb": "^6.14.1" }, "devDependencies": { "@mongodb-js/eslint-config-compass": "^1.3.6", @@ -56750,6 +56752,7 @@ "jsondiffpatch": "^0.5.0", "lodash": "^4.17.21", "mocha": "^10.2.0", + "mongodb": "^6.12.0", "mongodb-data-service": "^22.25.6", "mongodb-instance-model": "^12.26.6", "mongodb-ns": "^2.4.2", @@ -74726,6 +74729,7 @@ "lodash": "^4.17.21", "mocha": "^10.2.0", "moment": "^2.29.4", + "mongodb": "^6.14.1", "prettier": "^2.7.1", "sinon": "^17.0.1" }, diff --git a/packages/compass-components/src/components/document-list/document-edit-actions-footer.tsx b/packages/compass-components/src/components/document-list/document-edit-actions-footer.tsx index 6468e605f5b..4dffbae7253 100644 --- a/packages/compass-components/src/components/document-list/document-edit-actions-footer.tsx +++ b/packages/compass-components/src/components/document-list/document-edit-actions-footer.tsx @@ -6,6 +6,7 @@ import { css } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; import { spacing } from '@leafygreen-ui/tokens'; import { useDarkMode } from '../../hooks/use-theme'; +import { showErrorDetails } from '../../hooks/use-error-details'; type Status = | 'Initial' @@ -81,13 +82,30 @@ function useHadronDocumentStatus( ? 'Deleting' : 'Initial'; }); - const [errorMessage, setErrorMessage] = useState(null); + const [error, setError] = useState<{ + message: string; + details?: Record; + } | null>(null); const invalidElementsRef = useRef(new Set()); - const updateStatus = useCallback((newStatus: Status, errorMessage = null) => { - setStatus(newStatus); - setErrorMessage(errorMessage); - }, []); + const updateStatus = useCallback( + ( + newStatus: Status, + error: Error | null = null, + errorDetails?: Record + ) => { + setStatus(newStatus); + setError( + error + ? { + message: error?.message, + details: errorDetails, + } + : null + ); + }, + [] + ); useEffect(() => { if (status !== 'Initial') { @@ -128,8 +146,11 @@ function useHadronDocumentStatus( const onUpdateSuccess = () => { updateStatus('UpdateSuccess'); }; - const onUpdateError = (err: string) => { - updateStatus('UpdateError', err); + const onUpdateError = ( + err: Error, + errorDetails?: Record + ) => { + updateStatus('UpdateError', err, errorDetails); }; const onRemoveStart = () => { updateStatus('DeleteStart'); @@ -137,8 +158,11 @@ function useHadronDocumentStatus( const onRemoveSuccess = () => { updateStatus('DeleteSuccess'); }; - const onRemoveError = (err: string) => { - updateStatus('DeleteError', err); + const onRemoveError = ( + err: Error, + errorDetails?: Record + ) => { + updateStatus('DeleteError', err, errorDetails); }; doc.on(Element.Events.Added, onUpdate); @@ -183,30 +207,30 @@ function useHadronDocumentStatus( } }, [status, updateStatus]); - return { status, updateStatus, errorMessage }; + return { status, updateStatus, error }; } const container = css({ display: 'flex', - paddingTop: spacing[2], - paddingRight: spacing[2], - paddingBottom: spacing[2], - paddingLeft: spacing[3], + paddingTop: spacing[200], + paddingRight: spacing[200], + paddingBottom: spacing[200], + paddingLeft: spacing[400], alignItems: 'center', - gap: spacing[2], + gap: spacing[200], borderBottomLeftRadius: 'inherit', borderBottomRightRadius: 'inherit', }); const message = css({ - overflow: 'hidden', - textOverflow: 'ellipsis', + overflow: 'scroll', }); const buttonGroup = css({ display: 'flex', marginLeft: 'auto', - gap: spacing[2], + gap: spacing[200], + flexShrink: 0, }); const button = css({ @@ -275,7 +299,7 @@ const EditActionsFooter: React.FunctionComponent<{ const { status: _status, updateStatus, - errorMessage, + error, } = useHadronDocumentStatus(doc, editing, deleting); const darkMode = useDarkMode(); @@ -303,10 +327,25 @@ const EditActionsFooter: React.FunctionComponent<{ data-status={status} >
- {errorMessage ?? statusMessage} + {error?.message ?? statusMessage}
{!isSuccess(status) && (
+ {error?.details && ( + + )} + )} + + )} + + + ); +}; export default withLogger(InsertDocumentDialog, 'COMPASS-CRUD-UI'); diff --git a/packages/compass-crud/src/stores/crud-store.spec.ts b/packages/compass-crud/src/stores/crud-store.spec.ts index 50eba4ff896..2b1c47ac349 100644 --- a/packages/compass-crud/src/stores/crud-store.spec.ts +++ b/packages/compass-crud/src/stores/crud-store.spec.ts @@ -296,7 +296,6 @@ describe('store', function () { isOpen: false, jsonDoc: null, jsonView: false, - message: '', csfleState: { state: 'none' }, mode: 'modifying', }, @@ -501,7 +500,7 @@ describe('store', function () { }); it('sets the error for the document', function (done) { - hadronDoc.on('remove-error', (message) => { + hadronDoc.on('remove-error', ({ message }) => { expect(message).to.equal('error happened'); done(); }); @@ -611,7 +610,7 @@ describe('store', function () { }); it('sets the error for the document', function (done) { - hadronDoc.on('update-error', (message) => { + hadronDoc.on('update-error', ({ message }) => { expect(message).to.equal( 'Unable to update, no changes have been made.' ); @@ -634,7 +633,7 @@ describe('store', function () { }); it('sets the error for the document', function (done) { - hadronDoc.on('update-error', (message) => { + hadronDoc.on('update-error', ({ message }) => { expect(message).to.equal('error happened'); done(); }); @@ -726,7 +725,7 @@ describe('store', function () { const invalidHadronDoc = new HadronDocument(doc); (invalidHadronDoc as any).getId = null; - invalidHadronDoc.on('update-error', (message) => { + invalidHadronDoc.on('update-error', ({ message }) => { expect(message).to.equal( 'An error occured when attempting to update the document: this.getId is not a function' ); @@ -996,7 +995,7 @@ describe('store', function () { }); it('sets the error for the document', function (done) { - hadronDoc.on('update-error', (message) => { + hadronDoc.on('update-error', ({ message }) => { expect(message).to.equal('error happened'); done(); }); @@ -1125,7 +1124,7 @@ describe('store', function () { expect(state.insert.jsonDoc).to.equal(null); expect(state.insert.isOpen).to.equal(false); expect(state.insert.jsonView).to.equal(false); - expect(state.insert.message).to.equal(''); + expect(state.insert.error).to.equal(undefined); }); store.state.insert.doc = doc; @@ -1153,7 +1152,7 @@ describe('store', function () { expect(state.insert.jsonDoc).to.equal(null); expect(state.insert.isOpen).to.equal(false); expect(state.insert.jsonView).to.equal(false); - expect(state.insert.message).to.equal(''); + expect(state.insert.error).to.equal(undefined); }); void store.insertDocument(); @@ -1181,7 +1180,8 @@ describe('store', function () { expect(state.insert.jsonDoc).to.equal(jsonDoc); expect(state.insert.isOpen).to.equal(true); expect(state.insert.jsonView).to.equal(true); - expect(state.insert.message).to.not.equal(''); + expect(state.insert.error).to.exist; + expect(state.insert.error.message).to.not.be.empty; expect(state.insert.mode).to.equal('error'); }); @@ -1216,7 +1216,8 @@ describe('store', function () { expect(state.insert.jsonDoc).to.equal(jsonDoc); expect(state.insert.isOpen).to.equal(true); expect(state.insert.jsonView).to.equal(true); - expect(state.insert.message).to.not.equal(''); + expect(state.insert.error).to.exist; + expect(state.insert.error.message).to.not.be.empty; }); void store.insertDocument(); @@ -1246,7 +1247,8 @@ describe('store', function () { expect(state.insert.jsonDoc).to.equal(jsonDoc); expect(state.insert.isOpen).to.equal(true); expect(state.insert.jsonView).to.equal(false); - expect(state.insert.message).to.not.equal(''); + expect(state.insert.error).to.exist; + expect(state.insert.error.message).to.not.be.empty; }); store.state.insert.doc = doc; @@ -1255,6 +1257,40 @@ describe('store', function () { 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; + }); + + afterEach(async function () { + await dataService.deleteMany('compass-crud.test', {}); + }); + + 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.insert.jsonDoc).to.equal(jsonDoc); + expect(state.insert.isOpen).to.equal(true); + expect(state.insert.jsonView).to.equal(true); + expect(state.insert.error).to.exist; + expect(state.insert.error.message).to.not.be.empty; + expect(state.insert.error.info).not.to.be.empty; + }); + + void store.insertDocument(); + + await listener; + }); + }); }); }); @@ -1287,7 +1323,7 @@ describe('store', function () { expect(state.insert.jsonDoc).to.equal(null); expect(state.insert.isOpen).to.equal(false); expect(state.insert.jsonView).to.equal(false); - expect(state.insert.message).to.equal(''); + expect(state.insert.error).to.equal(undefined); expect(state.status).to.equal('fetching'); expect(state.abortController).to.not.be.null; @@ -1347,7 +1383,7 @@ describe('store', function () { expect(state.insert.jsonDoc).to.equal(null); expect(state.insert.isOpen).to.equal(false); expect(state.insert.jsonView).to.equal(false); - expect(state.insert.message).to.equal(''); + expect(state.insert.error).to.equal(undefined); }); store.state.insert.jsonDoc = docs; @@ -1403,7 +1439,10 @@ describe('store', function () { expect(state.insert.jsonDoc).to.deep.equal(docs); expect(state.insert.isOpen).to.equal(true); expect(state.insert.jsonView).to.equal(true); - expect(state.insert.message).to.equal('Document failed validation'); + expect(state.insert.error).to.not.be.null; + expect(state.insert.error?.message).to.equal( + 'Document failed validation' + ); }); store.state.insert.jsonDoc = docs; diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index 13afb48418a..4ec244ec0f1 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -2,6 +2,7 @@ import type { Listenable, Store } from 'reflux'; import Reflux from 'reflux'; import toNS from 'mongodb-ns'; import { findIndex, isEmpty, isEqual } from 'lodash'; +import type { MongoServerError } from 'mongodb'; import semver from 'semver'; import StateMixin from '@mongodb-js/reflux-state-mixin'; import type { Element } from 'hadron-document'; @@ -267,10 +268,15 @@ export type InsertCSFLEState = { encryptedFields?: string[]; }; +export type WriteError = { + message: string; + info?: Record; +}; + type InsertState = { doc: null | Document; jsonDoc: null | string; - message: string; + error?: WriteError; csfleState: InsertCSFLEState; mode: 'modifying' | 'error'; jsonView: boolean; @@ -463,7 +469,6 @@ class CrudStoreImpl return { doc: null, jsonDoc: null, - message: '', csfleState: { state: 'none' }, mode: MODIFYING, jsonView: false, @@ -533,6 +538,13 @@ class CrudStoreImpl 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)); @@ -1005,7 +1017,7 @@ class CrudStoreImpl doc: hadronDoc, jsonDoc: jsonDoc, jsonView: true, - message: '', + error: undefined, csfleState, mode: MODIFYING, isOpen: true, @@ -1268,7 +1280,7 @@ class CrudStoreImpl doc: this.state.insert.doc, jsonView: true, jsonDoc: jsonDoc ?? null, - message: '', + error: undefined, csfleState: this.state.insert.csfleState, mode: MODIFYING, isOpen: true, @@ -1289,7 +1301,7 @@ class CrudStoreImpl doc: hadronDoc, jsonView: false, jsonDoc: this.state.insert.jsonDoc, - message: '', + error: undefined, csfleState: this.state.insert.csfleState, mode: MODIFYING, isOpen: true, @@ -1311,7 +1323,7 @@ class CrudStoreImpl doc: new Document({}), jsonDoc: this.state.insert.jsonDoc, jsonView: jsonView, - message: '', + error: undefined, csfleState: this.state.insert.csfleState, mode: MODIFYING, isOpen: true, @@ -1332,7 +1344,7 @@ class CrudStoreImpl doc: new Document({}), jsonDoc: value, jsonView: true, - message: '', + error: undefined, csfleState: this.state.insert.csfleState, mode: MODIFYING, isOpen: true, @@ -1381,7 +1393,7 @@ class CrudStoreImpl doc: new Document({}), jsonDoc: this.state.insert.jsonDoc, jsonView: true, - message: (error as Error).message, + error: this.getWriteError(error as Error), csfleState: this.state.insert.csfleState, mode: ERROR, isOpen: true, @@ -1443,7 +1455,7 @@ class CrudStoreImpl doc: this.state.insert.doc, jsonDoc: this.state.insert.jsonDoc, jsonView: this.state.insert.jsonView, - message: (error as Error).message, + error: this.getWriteError(error as Error), csfleState: this.state.insert.csfleState, mode: ERROR, isOpen: true, diff --git a/packages/compass-e2e-tests/helpers/commands/set-validation.ts b/packages/compass-e2e-tests/helpers/commands/set-validation.ts index da7e0d60265..c4a0052fca0 100644 --- a/packages/compass-e2e-tests/helpers/commands/set-validation.ts +++ b/packages/compass-e2e-tests/helpers/commands/set-validation.ts @@ -1,10 +1,17 @@ import type { CompassBrowser } from '../compass-browser'; import * as Selectors from '../selectors'; -export async function setValidation( +export async function setValidationWithinValidationTab( browser: CompassBrowser, value: string ): Promise { + // enter edit mode if not already entered + const enableEditBtn = browser.$(Selectors.EnableEditValidationButton); + if (await enableEditBtn.isExisting()) { + await browser.clickVisible(enableEditBtn); + } + + // change value await browser.setCodemirrorEditorValue(Selectors.ValidationEditor, value); // it should eventually detect that the text changed @@ -40,3 +47,29 @@ export async function setValidation( // replaced await browser.pause(2000); } + +export async function setValidation( + browser: CompassBrowser, + { + connectionName, + database, + collection, + validator, + }: { + connectionName: string; + database: string; + collection: string; + validator: string; + } +): Promise { + await browser.navigateToCollectionTab( + connectionName, + database, + collection, + 'Validation' + ); + await browser.clickVisible(Selectors.AddRuleButton); + const element = browser.$(Selectors.ValidationEditor); + await element.waitForDisplayed(); + await browser.setValidationWithinValidationTab(validator); +} diff --git a/packages/compass-e2e-tests/helpers/commands/try-to-insert-document.ts b/packages/compass-e2e-tests/helpers/commands/try-to-insert-document.ts new file mode 100644 index 00000000000..87334159004 --- /dev/null +++ b/packages/compass-e2e-tests/helpers/commands/try-to-insert-document.ts @@ -0,0 +1,34 @@ +import type { CompassBrowser } from '../compass-browser'; +import * as Selectors from '../selectors'; +import { expect } from 'chai'; + +export async function tryToInsertDocument( + browser: CompassBrowser, + document?: string +) { + // browse to the "Insert to Collection" modal + await browser.clickVisible(Selectors.AddDataButton); + const insertDocumentOption = browser.$(Selectors.InsertDocumentOption); + await insertDocumentOption.waitForDisplayed(); + await browser.clickVisible(Selectors.InsertDocumentOption); + + // wait for the modal to appear + const insertDialog = browser.$(Selectors.InsertDialog); + await insertDialog.waitForDisplayed(); + + if (document) { + // set the text in the editor + await browser.setCodemirrorEditorValue( + Selectors.InsertJSONEditor, + document + ); + } + + // confirm + const insertConfirm = browser.$(Selectors.InsertConfirm); + // this selector is very brittle, so just make sure it works + expect(await insertConfirm.isDisplayed()).to.be.true; + expect(await insertConfirm.getText()).to.equal('Insert'); + await insertConfirm.waitForEnabled(); + await browser.clickVisible(Selectors.InsertConfirm); +} diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index 152a09d70fe..e831699616c 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -593,6 +593,7 @@ export const DocumentListFetchingStopButton = '[data-testid="documents-content"] [data-testid="fetching-documents"] button'; export const DocumentListError = '[data-testid="document-list-error-summary"]'; export const AddDataButton = '[data-testid="crud-add-data-show-actions"]'; +export const EditDocumentButton = '[data-testid="edit-document-button"]'; export const InsertDocumentOption = '[data-testid="crud-add-data-insert-document-action"]'; export const ImportFileOption = @@ -608,6 +609,8 @@ export const CloneDocumentButton = '[data-testid="clone-document-button"]'; export const DeleteDocumentButton = '[data-testid="remove-document-button"]'; export const DocumentFooter = '[data-testid="document-footer"]'; export const DocumentFooterMessage = '[data-testid="document-footer-message"]'; +export const DocumentFooterErrorDetailsButton = + '[data-testid="edit-actions-footer-error-details-button"]'; export const UpdateDocumentButton = `${DocumentFooter} [data-testid="update-button"]`; export const ConfirmDeleteDocumentButton = `${DocumentFooter} [data-testid="delete-button"]`; export const JSONDocumentCard = '[data-testid="editable-json"]'; @@ -615,12 +618,15 @@ export const JSONEditDocumentButton = `${JSONDocumentCard} [data-testid="editor- export const ShowMoreFieldsButton = '[data-testid="show-more-fields-button"]'; export const OpenBulkUpdateButton = '[data-testid="crud-update"]'; export const OpenBulkDeleteButton = '[data-testid="crud-bulk-delete"]'; +export const ErrorDetailsJson = '[data-testid="error-details-json"]'; // Insert Document modal export const InsertDialog = '[data-testid="insert-document-modal"]'; export const InsertDialogErrorMessage = '[data-testid="insert-document-banner"][data-variant="danger"]'; +export const InsertDialogErrorDetailsBtn = + 'button[data-testid="insert-document-error-details-button"]'; export const InsertJSONEditor = '[data-testid="insert-document-json-editor"]'; export const InsertConfirm = '[data-testid="insert-document-modal"] [data-testid="submit-button"]'; @@ -647,6 +653,8 @@ export const ImportAnalyzeError = export const ImportConfirm = '[data-testid="import-modal"] [data-testid="import-button"]'; export const ImportToast = '[data-testid="toast-import-toast"]'; +export const ImportToastErrorDetailsBtn = + '[data-testid="toast-import-toast"] [data-testid="import-error-details-button"]'; export const ImportToastAbort = '[data-testid="toast-action-stop"]'; export const ImportFieldLabel = '[data-testid="import-modal"] .import-field-label'; @@ -1311,11 +1319,11 @@ export const ConfirmationModalInput = `${ConfirmationModal} input`; export const confirmationModalConfirmButton = ( modalSelector = ConfirmationModal -) => `${modalSelector} [role=dialog] button:nth-of-type(1)`; +) => `${modalSelector} [role=dialog] [data-testid*="confirm_button"]`; export const confirmationModalCancelButton = ( modalSelector = ConfirmationModal -) => `${modalSelector} [role=dialog] button:nth-of-type(2)`; +) => `${modalSelector} [role=dialog] [data-testid*="cancel_button"]`; // New pipeline from text modal export const NewPipelineFromTextModal = '[data-testid="import-pipeline-modal"]'; diff --git a/packages/compass-e2e-tests/tests/collection-documents-tab.test.ts b/packages/compass-e2e-tests/tests/collection-documents-tab.test.ts index 0757e4f6633..ad8a568c70d 100644 --- a/packages/compass-e2e-tests/tests/collection-documents-tab.test.ts +++ b/packages/compass-e2e-tests/tests/collection-documents-tab.test.ts @@ -17,8 +17,9 @@ import { createNestedDocumentsCollection, createNumbersCollection, } from '../helpers/insert-data'; -import { context } from '../helpers/test-runner-context'; +import { context as testRunnerContext } from '../helpers/test-runner-context'; import type { ChainablePromiseElement } from 'webdriverio'; +import { tryToInsertDocument } from '../helpers/commands/try-to-insert-document'; const { expect } = chai; @@ -553,7 +554,7 @@ FindIterable result = collection.find(filter);`); }); it('can copy a document from the contextual toolbar', async function () { - if (context.disableClipboardUsage) { + if (testRunnerContext.disableClipboardUsage) { this.skip(); } @@ -685,4 +686,183 @@ FindIterable result = collection.find(filter);`); expect(numExpandedHadronElementsPostSwitch).to.equal(14); }); }); + + context('with existing validation rule', function () { + const REQUIRE_PHONE_VALIDATOR = + '{ $jsonSchema: { bsonType: "object", required: [ "phone" ] } }'; + beforeEach(async function () { + await browser.setValidation({ + connectionName: DEFAULT_CONNECTION_NAME_1, + database: 'test', + collection: 'numbers', + validator: REQUIRE_PHONE_VALIDATOR, + }); + }); + + it('Shows error info when inserting', async function () { + await browser.navigateToCollectionTab( + DEFAULT_CONNECTION_NAME_1, + 'test', + 'numbers', + 'Documents' + ); + await tryToInsertDocument(browser, '{}'); + + const errorElement = browser.$(Selectors.InsertDialogErrorMessage); + await errorElement.waitForDisplayed(); + expect(await errorElement.getText()).to.include( + 'Document failed validation' + ); + // enter details + const errorDetailsBtn = browser.$(Selectors.InsertDialogErrorDetailsBtn); + await errorElement.waitForDisplayed(); + await errorDetailsBtn.click(); + + const errorDetailsJson = browser.$(Selectors.ErrorDetailsJson); + await errorDetailsJson.waitForDisplayed(); + + // exit details + await browser.clickVisible(Selectors.confirmationModalConfirmButton()); + await errorElement.waitForDisplayed(); + }); + + describe('Editing', function () { + beforeEach(async function () { + await browser.navigateWithinCurrentCollectionTabs('Documents'); + await tryToInsertDocument(browser, `{ "phone": 12345 }`); + await browser.runFindOperation('Documents', '{ "phone": 12345 }'); + }); + + it('shows error info when editing via list view', async function () { + const document = browser.$(Selectors.DocumentListEntry); + await document.waitForDisplayed(); + + // enter edit mode + await browser.hover(Selectors.DocumentListEntry); + await browser.clickVisible(Selectors.EditDocumentButton); + + // rename the required field + const input = document.$( + `${Selectors.HadronDocumentElement}:last-child ${Selectors.HadronDocumentKeyEditor}` + ); + await browser.setValueVisible(input, 'somethingElse'); + + // confirm update + const footer = document.$(Selectors.DocumentFooterMessage); + expect(await footer.getText()).to.equal('Document modified.'); + + const button = document.$(Selectors.UpdateDocumentButton); + await button.click(); + + const errorMessage = browser.$(Selectors.DocumentFooterMessage); + await errorMessage.waitForDisplayed(); + expect(await errorMessage.getText()).to.include( + 'Document failed validation' + ); + + // enter details + const errorDetailsBtn = browser.$( + Selectors.DocumentFooterErrorDetailsButton + ); + await errorDetailsBtn.waitForDisplayed(); + await errorDetailsBtn.click(); + + const errorDetailsJson = browser.$(Selectors.ErrorDetailsJson); + await errorDetailsJson.waitForDisplayed(); + + // exit details + await browser.clickVisible(Selectors.confirmationModalConfirmButton()); + await errorDetailsJson.waitForDisplayed({ reverse: true }); + }); + + it('shows error info when editing via json view', async function () { + await browser.clickVisible(Selectors.SelectJSONView); + + const document = browser.$(Selectors.DocumentJSONEntry); + await document.waitForDisplayed(); + + await waitForJSON(browser, document); + + // enter edit mode + await browser.hover(Selectors.JSONDocumentCard); + await browser.clickVisible(Selectors.JSONEditDocumentButton); + + // remove the required field + await browser.setCodemirrorEditorValue( + Selectors.DocumentJSONEntry, + `{}` + ); + + // confirm update + const footer = document.$(Selectors.DocumentFooterMessage); + expect(await footer.getText()).to.equal('Document modified.'); + + const button = document.$(Selectors.UpdateDocumentButton); + await button.click(); + + const errorMessage = browser.$(Selectors.DocumentFooterMessage); + await errorMessage.waitForDisplayed(); + expect(await errorMessage.getText()).to.include( + 'Document failed validation' + ); + + // enter details + const errorDetailsBtn = browser.$( + Selectors.DocumentFooterErrorDetailsButton + ); + await errorDetailsBtn.waitForDisplayed(); + await errorDetailsBtn.click(); + + const errorDetailsJson = browser.$(Selectors.ErrorDetailsJson); + await errorDetailsJson.waitForDisplayed(); + + // exit details + await browser.clickVisible(Selectors.confirmationModalConfirmButton()); + await errorDetailsJson.waitForDisplayed({ reverse: true }); + }); + + it('shows error info when editing via table view', async function () { + await browser.clickVisible(Selectors.SelectTableView); + + const document = browser.$('.ag-center-cols-clipper .ag-row-first'); + await document.waitForDisplayed(); + + // enter edit mode + const value = document.$('[col-id="phone"] .element-value'); + await value.doubleClick(); + + // remove the required field + await browser.clickVisible( + '[data-testid="table-view-cell-editor-remove-field-button"]' + ); + + // confirm update + const footer = browser.$(Selectors.DocumentFooterMessage); + expect(await footer.getText()).to.equal('Document modified.'); + + const button = browser.$(Selectors.UpdateDocumentButton); + await button.click(); + + const errorMessage = browser.$(Selectors.DocumentFooterMessage); + await errorMessage.waitForDisplayed(); + expect(await errorMessage.getText()).to.include( + 'Document failed validation' + ); + + // enter details + const errorDetailsBtn = browser.$( + Selectors.DocumentFooterErrorDetailsButton + ); + await errorDetailsBtn.waitForDisplayed(); + await errorDetailsBtn.click(); + + const errorDetailsJson = browser.$(Selectors.ErrorDetailsJson); + await errorDetailsJson.waitForDisplayed(); + + // exit details + await browser.clickVisible(Selectors.confirmationModalConfirmButton()); + await errorDetailsJson.waitForDisplayed({ reverse: true }); + }); + }); + }); }); diff --git a/packages/compass-e2e-tests/tests/collection-import.test.ts b/packages/compass-e2e-tests/tests/collection-import.test.ts index e1712edb2cd..e83f82d3469 100644 --- a/packages/compass-e2e-tests/tests/collection-import.test.ts +++ b/packages/compass-e2e-tests/tests/collection-import.test.ts @@ -509,6 +509,129 @@ describe('Collection import', function () { await toastElement.waitForDisplayed({ reverse: true }); }); + context('with validation', function () { + beforeEach(async function () { + const FAILING_VALIDATOR = + '{ $jsonSchema: { bsonType: "object", required: [ "abcdefgh" ] } }'; + await browser.setValidation({ + connectionName: DEFAULT_CONNECTION_NAME_1, + database: 'test', + collection: 'extended-json-file', + validator: FAILING_VALIDATOR, + }); + }); + + afterEach(async function () { + await browser.navigateWithinCurrentCollectionTabs('Validation'); + await browser.setValidationWithinValidationTab('{}'); + }); + + it('with JSON + abort on error checked, it displays a validation error with details', async function () { + const jsonPath = path.resolve( + __dirname, + '..', + 'fixtures', + 'three-documents.json' + ); + + await browser.navigateWithinCurrentCollectionTabs('Documents'); + + // open the import modal + await browser.clickVisible(Selectors.AddDataButton); + const insertDocumentOption = browser.$(Selectors.ImportFileOption); + await insertDocumentOption.waitForDisplayed(); + await browser.clickVisible(Selectors.ImportFileOption); + + // Select the file. + await browser.selectFile(Selectors.ImportFileInput, jsonPath); + // Wait for the modal to appear. + const importModal = browser.$(Selectors.ImportModal); + await importModal.waitForDisplayed(); + + // Click the stop on errors checkbox. + const stopOnErrorsCheckbox = browser.$( + Selectors.ImportStopOnErrorsCheckbox + ); + const stopOnErrorsLabel = stopOnErrorsCheckbox.parentElement(); + await stopOnErrorsLabel.click(); + + // Confirm import. + await browser.clickVisible(Selectors.ImportConfirm); + + // Wait for the modal to go away. + await importModal.waitForDisplayed({ reverse: true }); + + // Wait for the error toast to appear + const toastElement = browser.$(Selectors.ImportToast); + await toastElement.waitForDisplayed(); + const errorText = await toastElement.getText(); + expect(errorText).to.include('Document failed validation'); + + // Visit error details + await browser.clickVisible(Selectors.ImportToastErrorDetailsBtn); + const errorDetailsJson = browser.$(Selectors.ErrorDetailsJson); + await errorDetailsJson.waitForDisplayed(); + expect(await errorDetailsJson.getText()).to.include( + 'schemaRulesNotSatisfied' + ); + await browser.clickVisible(Selectors.confirmationModalConfirmButton()); + }); + + it('with CSV + abort on error unchecked, it includes the details in a file', async function () { + const filename = 'array-documents.csv'; + const csvPath = path.resolve(__dirname, '..', 'fixtures', filename); + + await browser.navigateWithinCurrentCollectionTabs('Documents'); + + // open the import modal + await browser.clickVisible(Selectors.AddDataButton); + const insertDocumentOption = browser.$(Selectors.ImportFileOption); + await insertDocumentOption.waitForDisplayed(); + await browser.clickVisible(Selectors.ImportFileOption); + + // Select the file. + await browser.selectFile(Selectors.ImportFileInput, csvPath); + // Wait for the modal to appear. + const importModal = browser.$(Selectors.ImportModal); + await importModal.waitForDisplayed(); + + // Confirm import. + await browser.clickVisible(Selectors.ImportConfirm); + + // Wait for the modal to go away. + await importModal.waitForDisplayed({ reverse: true }); + + // Wait for the error toast to appear + const toastElement = browser.$(Selectors.ImportToast); + await toastElement.waitForDisplayed(); + const errorText = await toastElement.getText(); + expect(errorText).to.include('Document failed validation'); + expect(errorText).to.include('VIEW LOG'); + + // Find the log file + const logFilePath = path.resolve( + compass.userDataPath || '', + compass.appName || '', + 'ImportErrorLogs', + `import-${filename}.log` + ); + await expect(fs.stat(logFilePath)).to.not.be.rejected; + + // Check the log file contents for 3 errors. + const logFileContent = await fs.readFile(logFilePath, 'utf-8'); + expect(logFileContent.includes('schemaRulesNotSatisfied:')); + + // Close the toast + await browser + .$(Selectors.closeToastButton(Selectors.ImportToast)) + .waitForDisplayed(); + await browser.clickVisible( + Selectors.closeToastButton(Selectors.ImportToast) + ); + await toastElement.waitForDisplayed({ reverse: true }); + }); + }); + it('supports CSV files', async function () { const csvPath = path.resolve(__dirname, '..', 'fixtures', 'listings.csv'); diff --git a/packages/compass-e2e-tests/tests/collection-validation-tab.test.ts b/packages/compass-e2e-tests/tests/collection-validation-tab.test.ts index 79c5358cc38..4c56ae5c8a5 100644 --- a/packages/compass-e2e-tests/tests/collection-validation-tab.test.ts +++ b/packages/compass-e2e-tests/tests/collection-validation-tab.test.ts @@ -51,7 +51,7 @@ describe('Collection validation tab', function () { const element = browser.$(Selectors.ValidationEditor); await element.waitForDisplayed(); - await browser.setValidation(validation); + await browser.setValidationWithinValidationTab(validation); } context('when the schema validation is empty', function () { @@ -89,7 +89,7 @@ describe('Collection validation tab', function () { }); // generated rules can be edited and saved - await browser.setValidation(PASSING_VALIDATOR); + await browser.setValidationWithinValidationTab(PASSING_VALIDATOR); }); }); @@ -145,7 +145,7 @@ describe('Collection validation tab', function () { }); // the automatic indentation and brackets makes multi-line values very fiddly here - await browser.setValidation(PASSING_VALIDATOR); + await browser.setValidationWithinValidationTab(PASSING_VALIDATOR); await browser.clickVisible(Selectors.ValidationLoadSampleDocumentsBtn); // nothing failed, everything passed diff --git a/packages/compass-e2e-tests/tests/connection.test.ts b/packages/compass-e2e-tests/tests/connection.test.ts index bf8627129cb..542ab573466 100644 --- a/packages/compass-e2e-tests/tests/connection.test.ts +++ b/packages/compass-e2e-tests/tests/connection.test.ts @@ -24,6 +24,7 @@ import { DEFAULT_CONNECTION_NAMES, isTestingWeb, } from '../helpers/test-runner-context'; +import { tryToInsertDocument } from '../helpers/commands/try-to-insert-document'; async function disconnect(browser: CompassBrowser) { try { @@ -162,25 +163,8 @@ async function assertCannotInsertData( 'Documents' ); - // browse to the "Insert to Collection" modal - await browser.clickVisible(Selectors.AddDataButton); - const insertDocumentOption = browser.$(Selectors.InsertDocumentOption); - await insertDocumentOption.waitForDisplayed(); - await browser.clickVisible(Selectors.InsertDocumentOption); - - // wait for the modal to appear - const insertDialog = browser.$(Selectors.InsertDialog); - await insertDialog.waitForDisplayed(); - // go with the default text which should just be a random new id and therefore valid - - // confirm - const insertConfirm = browser.$(Selectors.InsertConfirm); - // this selector is very brittle, so just make sure it works - expect(await insertConfirm.isDisplayed()).to.be.true; - expect(await insertConfirm.getText()).to.equal('Insert'); - await insertConfirm.waitForEnabled(); - await browser.clickVisible(Selectors.InsertConfirm); + await tryToInsertDocument(browser); // make sure that there's an error and that the insert button is disabled const errorElement = browser.$(Selectors.InsertDialogErrorMessage); @@ -190,6 +174,7 @@ async function assertCannotInsertData( ); // cancel and wait for the modal to go away + const insertDialog = browser.$(Selectors.InsertDialog); await browser.clickVisible(Selectors.InsertCancel); await insertDialog.waitForDisplayed({ reverse: true }); } diff --git a/packages/compass-import-export/src/components/export-code-view.tsx b/packages/compass-import-export/src/components/export-code-view.tsx index 4260ed16c34..0d91d5d9ea2 100644 --- a/packages/compass-import-export/src/components/export-code-view.tsx +++ b/packages/compass-import-export/src/components/export-code-view.tsx @@ -11,6 +11,8 @@ import { queryAsShellJSString, } from '../utils/get-shell-js-string'; +export const codeElementId = 'export-collection-code-preview-wrapper'; + const containerStyles = css({ marginBottom: spacing[3], }); @@ -74,6 +76,7 @@ function ExportCodeView({ diff --git a/packages/compass-import-export/src/components/export-modal.tsx b/packages/compass-import-export/src/components/export-modal.tsx index 82e101ed450..97d07dedd89 100644 --- a/packages/compass-import-export/src/components/export-modal.tsx +++ b/packages/compass-import-export/src/components/export-modal.tsx @@ -30,7 +30,7 @@ import type { ExportStatus, FieldsToExportOption } from '../modules/export'; import type { RootExportState } from '../stores/export-store'; import { SelectFileType } from './select-file-type'; import { ExportSelectFields } from './export-select-fields'; -import { ExportCodeView } from './export-code-view'; +import { codeElementId, ExportCodeView } from './export-code-view'; import type { ExportAggregation, ExportQuery } from '../export/export-types'; import { queryHasProjection } from '../utils/query-has-projection'; import { FieldsToExportOptions } from './export-field-options'; @@ -246,7 +246,12 @@ function ExportModal({ }, [isOpen, resetExportFormState]); return ( - + void +) { openToast(importToastId, { title: 'Failed to import with the following error:', - description: err?.message, + description: ( + <> + {err?.message}  + {showErrorDetails && ( + { + showErrorDetails(); + closeToast(importToastId); + }} + data-testid="import-error-details-button" + > + View error details + + )} + + ), variant: 'warning', }); } diff --git a/packages/compass-import-export/src/import/import-csv.spec.ts b/packages/compass-import-export/src/import/import-csv.spec.ts index a97d715ebf0..3c4c4353014 100644 --- a/packages/compass-import-export/src/import/import-csv.spec.ts +++ b/packages/compass-import-export/src/import/import-csv.spec.ts @@ -777,13 +777,19 @@ describe('importCSV', function () { const errors = errorCallback.args.map((args) => args[0]); for (const [index, error] of errors.entries()) { expect(error.op).to.exist; - // cheat and copy them over because it is big and with buffers - expectedErrors[index].op = error.op; + expect(error).to.deep.contain(expectedErrors[index]); + expect(error).to.have.nested.property('errInfo.details'); } - expect(errors).to.deep.equal(expectedErrors); - const errorsText = await fs.promises.readFile(output.path, 'utf8'); - expect(errorsText).to.equal(formatErrorLines(expectedErrors)); + const outputErrors = await fs.promises.readFile(output.path, 'utf8'); + const parsedOutputErrors = outputErrors + .trim() + .split('\n') + .map((err) => JSON.parse(err)); + for (const [index, error] of parsedOutputErrors.entries()) { + expect(error).to.deep.contain(expectedErrors[index]); + expect(error).to.have.nested.property('errInfo.details'); + } }); it('responds to abortSignal.aborted', async function () { diff --git a/packages/compass-import-export/src/import/import-json.spec.ts b/packages/compass-import-export/src/import/import-json.spec.ts index a348e6814a9..24a0a3772d9 100644 --- a/packages/compass-import-export/src/import/import-json.spec.ts +++ b/packages/compass-import-export/src/import/import-json.spec.ts @@ -555,13 +555,19 @@ describe('importJSON', function () { const errors = errorCallback.args.map((args) => args[0]); for (const [index, error] of errors.entries()) { expect(error.op).to.exist; - // cheat and copy them over because it is big and with buffers - expectedErrors[index].op = error.op; + expect(error).to.deep.contain(expectedErrors[index]); + expect(error).to.have.nested.property('errInfo.details'); } - expect(errors).to.deep.equal(expectedErrors); - const errorsText = await fs.promises.readFile(output.path, 'utf8'); - expect(errorsText).to.equal(formatErrorLines(expectedErrors)); + const outputErrors = await fs.promises.readFile(output.path, 'utf8'); + const parsedOutputErrors = outputErrors + .trim() + .split('\n') + .map((err) => JSON.parse(err)); + for (const [index, error] of parsedOutputErrors.entries()) { + expect(error).to.deep.contain(expectedErrors[index]); + expect(error).to.have.nested.property('errInfo.details'); + } }); it('responds to abortSignal.aborted', async function () { diff --git a/packages/compass-import-export/src/import/import-types.ts b/packages/compass-import-export/src/import/import-types.ts index 252263298cb..564b9cd53a1 100644 --- a/packages/compass-import-export/src/import/import-types.ts +++ b/packages/compass-import-export/src/import/import-types.ts @@ -28,7 +28,7 @@ export type ErrorJSON = { index?: number; code?: string | number; op?: any; - errorInfo?: Document; + errInfo?: Document; numErrors?: number; }; diff --git a/packages/compass-import-export/src/import/import-utils.ts b/packages/compass-import-export/src/import/import-utils.ts index 4100b94bfaf..5d31b078830 100644 --- a/packages/compass-import-export/src/import/import-utils.ts +++ b/packages/compass-import-export/src/import/import-utils.ts @@ -40,7 +40,7 @@ export function errorToJSON(error: any): ErrorJSON { message: error.message, }; - for (const key of ['index', 'code', 'op', 'errorInfo'] as const) { + for (const key of ['index', 'code', 'op', 'errInfo'] as const) { if (error[key] !== undefined) { obj[key] = error[key]; } diff --git a/packages/compass-import-export/src/modules/import.ts b/packages/compass-import-export/src/modules/import.ts index e1df39a9993..0357dfd2feb 100644 --- a/packages/compass-import-export/src/modules/import.ts +++ b/packages/compass-import-export/src/modules/import.ts @@ -35,6 +35,7 @@ import { import type { ImportThunkAction } from '../stores/import-store'; import { openFile } from '../utils/open-file'; import type { DataService } from 'mongodb-data-service'; +import { showErrorDetails } from '@mongodb-js/compass-components'; const checkFileExists = promisify(fs.exists); const getFileStats = promisify(fs.stat); @@ -47,6 +48,8 @@ export const STARTED = `${PREFIX}/STARTED`; export const CANCELED = `${PREFIX}/CANCELED`; export const FINISHED = `${PREFIX}/FINISHED`; export const FAILED = `${PREFIX}/FAILED`; +export const ERROR_DETAILS_OPENED = `${PREFIX}/ERROR_DETAILS_OPENED`; +export const ERROR_DETAILS_CLOSED = `${PREFIX}/ERROR_DETAILS_CLOSED`; export const FILE_TYPE_SELECTED = `${PREFIX}/FILE_TYPE_SELECTED`; export const FILE_SELECTED = `${PREFIX}/FILE_SELECTED`; export const FILE_SELECT_ERROR = `${PREFIX}/FILE_SELECT_ERROR`; @@ -373,9 +376,19 @@ export const startImport = (): ImportThunkAction> => { debug('Error while importing:', err.stack); progressCallback.flush(); - showFailedToast(err); + const errInfo = + err?.writeErrors?.length && err?.writeErrors[0]?.err?.errInfo; + showFailedToast( + err as Error, + errInfo && + (() => + showErrorDetails({ + details: errInfo, + closeAction: 'close', + })) + ); - dispatch(onFailed(err)); + dispatch(onFailed(err as Error)); return; } finally { errorLogWriteStream?.close(); diff --git a/packages/hadron-document/package.json b/packages/hadron-document/package.json index ac125902a67..01058bf529d 100644 --- a/packages/hadron-document/package.json +++ b/packages/hadron-document/package.json @@ -49,6 +49,7 @@ "bson": "^6.10.3", "eventemitter3": "^4.0.0", "hadron-type-checker": "^7.4.6", + "mongodb": "^6.14.1", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/packages/hadron-document/src/document.ts b/packages/hadron-document/src/document.ts index 1670ca3ccbd..76068b3d6de 100644 --- a/packages/hadron-document/src/document.ts +++ b/packages/hadron-document/src/document.ts @@ -12,6 +12,7 @@ import type { BSONArray, BSONObject, BSONValue } from './utils'; import { objectToIdiomaticEJSON } from './utils'; import type { HadronEJSONOptions } from './utils'; import { DocumentEvents } from '.'; +import type { MongoServerError } from 'mongodb'; /** * The event constant. @@ -478,7 +479,7 @@ export class Document extends EventEmitter { } onUpdateError(error: Error) { - this.emit('update-error', error.message); + this.emit('update-error', error, (error as MongoServerError).errInfo); } markForDeletion() { @@ -505,7 +506,7 @@ export class Document extends EventEmitter { } onRemoveError(error: Error) { - this.emit('remove-error', error.message); + this.emit('remove-error', error, (error as MongoServerError).errInfo); } setModifiedEJSONString(ejson: string | null) {