diff --git a/package-lock.json b/package-lock.json index b878ce29b19..0d46ca0d3ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95286,6 +95286,7 @@ "@mongodb-js/compass-logging": "^0.6.1", "@mongodb-js/connect-form": "^0.4.1", "debug": "^4.2.0", + "lodash": "^4.17.21", "mongodb": "^4.3.0", "react": "^16.14.0", "react-dom": "^16.14.0", @@ -115145,6 +115146,7 @@ "debug": "^4.2.0", "depcheck": "^1.4.1", "eslint": "^7.25.0", + "lodash": "*", "mocha": "^8.4.0", "mongodb": "^4.3.0", "mongodb-build-info": "^1.3.0", diff --git a/packages/connections/package.json b/packages/connections/package.json index d605c106743..57bd4ad9031 100644 --- a/packages/connections/package.json +++ b/packages/connections/package.json @@ -57,6 +57,7 @@ "@mongodb-js/compass-logging": "^0.6.1", "@mongodb-js/connect-form": "^0.4.1", "debug": "^4.2.0", + "lodash": "^4.17.21", "mongodb": "^4.3.0", "react": "^16.14.0", "react-dom": "^16.14.0", diff --git a/packages/connections/src/components/connection-list/connection-list.tsx b/packages/connections/src/components/connection-list/connection-list.tsx index 4b04ef7b5e1..77b03605ffe 100644 --- a/packages/connections/src/components/connection-list/connection-list.tsx +++ b/packages/connections/src/components/connection-list/connection-list.tsx @@ -142,6 +142,8 @@ function ConnectionList({ setActiveConnectionId, onDoubleClick, removeAllRecentsConnections, + duplicateConnection, + removeConnection, }: { activeConnectionId?: string; connections: ConnectionInfo[]; @@ -149,6 +151,8 @@ function ConnectionList({ setActiveConnectionId: (connectionId?: string) => void; onDoubleClick: (connectionInfo: ConnectionInfo) => void; removeAllRecentsConnections: () => void; + duplicateConnection: (connectionInfo: ConnectionInfo) => void; + removeConnection: (connectionInfo: ConnectionInfo) => void; }): React.ReactElement { const [recentHeaderHover, setRecentHover] = useState(false); const favoriteConnections = connections @@ -208,6 +212,8 @@ function ConnectionList({ connectionInfo={connectionInfo} onClick={() => setActiveConnectionId(connectionInfo.id)} onDoubleClick={onDoubleClick} + removeConnection={removeConnection} + duplicateConnection={duplicateConnection} /> ))} @@ -243,6 +249,8 @@ function ConnectionList({ connectionInfo={connectionInfo} onClick={() => setActiveConnectionId(connectionInfo.id)} onDoubleClick={onDoubleClick} + removeConnection={removeConnection} + duplicateConnection={duplicateConnection} /> ))} diff --git a/packages/connections/src/components/connection-list/connection-menu.spec.tsx b/packages/connections/src/components/connection-list/connection-menu.spec.tsx index 13748a62668..c1500cc700e 100644 --- a/packages/connections/src/components/connection-list/connection-menu.spec.tsx +++ b/packages/connections/src/components/connection-list/connection-menu.spec.tsx @@ -4,11 +4,26 @@ import { expect } from 'chai'; import sinon from 'sinon'; import ConnectionMenu from './connection-menu'; +import { ConnectionInfo } from 'mongodb-data-service'; describe('ConnectionMenu Component', function () { - describe('when rendered', function () { + describe('on non-favorite item', function () { beforeEach(function () { - render(); + const connectionInfo: ConnectionInfo = { + id: 'test-id', + connectionOptions: { + connectionString: 'mongodb://kaleesi', + }, + }; + render( + true} + removeConnection={() => true} + connectionInfo={connectionInfo} + iconColor="#EAEAEA" + /> + ); }); it('shows a button', function () { @@ -17,6 +32,60 @@ describe('ConnectionMenu Component', function () { it('does not show the menu items', function () { expect(screen.queryByText('Copy Connection String')).to.not.exist; + expect(screen.queryByText('Duplicate')).to.not.exist; + expect(screen.queryByText('Remove')).to.not.exist; + }); + + describe('when clicked', function () { + beforeEach(function () { + const button = screen.getByRole('button'); + + fireEvent( + button, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + }); + + it('shows the menu items without the "Duplicate" option', function () { + expect(screen.getByText('Copy Connection String')).to.be.visible; + expect(screen.queryByText('Duplicate')).to.throw; + expect(screen.getByText('Remove')).to.be.visible; + }); + }); + }); + describe('on favorite item', function () { + beforeEach(function () { + const connectionInfo: ConnectionInfo = { + id: 'test-id', + favorite: { + name: 'First Server', + }, + connectionOptions: { + connectionString: 'mongodb://kaleesi', + }, + }; + render( + true} + removeConnection={() => true} + connectionInfo={connectionInfo} + iconColor="#EAEAEA" + /> + ); + }); + + it('shows a button', function () { + expect(screen.getByRole('button')).to.be.visible; + }); + + it('does not show the menu items', function () { + expect(screen.queryByText('Copy Connection String')).to.not.exist; + expect(screen.queryByText('Duplicate')).to.not.exist; + expect(screen.queryByText('Remove')).to.not.exist; }); describe('when clicked', function () { @@ -34,6 +103,8 @@ describe('ConnectionMenu Component', function () { it('shows the menu items', function () { expect(screen.getByText('Copy Connection String')).to.be.visible; + expect(screen.getByText('Duplicate')).to.be.visible; + expect(screen.getByText('Remove')).to.be.visible; }); describe('when copy connection is clicked', function () { @@ -174,4 +245,83 @@ describe('ConnectionMenu Component', function () { }); }); }); + describe('function calls', function () { + it('should call the removeConnection function', function () { + const connectionInfo: ConnectionInfo = { + id: 'test-id', + favorite: { + name: 'First Server', + }, + connectionOptions: { + connectionString: 'mongodb://kaleesi', + }, + }; + const mockRemoveConnection = sinon.fake.resolves(null); + render( + true} + removeConnection={mockRemoveConnection} + connectionInfo={connectionInfo} + iconColor="#EAEAEA" + /> + ); + + fireEvent( + screen.getByRole('button'), + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + + const removeConnectionButton = screen.getByText('Remove'); + fireEvent( + removeConnectionButton, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + expect(mockRemoveConnection.called).to.equal(true); + }); + it('should call the duplicateConnection function', function () { + const connectionInfo: ConnectionInfo = { + id: 'test-id', + favorite: { + name: 'First Server', + }, + connectionOptions: { + connectionString: 'mongodb://kaleesi', + }, + }; + const mockDuplicateConnection = sinon.fake.resolves(null); + render( + true} + connectionInfo={connectionInfo} + iconColor="#EAEAEA" + /> + ); + + fireEvent( + screen.getByRole('button'), + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + const duplicateConnectionButton = screen.getByText('Duplicate'); + fireEvent( + duplicateConnectionButton, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + expect(mockDuplicateConnection.called).to.equal(true); + }); + }); }); diff --git a/packages/connections/src/components/connection-list/connection-menu.tsx b/packages/connections/src/components/connection-list/connection-menu.tsx index f4580eafed5..654190b81fd 100644 --- a/packages/connections/src/components/connection-list/connection-menu.tsx +++ b/packages/connections/src/components/connection-list/connection-menu.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useReducer } from 'react'; +import React, { useEffect, useRef, useReducer, useState } from 'react'; import { IconButton, Icon, @@ -10,7 +10,7 @@ import { css, cx, } from '@mongodb-js/compass-components'; - +import { ConnectionInfo } from 'mongodb-data-service'; const dropdownButtonStyles = css({ position: 'absolute', right: spacing[1], @@ -74,15 +74,21 @@ function reducer(state: State, action: Action): State { function ConnectionMenu({ connectionString, iconColor, + connectionInfo, + duplicateConnection, + removeConnection, }: { connectionString: string; iconColor: string; + connectionInfo: ConnectionInfo; + duplicateConnection: (connectionInfo: ConnectionInfo) => void; + removeConnection: (connectionInfo: ConnectionInfo) => void; }): React.ReactElement { const [{ error, toastOpen, toastVariant }, dispatch] = useReducer(reducer, { ...defaultToastState, }); const toastHideTimeout = useRef | null>(null); - + const [menuIsOpen, setMenuIsOpen] = useState(false); function startToastHideTimeout() { if (toastHideTimeout.current) { // If we're currently showing a toast, cancel that previous timeout. @@ -164,10 +170,30 @@ function ConnectionMenu({ } + open={menuIsOpen} + setOpen={setMenuIsOpen} > - copyConnectionString(connectionString)}> + { + await copyConnectionString(connectionString); + setMenuIsOpen(false); + }} + > Copy Connection String + {connectionInfo.favorite && ( + { + duplicateConnection(connectionInfo); + setMenuIsOpen(false); + }} + > + Duplicate + + )} + removeConnection(connectionInfo)}> + Remove + ); diff --git a/packages/connections/src/components/connection-list/connection.tsx b/packages/connections/src/components/connection-list/connection.tsx index 2c7bddde418..806b49c11ae 100644 --- a/packages/connections/src/components/connection-list/connection.tsx +++ b/packages/connections/src/components/connection-list/connection.tsx @@ -126,11 +126,15 @@ function Connection({ connectionInfo, onClick, onDoubleClick, + duplicateConnection, + removeConnection, }: { isActive: boolean; connectionInfo: ConnectionInfo; onClick: () => void; onDoubleClick: (connectionInfo: ConnectionInfo) => void; + duplicateConnection: (connectionInfo: ConnectionInfo) => void; + removeConnection: (connectionInfo: ConnectionInfo) => void; }): React.ReactElement { const connectionTitle = getConnectionTitle(connectionInfo); const { @@ -189,7 +193,10 @@ function Connection({ ? uiColors.gray.dark3 : uiColors.white } - connectionString={connectionString} + connectionString={connectionInfo.connectionOptions.connectionString} + connectionInfo={connectionInfo} + duplicateConnection={duplicateConnection} + removeConnection={removeConnection} /> diff --git a/packages/connections/src/components/connections.tsx b/packages/connections/src/components/connections.tsx index 8f4ab0f1f19..327a2127dbc 100644 --- a/packages/connections/src/components/connections.tsx +++ b/packages/connections/src/components/connections.tsx @@ -77,6 +77,8 @@ function Connections({ hideStoreConnectionError, setActiveConnectionById, removeAllRecentsConnections, + removeConnection, + duplicateConnection, }, ] = useConnections(onConnected, connectionStorage, connectFn); @@ -94,6 +96,8 @@ function Connections({ setActiveConnectionId={setActiveConnectionById} onConnectionDoubleClicked={connect} removeAllRecentsConnections={removeAllRecentsConnections} + removeConnection={removeConnection} + duplicateConnection={duplicateConnection} />
{storeConnectionError && ( diff --git a/packages/connections/src/components/resizeable-sidebar.tsx b/packages/connections/src/components/resizeable-sidebar.tsx index 7dafab5e3bd..6589e92c457 100644 --- a/packages/connections/src/components/resizeable-sidebar.tsx +++ b/packages/connections/src/components/resizeable-sidebar.tsx @@ -36,6 +36,8 @@ function ResizableSidebar({ setActiveConnectionId, onConnectionDoubleClicked, removeAllRecentsConnections, + duplicateConnection, + removeConnection, }: { activeConnectionId?: string; connections: ConnectionInfo[]; @@ -43,6 +45,8 @@ function ResizableSidebar({ setActiveConnectionId: (newConnectionId?: string) => void; onConnectionDoubleClicked: (connectionInfo: ConnectionInfo) => void; removeAllRecentsConnections: () => void; + duplicateConnection: (connectionInfo: ConnectionInfo) => void; + removeConnection: (connectionInfo: ConnectionInfo) => void; }): React.ReactElement { const [width, setWidth] = useState(initialSidebarWidth); @@ -61,6 +65,8 @@ function ResizableSidebar({ setActiveConnectionId={setActiveConnectionId} onDoubleClick={onConnectionDoubleClicked} removeAllRecentsConnections={removeAllRecentsConnections} + removeConnection={removeConnection} + duplicateConnection={duplicateConnection} /> setWidth(newWidth)} diff --git a/packages/connections/src/stores/connections-store.ts b/packages/connections/src/stores/connections-store.ts index 95634a3c4c8..0b2aacde24c 100644 --- a/packages/connections/src/stores/connections-store.ts +++ b/packages/connections/src/stores/connections-store.ts @@ -1,4 +1,3 @@ -import { v4 as uuidv4 } from 'uuid'; import { ConnectionInfo, ConnectionOptions, @@ -17,7 +16,8 @@ import { trackNewConnectionEvent, trackConnectionFailedEvent, } from '../modules/telemetry'; - +import { v4 as uuidv4 } from 'uuid'; +import { cloneDeep } from 'lodash'; const debug = debugModule('mongodb-compass:connections:connections-store'); export function createNewConnectionInfo(): ConnectionInfo { @@ -94,6 +94,11 @@ type Action = | { type: 'set-connections'; connections: ConnectionInfo[]; + } + | { + type: 'set-connections-and-select'; + connections: ConnectionInfo[]; + activeConnectionInfo: ConnectionInfo; }; export function connectionsReducer(state: State, action: Action): State { @@ -151,6 +156,13 @@ export function connectionsReducer(state: State, action: Action): State { ...state, connections: action.connections, }; + case 'set-connections-and-select': + return { + ...state, + connections: action.connections, + activeConnectionId: action.activeConnectionInfo.id, + activeConnectionInfo: action.activeConnectionInfo, + }; default: return state; } @@ -191,13 +203,16 @@ export function useConnections( hideStoreConnectionError(): void; setActiveConnectionById(newConnectionId?: string | undefined): void; removeAllRecentsConnections(): void; + duplicateConnection(connectioInfo: ConnectionInfo): void; + removeConnection(connectionInfo: ConnectionInfo): void; } ] { const [state, dispatch]: [State, React.Dispatch] = useReducer( connectionsReducer, defaultConnectionsState() ); - const { isConnected, connectionAttempt, connections } = state; + const { isConnected, connectionAttempt, connections, activeConnectionId } = + state; const connectingConnectionAttempt = useRef(); const connectedConnectionInfo = useRef(); @@ -206,7 +221,6 @@ export function useConnections( async function saveConnectionInfo(connectionInfo: ConnectionInfo) { try { await connectionStorage.save(connectionInfo); - debug(`saved connection with id ${connectionInfo.id || ''}`); } catch (err) { debug( @@ -379,6 +393,37 @@ export function useConnections( }), }); }, + async removeConnection(connectionInfo: ConnectionInfo) { + await connectionStorage.delete(connectionInfo); + dispatch({ + type: 'set-connections', + connections: connections.filter( + (conn) => conn.id !== connectionInfo.id + ), + }); + if (activeConnectionId === connectionInfo.id) { + const nextActiveConnection = createNewConnectionInfo(); + dispatch({ + type: 'set-active-connection', + connectionId: nextActiveConnection.id, + connectionInfo: nextActiveConnection, + }); + } + }, + async duplicateConnection(connectionInfo: ConnectionInfo) { + const duplicate: ConnectionInfo = { + ...cloneDeep(connectionInfo), + id: uuidv4(), + }; + duplicate.favorite!.name += ' (copy)'; + + await saveConnectionInfo(duplicate); + dispatch({ + type: 'set-connections-and-select', + connections: [...connections, duplicate], + activeConnectionInfo: duplicate, + }); + }, }, ]; }