diff --git a/packages/compass-e2e-tests/helpers/commands/set-validation.ts b/packages/compass-e2e-tests/helpers/commands/set-validation.ts index 8483d48f3a6..b7034eb98c3 100644 --- a/packages/compass-e2e-tests/helpers/commands/set-validation.ts +++ b/packages/compass-e2e-tests/helpers/commands/set-validation.ts @@ -1,7 +1,7 @@ import type { CompassBrowser } from '../compass-browser'; import * as Selectors from '../selectors'; -export async function setValidation( +export async function setValidationWithinValidationTab( browser: CompassBrowser, value: string ): Promise { @@ -32,3 +32,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/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index 6ac2a1cd740..19e43fe88b6 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -654,6 +654,10 @@ 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 ImportErrorDetailsModal = + '[data-testid="import-error-details-modal"]'; export const ImportToastAbort = '[data-testid="toast-action-stop"]'; export const ImportFieldLabel = '[data-testid="import-modal"] .import-field-label'; 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 adc926a1d51..1819c645d6d 100644 --- a/packages/compass-e2e-tests/tests/collection-documents-tab.test.ts +++ b/packages/compass-e2e-tests/tests/collection-documents-tab.test.ts @@ -691,16 +691,12 @@ FindIterable result = collection.find(filter);`); const REQUIRE_PHONE_VALIDATOR = '{ $jsonSchema: { bsonType: "object", required: [ "phone" ] } }'; beforeEach(async function () { - await browser.navigateToCollectionTab( - DEFAULT_CONNECTION_NAME_1, - 'test', - 'numbers', - 'Validation' - ); - await browser.clickVisible(Selectors.AddRuleButton); - const element = browser.$(Selectors.ValidationEditor); - await element.waitForDisplayed(); - await browser.setValidation(REQUIRE_PHONE_VALIDATOR); + await browser.setValidation({ + connectionName: DEFAULT_CONNECTION_NAME_1, + database: 'test', + collection: 'numbers', + validator: REQUIRE_PHONE_VALIDATOR, + }); }); it('Shows error info when inserting', async function () { diff --git a/packages/compass-e2e-tests/tests/collection-import.test.ts b/packages/compass-e2e-tests/tests/collection-import.test.ts index e1712edb2cd..28a3c57e623 100644 --- a/packages/compass-e2e-tests/tests/collection-import.test.ts +++ b/packages/compass-e2e-tests/tests/collection-import.test.ts @@ -509,6 +509,139 @@ 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 errorDetailsModal = browser.$(Selectors.ImportErrorDetailsModal); + await errorDetailsModal.waitForDisplayed(); + expect(await errorDetailsModal.getText()).to.include( + 'schemaRulesNotSatisfied' + ); + await browser.clickVisible(Selectors.ErrorDetailsCloseButton); + + // Close the toast + await browser + .$(Selectors.closeToastButton(Selectors.ImportToast)) + .waitForDisplayed(); + await browser.clickVisible( + Selectors.closeToastButton(Selectors.ImportToast) + ); + await toastElement.waitForDisplayed({ reverse: true }); + }); + + 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(); + console.log({ errorText }); + 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 b5e35f8d67e..225f126f0ef 100644 --- a/packages/compass-e2e-tests/tests/collection-validation-tab.test.ts +++ b/packages/compass-e2e-tests/tests/collection-validation-tab.test.ts @@ -50,7 +50,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 set or modified', function () { @@ -99,7 +99,7 @@ describe('Collection validation tab', function () { // Reset the validation again to make everything valid for future tests // 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.ValidationLoadMatchingDocumentsBtn); await browser.clickVisible( Selectors.ValidationLoadNotMatchingDocumentsBtn diff --git a/packages/compass-import-export/src/components/import-error-details-modal.spec.tsx b/packages/compass-import-export/src/components/import-error-details-modal.spec.tsx new file mode 100644 index 00000000000..c8c890bf1be --- /dev/null +++ b/packages/compass-import-export/src/components/import-error-details-modal.spec.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { + render, + screen, + userEvent, + waitFor, +} from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import { configureStore } from '../stores/import-store'; +import { Provider } from 'react-redux'; + +import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; +import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider'; +import ImportErrorDetailsModal from './import-error-details-modal'; + +function renderModal(importState: any = {}) { + // TODO: mutating state directly doesn't guarantee that we are testing the + // component in a state that can actually be achieved when actions are emitted + // on the store. Refactor this to either test unconnected component, or to + // not mutate state directly for tests + const store = configureStore({ + dataService: {}, + globalAppRegistry: {}, + logger: createNoopLogger(), + track: createNoopTrack(), + connections: { + getConnectionById: () => ({ info: { id: 'TEST' } }), + }, + } as any); + const state = store.getState(); + state.import = { + ...state.import, + ...importState, + }; + const renderResult = render( + + + + ); + return { renderResult, store }; +} + +describe('ImportErrorDetailsModal Component', function () { + context('When import error details are open', function () { + const errorDetails = { details: 'abc' }; + + beforeEach(function () { + renderModal({ + errorDetails: { + isOpen: true, + details: errorDetails, + }, + }); + }); + + it('Should render error details and be closable', async function () { + const codeDetails = await screen.findByTestId('error-details-json'); + expect(codeDetails).to.be.visible; + expect(JSON.parse(codeDetails.textContent || '')).to.deep.equal( + errorDetails + ); + + const closeBtn = await screen.findByRole('button', { name: 'Close' }); + expect(closeBtn).to.be.visible; + + userEvent.click(closeBtn); + await waitFor(() => { + expect(screen.queryByTestId('import-error-details-modal')).not.to.exist; + }); + }); + }); +}); diff --git a/packages/compass-import-export/src/components/import-error-details-modal.tsx b/packages/compass-import-export/src/components/import-error-details-modal.tsx new file mode 100644 index 00000000000..c425ac44867 --- /dev/null +++ b/packages/compass-import-export/src/components/import-error-details-modal.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { ErrorDetailsModal } from '@mongodb-js/compass-components'; +import { connect } from 'react-redux'; +import type { RootImportState } from '../stores/import-store'; +import { onErrorDetailsClose } from '../modules/import'; + +const ImportErrorDetailsModal: React.FunctionComponent<{ + isOpen: boolean; + errorDetails?: Record; + onClose: () => void; +}> = ({ isOpen, errorDetails, onClose }) => { + return ( + + ); +}; + +const ConnectedImportErrorDetailsModal = connect( + (state: RootImportState) => ({ + isOpen: state.import.errorDetails.isOpen, + errorDetails: state.import.errorDetails.details, + }), + { + onClose: onErrorDetailsClose, + } +)(ImportErrorDetailsModal); + +export default ConnectedImportErrorDetailsModal; diff --git a/packages/compass-import-export/src/components/import-toast.tsx b/packages/compass-import-export/src/components/import-toast.tsx index 91bf7dc97f2..f95f2ea5c8c 100644 --- a/packages/compass-import-export/src/components/import-toast.tsx +++ b/packages/compass-import-export/src/components/import-toast.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { Body, + closeToast, css, + Link, openToast, ToastBody, } from '@mongodb-js/compass-components'; @@ -221,10 +223,28 @@ export function showCancelledToast({ }); } -export function showFailedToast(err: Error | undefined) { +export function showFailedToast( + err: Error | undefined, + showErrorDetails?: () => 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-plugin.tsx b/packages/compass-import-export/src/import-plugin.tsx index e260279aa4b..3fd4fa31c66 100644 --- a/packages/compass-import-export/src/import-plugin.tsx +++ b/packages/compass-import-export/src/import-plugin.tsx @@ -1,12 +1,14 @@ import React from 'react'; import ImportModal from './components/import-modal'; import ImportInProgressModal from './components/import-in-progress-modal'; +import ImportErrorDetailsModal from './components/import-error-details-modal'; function ImportPlugin() { return ( <> + ); } 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..3ebb0f3ab97 100644 --- a/packages/compass-import-export/src/modules/import.ts +++ b/packages/compass-import-export/src/modules/import.ts @@ -47,6 +47,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`; @@ -80,6 +82,16 @@ type FieldFromJSON = { }; type FieldType = FieldFromJSON | FieldFromCSV; +export type ErrorDetailsDialogState = + | { + isOpen: false; + details?: Record; + } + | { + isOpen: true; + details: Record; + }; + type ImportState = { isOpen: boolean; isInProgressMessageOpen: boolean; @@ -87,6 +99,7 @@ type ImportState = { fileType: AcceptedFileType | ''; fileName: string; errorLogFilePath: string; + errorDetails: ErrorDetailsDialogState; fileIsMultilineJSON: boolean; useHeaderLines: boolean; status: ProcessStatus; @@ -122,6 +135,7 @@ export const INITIAL_STATE: ImportState = { firstErrors: [], fileName: '', errorLogFilePath: '', + errorDetails: { isOpen: false }, fileIsMultilineJSON: false, useHeaderLines: true, status: PROCESS_STATUS.UNSPECIFIED, @@ -169,6 +183,8 @@ const onFinished = ({ const onFailed = (error: Error) => ({ type: FAILED, error }); +export const onErrorDetailsClose = () => ({ type: ERROR_DETAILS_CLOSED }); + const onFileSelectError = (error: Error) => ({ type: FILE_SELECT_ERROR, error, @@ -373,9 +389,14 @@ export const startImport = (): ImportThunkAction> => { debug('Error while importing:', err.stack); progressCallback.flush(); - showFailedToast(err); - - dispatch(onFailed(err)); + const errInfo = + err?.writeErrors?.length && err?.writeErrors[0]?.err?.errInfo; + const showErrorDetails: () => void | undefined = + errInfo && + (() => dispatch({ type: ERROR_DETAILS_OPENED, errorDetails: errInfo })); + showFailedToast(err as Error, showErrorDetails); + + dispatch(onFailed(err as Error)); return; } finally { errorLogWriteStream?.close(); @@ -1069,6 +1090,26 @@ export const importReducer: Reducer = ( }; } + if (action.type === ERROR_DETAILS_OPENED) { + return { + ...state, + errorDetails: { + isOpen: true, + details: action.errorDetails, + }, + }; + } + + if (action.type === ERROR_DETAILS_CLOSED) { + return { + ...state, + errorDetails: { + ...state.errorDetails, + isOpen: false, + }, + }; + } + if (action.type === STARTED) { return { ...state,