From be1f8b90f993635468058065bebf2d0c74cb2efc Mon Sep 17 00:00:00 2001 From: chieftn Date: Tue, 14 Apr 2020 19:50:44 -0700 Subject: [PATCH] Rkessler/resource view (#14) (#252) * Rkessler/resource view (#14) Adds resource view. * Rename and consolidate per pr. --- .../azureResourcesView.spec.tsx.snap | 16 ++ .../components/azureResourcesView.spec.tsx | 25 +++ .../components/azureResourcesView.tsx | 11 ++ src/app/connectionStrings/actions.spec.ts | 12 +- src/app/connectionStrings/actions.ts | 8 +- .../connectionString.spec.tsx.snap | 70 ++++++++ .../connectionStringDelete.spec.tsx.snap | 91 ++++++++++ .../connectionStringEditView.spec.tsx.snap | 113 +++++++++++++ .../connectionStringProperties.spec.tsx.snap | 27 +++ .../connectionStringsView.spec.tsx.snap | 111 ++++++++++++ .../components/connectionString.scss | 56 ++++++ .../components/connectionString.spec.tsx | 114 +++++++++++++ .../components/connectionString.tsx | 108 ++++++++++++ .../components/connectionStringDelete.scss | 20 +++ .../connectionStringDelete.spec.tsx | 63 +++++++ .../components/connectionStringDelete.tsx | 62 +++++++ .../components/connectionStringEditView.scss | 27 +++ .../connectionStringEditView.spec.tsx | 146 ++++++++++++++++ .../components/connectionStringEditView.tsx | 152 +++++++++++++++++ .../connectionStringProperties.spec.tsx | 21 +++ .../components/connectionStringProperties.tsx | 49 ++++++ .../components/connectionStringsView.scss | 9 + .../components/connectionStringsView.spec.tsx | 159 ++++++++++++++++++ .../components/connectionStringsView.tsx | 124 ++++++++++++++ src/app/connectionStrings/reducer.spec.ts | 32 +++- src/app/connectionStrings/reducer.ts | 15 +- src/app/connectionStrings/sagas.spec.ts | 6 +- src/app/connectionStrings/sagas.ts | 6 +- .../sagas/upsertConnectionStringSaga.spec.ts | 113 +++++++++++++ .../sagas/upsertConnectionStringSaga.ts | 25 +++ src/app/constants/actionTypes.ts | 2 + src/app/css/_connectivityPane.scss | 1 + src/app/css/_index.scss | 1 + src/app/shared/components/application.tsx | 3 +- src/localization/locales/en.json | 77 +++++++++ src/localization/resourceKeys.ts | 77 +++++++++ tslint.json | 1 + 37 files changed, 1944 insertions(+), 9 deletions(-) create mode 100644 src/app/azureResources/components/__snapshots__/azureResourcesView.spec.tsx.snap create mode 100644 src/app/azureResources/components/azureResourcesView.spec.tsx create mode 100644 src/app/azureResources/components/azureResourcesView.tsx create mode 100644 src/app/connectionStrings/components/__snapshots__/connectionString.spec.tsx.snap create mode 100644 src/app/connectionStrings/components/__snapshots__/connectionStringDelete.spec.tsx.snap create mode 100644 src/app/connectionStrings/components/__snapshots__/connectionStringEditView.spec.tsx.snap create mode 100644 src/app/connectionStrings/components/__snapshots__/connectionStringProperties.spec.tsx.snap create mode 100644 src/app/connectionStrings/components/__snapshots__/connectionStringsView.spec.tsx.snap create mode 100644 src/app/connectionStrings/components/connectionString.scss create mode 100644 src/app/connectionStrings/components/connectionString.spec.tsx create mode 100644 src/app/connectionStrings/components/connectionString.tsx create mode 100644 src/app/connectionStrings/components/connectionStringDelete.scss create mode 100644 src/app/connectionStrings/components/connectionStringDelete.spec.tsx create mode 100644 src/app/connectionStrings/components/connectionStringDelete.tsx create mode 100644 src/app/connectionStrings/components/connectionStringEditView.scss create mode 100644 src/app/connectionStrings/components/connectionStringEditView.spec.tsx create mode 100644 src/app/connectionStrings/components/connectionStringEditView.tsx create mode 100644 src/app/connectionStrings/components/connectionStringProperties.spec.tsx create mode 100644 src/app/connectionStrings/components/connectionStringProperties.tsx create mode 100644 src/app/connectionStrings/components/connectionStringsView.scss create mode 100644 src/app/connectionStrings/components/connectionStringsView.spec.tsx create mode 100644 src/app/connectionStrings/components/connectionStringsView.tsx create mode 100644 src/app/connectionStrings/sagas/upsertConnectionStringSaga.spec.ts create mode 100644 src/app/connectionStrings/sagas/upsertConnectionStringSaga.ts diff --git a/src/app/azureResources/components/__snapshots__/azureResourcesView.spec.tsx.snap b/src/app/azureResources/components/__snapshots__/azureResourcesView.spec.tsx.snap new file mode 100644 index 00000000..813f36c0 --- /dev/null +++ b/src/app/azureResources/components/__snapshots__/azureResourcesView.spec.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`azureResourcesView matches snapshot 1`] = ` + +`; diff --git a/src/app/azureResources/components/azureResourcesView.spec.tsx b/src/app/azureResources/components/azureResourcesView.spec.tsx new file mode 100644 index 00000000..d43c0561 --- /dev/null +++ b/src/app/azureResources/components/azureResourcesView.spec.tsx @@ -0,0 +1,25 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { AzureResourcesView } from './azureResourcesView'; + +describe('azureResourcesView', () => { + it('matches snapshot', () => { + + const routerprops = { + history: jest.fn() as any, // tslint:disable-line:no-any + location: jest.fn() as any, // tslint:disable-line:no-any + match: { + params: { + hostName: 'hostName' + }, + url: 'currentUrl', + } as any // tslint:disable-line:no-any + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/app/azureResources/components/azureResourcesView.tsx b/src/app/azureResources/components/azureResourcesView.tsx new file mode 100644 index 00000000..5194f5cd --- /dev/null +++ b/src/app/azureResources/components/azureResourcesView.tsx @@ -0,0 +1,11 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { ConnectionStringsViewContainer } from '../../connectionStrings/components/connectionStringsView'; + +export const AzureResourcesView: React.FC = props => { + return ; +}; diff --git a/src/app/connectionStrings/actions.spec.ts b/src/app/connectionStrings/actions.spec.ts index 3079ccc9..b671a1c2 100644 --- a/src/app/connectionStrings/actions.spec.ts +++ b/src/app/connectionStrings/actions.spec.ts @@ -5,7 +5,8 @@ import { addConnectionStringAction, deleteConnectionStringAction, - setConnectionStringsAction + setConnectionStringsAction, + upsertConnectionStringAction } from './actions'; describe('addConnectionStringAction', () => { @@ -34,3 +35,12 @@ describe('setConnectionStringAction', () => { }); }); }); + +describe('upsertConnectionStringAction', () => { + it('returns CONNECTION_STRINGS/UPSERT action object', () => { + expect(upsertConnectionStringAction({ newConnectionString: 'new', connectionString: 'old'})).toEqual({ + payload: { newConnectionString: 'new', connectionString: 'old'}, + type: 'CONNECTION_STRINGS/UPSERT' + }); + }); +}); diff --git a/src/app/connectionStrings/actions.ts b/src/app/connectionStrings/actions.ts index be0f6440..845d9ebb 100644 --- a/src/app/connectionStrings/actions.ts +++ b/src/app/connectionStrings/actions.ts @@ -3,11 +3,17 @@ * Licensed under the MIT License **********************************************************/ import actionCreatorFactory from 'typescript-fsa'; -import { ADD, DELETE, SET } from '../constants/actionTypes'; +import { ADD, DELETE, SET, UPSERT } from '../constants/actionTypes'; export const CONNECTION_STRINGS = 'CONNECTION_STRINGS'; +export interface UpsertConnectionStringActionPayload { + newConnectionString: string; + connectionString?: string; +} + const actionCreator = actionCreatorFactory(CONNECTION_STRINGS); export const addConnectionStringAction = actionCreator(ADD); export const deleteConnectionStringAction = actionCreator(DELETE); export const setConnectionStringsAction = actionCreator(SET); +export const upsertConnectionStringAction = actionCreator(UPSERT); diff --git a/src/app/connectionStrings/components/__snapshots__/connectionString.spec.tsx.snap b/src/app/connectionStrings/components/__snapshots__/connectionString.spec.tsx.snap new file mode 100644 index 00000000..f2d9b430 --- /dev/null +++ b/src/app/connectionStrings/components/__snapshots__/connectionString.spec.tsx.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`connectionString matches snapshot 1`] = ` +
+
+
+ + test + +
+
+ + +
+
+
+ + +
+
+`; diff --git a/src/app/connectionStrings/components/__snapshots__/connectionStringDelete.spec.tsx.snap b/src/app/connectionStrings/components/__snapshots__/connectionStringDelete.spec.tsx.snap new file mode 100644 index 00000000..edc083b1 --- /dev/null +++ b/src/app/connectionStrings/components/__snapshots__/connectionStringDelete.spec.tsx.snap @@ -0,0 +1,91 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConnectionStringDelete matches snapshot hidden 1`] = ` + +`; + +exports[`ConnectionStringDelete matches snapshot visible 1`] = ` + +`; diff --git a/src/app/connectionStrings/components/__snapshots__/connectionStringEditView.spec.tsx.snap b/src/app/connectionStrings/components/__snapshots__/connectionStringEditView.spec.tsx.snap new file mode 100644 index 00000000..369235bb --- /dev/null +++ b/src/app/connectionStrings/components/__snapshots__/connectionStringEditView.spec.tsx.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConnectionStringEdit matches snapshot in Add Scenario 1`] = ` + +
+ +
+
+`; + +exports[`ConnectionStringEdit matches snapshot in Edit / invalid scenario 1`] = ` + +
+ +
+
+`; + +exports[`ConnectionStringEdit matches snapshot in Edit / valid scenario 1`] = ` + +
+ +
+
+`; + +exports[`ConnectionStringEdit matches snapshot in Edit Scenario 1`] = ` + +
+ +
+
+`; diff --git a/src/app/connectionStrings/components/__snapshots__/connectionStringProperties.spec.tsx.snap b/src/app/connectionStrings/components/__snapshots__/connectionStringProperties.spec.tsx.snap new file mode 100644 index 00000000..2a4194e1 --- /dev/null +++ b/src/app/connectionStrings/components/__snapshots__/connectionStringProperties.spec.tsx.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConnectionSTringProperties matches snapshot 1`] = ` + + + + + +`; diff --git a/src/app/connectionStrings/components/__snapshots__/connectionStringsView.spec.tsx.snap b/src/app/connectionStrings/components/__snapshots__/connectionStringsView.spec.tsx.snap new file mode 100644 index 00000000..bb1551c6 --- /dev/null +++ b/src/app/connectionStrings/components/__snapshots__/connectionStringsView.spec.tsx.snap @@ -0,0 +1,111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConnectionStringsView matches snapshot when connection string count exceeds max 1`] = ` +
+
+ +
+
+
+
+
+`; + +exports[`ConnectionStringsView matches snapshot when connection strings present 1`] = ` +
+
+ +
+
+
+ +
+
+
+`; + +exports[`ConnectionStringsView matches snapshot when no connection strings 1`] = ` +
+
+ +
+
+
+
+
+`; diff --git a/src/app/connectionStrings/components/connectionString.scss b/src/app/connectionStrings/components/connectionString.scss new file mode 100644 index 00000000..366dafff --- /dev/null +++ b/src/app/connectionStrings/components/connectionString.scss @@ -0,0 +1,56 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ + @import '../../css/themes'; + @import '../../css/variables'; + + .connection-string { + margin-left: 20px; + margin-right: 20px; + margin-bottom: 20px; + margin-top: 20px; + width: 550px; + + .commands { + display: flex; + justify-content: space-between + } + + .actions { + margin-top: 5px; + margin-right: 5px; + justify-content: right; + } + + .name { + @include themify($themes) { + border: 1px solid themed('borderColor') + } + @include themify($themes) { + border-bottom: 1px solid themed('backgroundColor') + } + + font-size: 16px; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + padding-top: 10px; + padding-left: 10px; + padding-right: 10px; + margin-bottom: -1px; + text-overflow: ellipsis; + overflow: hidden; + width: 400px; + } + + .properties { + @include themify($themes) { + border: 1px solid themed('borderColor') + } + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + border-top-right-radius: 5px; + padding: 10px; + + } +} \ No newline at end of file diff --git a/src/app/connectionStrings/components/connectionString.spec.tsx b/src/app/connectionStrings/components/connectionString.spec.tsx new file mode 100644 index 00000000..9e3b81f7 --- /dev/null +++ b/src/app/connectionStrings/components/connectionString.spec.tsx @@ -0,0 +1,114 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { IconButton } from 'office-ui-fabric-react/lib/Button'; +import { ConnectionString, ConnectionStringProps } from './connectionString'; +import { ConnectionStringDelete } from './connectionStringDelete'; + +describe('connectionString', () => { + const connectionString = 'HostName=test.azure-devices-int.net;SharedAccessKeyName=iothubowner;SharedAccessKey=key'; + it('matches snapshot', () => { + const props: ConnectionStringProps = { + connectionString, + onDeleteConnectionString: jest.fn(), + onEditConnectionString: jest.fn(), + onSelectConnectionString: jest.fn() + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('calls onSelectConnectionString when link clicked', () => { + const onSelectConnectionString = jest.fn(); + const props: ConnectionStringProps = { + connectionString, + onDeleteConnectionString: jest.fn(), + onEditConnectionString: jest.fn(), + onSelectConnectionString + }; + + const wrapper = shallow(); + wrapper.find(Link).props().onClick(undefined); + + expect(onSelectConnectionString).toHaveBeenCalledWith(connectionString, 'test.azure-devices-int.net'); + }); + + it('calls onEditConnectionString when edit button clicked', () => { + const onEditConnectionString = jest.fn(); + const props: ConnectionStringProps = { + connectionString, + onDeleteConnectionString: jest.fn(), + onEditConnectionString, + onSelectConnectionString: jest.fn() + }; + + const wrapper = shallow(); + wrapper.find(IconButton).first().props().onClick(undefined); + + expect(onEditConnectionString).toHaveBeenCalledWith(connectionString); + }); + + describe('delete scenario', () => { + it('launches delete confirmation when delete clicked', () => { + const props: ConnectionStringProps = { + connectionString, + onDeleteConnectionString: jest.fn(), + onEditConnectionString: jest.fn(), + onSelectConnectionString: jest.fn() + }; + + const wrapper = shallow(); + wrapper.find(IconButton).get(1).props.onClick(undefined); + wrapper.update(); + + const connectionStringDelete = wrapper.find(ConnectionStringDelete); + expect(connectionStringDelete.props().hidden).toEqual(false); + }); + + it('calls onDeleteConnectionString when ConnectionStringDelete confirmed', () => { + const onDeleteConnectionString = jest.fn(); + const props: ConnectionStringProps = { + connectionString, + onDeleteConnectionString, + onEditConnectionString: jest.fn(), + onSelectConnectionString: jest.fn() + }; + + const wrapper = shallow(); + wrapper.find(IconButton).get(1).props.onClick(undefined); + wrapper.update(); + + const connectionStringDelete = wrapper.find(ConnectionStringDelete); + connectionStringDelete.props().onDeleteConfirm(); + + expect (onDeleteConnectionString).toHaveBeenCalledWith(connectionString); + }); + + it('hides delete confirmation when ConnnectionStringDelete canceled', () => { + const props: ConnectionStringProps = { + connectionString, + onDeleteConnectionString: jest.fn(), + onEditConnectionString: jest.fn(), + onSelectConnectionString: jest.fn() + }; + + const wrapper = shallow(); + wrapper.find(IconButton).get(1).props.onClick(undefined); + wrapper.update(); + + const connectionStringDelete = wrapper.find(ConnectionStringDelete); + expect(connectionStringDelete.props().hidden).toEqual(false); + + connectionStringDelete.props().onDeleteCancel(); + wrapper.update(); + + const updatedConnectionStringDelete = wrapper.find(ConnectionStringDelete); + expect(updatedConnectionStringDelete.props().hidden).toEqual(true); + }); + }); +}); diff --git a/src/app/connectionStrings/components/connectionString.tsx b/src/app/connectionStrings/components/connectionString.tsx new file mode 100644 index 00000000..e046e47f --- /dev/null +++ b/src/app/connectionStrings/components/connectionString.tsx @@ -0,0 +1,108 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import { IconButton } from 'office-ui-fabric-react/lib/Button'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { getConnectionInfoFromConnectionString } from '../../api/shared/utils'; +import { getResourceNameFromHostName } from '../../api/shared/hostNameUtils'; +import { ConnectionStringProperties } from './connectionStringProperties'; +import { useLocalizationContext } from '../../shared/contexts/localizationContext'; +import { ResourceKeys } from '../../../localization/resourceKeys'; +import { ConnectionStringDelete } from './connectionStringDelete'; +import MaskedCopyableTextFieldContainer from '../../shared/components/maskedCopyableTextFieldContainer'; +import './connectionString.scss'; + +export interface ConnectionStringProps { + connectionString: string; + onEditConnectionString(connectionString: string): void; + onDeleteConnectionString(connectionString: string): void; + onSelectConnectionString(connectionString: string, hostName: string): void; +} + +export const ConnectionString: React.FC = props => { + const { connectionString, onEditConnectionString, onDeleteConnectionString, onSelectConnectionString } = props; + const connectionSettings = getConnectionInfoFromConnectionString(connectionString); + const { hostName, sharedAccessKey, sharedAccessKeyName } = connectionSettings; + const resourceName = getResourceNameFromHostName(hostName); + const [ confirmingDelete, setConfirmingDelete ] = React.useState(false); + const { t } = useLocalizationContext(); + + const onEditConnectionStringClick = () => { + onEditConnectionString(connectionString); + }; + + const onDeleteConnectionStringClick = () => { + setConfirmingDelete(true); + }; + + const onDeleteConnectionStringConfirm = () => { + setConfirmingDelete(false); + onDeleteConnectionString(connectionString); + }; + + const onDeleteConnectionStringCancel = () => { + setConfirmingDelete(false); + }; + + const onSelectConnectionStringClick = () => { + onSelectConnectionString(connectionString, hostName); + }; + + return ( +
+
+
+ + {resourceName} + +
+
+ + +
+
+ +
+ + +
+
+ ); +}; diff --git a/src/app/connectionStrings/components/connectionStringDelete.scss b/src/app/connectionStrings/components/connectionStringDelete.scss new file mode 100644 index 00000000..cbd64553 --- /dev/null +++ b/src/app/connectionStrings/components/connectionStringDelete.scss @@ -0,0 +1,20 @@ +/*********************************************************** +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License +**********************************************************/ +@import '../../css/themes'; +@import '../../css/variables'; + +.connection-string-delete { + textArea { + resize: vertical; + margin-top: 5px; + margin-bottom: 5px; + + @include themify($themes) { + border: 1px solid themed('borderColor'); + background-color: themed('backgroundColor'); + color: themed('textColor'); + } + } +} \ No newline at end of file diff --git a/src/app/connectionStrings/components/connectionStringDelete.spec.tsx b/src/app/connectionStrings/components/connectionStringDelete.spec.tsx new file mode 100644 index 00000000..e24ab9bd --- /dev/null +++ b/src/app/connectionStrings/components/connectionStringDelete.spec.tsx @@ -0,0 +1,63 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { ConnectionStringDelete, ConnectionStringDeleteProps } from './connectionStringDelete'; + +describe('ConnectionStringDelete', () => { + it('matches snapshot hidden', () => { + const props: ConnectionStringDeleteProps = { + connectionString: 'connectionString', + hidden: true, + onDeleteCancel: jest.fn(), + onDeleteConfirm: jest.fn(), + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + it('matches snapshot visible', () => { + const props: ConnectionStringDeleteProps = { + connectionString: 'connectionString', + hidden: false, + onDeleteCancel: jest.fn(), + onDeleteConfirm: jest.fn(), + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('calls onDeleteCancel when Cancel clicked', () => { + const onDeleteCancel = jest.fn(); + const props: ConnectionStringDeleteProps = { + connectionString: 'connectionString', + hidden: false, + onDeleteCancel, + onDeleteConfirm: jest.fn(), + }; + + const wrapper = shallow(); + wrapper.find(DefaultButton).props().onClick(undefined); + + expect(onDeleteCancel).toHaveBeenCalled(); + }); + + it('calls onDeleteConfirm when Confirm clicked', () => { + const onDeleteConfirm = jest.fn(); + const props: ConnectionStringDeleteProps = { + connectionString: 'connectionString', + hidden: false, + onDeleteCancel: jest.fn(), + onDeleteConfirm + }; + + const wrapper = shallow(); + wrapper.find(PrimaryButton).props().onClick(undefined); + + expect(onDeleteConfirm).toHaveBeenCalled(); + }); +}); diff --git a/src/app/connectionStrings/components/connectionStringDelete.tsx b/src/app/connectionStrings/components/connectionStringDelete.tsx new file mode 100644 index 00000000..3775613f --- /dev/null +++ b/src/app/connectionStrings/components/connectionStringDelete.tsx @@ -0,0 +1,62 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import { Dialog, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { useLocalizationContext } from '../../shared/contexts/localizationContext'; +import { ResourceKeys } from '../../../localization/resourceKeys'; +import './connectionStringDelete.scss'; + +const ROWS_FOR_CONNECTION = 8; +const COLS_FOR_CONNECTION = 40; + +export interface ConnectionStringDeleteProps { + connectionString: string; + hidden: boolean; + onDeleteConfirm(): void; + onDeleteCancel(): void; +} + +export const ConnectionStringDelete: React.FC = props => { + const { connectionString, hidden, onDeleteCancel, onDeleteConfirm } = props; + const { t } = useLocalizationContext(); + + return ( + + ); +}; diff --git a/src/app/connectionStrings/components/connectionStringEditView.scss b/src/app/connectionStrings/components/connectionStringEditView.scss new file mode 100644 index 00000000..a5565af7 --- /dev/null +++ b/src/app/connectionStrings/components/connectionStringEditView.scss @@ -0,0 +1,27 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ + .connection-string-edit-header { + margin-left: 20px; + margin-right: 20px; + } + + .connection-string-edit-body { + margin-right: 20px; + margin-left: 20px; + .details { + margin-top: 10px; + } + } + + .connection-string-edit-footer { + display: flex; + margin-left: 20px; + margin-right: 20px; + margin-bottom: 10px; + button { + margin-right: 10px; + } +} + diff --git a/src/app/connectionStrings/components/connectionStringEditView.spec.tsx b/src/app/connectionStrings/components/connectionStringEditView.spec.tsx new file mode 100644 index 00000000..95acb1cb --- /dev/null +++ b/src/app/connectionStrings/components/connectionStringEditView.spec.tsx @@ -0,0 +1,146 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import { shallow, mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { Provider } from 'react-redux'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { ConnectionStringEditView, ConnectionStringEditViewProps } from './connectionStringEditView'; +import configureStore from '../../../app/shared/redux/store/configureStore'; + +describe('ConnectionStringEdit', () => { + const connectionString = 'HostName=test.azure-devices-int.net;SharedAccessKeyName=iothubowner;SharedAccessKey=key'; + + it('matches snapshot in Add Scenario', () => { + const props: ConnectionStringEditViewProps = { + connectionStringUnderEdit: '', + connectionStrings: [], + onCommit: jest.fn(), + onDismiss: jest.fn() + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('matches snapshot in Edit Scenario', () => { + const props: ConnectionStringEditViewProps = { + connectionStringUnderEdit: 'connectionString', + connectionStrings: [], + onCommit: jest.fn(), + onDismiss: jest.fn() + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('matches snapshot in Edit / invalid scenario', () => { + const props: ConnectionStringEditViewProps = { + connectionStringUnderEdit: 'connectionString', + connectionStrings: [], + onCommit: jest.fn(), + onDismiss: jest.fn() + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('matches snapshot in Edit / valid scenario', () => { + const props: ConnectionStringEditViewProps = { + connectionStringUnderEdit: connectionString, + connectionStrings: [], + onCommit: jest.fn(), + onDismiss: jest.fn() + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('calls onDismiss when Cancel button clicked', () => { + const onDismiss = jest.fn(); + const props: ConnectionStringEditViewProps = { + connectionStringUnderEdit: 'connectionString', + connectionStrings: [], + onCommit: jest.fn(), + onDismiss + }; + + const wrapper = mount(); + wrapper.find(DefaultButton).get(1).props.onClick(undefined); + + expect(onDismiss).toHaveBeenCalled(); + }); + + describe('edit scenario', () => { + it('disables commit when validation fails', () => { + const props: ConnectionStringEditViewProps = { + connectionStringUnderEdit: connectionString, + connectionStrings: [], + onCommit: jest.fn(), + onDismiss: jest.fn() + }; + + const wrapper = mount( + + + + ); + act(() => wrapper.find(TextField).props().onChange(undefined, 'badConnectionString')); + wrapper.update(); + + const disabled = wrapper.find(PrimaryButton).props().disabled; + expect(disabled).toEqual(true); + }); + + it('disables commit when duplicate validation', () => { + const props: ConnectionStringEditViewProps = { + connectionStringUnderEdit: '', + connectionStrings: [connectionString], + onCommit: jest.fn(), + onDismiss: jest.fn() + }; + + const wrapper = mount( + + + + ); + act(() => wrapper.find(TextField).props().onChange(undefined, connectionString)); + wrapper.update(); + + const disabled = wrapper.find(PrimaryButton).props().disabled; + expect(disabled).toEqual(true); + }); + + it('calls onCommit when validation passes', () => { + const onCommit = jest.fn(); + const props: ConnectionStringEditViewProps = { + connectionStringUnderEdit: '', + connectionStrings: [], + onCommit, + onDismiss: jest.fn() + }; + + const wrapper = mount( + + + + ); + act(() => wrapper.find(TextField).props().onChange(undefined, connectionString)); + wrapper.update(); + + const commitButton = wrapper.find(PrimaryButton); + expect(commitButton.props().disabled).toEqual(false); + commitButton.props().onClick(undefined); + + expect(onCommit).toHaveBeenCalled(); + }); + + }); +}); diff --git a/src/app/connectionStrings/components/connectionStringEditView.tsx b/src/app/connectionStrings/components/connectionStringEditView.tsx new file mode 100644 index 00000000..44403c64 --- /dev/null +++ b/src/app/connectionStrings/components/connectionStringEditView.tsx @@ -0,0 +1,152 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import { Panel, PanelType } from 'office-ui-fabric-react/lib/Panel'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; +import { ConnectionStringProperties } from './connectionStringProperties'; +import { getConnectionInfoFromConnectionString } from '../../api/shared/utils'; +import { generateConnectionStringValidationError } from '../../shared/utils/hubConnectionStringHelper'; +import { IoTHubConnectionSettings } from '../../api/services/devicesService'; +import { useLocalizationContext } from '../../shared/contexts/localizationContext'; +import { ResourceKeys } from '../../../localization/resourceKeys'; +import './connectionStringEditView.scss'; + +const LINES_FOR_CONNECTION = 8; + +export interface ConnectionStringEditViewProps { + connectionStringUnderEdit?: string; + connectionStrings: string[]; + onDismiss(): void; + onCommit(newConnectionString: string): void; +} + +export const ConnectionStringEditView: React.FC = props => { + const {connectionStringUnderEdit, connectionStrings, onDismiss, onCommit} = props; + const [connectionString, setConnectionString] = React.useState(connectionStringUnderEdit); + const [connectionStringValidationKey, setConnectionStringValidationKey] = React.useState(undefined); + const [connectionSettings, setConnectionSettings] = React.useState(undefined); + const { t } = useLocalizationContext(); + + React.useEffect(() => { + if (connectionString) { + validateConnectionString(connectionString); + } + }, []); // tslint:disable-line:align + + const onConnectionStringChange = (event: React.FormEvent, newValue?: string) => { + setConnectionString(newValue); + validateConnectionString(newValue); + }; + + const onCommitClick = () => { + onCommit(connectionString); + }; + + const onDismissClick = () => { + onDismiss(); + }; + + const validateConnectionString = (updatedConnectionString: string) => { + let validationKey = generateConnectionStringValidationError(updatedConnectionString) || ''; + + if (!validationKey) { + const extractedConnectionSettings = getConnectionInfoFromConnectionString(updatedConnectionString); + setConnectionSettings(extractedConnectionSettings); + } + + // check for duplicates and validate || after setting values (so properties display) + validationKey = (connectionStrings.indexOf(updatedConnectionString) >= 0 && updatedConnectionString !== connectionStringUnderEdit) ? + ResourceKeys.connectionStrings.editConnection.validations.duplicate : + validationKey; + + setConnectionStringValidationKey(validationKey); + }; + + const showProperties = (): boolean => { + if (!connectionSettings) { + return false; + } + + if (connectionStringValidationKey && connectionStringValidationKey !== ResourceKeys.connectionStrings.editConnection.validations.duplicate) { + return false; + } + + return true; + }; + + const renderHeader = (): JSX.Element => { + return ( +

+ {connectionStringUnderEdit ? + t(ResourceKeys.connectionStrings.editConnection.title.edit) : + t(ResourceKeys.connectionStrings.editConnection.title.add) + } +

+ ); + }; + + const renderFooter = (): JSX.Element => { + return ( +
+ + +
+ ); + }; + + return ( + +
+ + {showProperties() && +
+ +
+ } +
+
+ ); +}; diff --git a/src/app/connectionStrings/components/connectionStringProperties.spec.tsx b/src/app/connectionStrings/components/connectionStringProperties.spec.tsx new file mode 100644 index 00000000..1188353f --- /dev/null +++ b/src/app/connectionStrings/components/connectionStringProperties.spec.tsx @@ -0,0 +1,21 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { ConnectionStringProperties, ConnectionStringPropertiesProps } from './connectionStringProperties'; + +describe('ConnectionSTringProperties', () => { + it('matches snapshot', () => { + const props: ConnectionStringPropertiesProps = { + connectionString: 'connectionString', + hostName: 'hostName', + sharedAccessKey: 'sharedAccessKey', + sharedAccessKeyName: 'sharedAccessKeyName' + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/app/connectionStrings/components/connectionStringProperties.tsx b/src/app/connectionStrings/components/connectionStringProperties.tsx new file mode 100644 index 00000000..efcd23db --- /dev/null +++ b/src/app/connectionStrings/components/connectionStringProperties.tsx @@ -0,0 +1,49 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import MaskedCopyableTextFieldContainer from '../../shared/components/maskedCopyableTextFieldContainer'; +import { useLocalizationContext } from '../../shared/contexts/localizationContext'; +import { ResourceKeys } from '../../../localization/resourceKeys'; + +export interface ConnectionStringPropertiesProps { + connectionString: string; + hostName: string; + sharedAccessKey: string; + sharedAccessKeyName: string; +} + +export const ConnectionStringProperties: React.FC = props => { + const { connectionString, hostName, sharedAccessKey, sharedAccessKeyName} = props; + const { t } = useLocalizationContext(); + + return ( + <> + + + + + + + ); +}; diff --git a/src/app/connectionStrings/components/connectionStringsView.scss b/src/app/connectionStrings/components/connectionStringsView.scss new file mode 100644 index 00000000..6d543870 --- /dev/null +++ b/src/app/connectionStrings/components/connectionStringsView.scss @@ -0,0 +1,9 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ + .connection-strings { + display: flex; + flex-wrap: wrap; + } + diff --git a/src/app/connectionStrings/components/connectionStringsView.spec.tsx b/src/app/connectionStrings/components/connectionStringsView.spec.tsx new file mode 100644 index 00000000..25a89e13 --- /dev/null +++ b/src/app/connectionStrings/components/connectionStringsView.spec.tsx @@ -0,0 +1,159 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import { shallow, mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { CommandBarButton } from 'office-ui-fabric-react/lib/Button'; +import { ConnectionStringsView, ConnectionStringsViewProps } from './connectionStringsView'; +import { ConnectionString } from './connectionString'; +import { ConnectionStringEditView } from './connectionStringEditView'; +import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage'; + +describe('ConnectionStringsView', () => { + it('matches snapshot when no connection strings', () => { + const props: ConnectionStringsViewProps = { + connectionStrings: [], + onDeleteConnectionString: jest.fn(), + onSelectConnectionString: jest.fn(), + onUpsertConnectionString: jest.fn() + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('matches snapshot when connection strings present', () => { + const props: ConnectionStringsViewProps = { + connectionStrings: ['connectionString1'], + onDeleteConnectionString: jest.fn(), + onSelectConnectionString: jest.fn(), + onUpsertConnectionString: jest.fn() + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + + }); + + it('matches snapshot when connection string count exceeds max', () => { + const connectionStrings = new Array(CONNECTION_STRING_LIST_MAX_LENGTH + 1).map((s, i) => `connectionString${i}`); + const props: ConnectionStringsViewProps = { + connectionStrings, + onDeleteConnectionString: jest.fn(), + onSelectConnectionString: jest.fn(), + onUpsertConnectionString: jest.fn() + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + describe('add scenario', () => { + it('mounts edit view when add command clicked', () => { + const props: ConnectionStringsViewProps = { + connectionStrings: [], + onDeleteConnectionString: jest.fn(), + onSelectConnectionString: jest.fn(), + onUpsertConnectionString: jest.fn() + }; + + const wrapper = mount(); + expect(wrapper.find(ConnectionStringEditView).length).toEqual(0); + + act(() => { + wrapper.find(CommandBarButton).props().onClick(undefined); + }); + + wrapper.update(); + + expect(wrapper.find(ConnectionStringEditView).length).toEqual(1); + }); + + it('dismisses when edit view dismissed', () => { + const props: ConnectionStringsViewProps = { + connectionStrings: [], + onDeleteConnectionString: jest.fn(), + onSelectConnectionString: jest.fn(), + onUpsertConnectionString: jest.fn() + }; + + const wrapper = mount(); + act(() => { + wrapper.find(CommandBarButton).props().onClick(undefined); + }); + + wrapper.update(); + const connectionStringEditView = wrapper.find(ConnectionStringEditView).first(); + + act(() => connectionStringEditView.props().onDismiss()); + wrapper.update(); + + expect(wrapper.find(ConnectionStringEditView).length).toEqual(0); + }); + + it('upserts when edit view applied', () => { + const upsertSpy = jest.fn(); + const props: ConnectionStringsViewProps = { + connectionStrings: [], + onDeleteConnectionString: jest.fn(), + onSelectConnectionString: jest.fn(), + onUpsertConnectionString: upsertSpy + }; + + const wrapper = mount(); + act(() => { + wrapper.find(CommandBarButton).props().onClick(undefined); + }); + + wrapper.update(); + const connectionStringEditView = wrapper.find(ConnectionStringEditView).first(); + + act(() => connectionStringEditView.props().onCommit('newConnectionString')); + wrapper.update(); + + expect(upsertSpy).toHaveBeenCalledWith('newConnectionString', ''); + expect(wrapper.find(ConnectionStringEditView).length).toEqual(0); + }); + }); + + describe('edit scenario', () => { + const connectionString = 'HostName=test.azure-devices-int.net;SharedAccessKeyName=iothubowner;SharedAccessKey=key'; + it('mounts edit view when add command clicked', () => { + const props: ConnectionStringsViewProps = { + connectionStrings: [connectionString], + onDeleteConnectionString: jest.fn(), + onSelectConnectionString: jest.fn(), + onUpsertConnectionString: jest.fn() + }; + + const wrapper = shallow(); + act(() => wrapper.find(ConnectionString).props().onEditConnectionString(connectionString)); + wrapper.update(); + + expect(wrapper.find(ConnectionStringEditView).length).toEqual(1); + }); + + it('upserts when edit view applied', () => { + const upsertSpy = jest.fn(); + const props: ConnectionStringsViewProps = { + connectionStrings: [connectionString], + onDeleteConnectionString: jest.fn(), + onSelectConnectionString: jest.fn(), + onUpsertConnectionString: upsertSpy + }; + + const wrapper = shallow(); + act(() => wrapper.find(ConnectionString).first().props().onEditConnectionString(connectionString)); + wrapper.update(); + + const connectionStringEditView = wrapper.find(ConnectionStringEditView).first(); + act(() => connectionStringEditView.props().onCommit('newConnectionString')); + wrapper.update(); + + expect(upsertSpy).toHaveBeenCalledWith('newConnectionString', connectionString); + expect(wrapper.find(ConnectionStringEditView).length).toEqual(0); + }); + }); +}); diff --git a/src/app/connectionStrings/components/connectionStringsView.tsx b/src/app/connectionStrings/components/connectionStringsView.tsx new file mode 100644 index 00000000..bbbddd28 --- /dev/null +++ b/src/app/connectionStrings/components/connectionStringsView.tsx @@ -0,0 +1,124 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import * as React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { RouteComponentProps } from 'react-router-dom'; +import { CommandBar } from 'office-ui-fabric-react/lib/CommandBar'; +import { ConnectionString } from './connectionString'; +import { ConnectionStringEditView } from './connectionStringEditView'; +import { useLocalizationContext } from '../../shared/contexts/localizationContext'; +import { ResourceKeys } from '../../../localization/resourceKeys'; +import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage'; +import { StateInterface } from '../../shared/redux/state'; +import { upsertConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction } from '../actions'; +import { setActiveAzureResourceByConnectionStringAction } from '../../azureResource/actions'; +import { ROUTE_PARTS } from '../../constants/routes'; +import { formatConnectionStrings } from '../../shared/utils/hubConnectionStringHelper'; +import '../../css/_layouts.scss'; +import './connectionStringsView.scss'; + +export interface ConnectionStringsViewProps { + connectionStrings: string[]; + onDeleteConnectionString(connectionString: string): void; + onUpsertConnectionString(newConnectionString: string, connectionString: string): void; + onSelectConnectionString(connectionString: string, hostName: string): void; +} + +export const ConnectionStringsView: React.FC = props => { + const [ connectionStringUnderEdit, setConnectionStringUnderEdit ] = React.useState(undefined); + const { connectionStrings, onDeleteConnectionString, onUpsertConnectionString, onSelectConnectionString } = props; + const { t } = useLocalizationContext(); + + const onAddConnectionStringClick = () => { + setConnectionStringUnderEdit(''); + }; + + const onEditConnectionStringClick = (connectionString: string) => { + setConnectionStringUnderEdit(connectionString); + }; + + const onConnectionStringEditCommit = (connectionString: string) => { + onUpsertConnectionString(connectionString, connectionStringUnderEdit); + setConnectionStringUnderEdit(undefined); + }; + + const onConnectionStringEditDismiss = () => { + setConnectionStringUnderEdit(undefined); + }; + + return ( +
+
+ = CONNECTION_STRING_LIST_MAX_LENGTH, + iconProps: { iconName: 'Add' }, + key: 'add', + onClick: onAddConnectionStringClick, + text: t(ResourceKeys.connectionStrings.addConnectionCommand.label) + } + ]} + /> +
+
+
+ {connectionStrings.map(connectionString => + + )} +
+
+ {connectionStringUnderEdit !== undefined && + + } +
+ ); +}; + +export const ConnectionStringsViewContainer: React.FC = props => { + const connectionStrings = useSelector((state: StateInterface) => state.connectionStringsState.connectionStrings); + const dispatch = useDispatch(); + + const onUpsertConnectionString = (newConnectionString: string, connectionString?: string) => { + dispatch(upsertConnectionStringAction({newConnectionString, connectionString})); + }; + + const onDeleteConnectionString = (connectionString: string) => { + dispatch(deleteConnectionStringAction(connectionString)); + }; + + const onSelectConnectionString = (connectionString: string, hostName: string) => { + const updatedConnectionStrings = formatConnectionStrings(connectionStrings, connectionString); + + dispatch(setConnectionStringsAction(updatedConnectionStrings)); + dispatch(setActiveAzureResourceByConnectionStringAction({ + connectionString, + hostName + })); + + props.history.push(`/${ROUTE_PARTS.RESOURCE}/${hostName}/${ROUTE_PARTS.DEVICES}`); + }; + + return ( + + ); +}; diff --git a/src/app/connectionStrings/reducer.spec.ts b/src/app/connectionStrings/reducer.spec.ts index 7566aef9..dfdb776c 100644 --- a/src/app/connectionStrings/reducer.spec.ts +++ b/src/app/connectionStrings/reducer.spec.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License **********************************************************/ import { ConnectionStringsStateInterface } from './state'; -import { addConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction } from './actions'; +import { addConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction } from './actions'; import reducer from './reducer'; describe('addConnectionStringAction', () => { @@ -63,3 +63,33 @@ describe('setConnectionStringAction', () => { expect(result.connectionStrings).toEqual(['connectionString3']); }); }); + +describe('upsertConnectionStringAction', () => { + it('overwrites existing connection string', () => { + const initialState: ConnectionStringsStateInterface = { + connectionStrings: [ + 'connectionString1', + 'connectionString2' + ] + }; + + const action = upsertConnectionStringAction({ newConnectionString: 'newConnectionString2', connectionString: 'connectionString2'}); + const result = reducer(initialState, action); + expect(result.connectionStrings).toHaveLength(2); // tslint:disable-line:no-magic-numbers + expect(result.connectionStrings).toEqual(['connectionString1', 'newConnectionString2']); + }); + + it('appends neww connection string', () => { + const initialState: ConnectionStringsStateInterface = { + connectionStrings: [ + 'connectionString1', + 'connectionString2' + ] + }; + const action = upsertConnectionStringAction({ newConnectionString: 'connectionString3' }); + const result = reducer(initialState, action); + expect(result.connectionStrings).toHaveLength(3); // tslint:disable-line:no-magic-numbers + expect(result.connectionStrings).toEqual(['connectionString1', 'connectionString2', 'connectionString3']); + + }); +}); diff --git a/src/app/connectionStrings/reducer.ts b/src/app/connectionStrings/reducer.ts index aba0e497..ec125f5b 100644 --- a/src/app/connectionStrings/reducer.ts +++ b/src/app/connectionStrings/reducer.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License **********************************************************/ import { reducerWithInitialState } from 'typescript-fsa-reducers'; -import { addConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction } from './actions'; +import { addConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction, UpsertConnectionStringActionPayload } from './actions'; import { connectionStringsStateInitial, ConnectionStringsStateInterface } from './state'; const reducer = reducerWithInitialState(connectionStringsStateInitial()) @@ -23,6 +23,19 @@ const reducer = reducerWithInitialState(connect .case(setConnectionStringsAction, (state: ConnectionStringsStateInterface, payload: string[]) => { const updatedState = {...state}; updatedState.connectionStrings = payload; + return updatedState; + }) + + .case(upsertConnectionStringAction, (state: ConnectionStringsStateInterface, payload: UpsertConnectionStringActionPayload) => { + const { newConnectionString, connectionString } = payload; + const updatedState = {...state}; + if (connectionString) { + updatedState.connectionStrings = state.connectionStrings.map(s => s === connectionString ? newConnectionString : s); + } else { + updatedState.connectionStrings = updatedState.connectionStrings.filter(s => s !== connectionString); + updatedState.connectionStrings.push(newConnectionString); + } + return updatedState; }); diff --git a/src/app/connectionStrings/sagas.spec.ts b/src/app/connectionStrings/sagas.spec.ts index aadf6cb7..52109546 100644 --- a/src/app/connectionStrings/sagas.spec.ts +++ b/src/app/connectionStrings/sagas.spec.ts @@ -4,17 +4,19 @@ **********************************************************/ import { takeEvery, takeLatest } from 'redux-saga/effects'; import rootSaga from './sagas'; -import { addConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction } from './actions'; +import { addConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction } from './actions'; import { addConnectionStringSaga } from './sagas/addConnectionStringSaga'; import { deleteConnectionStringSaga } from './sagas/deleteConnectionStringSaga'; import { setConnectionStringsSaga } from './sagas/setConnectionStringsSaga'; +import { upsertConnectionStringSaga } from './sagas/upsertConnectionStringSaga'; describe('connectionStrings/saga/rootSaga', () => { it('returns specified sagas', () => { expect(rootSaga).toEqual([ takeEvery(addConnectionStringAction, addConnectionStringSaga), takeEvery(deleteConnectionStringAction, deleteConnectionStringSaga), - takeLatest(setConnectionStringsAction, setConnectionStringsSaga) + takeLatest(setConnectionStringsAction, setConnectionStringsSaga), + takeEvery(upsertConnectionStringAction, upsertConnectionStringSaga) ]); }); }); diff --git a/src/app/connectionStrings/sagas.ts b/src/app/connectionStrings/sagas.ts index b0abdd6f..f9e1ca6a 100644 --- a/src/app/connectionStrings/sagas.ts +++ b/src/app/connectionStrings/sagas.ts @@ -3,13 +3,15 @@ * Licensed under the MIT License **********************************************************/ import { takeEvery, takeLatest } from 'redux-saga/effects'; -import { addConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction } from './actions'; +import { addConnectionStringAction, deleteConnectionStringAction, setConnectionStringsAction, upsertConnectionStringAction } from './actions'; import { addConnectionStringSaga } from './sagas/addConnectionStringSaga'; import { deleteConnectionStringSaga } from './sagas/deleteConnectionStringSaga'; import { setConnectionStringsSaga } from './sagas/setConnectionStringsSaga'; +import { upsertConnectionStringSaga } from './sagas/upsertConnectionStringSaga'; export default [ takeEvery(addConnectionStringAction, addConnectionStringSaga), takeEvery(deleteConnectionStringAction, deleteConnectionStringSaga), - takeLatest(setConnectionStringsAction, setConnectionStringsSaga) + takeLatest(setConnectionStringsAction, setConnectionStringsSaga), + takeEvery(upsertConnectionStringAction, upsertConnectionStringSaga) ]; diff --git a/src/app/connectionStrings/sagas/upsertConnectionStringSaga.spec.ts b/src/app/connectionStrings/sagas/upsertConnectionStringSaga.spec.ts new file mode 100644 index 00000000..bae42ffb --- /dev/null +++ b/src/app/connectionStrings/sagas/upsertConnectionStringSaga.spec.ts @@ -0,0 +1,113 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import { call } from 'redux-saga/effects'; +import { cloneableGenerator } from 'redux-saga/utils'; +import { upsertConnectionStringAction } from '../actions'; +import { upsertConnectionStringSaga } from './upsertConnectionStringSaga'; +import { getConnectionStrings, setConnectionStrings } from './setConnectionStringsSaga'; +import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage'; + +describe('upsertConnectionStringSaga', () => { + describe('adding unlisted connection string', () => { + const upsertConnectionStringSagaGenerator = cloneableGenerator(upsertConnectionStringSaga)(upsertConnectionStringAction({ newConnectionString: 'connectionString2'})); + it('returns call effect to get connection strings', () => { + expect(upsertConnectionStringSagaGenerator.next()).toEqual({ + done: false, + value: call(getConnectionStrings) + }); + }); + + it('returns call effect to set connection strings', () => { + expect(upsertConnectionStringSagaGenerator.next('connectionString1')).toEqual({ + done: false, + value: call(setConnectionStrings, 'connectionString2,connectionString1') + }); + }); + + it('finishes', () => { + expect(upsertConnectionStringSagaGenerator.next()).toEqual({ + done: true, + }); + }); + }); + + describe('overwriting listed connection string', () => { + const upsertConnectionStringSagaGenerator = cloneableGenerator(upsertConnectionStringSaga)(upsertConnectionStringAction({ newConnectionString: 'newConnectionString1', connectionString: 'connectionString1')); + it('returns call effect to get connection strings', () => { + expect(upsertConnectionStringSagaGenerator.next()).toEqual({ + done: false, + value: call(getConnectionStrings) + }); + }); + + it('returns call effect to set connection strings', () => { + expect(upsertConnectionStringSagaGenerator.next('connectionString1')).toEqual({ + done: false, + value: call(setConnectionStrings, 'newConnectionString1') + }); + }); + + it('finishes', () => { + expect(upsertConnectionStringSagaGenerator.next()).toEqual({ + done: true, + }); + }); + }); + + describe('creating new list', () => { + const upsertConnectionStringSagaGenerator = cloneableGenerator(upsertConnectionStringSaga)(upsertConnectionStringAction({ newConnectionString: 'connectionString1'})); + it('returns call effect to get connection strings', () => { + expect(upsertConnectionStringSagaGenerator.next()).toEqual({ + done: false, + value: call(getConnectionStrings) + }); + }); + + it('returns call effect to set connection strings', () => { + expect(upsertConnectionStringSagaGenerator.next(undefined)).toEqual({ + done: false, + value: call(setConnectionStrings, 'connectionString1') + }); + }); + + it('finishes', () => { + expect(upsertConnectionStringSagaGenerator.next()).toEqual({ + done: true, + }); + }); + }); + + describe('slice of last connection string when max length exceeded', () => { + const connectionStrings: string[] = []; + for (let i = CONNECTION_STRING_LIST_MAX_LENGTH; i > 0; i--) { + connectionStrings.push(`connectionString${i}`); + } + + const connectionStringsSerialized = connectionStrings.join(','); + const newConnectionString = `connectionString${CONNECTION_STRING_LIST_MAX_LENGTH + 1}`; + const upsertConnectionStringSagaGenerator = cloneableGenerator(upsertConnectionStringSaga)(upsertConnectionStringAction({newConnectionString})); + + it('returns call effect to get connection strings', () => { + expect(upsertConnectionStringSagaGenerator.next()).toEqual({ + done: false, + value: call(getConnectionStrings) + }); + }); + + it('returns call effect to set connection strings', () => { + expect(upsertConnectionStringSagaGenerator.next(connectionStringsSerialized)).toEqual({ + done: false, + value: call(setConnectionStrings, [newConnectionString, ...connectionStrings.splice(0, CONNECTION_STRING_LIST_MAX_LENGTH - 1)].join(',')) + }); + }); + + it('finishes', () => { + expect(upsertConnectionStringSagaGenerator.next()).toEqual({ + done: true, + }); + }); + + }); +}); diff --git a/src/app/connectionStrings/sagas/upsertConnectionStringSaga.ts b/src/app/connectionStrings/sagas/upsertConnectionStringSaga.ts new file mode 100644 index 00000000..b346f3bc --- /dev/null +++ b/src/app/connectionStrings/sagas/upsertConnectionStringSaga.ts @@ -0,0 +1,25 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import { call } from 'redux-saga/effects'; +import { Action } from 'typescript-fsa'; +import { CONNECTION_STRING_LIST_MAX_LENGTH } from '../../constants/browserStorage'; +import { UpsertConnectionStringActionPayload } from '../actions'; +import { getConnectionStrings, setConnectionStrings } from './setConnectionStringsSaga'; + +export function* upsertConnectionStringSaga(action: Action) { + const savedStrings: string = yield call(getConnectionStrings); + let updatedValue: string; + + if (savedStrings) { + const savedNames = savedStrings.split(',').filter(name => name !== action.payload.connectionString); // remove duplicates + const updatedNames = [action.payload.newConnectionString, ...savedNames].slice(0, CONNECTION_STRING_LIST_MAX_LENGTH); + updatedValue = updatedNames.join(','); + } + else { + updatedValue = action.payload.newConnectionString; + } + + yield call(setConnectionStrings, updatedValue); +} diff --git a/src/app/constants/actionTypes.ts b/src/app/constants/actionTypes.ts index ad0ffb77..f9257e0b 100644 --- a/src/app/constants/actionTypes.ts +++ b/src/app/constants/actionTypes.ts @@ -47,3 +47,5 @@ export const CLEAR = 'CLEAR'; export const DELETE = 'DELETE'; export const READ = 'READ'; export const SET = 'SET'; +export const UPSERT = 'UPSERT'; +export const UPDATE = 'UPDATE'; diff --git a/src/app/css/_connectivityPane.scss b/src/app/css/_connectivityPane.scss index 51cd1178..fed4a516 100644 --- a/src/app/css/_connectivityPane.scss +++ b/src/app/css/_connectivityPane.scss @@ -18,6 +18,7 @@ display: flex; align-items: center; justify-content: center; + overflow: auto; .main { @include themify($themes) { diff --git a/src/app/css/_index.scss b/src/app/css/_index.scss index babd00b8..b311f60a 100644 --- a/src/app/css/_index.scss +++ b/src/app/css/_index.scss @@ -8,6 +8,7 @@ body { margin: 0; height: 100vh; + overflow: hidden; } .ms-Fabric { diff --git a/src/app/shared/components/application.tsx b/src/app/shared/components/application.tsx index 6c504c1d..b5491dd5 100644 --- a/src/app/shared/components/application.tsx +++ b/src/app/shared/components/application.tsx @@ -7,6 +7,7 @@ import * as React from 'react'; import { HashRouter, Route, Switch } from 'react-router-dom'; import { ToastContainer } from 'react-toastify'; import { AzureResourceViewContainer } from '../../azureResource/components/azureResourceViewContainer'; +import { AzureResourcesView } from '../../azureResources/components/azureResourcesView'; import NoMatchError from './noMatchError'; import connectivityPaneContainer from '../../login/components/connectivityPaneContainer'; import { ROUTE_PARTS } from '../../constants/routes'; @@ -21,7 +22,7 @@ export const Application: React.FC = props => { <> - + diff --git a/src/localization/locales/en.json b/src/localization/locales/en.json index 9e89b299..8fbe8014 100644 --- a/src/localization/locales/en.json +++ b/src/localization/locales/en.json @@ -139,6 +139,83 @@ }, "value": "Value" }, + "connectionStrings": { + "addConnectionCommand": { + "label": "Add Connection", + "ariaLabel": "Add connection string" + }, + "deleteConnectionCommand": { + "label": "Delete Connection", + "ariaLabel": "Delete connection string {{connectionString}}" + }, + "editConnectionCommand": { + "label": "Edit Connection", + "ariaLabel": "Edit connection string {{connectionString}}" + }, + "copyConnectionCommand": { + "label": "Copy Connection", + "ariaLabel": "Copy connection string {{connectionString}}" + }, + "visitConnectionCommand": { + "ariaLabel": "View resource: {{connection string}}" + }, + "deleteConnection": { + "title": "Confirm Delete", + "body": "Delete the following connection string?", + "input": "Connection string to delete", + "yes": { + "ariaLabel": "Yes, delete {{connectionString}}", + "label": "Yes" + }, + "no": { + "ariaLabel": "No, do not delete {{connectionString}}", + "label": "No" + } + }, + "editConnection": { + "save": { + "label": "Save", + "ariaLabel": "Save connection string" + }, + "cancel": { + "label": "Cancel", + "ariaLabel": { + "add": "Cancel Add of Connection String", + "edit":"Cancel Edit of Connection String" + } + }, + "title": { + "add": "Add Connection String", + "edit": "Edit Connection String" + }, + "editField": { + "label": "Connection String", + "ariaLabel": "Connection String", + "placeholder": "enter a connection string" + }, + "validations": { + "duplicate": "The specified connection string is already listed." + } + }, + "properties": { + "hostName": { + "label": "Host Name", + "ariaLabel": "Host name for {{connection string}}" + }, + "sharedAccessPolicyName": { + "label": "Shared Access Policy Name", + "ariaLabel": "Shared Access Policy Name for {{connection string}}" + }, + "sharedAccessPolicyKey": { + "label": "Shared Access Policy Key", + "ariaLabel": "Shared Access Policy Key for {{connection string}}" + }, + "connectionString": { + "label": "Connection String", + "ariaLabel": "Connection String" + } + } + }, "connectivityPane": { "header": "App configurations", "connectionStringComboBox": { diff --git a/src/localization/resourceKeys.ts b/src/localization/resourceKeys.ts index f0d54369..b7ea8b12 100644 --- a/src/localization/resourceKeys.ts +++ b/src/localization/resourceKeys.ts @@ -107,6 +107,83 @@ export class ResourceKeys { }, }, }; + public static connectionStrings = { + addConnectionCommand : { + ariaLabel : "connectionStrings.addConnectionCommand.ariaLabel", + label : "connectionStrings.addConnectionCommand.label", + }, + copyConnectionCommand : { + ariaLabel : "connectionStrings.copyConnectionCommand.ariaLabel", + label : "connectionStrings.copyConnectionCommand.label", + }, + deleteConnection : { + body : "connectionStrings.deleteConnection.body", + input : "connectionStrings.deleteConnection.input", + no : { + ariaLabel : "connectionStrings.deleteConnection.no.ariaLabel", + label : "connectionStrings.deleteConnection.no.label", + }, + title : "connectionStrings.deleteConnection.title", + yes : { + ariaLabel : "connectionStrings.deleteConnection.yes.ariaLabel", + label : "connectionStrings.deleteConnection.yes.label", + }, + }, + deleteConnectionCommand : { + ariaLabel : "connectionStrings.deleteConnectionCommand.ariaLabel", + label : "connectionStrings.deleteConnectionCommand.label", + }, + editConnection : { + cancel : { + ariaLabel : { + add : "connectionStrings.editConnection.cancel.ariaLabel.add", + edit : "connectionStrings.editConnection.cancel.ariaLabel.edit", + }, + label : "connectionStrings.editConnection.cancel.label", + }, + editField : { + ariaLabel : "connectionStrings.editConnection.editField.ariaLabel", + label : "connectionStrings.editConnection.editField.label", + placeholder : "connectionStrings.editConnection.editField.placeholder", + }, + save : { + ariaLabel : "connectionStrings.editConnection.save.ariaLabel", + label : "connectionStrings.editConnection.save.label", + }, + title : { + add : "connectionStrings.editConnection.title.add", + edit : "connectionStrings.editConnection.title.edit", + }, + validations : { + duplicate : "connectionStrings.editConnection.validations.duplicate", + }, + }, + editConnectionCommand : { + ariaLabel : "connectionStrings.editConnectionCommand.ariaLabel", + label : "connectionStrings.editConnectionCommand.label", + }, + properties : { + connectionString : { + ariaLabel : "connectionStrings.properties.connectionString.ariaLabel", + label : "connectionStrings.properties.connectionString.label", + }, + hostName : { + ariaLabel : "connectionStrings.properties.hostName.ariaLabel", + label : "connectionStrings.properties.hostName.label", + }, + sharedAccessPolicyKey : { + ariaLabel : "connectionStrings.properties.sharedAccessPolicyKey.ariaLabel", + label : "connectionStrings.properties.sharedAccessPolicyKey.label", + }, + sharedAccessPolicyName : { + ariaLabel : "connectionStrings.properties.sharedAccessPolicyName.ariaLabel", + label : "connectionStrings.properties.sharedAccessPolicyName.label", + }, + }, + visitConnectionCommand : { + ariaLabel : "connectionStrings.visitConnectionCommand.ariaLabel", + }, + }; public static connectivityPane = { connectionStringComboBox : { ariaLabel : "connectivityPane.connectionStringComboBox.ariaLabel", diff --git a/tslint.json b/tslint.json index b493d5af..47602aa4 100644 --- a/tslint.json +++ b/tslint.json @@ -138,6 +138,7 @@ "redux-saga/effects", "redux-saga/utils", "redux-devtools-extension", + "react-dom", "react-toastify" ], "no-switch-case-fall-through": true,