diff --git a/package-lock.json b/package-lock.json index e470a4eb049..d4a3b909281 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5104,9 +5104,9 @@ } }, "node_modules/@leafygreen-ui/hooks": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@leafygreen-ui/hooks/-/hooks-7.0.0.tgz", - "integrity": "sha512-/UDdinXJbHcNvqodfY+5Ej4auxKdzbljJx0PkLy+bAUI0/P9W4xlYb2N0LZTIZBmKj4SUSQX3O/G7fvtSqJW7A==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/hooks/-/hooks-7.1.0.tgz", + "integrity": "sha512-eL0BKaaVgVsQuyNjZqYUUJM6b42Bmdn9gSvZ473EXeNtMD3o6/VqAlYFmOWnVYWHNj8ToLXgyWNuwnRuMRZ0qg==", "dependencies": { "lodash": "^4.17.21" } @@ -5419,6 +5419,55 @@ "react-dom": "^16.0.0" } }, + "node_modules/@leafygreen-ui/radio-box-group": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/radio-box-group/-/radio-box-group-6.1.5.tgz", + "integrity": "sha512-G8xOB9np41zfvx9Xv8cQ721pJAQ5vKeIkTNQbAnQGj6VgCWXHsJmPuLwo9XAxil0Sa0gAinXfgZ2Pn628kgEPA==", + "dependencies": { + "@leafygreen-ui/hooks": "^7.1.0", + "@leafygreen-ui/interaction-ring": "^1.1.0", + "@leafygreen-ui/lib": "^9.1.0", + "@leafygreen-ui/palette": "^3.2.2" + }, + "peerDependencies": { + "@leafygreen-ui/leafygreen-provider": "^2.1.3" + } + }, + "node_modules/@leafygreen-ui/radio-box-group/node_modules/@leafygreen-ui/emotion": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/emotion/-/emotion-4.0.0.tgz", + "integrity": "sha512-nr2g6OFsy+psaMto3H4HQ1ivM1tCwd9k1bbR5WH4U7YibDagfBekFTwlhohmC/K7hUM/eDVPGw0w4zQyC+BwZg==", + "dependencies": { + "@emotion/css": "^11.1.3", + "@emotion/react": "^11.4.0", + "@emotion/server": "^11.4.0" + } + }, + "node_modules/@leafygreen-ui/radio-box-group/node_modules/@leafygreen-ui/lib": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-9.1.0.tgz", + "integrity": "sha512-JH3mnoCZNtUcJfXrvVG4I3PAIm1ehlR1H5WkDqkjengcf/iVMo7+AluzwfTCQb2fqQgohURs+qY4vEhJe+9+2g==", + "dependencies": { + "@leafygreen-ui/emotion": "^4.0.0", + "facepaint": "^1.2.1", + "polished": "^4.1.3", + "prop-types": "^15.0.0" + }, + "peerDependencies": { + "react": "^17.0.0" + } + }, + "node_modules/@leafygreen-ui/radio-box-group/node_modules/polished": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.1.3.tgz", + "integrity": "sha512-ocPAcVBUOryJEKe0z2KLd1l9EBa1r5mSwlKpExmrLzsnIzJo4axsoU9O2BjOTkDGDT4mZ0WFE5XKTlR3nLnZOA==", + "dependencies": { + "@babel/runtime": "^7.14.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@leafygreen-ui/ripple": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@leafygreen-ui/ripple/-/ripple-1.1.1.tgz", @@ -63156,6 +63205,7 @@ "@leafygreen-ui/modal": "^7.0.0", "@leafygreen-ui/palette": "^3.2.2", "@leafygreen-ui/portal": "^3.1.3", + "@leafygreen-ui/radio-box-group": "^6.1.4", "@leafygreen-ui/select": "^3.0.4", "@leafygreen-ui/tabs": "^5.1.3", "@leafygreen-ui/text-area": "^4.0.3", @@ -98311,17 +98361,6 @@ "decamelize": "^1.2.0" } }, - "packages/compass/node_modules/@mongosh/node-runtime-worker-thread": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@mongosh/node-runtime-worker-thread/-/node-runtime-worker-thread-1.1.7.tgz", - "integrity": "sha512-CAIFF2Qi/jZcmd1YWVa9QWU4RXjwvQcMmxVsL9oUpDvD0TXrWqNcA7ws7xxQoQ5/5p790zsFmPNVA3MUdkUy3Q==", - "dependencies": { - "interruptor": "^1.0.1" - }, - "engines": { - "node": ">=12.4.0" - } - }, "packages/compass/node_modules/anymatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", @@ -98667,6 +98706,7 @@ "@mongodb-js/prettier-config-compass": "^0.3.0", "@mongodb-js/tsconfig-compass": "^0.3.0", "@testing-library/react": "^12.0.0", + "@testing-library/user-event": "^13.5.0", "@types/chai": "^4.2.21", "@types/chai-dom": "^0.0.10", "@types/mocha": "^9.0.0", @@ -111485,9 +111525,9 @@ } }, "@leafygreen-ui/hooks": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@leafygreen-ui/hooks/-/hooks-7.0.0.tgz", - "integrity": "sha512-/UDdinXJbHcNvqodfY+5Ej4auxKdzbljJx0PkLy+bAUI0/P9W4xlYb2N0LZTIZBmKj4SUSQX3O/G7fvtSqJW7A==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/hooks/-/hooks-7.1.0.tgz", + "integrity": "sha512-eL0BKaaVgVsQuyNjZqYUUJM6b42Bmdn9gSvZ473EXeNtMD3o6/VqAlYFmOWnVYWHNj8ToLXgyWNuwnRuMRZ0qg==", "requires": { "lodash": "^4.17.21" } @@ -111773,6 +111813,48 @@ "@leafygreen-ui/lib": "^8.0.0" } }, + "@leafygreen-ui/radio-box-group": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/radio-box-group/-/radio-box-group-6.1.5.tgz", + "integrity": "sha512-G8xOB9np41zfvx9Xv8cQ721pJAQ5vKeIkTNQbAnQGj6VgCWXHsJmPuLwo9XAxil0Sa0gAinXfgZ2Pn628kgEPA==", + "requires": { + "@leafygreen-ui/hooks": "^7.1.0", + "@leafygreen-ui/interaction-ring": "^1.1.0", + "@leafygreen-ui/lib": "^9.1.0", + "@leafygreen-ui/palette": "^3.2.2" + }, + "dependencies": { + "@leafygreen-ui/emotion": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/emotion/-/emotion-4.0.0.tgz", + "integrity": "sha512-nr2g6OFsy+psaMto3H4HQ1ivM1tCwd9k1bbR5WH4U7YibDagfBekFTwlhohmC/K7hUM/eDVPGw0w4zQyC+BwZg==", + "requires": { + "@emotion/css": "^11.1.3", + "@emotion/react": "^11.4.0", + "@emotion/server": "^11.4.0" + } + }, + "@leafygreen-ui/lib": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@leafygreen-ui/lib/-/lib-9.1.0.tgz", + "integrity": "sha512-JH3mnoCZNtUcJfXrvVG4I3PAIm1ehlR1H5WkDqkjengcf/iVMo7+AluzwfTCQb2fqQgohURs+qY4vEhJe+9+2g==", + "requires": { + "@leafygreen-ui/emotion": "^4.0.0", + "facepaint": "^1.2.1", + "polished": "^4.1.3", + "prop-types": "^15.0.0" + } + }, + "polished": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.1.3.tgz", + "integrity": "sha512-ocPAcVBUOryJEKe0z2KLd1l9EBa1r5mSwlKpExmrLzsnIzJo4axsoU9O2BjOTkDGDT4mZ0WFE5XKTlR3nLnZOA==", + "requires": { + "@babel/runtime": "^7.14.0" + } + } + } + }, "@leafygreen-ui/ripple": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@leafygreen-ui/ripple/-/ripple-1.1.1.tgz", @@ -117593,6 +117675,7 @@ "@leafygreen-ui/modal": "^7.0.0", "@leafygreen-ui/palette": "^3.2.2", "@leafygreen-ui/portal": "^3.1.3", + "@leafygreen-ui/radio-box-group": "^6.1.4", "@leafygreen-ui/select": "^3.0.4", "@leafygreen-ui/tabs": "^5.1.3", "@leafygreen-ui/text-area": "^4.0.3", @@ -142339,6 +142422,7 @@ "@mongodb-js/prettier-config-compass": "^0.3.0", "@mongodb-js/tsconfig-compass": "^0.3.0", "@testing-library/react": "^12.0.0", + "@testing-library/user-event": "^13.5.0", "@types/chai": "^4.2.21", "@types/chai-dom": "^0.0.10", "@types/mocha": "^9.0.0", @@ -178184,14 +178268,6 @@ "web-vitals": "^2.1.2" }, "dependencies": { - "@mongosh/node-runtime-worker-thread": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@mongosh/node-runtime-worker-thread/-/node-runtime-worker-thread-1.1.7.tgz", - "integrity": "sha512-CAIFF2Qi/jZcmd1YWVa9QWU4RXjwvQcMmxVsL9oUpDvD0TXrWqNcA7ws7xxQoQ5/5p790zsFmPNVA3MUdkUy3Q==", - "requires": { - "interruptor": "^1.0.1" - } - }, "anymatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", diff --git a/packages/compass-components/package.json b/packages/compass-components/package.json index 48f2df49ce1..a4fdef33833 100644 --- a/packages/compass-components/package.json +++ b/packages/compass-components/package.json @@ -48,6 +48,7 @@ "@leafygreen-ui/modal": "^7.0.0", "@leafygreen-ui/palette": "^3.2.2", "@leafygreen-ui/portal": "^3.1.3", + "@leafygreen-ui/radio-box-group": "^6.1.4", "@leafygreen-ui/select": "^3.0.4", "@leafygreen-ui/tabs": "^5.1.3", "@leafygreen-ui/text-area": "^4.0.3", diff --git a/packages/compass-components/src/compass-ui-colors.tsx b/packages/compass-components/src/compass-ui-colors.tsx index 882d672678f..6c60907265f 100644 --- a/packages/compass-components/src/compass-ui-colors.tsx +++ b/packages/compass-components/src/compass-ui-colors.tsx @@ -1,2 +1,3 @@ export const gray8 = '#f5f6f7'; export const transparentGray = 'rgba(180, 180, 180, 0.5)'; +export const transparentWhite = 'rgba(255, 255, 255, 0.5)'; diff --git a/packages/compass-components/src/components/accordion.tsx b/packages/compass-components/src/components/accordion.tsx index 4d5a68ed09b..1a91408f396 100644 --- a/packages/compass-components/src/components/accordion.tsx +++ b/packages/compass-components/src/components/accordion.tsx @@ -27,9 +27,9 @@ const containerStyles = css({ marginTop: spacing[3], display: 'flex', alignItems: 'center', - '&:hover': { - cursor: 'pointer', - }, +}); +const buttonIconStyles = css({ + marginRight: spacing[1], }); interface AccordionProps { dataTestId?: string; @@ -54,7 +54,10 @@ function Accordion( setOpen((currentOpen) => !currentOpen); }} > - + {props.text}

diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 76f5dbdbda1..a18945a3aca 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -32,6 +32,7 @@ export { default as Modal } from '@leafygreen-ui/modal'; export { uiColors } from '@leafygreen-ui/palette'; export * as compassUIColors from './compass-ui-colors'; export { default as Portal } from '@leafygreen-ui/portal'; +export { RadioBox, RadioBoxGroup } from '@leafygreen-ui/radio-box-group'; export { Select, Option, Size as SelectSize } from '@leafygreen-ui/select'; export { Tabs, Tab } from '@leafygreen-ui/tabs'; export { default as TextArea } from '@leafygreen-ui/text-area'; diff --git a/packages/connect-form/package.json b/packages/connect-form/package.json index 1c2f1d1bc66..e4f5d97caa9 100644 --- a/packages/connect-form/package.json +++ b/packages/connect-form/package.json @@ -65,6 +65,7 @@ "@mongodb-js/prettier-config-compass": "^0.3.0", "@mongodb-js/tsconfig-compass": "^0.3.0", "@testing-library/react": "^12.0.0", + "@testing-library/user-event": "^13.5.0", "@types/chai": "^4.2.21", "@types/chai-dom": "^0.0.10", "@types/mocha": "^9.0.0", diff --git a/packages/connect-form/src/advanced-connection-options.tsx b/packages/connect-form/src/advanced-connection-options.tsx deleted file mode 100644 index 7f674aa4697..00000000000 --- a/packages/connect-form/src/advanced-connection-options.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import { Accordion } from '@mongodb-js/compass-components'; -import AdvancedOptionsTabs from './advanced-options-tabs/advanced-options-tabs'; - -function AdvancedConnectionOptions(): React.ReactElement { - return ( - - - - ); -} - -export default AdvancedConnectionOptions; diff --git a/packages/connect-form/src/advanced-options-tabs/advanced-options-tabs.tsx b/packages/connect-form/src/advanced-options-tabs/advanced-options-tabs.tsx deleted file mode 100644 index bab53a4df62..00000000000 --- a/packages/connect-form/src/advanced-options-tabs/advanced-options-tabs.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, { useState } from 'react'; -import { Tabs, Tab } from '@mongodb-js/compass-components'; - -import GeneralTab from './general-tab'; -import SSLTab from './ssl-tab'; -import SSHTunnelTab from './ssh-tunnel-tab'; -import AdvancedTab from './advanced-tab'; - -interface TabObject { - name: string; - component: React.FunctionComponent; -} - -function renderTab(tabObject: TabObject, idx: number): React.ReactElement { - const TabComponent = tabObject.component; - return ( - - - - ); -} -function AdvancedOptionsTabs(): React.ReactElement { - const [activeTab, setActiveTab] = useState(0); - - const tabs: TabObject[] = [ - { name: 'General', component: GeneralTab }, - { name: 'SSL', component: SSLTab }, - { name: 'SSH Tunnel', component: SSHTunnelTab }, - { name: 'Advanced', component: AdvancedTab }, - ]; - return ( - - {tabs.map(renderTab)} - - ); -} - -export default AdvancedOptionsTabs; diff --git a/packages/connect-form/src/advanced-options-tabs/general-tab.tsx b/packages/connect-form/src/advanced-options-tabs/general-tab.tsx deleted file mode 100644 index c9d652b67d3..00000000000 --- a/packages/connect-form/src/advanced-options-tabs/general-tab.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -function GeneralTab(): React.ReactElement { - return

General

; -} - -export default GeneralTab; diff --git a/packages/connect-form/src/components/advanced-connection-options.tsx b/packages/connect-form/src/components/advanced-connection-options.tsx new file mode 100644 index 00000000000..adddb00d4dc --- /dev/null +++ b/packages/connect-form/src/components/advanced-connection-options.tsx @@ -0,0 +1,63 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { + Accordion, + compassUIColors, + spacing, +} from '@mongodb-js/compass-components'; +import ConnectionStringUrl from 'mongodb-connection-string-url'; + +import AdvancedOptionsTabs from './advanced-options-tabs/advanced-options-tabs'; +import { UpdateConnectionFormField } from '../hooks/use-connect-form'; +import { ConnectionFormError } from '../utils/connect-form-errors'; + +const disabledOverlayStyles = css({ + position: 'absolute', + top: 0, + // Space around it to ensure added focus borders are covered. + bottom: -spacing[1], + left: -spacing[1], + right: -spacing[1], + backgroundColor: compassUIColors.transparentWhite, + zIndex: 1, + cursor: 'not-allowed', +}); + +const connectionTabsContainer = css({ + position: 'relative', +}); + +function AdvancedConnectionOptions({ + disabled, + errors, + connectionStringUrl, + hideError, + updateConnectionFormField, +}: { + errors: ConnectionFormError[]; + disabled: boolean; + connectionStringUrl: ConnectionStringUrl; + hideError: (errorIndex: number) => void; + updateConnectionFormField: UpdateConnectionFormField; +}): React.ReactElement { + return ( + +
+ {disabled && ( +
+ )} + +
+ + ); +} + +export default AdvancedConnectionOptions; diff --git a/packages/connect-form/src/components/advanced-options-tabs/advanced-options-tabs.tsx b/packages/connect-form/src/components/advanced-options-tabs/advanced-options-tabs.tsx new file mode 100644 index 00000000000..a88e59ef7a7 --- /dev/null +++ b/packages/connect-form/src/components/advanced-options-tabs/advanced-options-tabs.tsx @@ -0,0 +1,73 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; +import { Tabs, Tab, spacing } from '@mongodb-js/compass-components'; +import ConnectionStringUrl from 'mongodb-connection-string-url'; + +import GeneralTab from './general-tab'; +import AuthenticationTab from './authentication-tab'; +import SSLTab from './ssl-tab'; +import SSHTunnelTab from './ssh-tunnel-tab'; +import AdvancedTab from './advanced-tab'; +import { UpdateConnectionFormField } from '../../hooks/use-connect-form'; +import { ConnectionFormError } from '../../utils/connect-form-errors'; + +const tabsStyles = css({ + marginTop: spacing[1], +}); +interface TabObject { + name: string; + component: React.FunctionComponent<{ + errors: ConnectionFormError[]; + connectionStringUrl: ConnectionStringUrl; + hideError: (errorIndex: number) => void; + updateConnectionFormField: UpdateConnectionFormField; + }>; +} + +function AdvancedOptionsTabs({ + errors, + connectionStringUrl, + hideError, + updateConnectionFormField, +}: { + errors: ConnectionFormError[]; + connectionStringUrl: ConnectionStringUrl; + hideError: (errorIndex: number) => void; + updateConnectionFormField: UpdateConnectionFormField; +}): React.ReactElement { + const [activeTab, setActiveTab] = useState(0); + + const tabs: TabObject[] = [ + { name: 'General', component: GeneralTab }, + { name: 'Authentication', component: AuthenticationTab }, + { name: 'TLS/SSL', component: SSLTab }, + { name: 'SSH Tunnel', component: SSHTunnelTab }, + { name: 'Advanced', component: AdvancedTab }, + ]; + + return ( + + {tabs.map((tabObject: TabObject, idx: number) => { + const TabComponent = tabObject.component; + + return ( + + + + ); + })} + + ); +} + +export default AdvancedOptionsTabs; diff --git a/packages/connect-form/src/advanced-options-tabs/advanced-tab.tsx b/packages/connect-form/src/components/advanced-options-tabs/advanced-tab.tsx similarity index 100% rename from packages/connect-form/src/advanced-options-tabs/advanced-tab.tsx rename to packages/connect-form/src/components/advanced-options-tabs/advanced-tab.tsx diff --git a/packages/connect-form/src/components/advanced-options-tabs/authentication-tab.tsx b/packages/connect-form/src/components/advanced-options-tabs/authentication-tab.tsx new file mode 100644 index 00000000000..da7a17e1215 --- /dev/null +++ b/packages/connect-form/src/components/advanced-options-tabs/authentication-tab.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +function AuthenticationTab(): React.ReactElement { + return

Authentication

; +} + +export default AuthenticationTab; diff --git a/packages/connect-form/src/components/advanced-options-tabs/general-tab.spec.tsx b/packages/connect-form/src/components/advanced-options-tabs/general-tab.spec.tsx new file mode 100644 index 00000000000..da236ebd0a4 --- /dev/null +++ b/packages/connect-form/src/components/advanced-options-tabs/general-tab.spec.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { expect } from 'chai'; +import ConnectionStringUrl from 'mongodb-connection-string-url'; + +import GeneralTab from './general-tab'; + +const noop = () => { + /* */ +}; + +describe('GeneralTab', function () { + describe('with a srv connection string schema (mongodb+srv://)', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb+srv://0ranges:p!neapp1es@localhost/?ssl=true' + ); + render( + + ); + }); + + it('should not render the direct connection input', function () { + expect(screen.queryByText('Direct Connection')).to.not.exist; + }); + + it('should render the schema input', function () { + expect(screen.getByText('Schema')).to.be.visible; + }); + + it('should render the hostname input', function () { + expect(screen.getByText('Hostname')).to.be.visible; + }); + }); + + describe('with a standard connection string schema (mongodb://) with one host', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb://0ranges:p!neapp1es@localhost:27107/?ssl=true' + ); + render( + + ); + }); + + it('should render the direct connection input', function () { + expect(screen.getByText('Direct Connection')).to.be.visible; + }); + }); + + describe('with a standard connection string schema (mongodb://) with multiple hosts', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb://0ranges:p!neapp1es@localhost:27017,localhost:27019/?ssl=true' + ); + render( + + ); + }); + + it('should not render the direct connection input', function () { + expect(screen.queryByText('Direct Connection')).to.not.exist; + }); + }); + + describe('standard schema (mongodb://) with multiple hosts and directConnection=true', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb://0ranges:p!neapp1es@localhost:27017,localhost:27019/?ssl=true&directConnection=true' + ); + render( + + ); + }); + + it('should not render the direct connection input', function () { + expect(screen.queryByText('Direct Connection')).to.be.visible; + }); + }); +}); diff --git a/packages/connect-form/src/components/advanced-options-tabs/general-tab.tsx b/packages/connect-form/src/components/advanced-options-tabs/general-tab.tsx new file mode 100644 index 00000000000..82330d1b209 --- /dev/null +++ b/packages/connect-form/src/components/advanced-options-tabs/general-tab.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import ConnectionStringUrl from 'mongodb-connection-string-url'; + +import SchemaInput from './general/schema-input'; +import { UpdateConnectionFormField } from '../../hooks/use-connect-form'; +import { ConnectionFormError } from '../../utils/connect-form-errors'; +import FormFieldContainer from '../form-field-container'; +import HostInput from './general/host-input'; + +function GeneralTab({ + errors, + connectionStringUrl, + hideError, + updateConnectionFormField, +}: { + errors: ConnectionFormError[]; + connectionStringUrl: ConnectionStringUrl; + hideError: (errorIndex: number) => void; + updateConnectionFormField: UpdateConnectionFormField; +}): React.ReactElement { + return ( +
+ + + + +
+ ); +} + +export default GeneralTab; diff --git a/packages/connect-form/src/components/advanced-options-tabs/general/direct-connection-input.spec.tsx b/packages/connect-form/src/components/advanced-options-tabs/general/direct-connection-input.spec.tsx new file mode 100644 index 00000000000..24f18f4ad73 --- /dev/null +++ b/packages/connect-form/src/components/advanced-options-tabs/general/direct-connection-input.spec.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import ConnectionStringUrl from 'mongodb-connection-string-url'; + +import DirectConnectionInput from './direct-connection-input'; + +describe('DirectConnectionInput', function () { + let updateConnectionFormFieldSpy: sinon.SinonSpy; + + beforeEach(function () { + updateConnectionFormFieldSpy = sinon.spy(); + }); + + afterEach(cleanup); + + describe('when directConnection is "true" on the connection string', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb://localhost:27019/?directConnection=true&ssl=false' + ); + render( + + ); + }); + + it('should render checked', function () { + const checkbox: HTMLInputElement = screen.getByRole('checkbox'); + expect(checkbox.checked).to.equal(true); + }); + + describe('when the checkbox is clicked', function () { + beforeEach(function () { + const checkbox: HTMLInputElement = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + }); + + it('should call to update with direct connection = false', function () { + expect(updateConnectionFormFieldSpy.callCount).to.equal(1); + expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ + type: 'update-direct-connection', + isDirectConnection: false, + }); + }); + }); + }); + + describe('when directConnection is unset on the connection string', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb://localhost:27019/?ssl=true' + ); + render( + + ); + }); + + it('should render not checked', function () { + const checkbox: HTMLInputElement = screen.getByRole('checkbox'); + expect(checkbox.checked).to.equal(false); + }); + + describe('when the checkbox is clicked', function () { + beforeEach(function () { + const checkbox: HTMLInputElement = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + }); + + it('should call to update with direct connection = true', function () { + expect(updateConnectionFormFieldSpy.callCount).to.equal(1); + expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ + type: 'update-direct-connection', + isDirectConnection: true, + }); + }); + }); + }); + + describe('when directConnection is "NOT_TRUE_OR_FALSE" on the connection string', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb://localhost:27019/?directConnection=NOT_TRUE_OR_FALSE&ssl=false' + ); + render( + + ); + }); + + it('should render not checked', function () { + const checkbox: HTMLInputElement = screen.getByRole('checkbox'); + expect(checkbox.checked).to.equal(false); + }); + + describe('when the checkbox is clicked', function () { + beforeEach(function () { + const checkbox: HTMLInputElement = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + }); + + it('should call to update with direct connection = true', function () { + expect(updateConnectionFormFieldSpy.callCount).to.equal(1); + expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ + type: 'update-direct-connection', + isDirectConnection: true, + }); + }); + }); + }); +}); diff --git a/packages/connect-form/src/components/advanced-options-tabs/general/direct-connection-input.tsx b/packages/connect-form/src/components/advanced-options-tabs/general/direct-connection-input.tsx new file mode 100644 index 00000000000..05b665114aa --- /dev/null +++ b/packages/connect-form/src/components/advanced-options-tabs/general/direct-connection-input.tsx @@ -0,0 +1,43 @@ +import React, { useCallback } from 'react'; +import { Checkbox, Description } from '@mongodb-js/compass-components'; +import ConnectionStringUrl from 'mongodb-connection-string-url'; + +import { UpdateConnectionFormField } from '../../../hooks/use-connect-form'; + +function DirectConnectionInput({ + connectionStringUrl, + updateConnectionFormField, +}: { + connectionStringUrl: ConnectionStringUrl; + updateConnectionFormField: UpdateConnectionFormField; +}): React.ReactElement { + const isDirectConnection = + connectionStringUrl.searchParams.get('directConnection') === 'true'; + + const updateDirectConnection = useCallback( + (event: React.ChangeEvent) => { + updateConnectionFormField({ + type: 'update-direct-connection', + isDirectConnection: event.target.checked, + }); + }, + [updateConnectionFormField] + ); + + return ( + <> + + + Specifies whether to force dispatch all operations to the specified + host. + + + ); +} + +export default DirectConnectionInput; diff --git a/packages/connect-form/src/components/advanced-options-tabs/general/host-input.spec.tsx b/packages/connect-form/src/components/advanced-options-tabs/general/host-input.spec.tsx new file mode 100644 index 00000000000..fc3427cbbca --- /dev/null +++ b/packages/connect-form/src/components/advanced-options-tabs/general/host-input.spec.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import ConnectionStringUrl from 'mongodb-connection-string-url'; + +import HostInput from './host-input'; +import { MARKABLE_FORM_FIELD_NAMES } from '../../../constants/markable-form-fields'; + +describe('HostInput', function () { + let updateConnectionFormFieldSpy: sinon.SinonSpy; + + beforeEach(function () { + updateConnectionFormFieldSpy = sinon.spy(); + }); + + afterEach(cleanup); + + describe('connection string srv schema (mongodb+srv://)', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb+srv://0ranges:p!neapp1es@outerspace/?ssl=true' + ); + render( + + ); + }); + + it('renders a host input', function () { + expect(screen.getByRole('textbox')).to.exist; + }); + + it('renders the host value in the input', function () { + expect(screen.getByRole('textbox').getAttribute('value')).to.equal( + 'outerspace' + ); + }); + + it('does not render a plus button to add more hosts', function () { + expect(screen.queryByRole('button')).to.not.exist; + }); + + describe('when the host is updated', function () { + beforeEach(function () { + userEvent.tab(); + userEvent.keyboard('s'); + }); + + it('should call to update the hosts', function () { + expect(updateConnectionFormFieldSpy.callCount).to.equal(1); + expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ + type: 'update-host', + hostIndex: 0, + newHostValue: 'outerspaces', + }); + }); + }); + + describe('when the host is updated to an invalid value', function () { + beforeEach(function () { + userEvent.tab(); + userEvent.keyboard('@'); + }); + + it('should call to update the connection string url', function () { + expect(updateConnectionFormFieldSpy.callCount).to.equal(1); + expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ + type: 'update-host', + hostIndex: 0, + newHostValue: 'outerspace@', + }); + }); + }); + }); + + describe('when the host has an error', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb://0ranges:p!neapp1es@outerspace:27017,outerspace:27099,outerspace:27098,localhost:27098/?ssl=true' + ); + render( + + ); + }); + + it('renders the error', function () { + expect(screen.getByText('Eeeee!!!')).to.be.visible; + }); + }); + + describe('connection string standard schema (mongodb://)', function () { + describe('with a single host', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb://0ranges:p!neapp1es@outerspace:27019/?ssl=true' + ); + render( + + ); + }); + + it('does not render the remove host', function () { + expect(screen.queryByLabelText('Remove host')).to.not.exist; + }); + + describe('when the host is changed', function () { + beforeEach(function () { + const hostInput = screen.getByRole('textbox'); + userEvent.click(hostInput); + userEvent.keyboard('7'); + }); + + it('should call to update the connection string url with the updated host', function () { + expect(updateConnectionFormFieldSpy.callCount).to.equal(1); + expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ + type: 'update-host', + hostIndex: 0, + newHostValue: 'outerspace:270197', + }); + }); + }); + }); + + describe('with multiple hosts', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb://0ranges:p!neapp1es@outerspace:27017,outerspace:27098,outerspace:27099,localhost:27098/?ssl=true' + ); + render( + + ); + }); + + it('renders inputs for all of the hosts', function () { + expect(screen.getAllByRole('textbox').length).to.equal(4); + }); + + describe('when the remove host button is clicked', function () { + beforeEach(function () { + const removeHostButton = screen.getAllByLabelText('Remove host')[2]; + fireEvent.click(removeHostButton); + }); + + it('should call to remove the host', function () { + expect(updateConnectionFormFieldSpy.callCount).to.equal(1); + expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ + type: 'remove-host', + hostIndexToRemove: 2, + }); + }); + }); + + describe('when the add host button is clicked', function () { + beforeEach(function () { + const addHostButton = screen.getAllByLabelText('Add new host')[2]; + fireEvent.click(addHostButton); + }); + + it('should call to a new host at the location', function () { + expect(updateConnectionFormFieldSpy.callCount).to.equal(1); + expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ + type: 'add-new-host', + hostIndexToAddAfter: 2, + }); + }); + }); + + describe('when a host is changed', function () { + beforeEach(function () { + const hostInput = screen.getAllByRole('textbox')[2]; + userEvent.click(hostInput); + userEvent.keyboard('8'); + }); + + it('should call to update the connection string url with the updated host', function () { + expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ + type: 'update-host', + hostIndex: 2, + newHostValue: 'outerspace:270998', + }); + }); + }); + }); + }); +}); diff --git a/packages/connect-form/src/components/advanced-options-tabs/general/host-input.tsx b/packages/connect-form/src/components/advanced-options-tabs/general/host-input.tsx new file mode 100644 index 00000000000..7a7e623a514 --- /dev/null +++ b/packages/connect-form/src/components/advanced-options-tabs/general/host-input.tsx @@ -0,0 +1,145 @@ +import { css } from '@emotion/css'; +import React, { useCallback, useEffect, useState } from 'react'; +import { + Label, + Icon, + IconButton, + TextInput, + spacing, +} from '@mongodb-js/compass-components'; +import ConnectionStringUrl from 'mongodb-connection-string-url'; + +import { UpdateConnectionFormField } from '../../../hooks/use-connect-form'; +import { ConnectionFormError } from '../../../utils/connect-form-errors'; +import { MARKABLE_FORM_FIELD_NAMES } from '../../../constants/markable-form-fields'; +import DirectConnectionInput from './direct-connection-input'; +import FormFieldContainer from '../../form-field-container'; + +const hostInputContainerStyles = css({ + display: 'flex', + flexDirection: 'row', + width: '100%', + marginBottom: spacing[2], +}); + +const hostInputStyles = css({ + flexGrow: 1, +}); + +const hostActionButtonStyles = css({ + marginLeft: spacing[1], + marginTop: spacing[1], +}); + +function HostInput({ + errors, + connectionStringUrl, + updateConnectionFormField, +}: { + errors: ConnectionFormError[]; + connectionStringUrl: ConnectionStringUrl; + updateConnectionFormField: UpdateConnectionFormField; +}): React.ReactElement { + const [hosts, setHosts] = useState([...connectionStringUrl.hosts]); + const { isSRV } = connectionStringUrl; + + const showDirectConnectionInput = + connectionStringUrl.searchParams.get('directConnection') === 'true' || + (!connectionStringUrl.isSRV && hosts.length === 1); + + useEffect(() => { + // Update the hosts in the state when the underlying connection string hosts + // change. This can be when a user changes connections, pastes in a new + // connection string, or changes a setting which also updates the hosts. + setHosts([...connectionStringUrl.hosts]); + }, [connectionStringUrl]); + + const onHostChange = useCallback( + (event: React.ChangeEvent, index: number) => { + const newHosts = [...hosts]; + newHosts[index] = event.target.value || ''; + + setHosts(newHosts); + updateConnectionFormField({ + type: 'update-host', + hostIndex: index, + newHostValue: event.target.value, + }); + }, + [hosts, setHosts, updateConnectionFormField] + ); + + const hostsErrorIndex = errors.findIndex( + (error) => error.fieldName === MARKABLE_FORM_FIELD_NAMES.HOSTS + ); + const hostsError = errors[hostsErrorIndex]; + + return ( + <> + + + {hosts.map((host, index) => ( +
+ onHostChange(e, index)} + /> + {!isSRV && ( + + updateConnectionFormField({ + type: 'add-new-host', + hostIndexToAddAfter: index, + }) + } + > + + + )} + {!isSRV && hosts.length > 1 && ( + + updateConnectionFormField({ + type: 'remove-host', + hostIndexToRemove: index, + }) + } + > + + + )} +
+ ))} +
+ {showDirectConnectionInput && ( + + + + )} + + ); +} + +export default HostInput; diff --git a/packages/connect-form/src/components/advanced-options-tabs/general/schema-input.spec.tsx b/packages/connect-form/src/components/advanced-options-tabs/general/schema-input.spec.tsx new file mode 100644 index 00000000000..efc07dac4a4 --- /dev/null +++ b/packages/connect-form/src/components/advanced-options-tabs/general/schema-input.spec.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import ConnectionStringUrl from 'mongodb-connection-string-url'; + +import SchemaInput from './schema-input'; +import { MARKABLE_FORM_FIELD_NAMES } from '../../../constants/markable-form-fields'; + +describe('SchemaInput', function () { + let updateConnectionFormFieldSpy: sinon.SinonSpy; + + beforeEach(function () { + updateConnectionFormFieldSpy = sinon.spy(); + }); + + afterEach(cleanup); + + describe('with a srv connection string schema (mongodb+srv://)', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb+srv://0ranges:p!neapp1es@localhost/?ssl=true' + ); + render( + + ); + }); + + it('should render the srv box selected', function () { + const srvRadioBox = screen.getAllByRole('radio')[1] as HTMLInputElement; + expect(srvRadioBox.checked).to.equal(true); + expect(srvRadioBox.getAttribute('aria-checked')).to.equal('true'); + }); + + it('should render the standard box not selected', function () { + const standardSchemaRadioBox = screen.getAllByRole( + 'radio' + )[0] as HTMLInputElement; + expect(standardSchemaRadioBox.checked).to.equal(false); + expect(standardSchemaRadioBox.getAttribute('aria-checked')).to.equal( + 'false' + ); + }); + + describe('when the standard schema radio box is clicked', function () { + beforeEach(function () { + const standardSchemaRadioBox = screen.getAllByRole('radio')[0]; + fireEvent.click(standardSchemaRadioBox); + }); + + it('should call to update the connection string with standard schema', function () { + expect(updateConnectionFormFieldSpy.callCount).to.equal(1); + expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ + type: 'update-connection-schema', + isSrv: false, + }); + }); + }); + + describe('when the srv radio box is clicked again', function () { + beforeEach(function () { + const srvRadioBox = screen.getAllByRole('radio')[1]; + fireEvent.click(srvRadioBox); + }); + + it('should not call to update the field string', function () { + expect(updateConnectionFormFieldSpy.callCount).to.equal(0); + }); + }); + }); + + describe('with a standard connection string schema (mongodb://)', function () { + describe('with a single host', function () { + beforeEach(function () { + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb://0ranges:p!neapp1es@outerspace:27017/?ssl=true' + ); + render( + + ); + }); + + it('should render the standard selected', function () { + const srvRadioBox = screen.getAllByRole('radio')[0] as HTMLInputElement; + expect(srvRadioBox.checked).to.equal(true); + expect(srvRadioBox.getAttribute('aria-checked')).to.equal('true'); + }); + + it('should render the srv box not selected', function () { + const srvRadioBox = screen.getAllByRole('radio')[1] as HTMLInputElement; + expect(srvRadioBox.checked).to.equal(false); + expect(srvRadioBox.getAttribute('aria-checked')).to.equal('false'); + }); + + describe('when the srv schema radio box is clicked', function () { + beforeEach(function () { + const srvSchemaRadioBox = screen.getAllByRole('radio')[1]; + fireEvent.click(srvSchemaRadioBox); + }); + + it('should call to update the connection string with srv schema', function () { + expect(updateConnectionFormFieldSpy.callCount).to.equal(1); + expect(updateConnectionFormFieldSpy.firstCall.args[0]).to.deep.equal({ + type: 'update-connection-schema', + isSrv: true, + }); + }); + }); + }); + }); + + describe('when there is a schema error', function () { + let hideErrorSpy: sinon.SinonSpy; + + beforeEach(function () { + hideErrorSpy = sinon.spy(); + const connectionStringUrl = new ConnectionStringUrl( + 'mongodb://0ranges:p!neapp1es@outerspace:27017/?ssl=true' + ); + render( + + ); + }); + + it('should render the schema conversion error', function () { + expect(screen.getByText('aaaa!!!1!')).to.be.visible; + }); + + describe('when the x button is clicked', function () { + beforeEach(function () { + const hideErrorButton = screen.getByLabelText('X Icon'); + fireEvent.click(hideErrorButton); + }); + + it('should call to hide the error with the correct index', function () { + expect(hideErrorSpy.callCount).to.equal(1); + expect(hideErrorSpy.firstCall.args[0]).to.equal(1); + }); + }); + }); +}); diff --git a/packages/connect-form/src/components/advanced-options-tabs/general/schema-input.tsx b/packages/connect-form/src/components/advanced-options-tabs/general/schema-input.tsx new file mode 100644 index 00000000000..ca6c9731edb --- /dev/null +++ b/packages/connect-form/src/components/advanced-options-tabs/general/schema-input.tsx @@ -0,0 +1,87 @@ +import { css } from '@emotion/css'; +import React, { useCallback } from 'react'; +import { + Banner, + BannerVariant, + Description, + Label, + RadioBox, + RadioBoxGroup, + spacing, +} from '@mongodb-js/compass-components'; +import ConnectionStringUrl from 'mongodb-connection-string-url'; + +import { UpdateConnectionFormField } from '../../../hooks/use-connect-form'; +import { ConnectionFormError } from '../../../utils/connect-form-errors'; +import { MARKABLE_FORM_FIELD_NAMES } from '../../../constants/markable-form-fields'; + +enum MONGODB_SCHEMA { + MONGODB = 'MONGODB', + MONGODB_SRV = 'MONGODB_SRV', +} + +const descriptionStyles = css({ + marginTop: spacing[1], +}); + +const regularSchemaDescription = + 'Standard Connection String Format. The standard format of the MongoDB connection URI is used to connect to a MongoDB deployment: standalone, replica set, or a sharded cluster.'; +const srvSchemaDescription = + 'DNS Seed List Connection Format. The +srv indicates to the client that the hostname that follows corresponds to a DNS SRV record.'; + +function SchemaInput({ + connectionStringUrl, + errors, + hideError, + updateConnectionFormField, +}: { + connectionStringUrl: ConnectionStringUrl; + errors: ConnectionFormError[]; + hideError: (errorIndex: number) => void; + updateConnectionFormField: UpdateConnectionFormField; +}): React.ReactElement { + const { isSRV } = connectionStringUrl; + + const onChangeConnectionSchema = useCallback( + (event: React.ChangeEvent) => { + updateConnectionFormField({ + type: 'update-connection-schema', + isSrv: event.target.value === MONGODB_SCHEMA.MONGODB_SRV, + }); + }, + [updateConnectionFormField] + ); + + const schemaUpdateErrorIndex = errors.findIndex( + (error) => error.fieldName === MARKABLE_FORM_FIELD_NAMES.IS_SRV + ); + const schemaUpdateError = errors[schemaUpdateErrorIndex]; + + return ( + <> + + + mongodb + mongodb+srv + + + {isSRV ? srvSchemaDescription : regularSchemaDescription} + + {schemaUpdateError && ( + hideError(schemaUpdateErrorIndex)} + > + {schemaUpdateError.message} + + )} + + ); +} + +export default SchemaInput; diff --git a/packages/connect-form/src/advanced-options-tabs/ssh-tunnel-tab.tsx b/packages/connect-form/src/components/advanced-options-tabs/ssh-tunnel-tab.tsx similarity index 100% rename from packages/connect-form/src/advanced-options-tabs/ssh-tunnel-tab.tsx rename to packages/connect-form/src/components/advanced-options-tabs/ssh-tunnel-tab.tsx diff --git a/packages/connect-form/src/advanced-options-tabs/ssl-tab.tsx b/packages/connect-form/src/components/advanced-options-tabs/ssl-tab.tsx similarity index 77% rename from packages/connect-form/src/advanced-options-tabs/ssl-tab.tsx rename to packages/connect-form/src/components/advanced-options-tabs/ssl-tab.tsx index 4b188187d5d..47e4b0e131c 100644 --- a/packages/connect-form/src/advanced-options-tabs/ssl-tab.tsx +++ b/packages/connect-form/src/components/advanced-options-tabs/ssl-tab.tsx @@ -1,7 +1,7 @@ import React from 'react'; function SSLTab(): React.ReactElement { - return

SSL

; + return

SSL/TLS

; } export default SSLTab; diff --git a/packages/connect-form/src/confirm-edit-connection-string.spec.tsx b/packages/connect-form/src/components/confirm-edit-connection-string.spec.tsx similarity index 100% rename from packages/connect-form/src/confirm-edit-connection-string.spec.tsx rename to packages/connect-form/src/components/confirm-edit-connection-string.spec.tsx diff --git a/packages/connect-form/src/confirm-edit-connection-string.tsx b/packages/connect-form/src/components/confirm-edit-connection-string.tsx similarity index 100% rename from packages/connect-form/src/confirm-edit-connection-string.tsx rename to packages/connect-form/src/components/confirm-edit-connection-string.tsx diff --git a/packages/connect-form/src/components/connect-form-actions.tsx b/packages/connect-form/src/components/connect-form-actions.tsx new file mode 100644 index 00000000000..fe7e86504c4 --- /dev/null +++ b/packages/connect-form/src/components/connect-form-actions.tsx @@ -0,0 +1,30 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { + Button, + ButtonVariant, + spacing, + uiColors, +} from '@mongodb-js/compass-components'; + +const formActionStyles = css({ + padding: spacing[4], + borderTop: `1px solid ${uiColors.gray.light2}`, + textAlign: 'right', +}); + +function ConnectFormActions({ + onConnectClicked, +}: { + onConnectClicked: () => void; +}): React.ReactElement { + return ( +
+ +
+ ); +} + +export default ConnectFormActions; diff --git a/packages/connect-form/src/connect-form.spec.tsx b/packages/connect-form/src/components/connect-form.spec.tsx similarity index 98% rename from packages/connect-form/src/connect-form.spec.tsx rename to packages/connect-form/src/components/connect-form.spec.tsx index a68fbe3e6fc..4eaf7277b16 100644 --- a/packages/connect-form/src/connect-form.spec.tsx +++ b/packages/connect-form/src/components/connect-form.spec.tsx @@ -11,6 +11,7 @@ function renderForm() { /* */ }} initialConnectionInfo={{ + id: 'test', connectionOptions: { connectionString: 'mongodb://pineapple:orangutans@localhost:27019', }, diff --git a/packages/connect-form/src/components/connect-form.tsx b/packages/connect-form/src/components/connect-form.tsx new file mode 100644 index 00000000000..7a1d320a62b --- /dev/null +++ b/packages/connect-form/src/components/connect-form.tsx @@ -0,0 +1,106 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { ConnectionInfo } from 'mongodb-data-service'; +import { + Banner, + BannerVariant, + Card, + Description, + H3, + spacing, +} from '@mongodb-js/compass-components'; + +import ConnectionStringInput from './connection-string-input'; +import AdvancedConnectionOptions from './advanced-connection-options'; +import ConnectFormActions from './connect-form-actions'; +import { useConnectForm } from '../hooks/use-connect-form'; + +const formContainerStyles = css({ + margin: 0, + padding: 0, + height: 'fit-content', + flexGrow: 1, + minWidth: 650, + maxWidth: 760, + position: 'relative', + display: 'inline-block', +}); + +const formCardStyles = css({ + margin: 0, + padding: spacing[2], + height: 'fit-content', + width: '100%', + position: 'relative', +}); + +const descriptionStyles = css({ + marginTop: spacing[2], +}); + +const formContentContainerStyles = css({ + padding: spacing[4], +}); + +function ConnectForm({ + initialConnectionInfo, + onConnectClicked, +}: { + initialConnectionInfo: ConnectionInfo; + onConnectClicked: (connectionInfo: ConnectionInfo) => void; +}): React.ReactElement { + const [ + { errors, connectionStringUrl, connectionStringInvalidError }, + { + updateConnectionFormField, + setConnectionStringUrl, + setConnectionStringError, + hideError, + }, + ] = useConnectForm(initialConnectionInfo); + + const editingConnectionStringUrl = connectionStringUrl; + + return ( +
+ +
+

New Connection

+ + Connect to a MongoDB deployment + + + {connectionStringInvalidError && ( + + {connectionStringInvalidError} + + )} + +
+ + onConnectClicked({ + ...initialConnectionInfo, + connectionOptions: { + ...initialConnectionInfo.connectionOptions, + connectionString: editingConnectionStringUrl.toString(), + }, + }) + } + /> +
+
+ ); +} + +export default ConnectForm; diff --git a/packages/connect-form/src/connection-string-input.spec.tsx b/packages/connect-form/src/components/connection-string-input.spec.tsx similarity index 69% rename from packages/connect-form/src/connection-string-input.spec.tsx rename to packages/connect-form/src/components/connection-string-input.spec.tsx index 22e6fe3380f..6bf54a3c852 100644 --- a/packages/connect-form/src/connection-string-input.spec.tsx +++ b/packages/connect-form/src/components/connection-string-input.spec.tsx @@ -1,5 +1,12 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + waitFor, + cleanup, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { expect } from 'chai'; import sinon from 'sinon'; @@ -8,7 +15,14 @@ import ConnectionStringInput, { } from './connection-string-input'; describe('ConnectionStringInput Component', function () { - let setConnectionStringSpy: sinon.SinonSpy; + let setConnectionStringErrorSpy: sinon.SinonSpy; + let setConnectionStringUrlSpy: sinon.SinonSpy; + + beforeEach(function () { + setConnectionStringErrorSpy = sinon.spy(); + setConnectionStringUrlSpy = sinon.spy(); + }); + afterEach(cleanup); describe('#hidePasswordInConnectionString', function () { it('returns the connection string when it cannot be parsed', function () { @@ -49,20 +63,15 @@ describe('ConnectionStringInput Component', function () { describe('with an empty connection string', function () { beforeEach(function () { - setConnectionStringSpy = sinon.spy(); - render( ); }); - afterEach(function () { - setConnectionStringSpy = null; - }); - it('should show the connection string in the text area', function () { const textArea = screen.getByRole('textbox'); expect(textArea).to.have.text(''); @@ -72,16 +81,53 @@ describe('ConnectionStringInput Component', function () { const textArea = screen.getByRole('textbox'); expect(textArea).to.not.match('[disabled]'); }); + + describe('when an invalid connection string is inputted', function () { + beforeEach(function () { + // Focus the input. + userEvent.tab(); + userEvent.tab(); + userEvent.tab(); + userEvent.keyboard('z'); + }); + + it('should call setConnectionStringError', function () { + expect(setConnectionStringErrorSpy.callCount).to.equal(1); + expect(setConnectionStringErrorSpy.firstCall.args[0]).to.equal( + 'Invalid schema, expected connection string to start with `mongodb://` or `mongodb+srv://`' + ); + }); + + it('should not call setConnectionStringUrl', function () { + expect(setConnectionStringUrlSpy.callCount).to.equal(0); + }); + }); + + describe('when a valid connection string is inputted', function () { + beforeEach(function () { + // Focus the input. + userEvent.tab(); + userEvent.tab(); + userEvent.tab(); + userEvent.keyboard('mongodb://localhost'); + }); + + it('should call setConnectionStringUrl with the connection string', function () { + expect(setConnectionStringUrlSpy.callCount).to.equal(9); + expect(setConnectionStringUrlSpy.lastCall.args[0].toString()).to.equal( + 'mongodb://localhost/' + ); + }); + }); }); describe('the info button', function () { beforeEach(function () { - setConnectionStringSpy = sinon.spy(); - render( ); }); @@ -103,20 +149,15 @@ describe('ConnectionStringInput Component', function () { describe('with a connection string', function () { beforeEach(function () { - setConnectionStringSpy = sinon.spy(); - render( ); }); - afterEach(function () { - setConnectionStringSpy = null; - }); - it('shows the connection string in the text area', function () { const textArea = screen.getByRole('textbox'); expect(textArea).to.have.text('mongodb+srv://turtles:*****@localhost/'); @@ -157,6 +198,27 @@ describe('ConnectionStringInput Component', function () { ); }); + describe('when a valid connection string is inputted', function () { + beforeEach(function () { + // Focus the input. + userEvent.tab(); + userEvent.tab(); + userEvent.tab(); + userEvent.keyboard('?ssl=true'); + }); + + it('should call setConnectionStringUrl with the new connection string', function () { + expect(setConnectionStringUrlSpy.callCount).to.equal(9); + expect( + setConnectionStringUrlSpy.lastCall.args[0].toString() + ).to.equal('mongodb+srv://turtles:pineapples@localhost/?ssl=true'); + }); + + it('should not call setConnectionStringError', function () { + expect(setConnectionStringErrorSpy.callCount).to.equal(0); + }); + }); + describe('clicking on edit connection string toggle again', function () { beforeEach(function () { // Wait for the modal to close. diff --git a/packages/connect-form/src/connection-string-input.tsx b/packages/connect-form/src/components/connection-string-input.tsx similarity index 60% rename from packages/connect-form/src/connection-string-input.tsx rename to packages/connect-form/src/components/connection-string-input.tsx index 60fd05039d6..6be88fa96c6 100644 --- a/packages/connect-form/src/connection-string-input.tsx +++ b/packages/connect-form/src/components/connection-string-input.tsx @@ -1,5 +1,12 @@ import { css } from '@emotion/css'; -import React, { ChangeEvent, Fragment, useRef, useReducer } from 'react'; +import React, { + ChangeEvent, + Fragment, + useCallback, + useEffect, + useReducer, + useRef, +} from 'react'; import { Icon, IconButton, @@ -9,7 +16,9 @@ import { spacing, } from '@mongodb-js/compass-components'; import ConfirmEditConnectionString from './confirm-edit-connection-string'; -import { redactConnectionString } from 'mongodb-connection-string-url'; +import ConnectionStringUrl, { + redactConnectionString, +} from 'mongodb-connection-string-url'; const uriLabelStyles = css({ padding: 0, @@ -24,6 +33,7 @@ const infoButtonStyles = css({ const textAreaContainerStyle = css({ position: 'relative', + marginBottom: spacing[2], }); const connectionStringStyles = css({ @@ -54,19 +64,36 @@ const textAreaLabelContainerStyles = css({ const connectionStringInputId = 'connectionString'; +function connectionStringHasValidScheme(connectionString: string) { + return ( + connectionString.startsWith('mongodb://') || + connectionString.startsWith('mongodb+srv://') + ); +} + type State = { + editingConnectionString: string; enableEditingConnectionString: boolean; showConfirmEditConnectionStringPrompt: boolean; }; type Action = | { type: 'enable-editing-connection-string' } + | { + type: 'set-editing-connection-string'; + editingConnectionString: string; + } | { type: 'show-edit-connection-string-confirmation' } | { type: 'hide-edit-connection-string-confirmation' } - | { type: 'hide-connection-string' }; + | { type: 'disable-editing-connection-string' }; function reducer(state: State, action: Action): State { switch (action.type) { + case 'set-editing-connection-string': + return { + ...state, + editingConnectionString: action.editingConnectionString, + }; case 'enable-editing-connection-string': return { ...state, @@ -78,7 +105,7 @@ function reducer(state: State, action: Action): State { ...state, showConfirmEditConnectionStringPrompt: true, }; - case 'hide-connection-string': + case 'disable-editing-connection-string': return { ...state, showConfirmEditConnectionStringPrompt: false, @@ -105,31 +132,90 @@ export function hidePasswordInConnectionString( function ConnectStringInput({ connectionString, - setConnectionString, + setConnectionStringError, + setConnectionStringUrl, }: { - connectionString: string; - setConnectionString: (connectionString: string) => void; + connectionString?: string; + setConnectionStringError: (errorMessage: string | null) => void; + setConnectionStringUrl: (connectionStringUrl: ConnectionStringUrl) => void; }): React.ReactElement { + const textAreaEl = useRef(null); + const [ - { enableEditingConnectionString, showConfirmEditConnectionStringPrompt }, + { + editingConnectionString, + enableEditingConnectionString, + showConfirmEditConnectionStringPrompt, + }, dispatch, ] = useReducer(reducer, { // If there is a connection string default it to protected. - enableEditingConnectionString: !connectionString, + enableEditingConnectionString: + !connectionString || connectionString === 'mongodb://localhost:27017/', showConfirmEditConnectionStringPrompt: false, + editingConnectionString: connectionString || '', }); - const textAreaEl = useRef(null); + useEffect(() => { + // If the user isn't actively editing the connection string and it + // changes (form action) we update the string and disable editing. + if ( + editingConnectionString !== connectionString && + (!textAreaEl.current || textAreaEl.current !== document.activeElement) + ) { + dispatch({ + type: 'set-editing-connection-string', + editingConnectionString: connectionString || '', + }); + dispatch({ + type: 'disable-editing-connection-string', + }); + } + }, [ + connectionString, + enableEditingConnectionString, + editingConnectionString, + ]); + + const onChangeConnectionString = useCallback( + (event: ChangeEvent) => { + const newConnectionString = event.target.value; + + dispatch({ + type: 'set-editing-connection-string', + editingConnectionString: newConnectionString, + }); + + try { + // Ensure it's parsable connection string. + const connectionStringUrl = new ConnectionStringUrl( + newConnectionString + ); + setConnectionStringUrl(connectionStringUrl); + } catch (error) { + // Check if starts with url scheme. + if (!connectionStringHasValidScheme(newConnectionString)) { + setConnectionStringError( + 'Invalid schema, expected connection string to start with `mongodb://` or `mongodb+srv://`' + ); + return; + } + + setConnectionStringError((error as Error).message); + } + }, + [setConnectionStringUrl, setConnectionStringError] + ); const displayedConnectionString = enableEditingConnectionString - ? connectionString - : hidePasswordInConnectionString(connectionString); + ? editingConnectionString + : hidePasswordInConnectionString(editingConnectionString); return (