diff --git a/src/test/suite/views/webview-app/legacy/components/form/form-actions.test.tsx b/src/test/suite/views/webview-app/legacy/components/form/form-actions.test.tsx index 094774de0..954f1ed18 100644 --- a/src/test/suite/views/webview-app/legacy/components/form/form-actions.test.tsx +++ b/src/test/suite/views/webview-app/legacy/components/form/form-actions.test.tsx @@ -48,7 +48,8 @@ describe('Connect Form Actions Component Test Suite', () => { wrapper.find('#connectButton').simulate('click'); assert(fakeVscodeWindowPostMessage.called); assert( - fakeVscodeWindowPostMessage.firstCall.args[0].command === 'CONNECT' + fakeVscodeWindowPostMessage.firstCall.args[0].command === + 'LEGACY_CONNECT' ); assert.deepStrictEqual( fakeVscodeWindowPostMessage.firstCall.args[0].connectionModel, diff --git a/src/test/suite/views/webview-app/overview-page.test.tsx b/src/test/suite/views/webview-app/overview-page.test.tsx index 4e72538a4..afd371e09 100644 --- a/src/test/suite/views/webview-app/overview-page.test.tsx +++ b/src/test/suite/views/webview-app/overview-page.test.tsx @@ -1,11 +1,19 @@ import React from 'react'; import { expect } from 'chai'; -import { cleanup, render, screen } from '@testing-library/react'; +import Sinon from 'sinon'; +import { cleanup, render, screen, act } from '@testing-library/react'; import OverviewPage from '../../../../views/webview-app/overview-page'; +import vscode from '../../../../views/webview-app/vscode-api'; +import { MESSAGE_TYPES } from '../../../../views/webview-app/extension-app-message-constants'; + +const connectionFormTestId = 'connection-form-modal'; describe('OverviewPage test suite', function () { - afterEach(cleanup); + afterEach(() => { + cleanup(); + Sinon.restore(); + }); test('it should render OverviewPage', function () { render(); expect( @@ -28,13 +36,117 @@ describe('OverviewPage test suite', function () { expect(screen.queryByText('Product overview')).to.be.null; }); - test('it renders the new connection form when opened', function () { - render(); + describe('Connection Form', function () { + test('it is able to open and close the new connection form', function () { + render(); + + expect(screen.queryByTestId(connectionFormTestId)).to.not.exist; + + screen.getByText('Open form').click(); + expect(screen.getByTestId(connectionFormTestId)).to.exist; + + screen.getByLabelText('Close modal').click(); + expect(screen.queryByTestId(connectionFormTestId)).to.not.exist; + }); + + it('should send connect request to webview controller when clicked on Connect button', function () { + const postMessageSpy = Sinon.spy(vscode, 'postMessage'); + + render(); + screen.getByText('Open form').click(); + + expect(screen.getByDisplayValue('mongodb://localhost:27017/')).to.not.be + .null; + screen.getByTestId('connect-button').click(); + const argsWithoutConnectId = postMessageSpy.lastCall.args[0] as any; + expect(argsWithoutConnectId.command).to.equal(MESSAGE_TYPES.CONNECT); + expect( + argsWithoutConnectId.connectionInfo.connectionOptions.connectionString + ).to.equal('mongodb://localhost:27017'); + }); + + it('should display error message returned from connection attempt', function () { + render(); + const postMessageSpy = Sinon.spy(vscode, 'postMessage'); + screen.getByText('Open form').click(); + screen.getByTestId('connect-button').click(); + const connectionAttemptId = (postMessageSpy.lastCall.args[0] as any) + .connectionAttemptId; + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + command: MESSAGE_TYPES.CONNECT_RESULT, + connectionAttemptId, + connectionSuccess: false, + connectionMessage: 'server not found', + }, + }) + ); + }); + expect(screen.queryByTestId('connection-error-summary')).to.not.be.null; + }); + + it('should close the connection modal when connected successfully', function () { + render(); + const postMessageSpy = Sinon.spy(vscode, 'postMessage'); + screen.getByText('Open form').click(); + screen.getByTestId('connect-button').click(); + const connectionAttemptId = (postMessageSpy.lastCall.args[0] as any) + .connectionAttemptId; + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + command: MESSAGE_TYPES.CONNECT_RESULT, + connectionAttemptId, + connectionSuccess: true, + connectionMessage: '', + }, + }) + ); + }); + expect(screen.queryByTestId(connectionFormTestId)).to.not.exist; + }); + + it('should not display results from other connection attempts', function () { + render(); + screen.getByText('Open form').click(); + screen.getByTestId('connect-button').click(); - const connectionFormTestId = 'connection-form-modal'; - expect(screen.queryByTestId(connectionFormTestId)).to.not.exist; + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + command: MESSAGE_TYPES.CONNECT_RESULT, + connectionAttemptId: 1, // different from the attempt id generated by our click + connectionSuccess: true, + connectionMessage: '', + }, + }) + ); + }); + // won't be closed because the connect result message is ignored + expect(screen.queryByTestId(connectionFormTestId)).to.exist; - screen.getByText('Open form').click(); - expect(screen.getByTestId(connectionFormTestId)).to.exist; + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + command: MESSAGE_TYPES.CONNECT_RESULT, + connectionAttemptId: 2, // different from the attempt id generated by our click + connectionSuccess: false, + connectionMessage: 'something bad happened', + }, + }) + ); + }); + expect(screen.queryByTestId(connectionFormTestId)).to.exist; + // won't show an error message because the connect result is ignored. + expect(screen.queryByTestId('connection-error-summary')).to.not.be + .undefined; + }); }); }); diff --git a/src/test/suite/views/webviewController.test.ts b/src/test/suite/views/webviewController.test.ts index 796e9b8c2..9141e6edd 100644 --- a/src/test/suite/views/webviewController.test.ts +++ b/src/test/suite/views/webviewController.test.ts @@ -127,7 +127,8 @@ suite('Webview Test Suite', () => { ); }); - test('web view listens for a connect message and adds the connection', (done) => { + // TODO: VSCODE-491 - Remove this test case entirely when getting rid of legacy + test('web view listens for a legacy connect message and adds the connection', (done) => { const extensionContextStub = new ExtensionContextStub(); const testStorageController = new StorageController(extensionContextStub); const testTelemetryService = new TelemetryService( @@ -191,7 +192,7 @@ suite('Webview Test Suite', () => { // Mock a connection call. messageReceived({ - command: MESSAGE_TYPES.CONNECT, + command: MESSAGE_TYPES.LEGACY_CONNECT, connectionModel: { port: 27088, hostname: 'localhost', @@ -200,6 +201,81 @@ suite('Webview Test Suite', () => { }); }); + test('web view listens for a connect message and adds the connection', (done) => { + const extensionContextStub = new ExtensionContextStub(); + const testStorageController = new StorageController(extensionContextStub); + const testTelemetryService = new TelemetryService( + testStorageController, + extensionContextStub + ); + const testConnectionController = new ConnectionController({ + statusView: new StatusView(extensionContextStub), + storageController: testStorageController, + telemetryService: testTelemetryService, + }); + let messageReceivedSet = false; + let messageReceived; + + sandbox.stub(testTelemetryService, 'trackNewConnection'); + + const fakeWebview = { + html: '', + postMessage: async (): Promise => { + assert(testConnectionController.isCurrentlyConnected()); + assert( + testConnectionController.getActiveConnectionName() === + 'localhost:27088' + ); + + await testConnectionController.disconnect(); + done(); + }, + onDidReceiveMessage: (callback): void => { + messageReceived = callback; + messageReceivedSet = true; + }, + asWebviewUri: sandbox.fake.returns(''), + }; + + const fakeVSCodeCreateWebviewPanel = sandbox.fake.returns({ + webview: fakeWebview, + onDidDispose: sandbox.fake.returns(''), + }); + + sandbox.replace( + vscode.window, + 'createWebviewPanel', + fakeVSCodeCreateWebviewPanel + ); + + const testWebviewController = new WebviewController({ + connectionController: testConnectionController, + storageController: testStorageController, + telemetryService: testTelemetryService, + }); + + void testWebviewController.openWebview( + mdbTestExtension.extensionContextStub + ); + + assert( + messageReceivedSet, + 'Ensure it starts listening for messages from the webview.' + ); + + // Mock a connection call. + messageReceived({ + command: MESSAGE_TYPES.CONNECT, + connectionInfo: { + id: 2, + connectionOptions: { + connectionString: 'mongodb://localhost:27088', + }, + }, + connectionAttemptId: 1, + }); + }); + test('web view sends a successful connect result on a successful connection', (done) => { const extensionContextStub = new ExtensionContextStub(); const testStorageController = new StorageController(extensionContextStub); @@ -264,7 +340,7 @@ suite('Webview Test Suite', () => { // Mock a connection call. messageReceived({ - command: MESSAGE_TYPES.CONNECT, + command: MESSAGE_TYPES.LEGACY_CONNECT, connectionModel: { port: 27088, hostname: 'localhost', @@ -326,7 +402,7 @@ suite('Webview Test Suite', () => { // Mock a connection call. messageReceived({ - command: MESSAGE_TYPES.CONNECT, + command: MESSAGE_TYPES.LEGACY_CONNECT, connectionModel: { port: 2700002, // Bad port number. hostname: 'localhost', @@ -390,7 +466,7 @@ suite('Webview Test Suite', () => { // Mock a connection call. messageReceived({ - command: MESSAGE_TYPES.CONNECT, + command: MESSAGE_TYPES.LEGACY_CONNECT, connectionModel: { port: 27088, hostname: 'shouldfail', diff --git a/src/views/webview-app/connection-form.tsx b/src/views/webview-app/connection-form.tsx index 6e8766470..5436166e5 100644 --- a/src/views/webview-app/connection-form.tsx +++ b/src/views/webview-app/connection-form.tsx @@ -2,6 +2,7 @@ import React from 'react'; import CompassConnectionForm from '@mongodb-js/connection-form'; import { Modal, css, spacing } from '@mongodb-js/compass-components'; import { v4 as uuidv4 } from 'uuid'; +import type { ConnectionInfo } from 'mongodb-data-service-legacy'; const modalContentStyles = css({ // Override LeafyGreen width to accommodate the strict connection-form size. @@ -27,10 +28,11 @@ function createNewConnectionInfo() { const initialConnectionInfo = createNewConnectionInfo(); const ConnectionForm: React.FunctionComponent<{ - onConnectClicked: (onConnectClicked: unknown) => void; + onConnectClicked: (onConnectClicked: ConnectionInfo) => void; onClose: () => void; open: boolean; -}> = ({ onConnectClicked, onClose, open }) => { + connectionErrorMessage: string; +}> = ({ connectionErrorMessage, onConnectClicked, onClose, open }) => { return ( diff --git a/src/views/webview-app/extension-app-message-constants.ts b/src/views/webview-app/extension-app-message-constants.ts index 6b9a88cdf..dd4407bff 100644 --- a/src/views/webview-app/extension-app-message-constants.ts +++ b/src/views/webview-app/extension-app-message-constants.ts @@ -1,5 +1,6 @@ import type LegacyConnectionModel from './legacy/connection-model/legacy-connection-model'; import type { FilePickerActionTypes } from './legacy/store/actions'; +import type { ConnectionInfo } from 'mongodb-data-service-legacy'; export enum CONNECTION_STATUS { LOADING = 'LOADING', // When the connection status has not yet been shared from the extension. @@ -14,6 +15,7 @@ export const VSCODE_EXTENSION_SEGMENT_ANONYMOUS_ID = export enum MESSAGE_TYPES { CONNECT = 'CONNECT', + LEGACY_CONNECT = 'LEGACY_CONNECT', CONNECT_RESULT = 'CONNECT_RESULT', CONNECTION_STATUS_MESSAGE = 'CONNECTION_STATUS_MESSAGE', EXTENSION_LINK_CLICKED = 'EXTENSION_LINK_CLICKED', @@ -43,6 +45,13 @@ export interface ConnectionStatusMessage extends BasicWebviewMessage { export interface ConnectMessage extends BasicWebviewMessage { command: MESSAGE_TYPES.CONNECT; + connectionInfo: ConnectionInfo; + connectionAttemptId: string; +} + +// TODO: VSCODE-491 - Remove this entirely when getting rid of legacy +export interface LegacyConnectMessage extends BasicWebviewMessage { + command: MESSAGE_TYPES.LEGACY_CONNECT; connectionModel: LegacyConnectionModel; connectionAttemptId: string; } @@ -97,6 +106,7 @@ export interface ThemeChangedMessage extends BasicWebviewMessage { export type MESSAGE_FROM_WEBVIEW_TO_EXTENSION = | ConnectMessage + | LegacyConnectMessage | CreateNewPlaygroundMessage | GetConnectionStatusMessage | LinkClickedMessage diff --git a/src/views/webview-app/legacy/store/store.ts b/src/views/webview-app/legacy/store/store.ts index 738af1233..b8ecf56a1 100644 --- a/src/views/webview-app/legacy/store/store.ts +++ b/src/views/webview-app/legacy/store/store.ts @@ -59,13 +59,14 @@ const showFilePicker = ( }); }; -const sendConnectToExtension = ( +// TODO: VSCODE-491 - Remove this entirely when getting rid of legacy +const sendLegacyConnectToExtension = ( connectionModel: LegacyConnectionModel ): string => { const connectionAttemptId = uuidv4(); vscode.postMessage({ - command: MESSAGE_TYPES.CONNECT, + command: MESSAGE_TYPES.LEGACY_CONNECT, connectionModel, connectionAttemptId, }); @@ -124,7 +125,9 @@ export const rootReducer = ( isValid: true, isConnecting: true, isConnected: false, - connectionAttemptId: sendConnectToExtension(state.currentConnection), + connectionAttemptId: sendLegacyConnectToExtension( + state.currentConnection + ), }; case ActionTypes.CREATE_NEW_PLAYGROUND: diff --git a/src/views/webview-app/overview-page.tsx b/src/views/webview-app/overview-page.tsx index 93fa7e65b..3e86e2f46 100644 --- a/src/views/webview-app/overview-page.tsx +++ b/src/views/webview-app/overview-page.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useLayoutEffect, useState } from 'react'; import { HorizontalRule, + SpinLoaderWithLabel, css, resetGlobalCSS, spacing, @@ -12,6 +13,7 @@ import ConnectHelper from './connect-helper'; import AtlasCta from './atlas-cta'; import ResourcesPanel from './resources-panel/panel'; import { ConnectionForm } from './connection-form'; +import useConnectionForm from './use-connection-form'; const pageStyles = css({ width: '90%', @@ -26,9 +28,28 @@ const pageStyles = css({ fontSize: '14px', }); +const loadingContainerStyles = css({ + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, +}); + const OverviewPage: React.FC = () => { const [showResourcesPanel, setShowResourcesPanel] = useState(false); - const [showConnectionForm, setShowConnectionForm] = useState(false); + const { + connectionInProgress, + connectionFormOpened, + openConnectionForm, + closeConnectionForm, + connectionErrorMessage, + handleConnectClicked, + } = useConnectionForm(); const handleResourcesPanelClose = useCallback( () => setShowResourcesPanel(false), [] @@ -46,26 +67,26 @@ const OverviewPage: React.FC = () => { return (
+ {connectionInProgress && ( +
+ +
+ )} {showResourcesPanel && ( )} - {showConnectionForm && ( + {connectionFormOpened && ( { - // TODO(VSCODE-489): Type connection form and post message to the webview controller. - // Maintain connecting status. - console.log('connect', connectionInfo); - }} - onClose={() => setShowConnectionForm(false)} - open={showConnectionForm} + onConnectClicked={handleConnectClicked} + onClose={closeConnectionForm} + open={connectionFormOpened} + connectionErrorMessage={connectionErrorMessage} /> )} - setShowConnectionForm(true)} - /> +
); diff --git a/src/views/webview-app/use-connection-form.ts b/src/views/webview-app/use-connection-form.ts new file mode 100644 index 000000000..69827f124 --- /dev/null +++ b/src/views/webview-app/use-connection-form.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import type { ConnectionInfo } from 'mongodb-data-service-legacy'; +import { sendConnectToExtension } from './vscode-api'; +import { MESSAGE_TYPES } from './extension-app-message-constants'; +import type { MESSAGE_FROM_EXTENSION_TO_WEBVIEW } from './extension-app-message-constants'; + +export default function useConnectionForm() { + const [connectionInProgress, setConnectionInProgress] = useState(false); + const [connectionFormOpened, setConnectionFormOpened] = useState(false); + const [connectionAttemptId, setConnectionAttemptId] = useState(''); + const [connectionErrorMessage, setConnectionErrorMessage] = useState(''); + + useEffect(() => { + const handleConnectResultResponse = (event) => { + const message: MESSAGE_FROM_EXTENSION_TO_WEBVIEW = event.data; + if ( + message.command === MESSAGE_TYPES.CONNECT_RESULT && + message.connectionAttemptId === connectionAttemptId + ) { + setConnectionInProgress(false); + if (message.connectionSuccess) { + setConnectionFormOpened(false); + } else { + setConnectionErrorMessage(message.connectionMessage); + } + } + }; + window.addEventListener('message', handleConnectResultResponse); + () => window.removeEventListener('message', handleConnectResultResponse); + }, [connectionAttemptId]); + + return { + connectionFormOpened, + connectionInProgress, + connectionErrorMessage, + openConnectionForm: () => setConnectionFormOpened(true), + closeConnectionForm: () => { + setConnectionFormOpened(false); + setConnectionErrorMessage(''); + }, + handleConnectClicked: (connectionInfo: ConnectionInfo) => { + // Clears the error message from previous connect attempt + setConnectionErrorMessage(''); + + const nextAttemptId = uuidv4(); + setConnectionAttemptId(nextAttemptId); + setConnectionInProgress(true); + sendConnectToExtension(connectionInfo, nextAttemptId); + }, + }; +} diff --git a/src/views/webview-app/use-connection-status.ts b/src/views/webview-app/use-connection-status.ts index 920a20a67..c570661bc 100644 --- a/src/views/webview-app/use-connection-status.ts +++ b/src/views/webview-app/use-connection-status.ts @@ -20,8 +20,6 @@ const useConnectionStatus = () => { setConnectionStatus(message.connectionStatus); setConnectionName(message.activeConnectionName); } - // TODO(VSCODE-489): Also listen on the new connected event whenever that is - // implemented }; window.addEventListener('message', handleConnectionStatusResponse); diff --git a/src/views/webview-app/vscode-api.ts b/src/views/webview-app/vscode-api.ts index cf664f4aa..c9f1465cf 100644 --- a/src/views/webview-app/vscode-api.ts +++ b/src/views/webview-app/vscode-api.ts @@ -1,3 +1,4 @@ +import type { ConnectionInfo } from 'mongodb-data-service-legacy'; import { MESSAGE_TYPES, type MESSAGE_FROM_WEBVIEW_TO_EXTENSION, @@ -10,6 +11,17 @@ interface VSCodeApi { declare const acquireVsCodeApi: () => VSCodeApi; const vscode = acquireVsCodeApi(); +export const sendConnectToExtension = ( + connectionInfo: ConnectionInfo, + connectionAttemptId: string +) => { + vscode.postMessage({ + command: MESSAGE_TYPES.CONNECT, + connectionInfo, + connectionAttemptId, + }); +}; + export const renameActiveConnection = () => { vscode.postMessage({ command: MESSAGE_TYPES.RENAME_ACTIVE_CONNECTION, diff --git a/src/views/webviewController.ts b/src/views/webviewController.ts index 7e89d12a1..ede66d1d2 100644 --- a/src/views/webviewController.ts +++ b/src/views/webviewController.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import path from 'path'; import crypto from 'crypto'; +import type { ConnectionInfo } from 'mongodb-data-service-legacy'; import type ConnectionController from '../connectionController'; import { ConnectionTypes } from '../connectionController'; @@ -109,7 +110,8 @@ export default class WebviewController { this._themeChangedSubscription?.dispose(); } - handleWebviewConnectAttempt = async ( + // TODO: VSCODE-491 - Remove this entirely when getting rid of legacy + handleWebviewLegacyConnectAttempt = async ( panel: vscode.WebviewPanel, rawConnectionModel: LegacyConnectionModel, connectionAttemptId: string @@ -150,6 +152,45 @@ export default class WebviewController { } }; + handleWebviewConnectAttempt = async ( + panel: vscode.WebviewPanel, + connectionInfo: ConnectionInfo, + connectionAttemptId: string + ) => { + try { + const { successfullyConnected, connectionErrorMessage } = + await this._connectionController.saveNewConnectionFromFormAndConnect( + connectionInfo, + ConnectionTypes.CONNECTION_FORM + ); + + try { + // The webview may have been closed in which case this will throw. + void panel.webview.postMessage({ + command: MESSAGE_TYPES.CONNECT_RESULT, + connectionAttemptId, + connectionSuccess: successfullyConnected, + connectionMessage: successfullyConnected + ? `Successfully connected to ${this._connectionController.getActiveConnectionName()}.` + : connectionErrorMessage, + }); + } catch (err) { + log.error('Unable to send connection result to webview', err); + } + } catch (error) { + void vscode.window.showErrorMessage( + `Unable to load connection: ${error}` + ); + + void panel.webview.postMessage({ + command: MESSAGE_TYPES.CONNECT_RESULT, + connectionAttemptId, + connectionSuccess: false, + connectionMessage: `Unable to load connection: ${error}`, + }); + } + }; + handleWebviewOpenFilePickerRequest = async ( message: OpenFilePickerMessage, panel: vscode.WebviewPanel @@ -168,15 +209,24 @@ export default class WebviewController { }); }; + // eslint-disable-next-line complexity handleWebviewMessage = async ( message: MESSAGE_FROM_WEBVIEW_TO_EXTENSION, panel: vscode.WebviewPanel ): Promise => { switch (message.command) { + // TODO: VSCODE-491 - Remove this case entirely when getting rid of legacy + case MESSAGE_TYPES.LEGACY_CONNECT: + await this.handleWebviewLegacyConnectAttempt( + panel, + message.connectionModel, + message.connectionAttemptId + ); + return; case MESSAGE_TYPES.CONNECT: await this.handleWebviewConnectAttempt( panel, - message.connectionModel, + message.connectionInfo, message.connectionAttemptId ); return;