diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 6b1bc3d1f..7d60bad87 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -16,9 +16,20 @@ "dashboard": "Dashboard", "devices": "Devices", "rules": "Rules", - "maintenance": "Maintenance" + "maintenance": "Maintenance", + "packages": "Packages", + "deployments": "Deployments" }, "errorFormat": "Error: {{message}}", + "edgeAgentStatus": { + "200": "OK.", + "400": "The deployment configuration is malformed or invalid.", + "406": "The device is offline or not sending status reports.", + "412": "The deployment configuration schema version is invalid.", + "417": "The device's deployment configuration is not set.", + "500": "An error occurred in the IoT Edge runtime.", + "unknown": "An unknown code received." + }, "errorCode": { "noResponse": "Oops, there was no response from the server.", "notLoggedIn": "You need to login to call the service.", @@ -166,6 +177,7 @@ "lastRefreshed": "Last refreshed" }, "devices": { + "title": "Devices", "searchPlaceholder": "Search devices...", "noneFound": "No devices found.", "refresh": "Refresh", @@ -206,6 +218,11 @@ "description": "To run a method on one or more devices, close this pane, select the checkbox for the device(s), click <1><0>{{jobs}}, and then select <3><0>{{methods}}.", "noneExist": "No methods found for this device." }, + "modules": { + "title": "Deployment messages", + "description": "Edge module messages on the device", + "noneExist": "No messages found for this device" + }, "properties": { "title": "Properties", "description": "To change a property on one or more devices, close this pane, select the checkbox for the device(s), click <1><0>{{jobs}}, and then select <3><0>{{properties}}.", @@ -315,10 +332,15 @@ "count": { "label": "Number of devices" }, + "device": { + "label": "Device", + "device": "IoT device", + "edgeDevice": "IoT Edge device" + }, "deviceType": { - "label": "Device type", + "label": "Type", "simulated": "Simulated", - "physical": "Physical" + "physical": "Real" }, "deviceId": { "label": "Device ID", @@ -354,22 +376,24 @@ }, "SIMManagement": { "title": "SIM Management", - "provider" : "Provider", + "provider": "Provider", "summaryHeader": "Summary and instructions", "here": "here", "select": "Select...", - "header":{ - "telefonica": "As a Telefónica IoT customer you have the advantage of adding this feature to enrich automatically your Azure Remote Monitoring Solution with the connectivity data available in Telefónica IoT Connectivity Platform (Network Information, Data Consumption & Network Based Location)." }, + "header": { + "telefonica": "As a Telefónica IoT customer you have the advantage of adding this feature to enrich automatically your Azure Remote Monitoring Solution with the connectivity data available in Telefónica IoT Connectivity Platform (Network Information, Data Consumption & Network Based Location)." + }, "description": { "telefonica": "This feature is in Preview. In order to sync your connectivity data into Azure Remote Monitoring Solution, please fill a request <1><0>url, select the option “Azure Remote Monitoring” and include your contact data and we will automatically activate your account. \n\nIf you are not a Telefónica client yet and you want to enjoy this or other IoT Connectivity Cloud Ready services, you can contact us <1><0>url and select the option “Connectivity”; we will be glad to help you." }, - "operator":{ + "operator": { "telefonica": "Telefónica" } } } }, "rules": { + "title": "Rules", "searchPlaceholder": "Search rules...", "severity": { "info": "Info", @@ -563,6 +587,128 @@ "P7D": "Last week", "P1M": "Last month" }, + "deployments": { + "title": "Deployments", + "flyouts": { + "new": { + "contextMenuName": "New deployment", + "title": "New deployment", + "apply": "Apply", + "cancel": "Cancel", + "close": "Close", + "type": "Package type", + "deviceGroup": "Device Group", + "name": "Name", + "priority": "Priority", + "package": "Package", + "typePlaceHolder": "Select package type", + "packagePlaceHolder": "Select package", + "deviceGroupPlaceHolder": "Select device group", + "priorityPlaceHolder": "Enter priority", + "namePlaceHolder": "Enter name", + "targetText": "targeted devices", + "infoText": "This deployment runs continuously. Every edge device (and any you add in the future) in the selected device group will receive this package.", + "successText": "View your deployment status detail for <1><0>{{deploymentName}}.", + "creating": "Creating deployment", + "validation": { + "required": "Is required", + "positiveInteger": "Must be a positive integer less than 2147483648" + } + } + }, + "details": { + "deploymentName": "Deployment name", + "deviceGroup": "Device group", + "start": "Start", + "packageType": "Package type", + "package": "Package", + "priority": "Priority", + "applied": "Applied", + "targeted": "Targeted", + "failed": "Failed", + "succeeded": "Succeeded", + "pending": "Pending", + "devicesAffected": "Devices Affected", + "grid": { + "name": "Name", + "deploymentStatus": "Deployment Status", + "firmware": "Firmware", + "lastMessage": "Last Message", + "start": "Start", + "end": "End" + } + }, + "modals": { + "delete": { + "contextMenuName": "Delete", + "title": "Delete Deployment?", + "info": "Deleting '{{deploymentName}}' will stop this deployment from being applied to these devices. It may result in a lower priority edge manifest being applied." + } + }, + "typeOptions": { + "edgemanifest": "Edge Manifest", + "unknown": "Unknown" + }, + "grid": { + "name": "Name", + "package": "Package", + "deviceGroup": "Device group", + "priority": "Priority", + "type": "Package Type", + "targeted": "Targeted", + "applied": "Applied", + "failed": "Failed", + "succeeded": "Succeeded", + "dateCreated": "Created On" + } + }, + "packages": { + "searchPlaceholder": "Search packages...", + "noneFound": "No packages found.", + "title": "Packages", + "total": "total packages", + "new": "New Package", + "delete": "Delete", + "grid": { + "name": "Name", + "type": "Type", + "dateCreated": "Date Created" + }, + "flyouts": { + "new": { + "title": "New Package", + "header": "Upload a package", + "description": "Add a package to your solution", + "upload": "Upload", + "cancel": "Cancel", + "close": "Close", + "type": "Package type", + "browse": "Browse", + "browseText": "for a package file", + "placeHolder": "Select package type", + "package": "Package", + "deploymentsPage": "Deployments page", + "newDeployment": "+ New Deployment", + "deploymentText": "To deploy packages, go to the <1>Deployments page, and then click <3>+ New Deployment button.", + "validation": { + "required": "Is required" + } + } + }, + "modals": { + "delete": { + "title": "Delete Package?", + "info": "Deleting selected package will remove it. It will not impact any of the deployments of this package." + } + }, + "typeOptions": { + "edgemanifest": "Edge Manifest" + } + }, + "modal": { + "delete": "Delete", + "cancel": "Cancel" + }, "walkthrough": { "tabs": { "dashboard": "Dashboard", @@ -578,7 +724,7 @@ "pageBody": "Click the context button above to open a flyout.", "open": "Open Flyout", "flyouts": { - "example" : { + "example": { "header": "Example Flyout", "description": "This example flyout contains a simple form with action buttons.", "insertFormHere": "Insert form controls here. See 'src/components/shared/forms' for form specific shared controls you might leverage.", @@ -607,6 +753,10 @@ "panelBody": "This is a new panel." } } + }, + "panel": { + "header": "Example Panel", + "panelBody": "This is a new panel." } } } diff --git a/src/app.config.js b/src/app.config.js index 5e8291ba0..db1d838e2 100644 --- a/src/app.config.js +++ b/src/app.config.js @@ -49,9 +49,13 @@ const Config = { acknowledged: 'acknowledged' }, maxLogoFileSizeInBytes: 307200, + device: { + device: 'IoT device', + edgeDevice: 'IoT Edge device' + }, deviceType: { simulated: 'Simulated', - physical: 'Physical' + physical: 'Real' } }; diff --git a/src/assets/icons/failed.svg b/src/assets/icons/failed.svg new file mode 100644 index 000000000..5279c0133 --- /dev/null +++ b/src/assets/icons/failed.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/src/assets/icons/glimmer.svg b/src/assets/icons/glimmer.svg new file mode 100644 index 000000000..17549ac11 --- /dev/null +++ b/src/assets/icons/glimmer.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/packages.svg b/src/assets/icons/packages.svg new file mode 100644 index 000000000..47a752cd9 --- /dev/null +++ b/src/assets/icons/packages.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/tabDeployments.svg b/src/assets/icons/tabDeployments.svg new file mode 100644 index 000000000..96b32c6c1 --- /dev/null +++ b/src/assets/icons/tabDeployments.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/app.js b/src/components/app.js index 17e35a800..543477991 100644 --- a/src/components/app.js +++ b/src/components/app.js @@ -5,7 +5,14 @@ import React, { Component } from 'react'; import { svgs } from 'utilities'; import Shell from "components/shell/shell"; import { ManageDeviceGroupsContainer, SettingsContainer } from 'components/shell/flyouts'; -import { DashboardContainer, DevicesContainer, RulesContainer, MaintenanceContainer } from './pages'; +import { + DashboardContainer, + DevicesContainer, + RulesContainer, + MaintenanceContainer, + PackagesContainer, + DeploymentsRouter +} from './pages'; class App extends Component { @@ -45,6 +52,20 @@ class App extends Component { labelId: 'tabs.rules', component: RulesContainer }, + { + to: '/packages', + exact: true, + svg: svgs.tabs.packages, + labelId: 'tabs.packages', + component: PackagesContainer + }, + { + to: '/deployments', + exact: false, + svg: svgs.tabs.deployments, + labelId: 'tabs.deployments', + component: DeploymentsRouter + }, { to: '/maintenance', exact: false, @@ -70,6 +91,22 @@ class App extends Component { { to: '/rules', labelId: 'tabs.rules' } ] }, + { + path: '/packages', crumbs: [ + { to: '/packages', labelId: 'tabs.packages' } + ] + }, + { + path: '/deployments', crumbs: [ + { to: '/deployments', labelId: 'tabs.deployments' } + ] + }, + { + path: '/deployments/:id', crumbs: [ + { to: '/deployments', labelId: 'tabs.deployments' }, + { to: '/deployments/:id', matchParam: 'id' }, + ] + }, { path: '/maintenance', crumbs: [ { to: '/maintenance', labelId: 'tabs.maintenance' } diff --git a/src/components/pages/dashboard/dashboard.js b/src/components/pages/dashboard/dashboard.js index 20d71369f..f9b73f248 100644 --- a/src/components/pages/dashboard/dashboard.js +++ b/src/components/pages/dashboard/dashboard.js @@ -19,10 +19,12 @@ import { TelemetryPanel, AnalyticsPanel, MapPanel, + ExamplePanel, transformTelemetryResponse, chartColorObjects } from './panels'; import { + ComponentArray, ContextMenu, ContextMenuAlign, PageContent, @@ -94,158 +96,158 @@ export class Dashboard extends Component { .map(telemetry => ({ telemetry, telemetryIsPending: false })) // Stream emits new state // Retry any retryable errors .retryWhen(retryHandler(maxRetryAttempts, retryWaitTime)); - // Telemetry stream - END - - // Analytics stream - START - - // TODO: Add device ids to params - START - const getAnalyticsStream = ({ deviceIds = [], timeInterval }) => this.panelsRefresh$ - .delay(Config.dashboardRefreshInterval) - .startWith(0) - .do(_ => this.setState({ analyticsIsPending: true })) - .flatMap(_ => { - const devices = deviceIds.length ? deviceIds.join(',') : undefined; - const [ currentIntervalParams, previousIntervalParams ] = getIntervalParams(timeInterval); - - const currentParams = { ...currentIntervalParams, devices }; - const previousParams = { ...previousIntervalParams, devices }; - - return Observable.forkJoin( - TelemetryService.getActiveAlerts(currentParams), - TelemetryService.getActiveAlerts(previousParams), - - TelemetryService.getAlerts(currentParams), - TelemetryService.getAlerts(previousParams) - ) - }).map(([ - currentActiveAlerts, - previousActiveAlerts, - - currentAlerts, - previousAlerts - ]) => { - // Process all the data out of the currentAlerts list - const currentAlertsStats = currentAlerts.reduce((acc, alert) => { - const isOpen = alert.status === Config.alertStatus.open; - const isWarning = alert.severity === Config.ruleSeverity.warning; - const isCritical = alert.severity === Config.ruleSeverity.critical; - let updatedAlertsPerDeviceId = acc.alertsPerDeviceId; - if (alert.deviceId) { - updatedAlertsPerDeviceId = { - ...updatedAlertsPerDeviceId, - [alert.deviceId]: (updatedAlertsPerDeviceId[alert.deviceId] || 0) + 1 - }; - } - return { - openWarningCount: (acc.openWarningCount || 0) + (isWarning && isOpen ? 1 : 0), - openCriticalCount: (acc.openCriticalCount || 0) + (isCritical && isOpen ? 1 : 0), - totalCriticalCount: (acc.totalCriticalCount || 0) + (isCritical ? 1 : 0), - alertsPerDeviceId: updatedAlertsPerDeviceId - }; - }, - { alertsPerDeviceId: {} } - ); - - // ================== Critical Alerts Count - START - const currentCriticalAlerts = currentAlertsStats.totalCriticalCount; - const previousCriticalAlerts = previousAlerts.reduce( - (cnt, { severity }) => severity === Config.ruleSeverity.critical ? cnt + 1 : cnt, - 0 - ); - const criticalAlertsChange = ((currentCriticalAlerts - previousCriticalAlerts) / currentCriticalAlerts * 100).toFixed(2); - // ================== Critical Alerts Count - END - - // ================== Top Alerts - START - const currentTopAlerts = currentActiveAlerts - .sort(compareByProperty('count')) - .slice(0, Config.maxTopAlerts); - - // Find the previous counts for the current top analytics - const previousTopAlertsMap = previousActiveAlerts.reduce( - (acc, { ruleId, count }) => - (ruleId in acc) - ? { ...acc, [ruleId]: count } - : acc - , - currentTopAlerts.reduce((acc, { ruleId }) => ({ ...acc, [ruleId]: 0 }), {}) - ); - - const topAlerts = currentTopAlerts.map(({ ruleId, count }) => ({ - ruleId, - count, - previousCount: previousTopAlertsMap[ruleId] || 0 - })); - // ================== Top Alerts - END - - const devicesInAlert = currentAlerts - .filter(({ status }) => status === Config.alertStatus.open) - .reduce((acc, { deviceId, severity, ruleId}) => { - return { - ...acc, - [deviceId]: { severity, ruleId } - }; - }, {}); - - return ({ - analyticsIsPending: false, - analyticsVersion: this.state.analyticsVersion + 1, - - // Analytics data - currentActiveAlerts, - topAlerts, - criticalAlertsChange, - alertsPerDeviceId: currentAlertsStats.alertsPerDeviceId, - - // Summary data - openWarningCount: currentAlertsStats.openWarningCount, - openCriticalCount: currentAlertsStats.openCriticalCount, - - // Map data - devicesInAlert - }); - }) - // Retry any retryable errors - .retryWhen(retryHandler(maxRetryAttempts, retryWaitTime)); - // Analytics stream - END - - this.subscriptions.push( - this.dashboardRefresh$ - .subscribe(() => this.setState(initialState)) - ); + // Telemetry stream - END + + // Analytics stream - START + + // TODO: Add device ids to params - START + const getAnalyticsStream = ({ deviceIds = [], timeInterval }) => this.panelsRefresh$ + .delay(Config.dashboardRefreshInterval) + .startWith(0) + .do(_ => this.setState({ analyticsIsPending: true })) + .flatMap(_ => { + const devices = deviceIds.length ? deviceIds.join(',') : undefined; + const [currentIntervalParams, previousIntervalParams] = getIntervalParams(timeInterval); + + const currentParams = { ...currentIntervalParams, devices }; + const previousParams = { ...previousIntervalParams, devices }; + + return Observable.forkJoin( + TelemetryService.getActiveAlerts(currentParams), + TelemetryService.getActiveAlerts(previousParams), + + TelemetryService.getAlerts(currentParams), + TelemetryService.getAlerts(previousParams) + ) + }).map(([ + currentActiveAlerts, + previousActiveAlerts, + + currentAlerts, + previousAlerts + ]) => { + // Process all the data out of the currentAlerts list + const currentAlertsStats = currentAlerts.reduce((acc, alert) => { + const isOpen = alert.status === Config.alertStatus.open; + const isWarning = alert.severity === Config.ruleSeverity.warning; + const isCritical = alert.severity === Config.ruleSeverity.critical; + let updatedAlertsPerDeviceId = acc.alertsPerDeviceId; + if (alert.deviceId) { + updatedAlertsPerDeviceId = { + ...updatedAlertsPerDeviceId, + [alert.deviceId]: (updatedAlertsPerDeviceId[alert.deviceId] || 0) + 1 + }; + } + return { + openWarningCount: (acc.openWarningCount || 0) + (isWarning && isOpen ? 1 : 0), + openCriticalCount: (acc.openCriticalCount || 0) + (isCritical && isOpen ? 1 : 0), + totalCriticalCount: (acc.totalCriticalCount || 0) + (isCritical ? 1 : 0), + alertsPerDeviceId: updatedAlertsPerDeviceId + }; + }, + { alertsPerDeviceId: {} } + ); - this.subscriptions.push( - this.dashboardRefresh$ - .switchMap(getTelemetryStream) - .subscribe( - telemetryState => this.setState( - { ...telemetryState, lastRefreshed: moment() }, - () => this.telemetryRefresh$.next('r') - ), - telemetryError => this.setState({ telemetryError, telemetryIsPending: false }) - ) - ); + // ================== Critical Alerts Count - START + const currentCriticalAlerts = currentAlertsStats.totalCriticalCount; + const previousCriticalAlerts = previousAlerts.reduce( + (cnt, { severity }) => severity === Config.ruleSeverity.critical ? cnt + 1 : cnt, + 0 + ); + const criticalAlertsChange = ((currentCriticalAlerts - previousCriticalAlerts) / currentCriticalAlerts * 100).toFixed(2); + // ================== Critical Alerts Count - END + + // ================== Top Alerts - START + const currentTopAlerts = currentActiveAlerts + .sort(compareByProperty('count')) + .slice(0, Config.maxTopAlerts); + + // Find the previous counts for the current top analytics + const previousTopAlertsMap = previousActiveAlerts.reduce( + (acc, { ruleId, count }) => + (ruleId in acc) + ? { ...acc, [ruleId]: count } + : acc + , + currentTopAlerts.reduce((acc, { ruleId }) => ({ ...acc, [ruleId]: 0 }), {}) + ); - this.subscriptions.push( - this.dashboardRefresh$ - .switchMap(getAnalyticsStream) - .subscribe( - analyticsState => this.setState( - { ...analyticsState, lastRefreshed: moment() }, - () => this.panelsRefresh$.next('r') - ), - analyticsError => this.setState({ analyticsError, analyticsIsPending: false }) - ) + const topAlerts = currentTopAlerts.map(({ ruleId, count }) => ({ + ruleId, + count, + previousCount: previousTopAlertsMap[ruleId] || 0 + })); + // ================== Top Alerts - END + + const devicesInAlert = currentAlerts + .filter(({ status }) => status === Config.alertStatus.open) + .reduce((acc, { deviceId, severity, ruleId }) => { + return { + ...acc, + [deviceId]: { severity, ruleId } + }; + }, {}); + + return ({ + analyticsIsPending: false, + analyticsVersion: this.state.analyticsVersion + 1, + + // Analytics data + currentActiveAlerts, + topAlerts, + criticalAlertsChange, + alertsPerDeviceId: currentAlertsStats.alertsPerDeviceId, + + // Summary data + openWarningCount: currentAlertsStats.openWarningCount, + openCriticalCount: currentAlertsStats.openCriticalCount, + + // Map data + devicesInAlert + }); + }) + // Retry any retryable errors + .retryWhen(retryHandler(maxRetryAttempts, retryWaitTime)); + // Analytics stream - END + + this.subscriptions.push( + this.dashboardRefresh$ + .subscribe(() => this.setState(initialState)) + ); + + this.subscriptions.push( + this.dashboardRefresh$ + .switchMap(getTelemetryStream) + .subscribe( + telemetryState => this.setState( + { ...telemetryState, lastRefreshed: moment() }, + () => this.telemetryRefresh$.next('r') + ), + telemetryError => this.setState({ telemetryError, telemetryIsPending: false }) + ) + ); + + this.subscriptions.push( + this.dashboardRefresh$ + .switchMap(getAnalyticsStream) + .subscribe( + analyticsState => this.setState( + { ...analyticsState, lastRefreshed: moment() }, + () => this.panelsRefresh$.next('r') + ), + analyticsError => this.setState({ analyticsError, analyticsIsPending: false }) + ) + ); + + // Start polling all panels + if (this.props.deviceLastUpdated) { + this.dashboardRefresh$.next( + refreshEvent( + Object.keys(this.props.devices || {}), + this.props.timeInterval + ) ); - - // Start polling all panels - if (this.props.deviceLastUpdated) { - this.dashboardRefresh$.next( - refreshEvent( - Object.keys(this.props.devices || {}), - this.props.timeInterval - ) - ); - } + } } componentWillUnmount() { @@ -270,7 +272,7 @@ export class Dashboard extends Component { ) ); - render () { + render() { const { theme, timeInterval, @@ -354,85 +356,93 @@ export class Dashboard extends Component { }; }, {}); - return [ - - - - - - - - - - - - , - - - - + + + + + + + + + + - - - - + + + + + - - - - - - - - - - - - - - ]; + + + + + + + + + + + + + + + + { + Config.showWalkthroughExamples && + + + + } + + + + ); } } diff --git a/src/components/pages/dashboard/panels/_examplePanel/examplePanel.js b/src/components/pages/dashboard/panels/_examplePanel/examplePanel.js new file mode 100644 index 000000000..96f347026 --- /dev/null +++ b/src/components/pages/dashboard/panels/_examplePanel/examplePanel.js @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React, { Component } from 'react'; + +import { + Panel, + PanelHeader, + PanelHeaderLabel, + PanelContent, +} from 'components/pages/dashboard/panel'; + +import './examplePanel.css'; + +export class ExamplePanel extends Component { + constructor(props) { + super(props); + + this.state = { isPending: true }; + } + + render() { + const { t } = this.props; + + return ( + + + {t('examples.panel.header')} + + + {t('examples.panel.panelBody')} + + + ); + } +} diff --git a/src/components/pages/dashboard/panels/_examplePanel/examplePanel.scss b/src/components/pages/dashboard/panels/_examplePanel/examplePanel.scss new file mode 100644 index 000000000..e81169e5d --- /dev/null +++ b/src/components/pages/dashboard/panels/_examplePanel/examplePanel.scss @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +@import 'src/styles/mixins'; +@import 'src/styles/themes'; + +.example-panel-container { + display: flex; + flex-flow: column nowrap; + padding: 0 !important; + + @include themify($themes) { + color: themed('colorContentTextDim'); + } +} diff --git a/src/components/pages/dashboard/panels/_examplePanel/index.js b/src/components/pages/dashboard/panels/_examplePanel/index.js new file mode 100644 index 000000000..ef0305829 --- /dev/null +++ b/src/components/pages/dashboard/panels/_examplePanel/index.js @@ -0,0 +1,3 @@ +// Copyright (c) Microsoft. All rights reserved. + +export * from './examplePanel'; diff --git a/src/components/pages/dashboard/panels/index.js b/src/components/pages/dashboard/panels/index.js index 72fa85d73..257a9205d 100644 --- a/src/components/pages/dashboard/panels/index.js +++ b/src/components/pages/dashboard/panels/index.js @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +export * from './_examplePanel'; export * from './alerts'; export * from './overview'; export * from './map'; diff --git a/src/components/pages/deployments/deploymentDetails/deploymentDetails.container.js b/src/components/pages/deployments/deploymentDetails/deploymentDetails.container.js new file mode 100644 index 000000000..f9192047b --- /dev/null +++ b/src/components/pages/deployments/deploymentDetails/deploymentDetails.container.js @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { translate } from 'react-i18next'; +import { connect } from 'react-redux'; +import { DeploymentDetails } from './deploymentDetails'; +import { + getCurrentDeploymentDetailsError, + getCurrentDeploymentDetailsPendingStatus, + getCurrentDeploymentLastUpdated, + getCurrentDeploymentDetails, + getDeployedDevicesPendingStatus, + getDeployedDevicesError, + getDeployedDevices, + getDeleteDeploymentError, + getDeleteDeploymentPendingStatus, + epics as deploymentsEpics, + redux as deploymentsRedux +} from 'store/reducers/deploymentsReducer'; +import { + redux as appRedux, + epics as appEpics, +} from 'store/reducers/appReducer'; + +// Pass the global info needed +const mapStateToProps = state => ({ + isPending: getCurrentDeploymentDetailsPendingStatus(state), + error: getCurrentDeploymentDetailsError(state), + currentDeployment: getCurrentDeploymentDetails(state), + isDeployedDevicesPending: getDeployedDevicesPendingStatus(state), + deployedDevicesError: getDeployedDevicesError(state), + deployedDevices: getDeployedDevices(state), + lastUpdated: getCurrentDeploymentLastUpdated(state), + deleteIsPending: getDeleteDeploymentPendingStatus(state), + deleteError: getDeleteDeploymentError(state) +}); + +// Wrap the dispatch methods +const mapDispatchToProps = dispatch => ({ + fetchDeployment: id => dispatch(deploymentsEpics.actions.fetchDeployment(id)), + resetDeployedDevices: () => dispatch(deploymentsRedux.actions.resetDeployedDevices()), + deleteItem: deploymentId => dispatch(deploymentsEpics.actions.deleteDeployment(deploymentId)), + updateCurrentWindow: (currentWindow) => dispatch(appRedux.actions.updateCurrentWindow(currentWindow)), + logEvent: diagnosticsModel => dispatch(appEpics.actions.logEvent(diagnosticsModel)) +}); + +export const DeploymentDetailsContainer = translate()(connect(mapStateToProps, mapDispatchToProps)(DeploymentDetails)); diff --git a/src/components/pages/deployments/deploymentDetails/deploymentDetails.js b/src/components/pages/deployments/deploymentDetails/deploymentDetails.js new file mode 100644 index 000000000..7cff2d8bd --- /dev/null +++ b/src/components/pages/deployments/deploymentDetails/deploymentDetails.js @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React, { Component } from 'react'; +import { permissions, toSinglePropertyDiagnosticsModel } from 'services/models'; +import { + AjaxError, + Btn, + ComponentArray, + ContextMenu, + ContextMenuAlign, + DeleteModal, + Indicator, + PageContent, + Protected, + RefreshBar, + StatSection, + StatGroup, + StatProperty, + StatPropertyPair +} from 'components/shared'; +import { TimeRenderer } from 'components/shared/cellRenderers'; +import { getPackageTypeTranslation, svgs } from 'utilities'; +import { DeploymentDetailsGrid } from './deploymentDetailsGrid/deploymentDetailsGrid'; + +import "./deploymentDetails.css"; + +const closedModalState = { + openModalName: undefined +}; + +export class DeploymentDetails extends Component { + constructor(props) { + super(props); + + this.props.resetDeployedDevices(); + // Set the initial state + this.state = { + ...closedModalState, + pendingCount: undefined, + deploymentDeleted: false + }; + + this.props.updateCurrentWindow('DeploymentDetails'); + + props.fetchDeployment(props.match.params.id); + } + + componentWillReceiveProps(nextProps) { + const { currentDeployment } = nextProps; + if (currentDeployment && currentDeployment.deviceStatuses) { + let pendingCount = 0; + Object.values(currentDeployment.deviceStatuses) + .forEach( + (status) => { + if (status.toLowerCase() === 'pending') pendingCount++; + } + ); + this.setState({ pendingCount }); + } + } + + componentWillUnmount() { + this.props.resetDeployedDevices(); + } + + getOpenModal = () => { + const { t, deleteIsPending, deleteError, deleteItem, logEvent } = this.props; + if (this.state.openModalName === 'delete-deployment' && this.props.currentDeployment) { + logEvent( + toSinglePropertyDiagnosticsModel( + 'DeploymentDetail_DeleteClick', + 'DeploymentId', + this.props.currentDeployment ? this.props.currentDeployment.id : '')); + return + } + return null; + } + + openModal = (modalName) => () => this.setState({ + openModalName: modalName + }); + + closeModal = () => this.setState(closedModalState); + + onDelete = () => { + this.closeModal(); + this.props.history.push(`/deployments`) + } + + render() { + const { + t, + currentDeployment, + isPending, + error, + deployedDevices, + isDeployedDevicesPending, + deployedDevicesError, + fetchDeployment, + lastUpdated, + logEvent + } = this.props; + const { + id, + appliedCount, + targetedCount, + succeededCount, + failedCount, + name, + priority, + deviceGroupName, + createdDateTimeUtc, + type, + packageName + } = currentDeployment; + const pendingCount = this.state.pendingCount ? this.state.pendingCount : '0'; + + return ( + + {this.getOpenModal()} + + + + {t('deployments.modals.delete.contextMenuName')} + + + + + + fetchDeployment(id)} time={lastUpdated} isPending={isPending} t={t} /> + + {error && } + + {isPending && } + + { + !isPending && +
+
+ {t('deployments.details.deploymentName')} +
+
+ {name} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ } + +

+ {t('deployments.details.devicesAffected')} +

+ + {isDeployedDevicesPending && } + + { + deployedDevicesError && + + } + + {!isDeployedDevicesPending && } +
+
+ ); + } +} diff --git a/src/components/pages/deployments/deploymentDetails/deploymentDetails.scss b/src/components/pages/deployments/deploymentDetails/deploymentDetails.scss new file mode 100644 index 000000000..947e9c93c --- /dev/null +++ b/src/components/pages/deployments/deploymentDetails/deploymentDetails.scss @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +@import 'src/styles/variables'; +@import 'src/styles/mixins'; +@import 'src/styles/themes'; + +.deployments-details-container { + display: flex; + flex-flow: column nowrap; + flex-grow: 1; + + .deployment-details-summary-container { + @include rem-fallback(padding-right, 40px); + + .deployment-name{ + font-weight: 700; + @include rem-font-size(34px); + @include rem-fallback(padding, 10px, 24px, 0px, 0px); + } + + .deployment-details-summary-labels { + text-transform: uppercase; + @include rem-font-size(12px); + } + + .stat-failed { @include rem-font-size(16px); } + + .summary-container { @include rem-fallback(padding-top, 30px); } + + .summary-container-columns { + justify-content: space-around; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + @include rem-fallback(height, 50px); + @include rem-fallback(width, 200px); + } + + .summary-container-row1 { @include rem-fallback(padding-top, 30px); } + + .summary-container-row2 { @include rem-fallback(padding-top, 20px); } + + .summary-container-succeeded { + line-height: unset; + @include rem-fallback(padding-top, 1px); + @include rem-fallback(padding-bottom, 6px); + } + + .summary-container-pending { + padding-top: 0px; + line-height: unset; + @include rem-fallback(padding-bottom, 3px); + } + } + + .deployment-details-devices-affected { + @include rem-fallback(border-top, 1px, solid); + @include rem-fallback(padding, 35px, 0px, 0px, 0px); + @include rem-fallback(margin-top, 30px); + } + + @include themify($themes) { + .deployment-name, + .deployment-details-summary-labels { color: themed('colorHeaderText'); } + + .stat-failed { fill: themed('colorFailed'); } + + .deployment-details-devices-affected { border-color: themed('colorHeaderBorderColor'); } + } +} diff --git a/src/components/pages/deployments/deploymentDetails/deploymentDetails.test.js b/src/components/pages/deployments/deploymentDetails/deploymentDetails.test.js new file mode 100644 index 000000000..522063f63 --- /dev/null +++ b/src/components/pages/deployments/deploymentDetails/deploymentDetails.test.js @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React from 'react'; +import { shallow } from 'enzyme'; +import 'polyfills'; + +import { DeploymentDetails } from './deploymentDetails'; + +describe('Deployment details Component', () => { + it('Renders without crashing', () => { + + const fakeProps = { + t: () => { }, + match: { params: { id: 'testId' } }, + fetchDeployment: () => { }, + deleteItem: () => { }, + currentDeployment: {}, + updateCurrentWindow: () => { } + }; + + const wrapper = shallow( + + ); + }); +}); diff --git a/src/components/pages/deployments/deploymentDetails/deploymentDetailsGrid/deploymentDetailsGrid.js b/src/components/pages/deployments/deploymentDetails/deploymentDetailsGrid/deploymentDetailsGrid.js new file mode 100644 index 000000000..2de665925 --- /dev/null +++ b/src/components/pages/deployments/deploymentDetails/deploymentDetailsGrid/deploymentDetailsGrid.js @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React, { Component } from 'react'; +import { deploymentDetailsColumnDefs, defaultDeploymentDetailsGridProps } from './deploymentDetailsGridConfig'; +import { translateColumnDefs, isFunc } from 'utilities'; +import { PcsGrid, ComponentArray } from 'components/shared'; +import { DeviceDetailsContainer } from 'components/pages/devices/flyouts'; +import { toSinglePropertyDiagnosticsModel } from 'services/models'; + +const closedFlyoutState = { + openFlyoutName: undefined, + selectedDevice: undefined +}; + +export class DeploymentDetailsGrid extends Component { + constructor(props) { + super(props); + + this.state = { + ...closedFlyoutState + }; + + this.columnDefs = [ + deploymentDetailsColumnDefs.name, + deploymentDetailsColumnDefs.deploymentStatus, + deploymentDetailsColumnDefs.firmware, + deploymentDetailsColumnDefs.lastMessage, + deploymentDetailsColumnDefs.start, + deploymentDetailsColumnDefs.end + ]; + } + + onGridReady = gridReadyEvent => { + this.deployedDevicesGridApi = gridReadyEvent.api; + // Call the onReady props if it exists + if (isFunc(this.props.onGridReady)) { + this.props.onGridReady(gridReadyEvent); + } + }; + + getModuleStatus = data => ({ + code: data.code, + description: data.description + }); + + onSoftSelectChange = (deviceRowId, rowEvent) => { + const { onSoftSelectChange, logEvent } = this.props; + const rowData = (this.deployedDevicesGridApi.getDisplayedRowAtIndex(deviceRowId) || {}).data; + logEvent( + toSinglePropertyDiagnosticsModel( + 'DeploymentDetail_DeviceGridClick', + 'DeviceId', + rowData ? rowData.id : '')); + if (rowData && rowData.device) { + this.setState({ + openFlyoutName: 'deviceDetails', + selectedDevice: rowData.device, + moduleStatus: this.getModuleStatus(rowData) + }); + } else { + this.closeFlyout(); + } + if (isFunc(onSoftSelectChange)) { + onSoftSelectChange(rowData, rowEvent); + } + } + + closeFlyout = () => this.setState(closedFlyoutState); + + getSoftSelectId = ({ id = '' }) => id; + + render() { + const gridProps = { + /* Grid Properties */ + ...defaultDeploymentDetailsGridProps, + context: { + t: this.props.t, + }, + rowData: this.props.deployedDevices, + getRowNodeId: ({ id }) => id, + columnDefs: translateColumnDefs(this.props.t, this.columnDefs), + onGridReady: this.onGridReady, + getSoftSelectId: this.getSoftSelectId, + onSoftSelectChange: this.onSoftSelectChange + }; + + return ( + + + { + this.state.openFlyoutName === 'deviceDetails' && + + } + + ); + } +} diff --git a/src/components/pages/deployments/deploymentDetails/deploymentDetailsGrid/deploymentDetailsGridConfig.js b/src/components/pages/deployments/deploymentDetails/deploymentDetailsGrid/deploymentDetailsGridConfig.js new file mode 100644 index 000000000..9fa3ef42d --- /dev/null +++ b/src/components/pages/deployments/deploymentDetails/deploymentDetailsGrid/deploymentDetailsGridConfig.js @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +import Config from 'app.config'; +import { TimeRenderer, SoftSelectLinkRenderer } from 'components/shared/cellRenderers'; +import { getEdgeAgentStatusCode } from 'utilities'; +import { gridValueFormatters } from 'components/shared/pcsGrid/pcsGridConfig'; + +const { checkForEmpty } = gridValueFormatters; + +export const deploymentDetailsColumnDefs = { + name: { + headerName: 'deployments.details.grid.name', + field: 'id', + sort: 'asc', + cellRendererFramework: SoftSelectLinkRenderer + }, + deploymentStatus: { + headerName: 'deployments.details.grid.deploymentStatus', + field: 'deploymentStatus', + valueFormatter: ({ value }) => checkForEmpty(value) + }, + firmware: { + headerName: 'deployments.details.grid.firmware', + field: 'firmware', + valueFormatter: ({ value }) => checkForEmpty(value) + }, + lastMessage: { + headerName: 'deployments.details.grid.lastMessage', + field: 'code', + valueFormatter: ({ value, context: { t } }) => getEdgeAgentStatusCode(value, t) + }, + start: { + headerName: 'deployments.details.grid.start', + field: 'start', + cellRendererFramework: TimeRenderer + }, + end: { + headerName: 'deployments.details.grid.end', + field: 'end', + cellRendererFramework: TimeRenderer + } +}; + +export const defaultDeploymentDetailsGridProps = { + enableColResize: true, + pagination: true, + paginationPageSize: Config.paginationPageSize, + sizeColumnsToFit: true, + deltaRowDataMode: true, + enableSorting: true, + unSortIcon: true, + domLayout: 'autoHeight' +}; diff --git a/src/components/pages/deployments/deploymentDetails/index.js b/src/components/pages/deployments/deploymentDetails/index.js new file mode 100644 index 000000000..beeb79315 --- /dev/null +++ b/src/components/pages/deployments/deploymentDetails/index.js @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft. All rights reserved. + +export * from './deploymentDetails'; +export * from './deploymentDetails.container'; diff --git a/src/components/pages/deployments/deploymentsHome/deployments.container.js b/src/components/pages/deployments/deploymentsHome/deployments.container.js new file mode 100644 index 000000000..5a7277e3f --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/deployments.container.js @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { translate } from 'react-i18next'; +import { connect } from 'react-redux'; +import { Deployments } from './deployments'; +import { + getDeploymentsError, + getDeploymentsPendingStatus, + getDeployments, + getDeploymentsLastUpdated, + epics as deploymentsEpics +} from 'store/reducers/deploymentsReducer'; +import { + redux as appRedux, + epics as appEpics, +} from 'store/reducers/appReducer'; + +// Pass the global info needed +const mapStateToProps = state => ({ + isPending: getDeploymentsPendingStatus(state), + error: getDeploymentsError(state), + deployments: getDeployments(state), + lastUpdated: getDeploymentsLastUpdated(state) +}); + +// Wrap the dispatch methods +const mapDispatchToProps = dispatch => ({ + fetchDeployments: () => dispatch(deploymentsEpics.actions.fetchDeployments()), + updateCurrentWindow: (currentWindow) => dispatch(appRedux.actions.updateCurrentWindow(currentWindow)), + logEvent: diagnosticsModel => dispatch(appEpics.actions.logEvent(diagnosticsModel)) +}); + +export const DeploymentsContainer = translate()(connect(mapStateToProps, mapDispatchToProps)(Deployments)); diff --git a/src/components/pages/deployments/deploymentsHome/deployments.js b/src/components/pages/deployments/deploymentsHome/deployments.js new file mode 100644 index 000000000..4fa7f7341 --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/deployments.js @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React, { Component } from 'react'; + +import { permissions, toDiagnosticsModel } from 'services/models'; +import { + AjaxError, + Btn, + ComponentArray, + ContextMenu, + ContextMenuAlign, + PageContent, + Protected, + RefreshBar, + PageTitle +} from 'components/shared'; +import { DeviceGroupDropdownContainer as DeviceGroupDropdown } from 'components/shell/deviceGroupDropdown'; +import { ManageDeviceGroupsBtnContainer as ManageDeviceGroupsBtn } from 'components/shell/manageDeviceGroupsBtn'; +import { DeploymentsGrid } from './deploymentsGrid'; +import { DeploymentNewContainer } from './flyouts'; +import { svgs } from 'utilities'; + +import './deployments.css'; + +const closedFlyoutState = { openFlyoutName: undefined }; + +export class Deployments extends Component { + constructor(props) { + super(props); + this.state = { + ...closedFlyoutState, + contextBtns: null + }; + + this.props.updateCurrentWindow('Deployments'); + + if (!this.props.lastUpdated && !this.props.error) { + this.props.fetchDeployments(); + } + } + + componentWillReceiveProps(nextProps) { + if (nextProps.isPending && nextProps.isPending !== this.props.isPending) { + // If the grid data refreshes, hide the flyout + this.setState(closedFlyoutState); + } + } + + closeFlyout = () => this.setState(closedFlyoutState); + + onContextMenuChange = contextBtns => this.setState({ + contextBtns, + openFlyoutName: undefined + }); + + openNewDeploymentFlyout = () => { + this.props.logEvent(toDiagnosticsModel('Deployments_NewClick', {})); + this.setState({ + openFlyoutName: 'newDeployment' + }); + } + onGridReady = gridReadyEvent => this.deploymentGridApi = gridReadyEvent.api; + + getSoftSelectId = ({ id } = '') => id; + + onSoftSelectChange = (deviceRowId) => { + const rowData = (this.deploymentGridApi.getDisplayedRowAtIndex(deviceRowId) || {}).data; + this.props.logEvent( + toDiagnosticsModel('Deployments_GridRowClick', { + id: rowData.id, + displayName: rowData.name + }) + ); + this.props.history.push(`/deployments/${rowData.id}`) + } + + render() { + const { t, deployments, error, isPending, fetchDeployments, lastUpdated } = this.props; + const gridProps = { + onGridReady: this.onGridReady, + rowData: isPending ? undefined : deployments || [], + refresh: fetchDeployments, + onContextMenuChange: this.onContextMenuChange, + t: t, + getSoftSelectId: this.getSoftSelectId, + onSoftSelectChange: this.onSoftSelectChange + }; + + return ( + + + + + + + + + + {this.state.contextBtns} + + {t('deployments.flyouts.new.contextMenuName')} + + + + + + + {!!error && } + {!error && } + {this.state.openFlyoutName === 'newDeployment' && } + + + ); + } +} diff --git a/src/components/pages/deployments/deploymentsHome/deployments.scss b/src/components/pages/deployments/deploymentsHome/deployments.scss new file mode 100644 index 000000000..d388971c2 --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/deployments.scss @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +@import 'src/styles/variables'; +@import 'src/styles/mixins'; +@import 'src/styles/themes'; + +.deployments-page-container, +.deployments-details-container { + display: flex; + flex-flow: column nowrap; + padding: $baseContentPadding; +} diff --git a/src/components/pages/deployments/deploymentsHome/deployments.test.js b/src/components/pages/deployments/deploymentsHome/deployments.test.js new file mode 100644 index 000000000..dc2d75d67 --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/deployments.test.js @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React from 'react'; +import { shallow } from 'enzyme'; +import 'polyfills'; + +import { Deployments } from './deployments'; + +describe('Deployments Component', () => { + it('Renders without crashing', () => { + + const fakeProps = { + t: () => {}, + fetchDeployments: () => {}, + updateCurrentWindow: () => { } + }; + + const wrapper = shallow( + + ); + }); +}); diff --git a/src/components/pages/deployments/deploymentsHome/deploymentsGrid/deploymentsGrid.js b/src/components/pages/deployments/deploymentsHome/deploymentsGrid/deploymentsGrid.js new file mode 100644 index 000000000..f6da0f25d --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/deploymentsGrid/deploymentsGrid.js @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. +import React, { Component } from 'react'; +import { deploymentsColumnDefs, defaultDeploymentsGridProps } from './deploymentsGridConfig'; +import { PcsGrid } from 'components/shared'; +import { isFunc, translateColumnDefs } from 'utilities'; + +export class DeploymentsGrid extends Component { + constructor(props) { + super(props); + + this.columnDefs = [ + deploymentsColumnDefs.name, + deploymentsColumnDefs.package, + deploymentsColumnDefs.deviceGroup, + deploymentsColumnDefs.priority, + deploymentsColumnDefs.type, + deploymentsColumnDefs.targeted, + deploymentsColumnDefs.applied, + deploymentsColumnDefs.succeeded, + deploymentsColumnDefs.failed, + deploymentsColumnDefs.dateCreated, + ]; + } + /** + * Get the grid api options + * + * @param {Object} gridReadyEvent An object containing access to the grid APIs + */ + onGridReady = gridReadyEvent => { + this.deploymentsGridApi = gridReadyEvent.api; + // Call the onReady props if it exists + if (isFunc(this.props.onGridReady)) { + this.props.onGridReady(gridReadyEvent); + } + }; + + render() { + const gridProps = { + /* Grid Properties */ + ...defaultDeploymentsGridProps, + columnDefs: translateColumnDefs(this.props.t, this.columnDefs), + ...this.props, // Allow default property overrides + onGridReady: event => this.onGridReady(event), // Wrap in a function to avoid closure issues + getRowNodeId: ({ id }) => id, + context: { + t: this.props.t + } + }; + + return ( + + ); + } +} diff --git a/src/components/pages/deployments/deploymentsHome/deploymentsGrid/deploymentsGridConfig.js b/src/components/pages/deployments/deploymentsHome/deploymentsGrid/deploymentsGridConfig.js new file mode 100644 index 000000000..8c598abce --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/deploymentsGrid/deploymentsGridConfig.js @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +import Config from 'app.config'; +import { SoftSelectLinkRenderer, TimeRenderer } from 'components/shared/cellRenderers'; +import { getPackageTypeTranslation } from 'utilities'; +import { gridValueFormatters } from 'components/shared/pcsGrid/pcsGridConfig'; + +const { checkForEmpty } = gridValueFormatters; + +export const deploymentsColumnDefs = { + name: { + headerName: 'deployments.grid.name', + field: 'name', + sort: 'asc', + valueFormatter: ({ value }) => checkForEmpty(value), + cellRendererFramework: SoftSelectLinkRenderer + }, + package: { + headerName: 'deployments.grid.package', + field: 'packageName', + valueFormatter: ({ value }) => checkForEmpty(value) + }, + deviceGroup: { + headerName: 'deployments.grid.deviceGroup', + field: 'deviceGroupName', + valueFormatter: ({ value }) => checkForEmpty(value) + }, + priority: { + headerName: 'deployments.grid.priority', + field: 'priority', + valueFormatter: ({ value }) => checkForEmpty(value) + }, + type: { + headerName: 'deployments.grid.type', + field: 'type', + valueFormatter: ({ value, context: { t } }) => getPackageTypeTranslation(checkForEmpty(value), t) + }, + targeted: { + headerName: 'deployments.grid.targeted', + field: 'targetedCount', + valueFormatter: ({ value }) => checkForEmpty(value) + }, + applied: { + headerName: 'deployments.grid.applied', + field: 'appliedCount', + valueFormatter: ({ value }) => checkForEmpty(value) + }, + failed: { + headerName: 'deployments.grid.failed', + field: 'failedCount', + valueFormatter: ({ value }) => checkForEmpty(value) + }, + succeeded: { + headerName: 'deployments.grid.succeeded', + field: 'succeededCount', + valueFormatter: ({ value }) => checkForEmpty(value) + }, + dateCreated: { + headerName: 'deployments.grid.dateCreated', + field: 'createdDateTimeUtc', + cellRendererFramework: TimeRenderer + } +}; + +export const defaultDeploymentsGridProps = { + enableColResize: true, + pagination: true, + paginationPageSize: Config.paginationPageSize, + enableSorting: true, + unSortIcon: true, + sizeColumnsToFit: true, + deltaRowDataMode: true +}; diff --git a/src/components/pages/deployments/deploymentsHome/deploymentsGrid/index.js b/src/components/pages/deployments/deploymentsHome/deploymentsGrid/index.js new file mode 100644 index 000000000..2dd53da31 --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/deploymentsGrid/index.js @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft. All rights reserved. + +export * from './deploymentsGridConfig'; +export * from './deploymentsGrid'; diff --git a/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/deploymentNew.container.js b/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/deploymentNew.container.js new file mode 100644 index 000000000..f912fc43d --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/deploymentNew.container.js @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { connect } from 'react-redux'; +import { translate } from 'react-i18next'; +import { DeploymentNew } from './deploymentNew'; +import { + getCreateDeploymentError, + getCreateDeploymentPendingStatus, + getLastItemId, + epics as deploymentsEpics, + redux as deploymentsRedux +} from 'store/reducers/deploymentsReducer'; +import { + getPackages, + getPackagesPendingStatus, + getPackagesError, + epics as packagesEpics, + redux as packagesRedux +} from 'store/reducers/packagesReducer'; +import { + getDeviceGroups, + epics as appEpics, +} from 'store/reducers/appReducer'; +import { + getDevices, + getDevicesByConditionError, + getDevicesByConditionPendingStatus, + epics as devicesEpics, + redux as devicesRedux +} from 'store/reducers/devicesReducer'; + +// Pass the global info needed +const mapStateToProps = state => ({ + packages: getPackages(state), + packagesPending: getPackagesPendingStatus(state), + packagesError: getPackagesError(state), + deviceGroups: getDeviceGroups(state), + devices: getDevices(state), + devicesPending: getDevicesByConditionPendingStatus(state), + devicesError: getDevicesByConditionError(state), + createIsPending: getCreateDeploymentPendingStatus(state), + createError: getCreateDeploymentError(state), + createdDeploymentId: getLastItemId(state) +}); + +// Wrap the dispatch methods +const mapDispatchToProps = dispatch => ({ + createDeployment: deploymentModel => dispatch(deploymentsEpics.actions.createDeployment(deploymentModel)), + resetCreatePendingError: () => dispatch(deploymentsRedux.actions.resetPendingAndError(deploymentsEpics.actions.createDeployment)), + fetchPackages: () => dispatch(packagesEpics.actions.fetchPackages()), + resetPackagesPendingError: () => dispatch(packagesRedux.actions.resetPendingAndError(packagesEpics.actions.fetchPackages)), + fetchDevices: condition => dispatch(devicesEpics.actions.fetchDevicesByCondition(condition)), + resetDevicesPendingError: () => dispatch(devicesRedux.actions.resetPendingAndError(devicesEpics.actions.fetchDevicesByCondition)), + logEvent: diagnosticsModel => dispatch(appEpics.actions.logEvent(diagnosticsModel)) +}); + +export const DeploymentNewContainer = translate()(connect(mapStateToProps, mapDispatchToProps)(DeploymentNew)); diff --git a/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/deploymentNew.js b/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/deploymentNew.js new file mode 100644 index 000000000..68a588012 --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/deploymentNew.js @@ -0,0 +1,396 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React from 'react'; +import { Trans } from 'react-i18next'; +import { Link } from "react-router-dom"; + +import { packageTypeOptions, toDiagnosticsModel, toSinglePropertyDiagnosticsModel } from 'services/models'; +import { svgs, LinkedComponent, Validator } from 'utilities'; +import { + AjaxError, + Btn, + BtnToolbar, + ComponentArray, + Flyout, + FlyoutHeader, + FlyoutTitle, + FlyoutCloseBtn, + FlyoutContent, + Indicator, + FormControl, + FormGroup, + FormLabel, + SummaryBody, + SectionDesc, + SummaryCount, + SummarySection, + Svg +} from 'components/shared'; + +import './deploymentNew.css'; + +export class DeploymentNew extends LinkedComponent { + constructor(props) { + super(props); + + this.state = { + packageType: undefined, + deviceGroupId: undefined, + deviceGroupName: '', + deviceGroupQuery: '', + name: '', + priority: '', + packageId: undefined, + packageName: '', + targetedDeviceCount: '', + edgePackageSelected: false, + changesApplied: false + }; + } + + componentWillReceiveProps(nextProps) { + const { devices, packages, deviceGroups } = nextProps; + const { deviceGroupId, packageId } = this.state; + if (devices && devices.length > 0) { + this.setState({ targetedDeviceCount: devices.length }); + } + if (deviceGroupId !== undefined) { + const deviceGroupName = deviceGroups.find(deviceGroup => deviceGroup.id === deviceGroupId).displayName; + this.setState({ deviceGroupName }); + } + if (packageId !== undefined) { + const packageName = packages.find(packageItem => packageItem.id === packageId).name; + this.setState({ packageName }); + } + } + + componentWillUnmount() { + const { resetCreatePendingError, resetPackagesPendingError, resetDevicesPendingError } = this.props; + resetCreatePendingError(); + resetPackagesPendingError(); + resetDevicesPendingError(); + } + + apply = (event) => { + event.preventDefault(); + const { createDeployment, packages, logEvent } = this.props; + const { + packageName, + deviceGroupName, + deviceGroupQuery, + deviceGroupId, + name, + priority, + packageId, + packageType } = this.state; + + logEvent( + toDiagnosticsModel( + 'NewDeployment_ApplyClick', { + packageId, + packageType, + priority, + name, + deviceGroupId, + packageName, + }) + ); + if (this.formIsValid()) { + const packageContent = packages.find(packageObj => packageObj.id === packageId).content; + createDeployment({ + type: packageType, + packageName, + packageContent, + packageId, + deviceGroupName, + deviceGroupQuery, + deviceGroupId, + name, + priority + }); + this.setState({ changesApplied: true }); + } + } + + formIsValid = () => { + return [ + this.packageTypeLink, + this.nameLink, + this.deviceGroupIdLink, + this.priorityLink, + this.packageIdLink + ].every(link => !link.error); + } + + onPackageTypeSelected = (e) => { + const selectedPackageType = e.target.value.value; + this.props.logEvent( + toSinglePropertyDiagnosticsModel('NewDeployment_PackageTypeSelect', 'PackageType', selectedPackageType) + ); + + switch (selectedPackageType) { + // case Edge manifest + case 'EdgeManifest': + const { fetchPackages } = this.props; + fetchPackages(); + this.setState({ edgePackageSelected: true }); + break; + // other cases to be impletmented in Edge walk iteration + default: + break; + } + this.formControlChange(); + } + + onDeviceGroupSelected = (e) => { + const { fetchDevices, deviceGroups } = this.props; + const selectedDeviceGroupId = e.target.value.value; + this.props.logEvent( + toSinglePropertyDiagnosticsModel('NewDeployment_DeviceGroupSelect', 'DeviceGroup', selectedDeviceGroupId) + ); + const selectedDeviceGroup = deviceGroups.find(deviceGroup => deviceGroup.id === selectedDeviceGroupId); + this.setState({ deviceGroupQuery: JSON.stringify(selectedDeviceGroup.conditions) }); + fetchDevices(selectedDeviceGroup.conditions); + } + + formControlChange = () => { + if (this.state.changesApplied) { + this.setState({ changesApplied: false }); + } + } + + toPackageSelectOption = ({ id, name }) => ({ label: name, value: id }); + + toDeviceGroupSelectOption = ({ id, displayName }) => ({ label: displayName, value: id }); + + genericCloseClick = (eventName) => { + const { onClose, logEvent } = this.props; + logEvent(toDiagnosticsModel(eventName, {})); + onClose(); + } + + genericOnChange = (eventName, key, value) => { + this.props.logEvent( + toSinglePropertyDiagnosticsModel(eventName, key, value) + ); + this.formControlChange(); + } + + isPositiveInteger = (str) => /^\+?(0|[1-9]\d*)$/.test(str) && str <= 2147483647; + + render() { + const { + t, + createIsPending, + createError, + packagesPending, + packagesError, + packages, + deviceGroups, + devicesPending, + devicesError, + createdDeploymentId + } = this.props; + const { + name, + packageType, + deviceGroupId, + deviceGroupName, + packageName, + priority, + targetedDeviceCount, + changesApplied, + edgePackageSelected, + } = this.state; + + // Validators + const requiredValidator = (new Validator()).check(Validator.notEmpty, t('deployments.flyouts.new.validation.required')); + + // Links + this.packageTypeLink = this.linkTo('packageType').map(({ value }) => value).withValidator(requiredValidator); + this.nameLink = this.linkTo('name').withValidator(requiredValidator); + this.deviceGroupIdLink = this.linkTo('deviceGroupId').map(({ value }) => value).withValidator(requiredValidator); + this.priorityLink = this.linkTo('priority') + .check(Validator.notEmpty, () => this.props.t('deployments.flyouts.new.validation.required')) + .check(val => this.isPositiveInteger(val), t('deployments.flyouts.new.validation.positiveInteger')); + this.packageIdLink = this.linkTo('packageId').map(({ value }) => value).withValidator(requiredValidator); + + const isPackageTypeSelected = packageType !== undefined; + const isDeviceGroupSelected = deviceGroupId !== undefined; + const packageOptions = packages.map(this.toPackageSelectOption); + const deviceGroupOptions = deviceGroups.map(this.toDeviceGroupSelectOption); + const typeOptions = packageTypeOptions.map(value => ({ + label: t(`deployments.typeOptions.${value.toLowerCase()}`), + value + })); + const completedSuccessfully = changesApplied && !createError && !createIsPending; + const deviceFetchSuccessful = isDeviceGroupSelected && !devicesError && !devicesPending; + + return ( + + + {t('deployments.flyouts.new.title')} + this.genericCloseClick('NewDeployment_CloseClick')} /> + + +
+ + {t('deployments.flyouts.new.name')} + { + !completedSuccessfully && + this.genericOnChange('NewDeployment_NameText', 'Name', event.target.value)} + placeholder={t('deployments.flyouts.new.namePlaceHolder')} /> + } + { + completedSuccessfully && {name} + } + + + {t('deployments.flyouts.new.type')} + { + !completedSuccessfully && + + } + { + completedSuccessfully && {packageType} + } + + + {t('deployments.flyouts.new.package')} + {!packagesPending && !completedSuccessfully && + this.genericOnChange('NewDeployment_PackageSelect', 'Package', event.target.value.value)} + placeholder={isPackageTypeSelected ? t('deployments.flyouts.new.packagePlaceHolder') : ""} + clearable={false} + searchable={false} /> + } + { + packagesPending && + } + {/** Displays an error message if one occurs while fetching packages. */ + packagesError && + } + { + completedSuccessfully && {packageName} + } + + + {t('deployments.flyouts.new.deviceGroup')} + { + !completedSuccessfully && + + } + { + completedSuccessfully && {deviceGroupName} + } + + + {t('deployments.flyouts.new.priority')} + { + !completedSuccessfully && + this.genericOnChange('NewDeployment_PriorityNumber', 'Priority', event.target.value)} + placeholder={t('deployments.flyouts.new.priorityPlaceHolder')} /> + } + { + completedSuccessfully && {priority} + } + + + + {/** Displays targeted devices count once device goup is selected. */ + deviceFetchSuccessful && + + {targetedDeviceCount} + {t('deployments.flyouts.new.targetText')} + {completedSuccessfully && } + + } + { + createIsPending && + + + {t('deployments.flyouts.new.creating')} + + } + + + {/** Displays a info message if package type selected is edge Manifest */ + !changesApplied && edgePackageSelected && +
+ * + {t('deployments.flyouts.new.infoText')} +
+ } + {/** Displays a success message if deployment is created successfully */ + completedSuccessfully && +
+ + View your deployment status detail for + + {{ deploymentName: name }} + + . + +
+ } + {/** Displays an error message if one occurs while creating deployment. */ + changesApplied && createError && + } + { + /** If form is complete, show the buttons for creating a deployment and closing the flyout. */ + (!completedSuccessfully) && + + {t('deployments.flyouts.new.apply')} + this.genericCloseClick('NewDeployment_CancelClick')}>{ + t('deployments.flyouts.new.cancel')} + + + } + { + /** After successful deployment creation, show only close button. */ + (completedSuccessfully) && + + this.genericCloseClick('NewDeployment_CancelClick')}> + {t('deployments.flyouts.new.close')} + + + } +
+
+
+
+ ); + } +} diff --git a/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/deploymentNew.scss b/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/deploymentNew.scss new file mode 100644 index 000000000..98141f498 --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/deploymentNew.scss @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +@import 'src/styles/variables'; +@import 'src/styles/mixins'; +@import 'src/styles/themes'; + +.new-deployment-content { + @include rem-fallback(padding-top, 24px); + + .new-deployment-info-text { + line-height: 1.7; + @include rem-fallback(padding-top, 24px); + @include rem-font-size(14px); + } + + .new-deployment-flyout-error { @include rem-fallback(padding-top, 24px); } + + .new-deployment-formGroup, + { + @include rem-fallback(padding-top, 12px); + @include rem-fallback(padding-bottom, 12px); + } + + .summary-icon svg { + @include square-px-rem(16px); + @include rem-fallback(margin-left, 8px); + } + + .new-deployment-info-star { @include rem-font-size(24px); } + + @include themify($themes) { + .summary-icon svg { fill: themed('colorContentText'); } + + .new-deployment-flyout-error { border-color: themed('colorAlert'); } + + .new-deployment-success-labels { color: themed('colorFlyoutText'); } + + .new-deployment-detail-page-link, + .new-deployment-info-text, { color: themed('colorContentText'); } + + .new-deployment-info-star { color: themed('colorAlert'); } + } +} diff --git a/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/index.js b/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/index.js new file mode 100644 index 000000000..fd6d1aa3b --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/index.js @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft. All rights reserved. + +export * from './deploymentNew'; +export * from './deploymentNew.container'; diff --git a/src/components/pages/deployments/deploymentsHome/flyouts/index.js b/src/components/pages/deployments/deploymentsHome/flyouts/index.js new file mode 100644 index 000000000..91b0e18be --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/flyouts/index.js @@ -0,0 +1,3 @@ +// Copyright (c) Microsoft. All rights reserved. + +export * from './deploymentNew'; diff --git a/src/components/pages/deployments/deploymentsHome/index.js b/src/components/pages/deployments/deploymentsHome/index.js new file mode 100644 index 000000000..475baf036 --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/index.js @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft. All rights reserved. + +export * from './deployments'; +export * from './deployments.container'; diff --git a/src/components/pages/deployments/deploymentsRouter.js b/src/components/pages/deployments/deploymentsRouter.js new file mode 100644 index 000000000..af6da2c9f --- /dev/null +++ b/src/components/pages/deployments/deploymentsRouter.js @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React from 'react'; +import { Route, Redirect, Switch } from 'react-router-dom'; +import { DeploymentDetailsContainer } from './deploymentDetails'; +import { DeploymentsContainer } from './deploymentsHome'; + +export const DeploymentsRouter = () => ( + + } /> + } /> + + +); diff --git a/src/components/pages/devices/devices.js b/src/components/pages/devices/devices.js index 099fa6cb5..cf67e7ad7 100644 --- a/src/components/pages/devices/devices.js +++ b/src/components/pages/devices/devices.js @@ -9,9 +9,11 @@ import { ManageDeviceGroupsBtnContainer as ManageDeviceGroupsBtn } from 'compone import { AjaxError, Btn, + ComponentArray, ContextMenu, ContextMenuAlign, PageContent, + PageTitle, Protected, RefreshBar, SearchInput @@ -75,32 +77,35 @@ export class Devices extends Component { const error = deviceGroupError || deviceError; - return [ - - - - - - - - - - { this.state.contextBtns } - - {t('devices.flyouts.SIMManagement.title')} - - - {t('devices.flyouts.new.contextMenuName')} - - - , - - - { !!error && } - { !error && } - { newDeviceFlyoutOpen && } - { simManagementFlyoutOpen && } - - ]; + return ( + + + + + + + + + + + {this.state.contextBtns} + + {t('devices.flyouts.SIMManagement.title')} + + + {t('devices.flyouts.new.contextMenuName')} + + + + + + + {!!error && } + {!error && } + {newDeviceFlyoutOpen && } + {simManagementFlyoutOpen && } + + + ); } } diff --git a/src/components/pages/devices/devicesGrid/devicesGrid.js b/src/components/pages/devices/devicesGrid/devicesGrid.js index 4923226d6..9522e5c43 100644 --- a/src/components/pages/devices/devicesGrid/devicesGrid.js +++ b/src/components/pages/devices/devicesGrid/devicesGrid.js @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import { permissions } from 'services/models'; -import { Btn, PcsGrid, Protected } from 'components/shared'; +import { Btn, ComponentArray, PcsGrid, Protected } from 'components/shared'; import { deviceColumnDefs, defaultDeviceGridProps } from './devicesGridConfig'; import { DeviceDeleteContainer } from '../flyouts/deviceDelete'; import { DeviceJobsContainer } from '../flyouts/deviceJobs'; @@ -39,14 +39,15 @@ export class DevicesGrid extends Component { deviceColumnDefs.lastConnection ]; - this.contextBtns = [ - - {props.t('devices.flyouts.jobs.title')} - , - - {props.t('devices.flyouts.delete.title')} - - ]; + this.contextBtns = + + + {props.t('devices.flyouts.jobs.title')} + + + {props.t('devices.flyouts.delete.title')} + + ; } /** @@ -119,7 +120,7 @@ export class DevicesGrid extends Component { } } - getSoftSelectId = ({ id } = {}) => id; + getSoftSelectId = ({ id } = '') => id; render() { const gridProps = { diff --git a/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.container.js b/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.container.js index 7a3f6e00f..144512b45 100644 --- a/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.container.js +++ b/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.container.js @@ -10,6 +10,13 @@ import { getRulesLastUpdated, getRulesPendingStatus } from 'store/reducers/rulesReducer'; +import { + getDeviceModuleStatus, + getDeviceModuleStatusPendingStatus, + getDeviceModuleStatusError, + epics as devicesEpics, + redux as devicesRedux +} from 'store/reducers/devicesReducer'; // Pass the device details const mapStateToProps = state => ({ @@ -18,12 +25,17 @@ const mapStateToProps = state => ({ rulesLastUpdated: getRulesLastUpdated(state), deviceGroups: getDeviceGroups(state), theme: getTheme(state), - timeSeriesExplorerUrl: getTimeSeriesExplorerUrl(state) + timeSeriesExplorerUrl: getTimeSeriesExplorerUrl(state), + deviceModuleStatus: getDeviceModuleStatus(state), + isDeviceModuleStatusPending: getDeviceModuleStatusPendingStatus(state), + deviceModuleStatusError: getDeviceModuleStatusError(state) }); // Wrap the dispatch method const mapDispatchToProps = dispatch => ({ fetchRules: () => dispatch(ruleEpics.actions.fetchRules()), + fetchModules: (deviceId) => dispatch(devicesEpics.actions.fetchEdgeAgent(deviceId)), + resetPendingAndError: () => dispatch(devicesRedux.actions.resetPendingAndError(devicesEpics.actions.fetchEdgeAgent)) }); export const DeviceDetailsContainer = translate()(connect(mapStateToProps, mapDispatchToProps)(DeviceDetails)); diff --git a/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.js b/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.js index a7961380b..110aa1d13 100644 --- a/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.js +++ b/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.js @@ -19,6 +19,7 @@ import { import { Btn, BtnToolbar, + ComponentArray, ErrorMsg, Hyperlink, PropertyGrid as Grid, @@ -31,6 +32,7 @@ import { import Flyout from 'components/shared/flyout'; import { TelemetryChart, chartColorObjects } from 'components/pages/dashboard/panels/telemetry'; import { transformTelemetryResponse } from 'components/pages/dashboard/panels'; +import { getEdgeAgentStatusCode } from 'utilities'; import './deviceDetails.css'; @@ -49,9 +51,10 @@ export class DeviceDetails extends Component { telemetryIsPending: true, telemetryError: null, - showRawMessage: false + showRawMessage: false, + currentModuleStatus: undefined }; - + this.baseState = this.state; this.columnDefs = [ { ...rulesColumnDefs.ruleName, @@ -64,6 +67,14 @@ export class DeviceDetails extends Component { this.resetTelemetry$ = new Subject(); this.telemetryRefresh$ = new Subject(); + if (this.props.moduleStatus) { + this.state = { + ...this.state, + currentModuleStatus: this.props.moduleStatus + }; + } else { + this.props.fetchModules(this.props.device.id); + } } componentDidMount() { @@ -118,11 +129,49 @@ export class DeviceDetails extends Component { } componentWillReceiveProps(nextProps) { - if ((this.props.device || {}).id !== nextProps.device.id) { - const deviceId = (nextProps.device || {}).id; + const { + deviceModuleStatus, + isDeviceModuleStatusPending, + deviceModuleStatusError, + moduleStatus, + resetPendingAndError, + device, + fetchModules + } = nextProps; + let tempState = {}; + /* + deviceModuleStatus is a prop fetched by making fetchModules() API call through deviceDetails.container on demand. + moduleStatus is a prop sent from deploymentDetailsGrid which it already has in rowData. + Both deviceModuleStatus and moduleStatus have the same content, + but come from different sources based on the page that opens this flyout. + Depending on which one is available, currentModuleStatus is set in component state. + */ + + if ((this.props.device || {}).id !== device.id) { + // Reset state if the device changes. + resetPendingAndError(); + tempState = { ...this.baseState }; + + if (moduleStatus) { + // If moduleStatus exist in props, set it in state. + tempState = { + ...tempState, + currentModuleStatus: moduleStatus + }; + } else { + // Otherwise make an API call to get deviceModuleStatus. + fetchModules(device.id); + } + + const deviceId = (device || {}).id; this.resetTelemetry$.next(deviceId); this.fetchAlerts(deviceId); + } else if (!moduleStatus && !isDeviceModuleStatusPending && !deviceModuleStatusError) { + // set deviceModuleStatus in state, if moduleStatus doesn't exist and devicesReducer successfully received the API response. + tempState = { currentModuleStatus: deviceModuleStatus }; } + + if (Object.keys(tempState).length) this.setState(tempState); } componentWillUnmount() { @@ -161,8 +210,16 @@ export class DeviceDetails extends Component { } render() { - const { t, onClose, device, theme, timeSeriesExplorerUrl } = this.props; - const { telemetry, lastMessage } = this.state; + const { + t, + onClose, + device, + theme, + timeSeriesExplorerUrl, + isDeviceModuleStatusPending, + deviceModuleStatusError + } = this.props; + const { telemetry, lastMessage, currentModuleStatus } = this.state; const lastMessageTime = (lastMessage || {}).time; const isPending = this.state.isAlertsPending && this.props.isRulesPending; const rulesGridProps = { @@ -175,12 +232,16 @@ export class DeviceDetails extends Component { }; const tags = Object.entries(device.tags || {}); const properties = Object.entries(device.properties || {}); - + const moduleQuerySuccessful = currentModuleStatus && + currentModuleStatus !== {} && + !isDeviceModuleStatusPending && + !deviceModuleStatusError; // Add parameters to Time Series Insights Url + const timeSeriesParamUrl = timeSeriesExplorerUrl ? timeSeriesExplorerUrl + - `&relativeMillis=1800000&timeSeriesDefinitions=[{"name":"${device.id}","measureName":"${Object.keys(telemetry).sort()[0]}","predicate":"'${device.id}'"}]` + `&relativeMillis=1800000&timeSeriesDefinitions=[{"name":"${device.id}","measureName":"${Object.keys(telemetry).sort()[0]}","predicate":"'${device.id}'"}]` : undefined; return ( @@ -310,8 +371,9 @@ export class DeviceDetails extends Component { t('devices.flyouts.details.properties.noneExist') } { - (properties.length > 0) && [ - + (properties.length > 0) && + + {t('devices.flyouts.details.properties.keyHeader')} @@ -334,14 +396,14 @@ export class DeviceDetails extends Component { }) } - , - + + {t('devices.flyouts.details.properties.copyAllProperties')} {t('devices.flyouts.details.properties.copy')} - ] + } @@ -364,18 +426,19 @@ export class DeviceDetails extends Component { {device.connected ? t('devices.flyouts.details.connected') : t('devices.flyouts.details.notConnected')} { - device.connected && [ - + device.connected && + + {t('devices.flyouts.details.diagnostics.lastMessage')} {lastMessageTime ? moment(lastMessageTime).format(DEFAULT_TIME_FORMAT) : '---'} - , - + + {t('devices.flyouts.details.diagnostics.message')} {t('devices.flyouts.details.diagnostics.showMessage')} - ] + } { this.state.showRawMessage && @@ -388,6 +451,28 @@ export class DeviceDetails extends Component { + + + {t('devices.flyouts.details.modules.title')} + + + {t("devices.flyouts.details.modules.description")} + +
+ { + !moduleQuerySuccessful && + t('devices.flyouts.details.modules.noneExist') + } + { + moduleQuerySuccessful && + +
{currentModuleStatus.code}: {getEdgeAgentStatusCode(currentModuleStatus.code, t)}
+
{currentModuleStatus.description}
+
+ } +
+
+
} diff --git a/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.scss b/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.scss index 20b4eb214..a9fafed83 100644 --- a/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.scss +++ b/src/components/pages/devices/flyouts/deviceDetails/deviceDetails.scss @@ -27,6 +27,13 @@ } } + .device-details-deployment-contentbox { + @include rem-fallback(padding, 20px, 0px, 20px, 20px); + @include rem-fallback(border-top, 1px, solid); + @include rem-fallback(margin-top, 20px); + @include rem-fallback(border-bottom, 1px, solid); + } + .device-properties-actions .row { border: 0; @include rem-fallback(font-size, 12px); @@ -53,5 +60,7 @@ } .device-properties-actions .row { color: themed('colorContentTextDim'); } + + .device-details-deployment-contentbox { border-color: themed('colorHeaderBorderColor'); } } } diff --git a/src/components/pages/devices/flyouts/deviceJobs/deviceJobMethods.js b/src/components/pages/devices/flyouts/deviceJobs/deviceJobMethods.js index d5500a49c..dea3872d1 100644 --- a/src/components/pages/devices/flyouts/deviceJobs/deviceJobMethods.js +++ b/src/components/pages/devices/flyouts/deviceJobs/deviceJobMethods.js @@ -12,6 +12,7 @@ import { AjaxError, Btn, BtnToolbar, + ComponentArray, FormControl, FormGroup, FormLabel, @@ -187,16 +188,16 @@ export class DeviceJobMethods extends LinkedComponent { { this.isFirmwareUpdate() && - [ - + + {t('devices.flyouts.jobs.methods.firmwareVersion')} - , - + + {t('devices.flyouts.jobs.methods.firmwareUri')} - ] + } diff --git a/src/components/pages/devices/flyouts/deviceJobs/deviceJobProperties.js b/src/components/pages/devices/flyouts/deviceJobs/deviceJobProperties.js index 869549cad..6718f3d4a 100644 --- a/src/components/pages/devices/flyouts/deviceJobs/deviceJobProperties.js +++ b/src/components/pages/devices/flyouts/deviceJobs/deviceJobProperties.js @@ -12,6 +12,7 @@ import { AjaxError, Btn, BtnToolbar, + ComponentArray, ErrorMsg, FormControl, FormGroup, @@ -276,21 +277,25 @@ export class DeviceJobProperties extends LinkedComponent { { Object.keys(commonProperties).length > 0 && - propertyLinks.map(({ name, value, type, readOnly, edited, error }, idx) => [ - - - {name.value} - - - - - {type.value} - - , - error - ? {error} - : null - ]) + propertyLinks.map(({ name, value, type, readOnly, edited, error }, idx) => + + + + {name.value} + + + + + {type.value} + + + { + error + ? {error} + : null + } + + ) }
diff --git a/src/components/pages/devices/flyouts/deviceJobs/deviceJobTags.js b/src/components/pages/devices/flyouts/deviceJobs/deviceJobTags.js index c546cc0e6..a0caed6c5 100644 --- a/src/components/pages/devices/flyouts/deviceJobs/deviceJobTags.js +++ b/src/components/pages/devices/flyouts/deviceJobs/deviceJobTags.js @@ -13,6 +13,7 @@ import { AjaxError, Btn, BtnToolbar, + ComponentArray, ErrorMsg, FormControl, FormGroup, @@ -261,24 +262,28 @@ export class DeviceJobTags extends LinkedComponent { { Object.keys(commonTags).length > 0 && - tagLinks.map(({ name, value, type, edited, error }, idx) => [ - - - - - - - - - - - - - , - error - ? {error} - : null - ]) + tagLinks.map(({ name, value, type, edited, error }, idx) => + + + + + + + + + + + + + + + { + error + ? {error} + : null + } + + ) } diff --git a/src/components/pages/devices/flyouts/deviceJobs/deviceJobs.js b/src/components/pages/devices/flyouts/deviceJobs/deviceJobs.js index 891813147..3f7964b57 100644 --- a/src/components/pages/devices/flyouts/deviceJobs/deviceJobs.js +++ b/src/components/pages/devices/flyouts/deviceJobs/deviceJobs.js @@ -5,6 +5,7 @@ import React from 'react'; import { LinkedComponent } from 'utilities'; import { permissions } from 'services/models'; import { + ComponentArray, Flyout, FlyoutHeader, FlyoutTitle, @@ -72,8 +73,9 @@ export class DeviceJobs extends LinkedComponent { {t("devices.flyouts.jobs.noDevices")} } { - devices.length > 0 && [ - + devices.length > 0 && + + {t('devices.flyouts.jobs.selectJob')} {t('devices.flyouts.jobs.tags.radioLabel')} @@ -84,17 +86,23 @@ export class DeviceJobs extends LinkedComponent { {t('devices.flyouts.jobs.properties.radioLabel')} - , - this.jobTypeLink.value === 'tags' - ? - : null, - this.jobTypeLink.value === 'methods' - ? - : null, - this.jobTypeLink.value === 'properties' - ? - : null - ] + + { + this.jobTypeLink.value === 'tags' + ? + : null + } + { + this.jobTypeLink.value === 'methods' + ? + : null + } + { + this.jobTypeLink.value === 'properties' + ? + : null + } + } diff --git a/src/components/pages/devices/flyouts/deviceNew/deviceNew.js b/src/components/pages/devices/flyouts/deviceNew/deviceNew.js index 74c5950d9..cfbf55b29 100644 --- a/src/components/pages/devices/flyouts/deviceNew/deviceNew.js +++ b/src/components/pages/devices/flyouts/deviceNew/deviceNew.js @@ -25,6 +25,7 @@ import { AjaxError, Btn, BtnToolbar, + ComponentArray, Flyout, FlyoutHeader, FlyoutTitle, @@ -52,6 +53,18 @@ const isIntRegex = /^-?\d*$/; const nonInteger = x => !x.match(isIntRegex); const stringToInt = x => x === '' || x === '-' ? x : int(x); +const deviceOptions = { + labelName: 'devices.flyouts.new.device.label', + device: { + labelName: 'devices.flyouts.new.device.device', + value: false + }, + edgeDevice: { + labelName: 'devices.flyouts.new.device.edgeDevice', + value: true + } +}; + const deviceTypeOptions = { labelName: 'devices.flyouts.new.deviceType.label', simulated: { @@ -148,6 +161,7 @@ export class DeviceNew extends LinkedComponent { formData: { count: 1, deviceId: '', + isEdgeDevice: deviceOptions.device.value, isGenerateId: deviceIdTypeOptions.manual.value, isSimulated: deviceTypeOptions.simulated.value, deviceModel: undefined, @@ -166,6 +180,9 @@ export class DeviceNew extends LinkedComponent { // Linked components this.formDataLink = this.linkTo('formData'); + this.deviceLink = this.formDataLink.forkTo('isEdgeDevice') + .map(stringToBoolean); + this.deviceTypeLink = this.formDataLink.forkTo('isSimulated') .map(stringToBoolean); @@ -238,6 +255,7 @@ export class DeviceNew extends LinkedComponent { formIsValid() { return [ + this.deviceLink, this.deviceTypeLink, this.countLink, this.deviceIdLink, @@ -250,9 +268,32 @@ export class DeviceNew extends LinkedComponent { ].every(link => !link.error); } - deviceTypeChange = ({ target: { value }}) => { + deviceChange = ({ target: { value } }) => { + this.props.logEvent(toSinglePropertyDiagnosticsModel('Devices_DeviceSelect', 'Device', + (value === 'true') ? Config.device.edgeDevice : Config.device.device)); + if (value === 'true') { + this.setState({ + formData: { + ...this.state.formData, + isEdgeDevice: true, + isSimulated: false + } + }); + } else { + this.setState({ + formData: { + ...this.state.formData, + isEdgeDevice: false, + isSimulated: true + } + }); + } + this.formControlChange(); + } + + deviceTypeChange = ({ target: { value } }) => { this.props.logEvent(toSinglePropertyDiagnosticsModel('Devices_DeviceTypeSelect', 'DeviceType', - (value === 'true') ? Config.deviceType.simulated: Config.deviceType.physical)); + (value === 'true') ? Config.deviceType.simulated : Config.deviceType.physical)); this.formControlChange(); } @@ -343,6 +384,7 @@ export class DeviceNew extends LinkedComponent { const isGenerateId = this.isGenerateIdLink.value === deviceIdTypeOptions.generate.value; const deviceName = this.deviceModelLink.value || t('devices.flyouts.new.deviceIdExample.deviceName'); + const isEdgeDevice = this.deviceLink.value === deviceOptions.device.value; const isSimulatedDevice = this.deviceTypeLink.value === deviceTypeOptions.simulated.value; const isX509 = this.authenticationTypeLink.value === authTypeOptions.x509.value; const isGenerateKeys = this.isGenerateKeysLink.value === authKeyTypeOptions.generate.value; @@ -361,37 +403,51 @@ export class DeviceNew extends LinkedComponent {
- {t(deviceTypeOptions.labelName)} - - {t(deviceTypeOptions.simulated.labelName)} + {t(deviceOptions.labelName)} + + {t(deviceOptions.device.labelName)} - - {t(deviceTypeOptions.physical.labelName)} + + {t(deviceOptions.edgeDevice.labelName)} { - isSimulatedDevice && [ - + isEdgeDevice && + + {t(deviceTypeOptions.labelName)} + + {t(deviceTypeOptions.simulated.labelName)} + + + {t(deviceTypeOptions.physical.labelName)} + + + } + { + isSimulatedDevice && + + {t('devices.flyouts.new.count.label')} - , - + + {t('devices.flyouts.new.deviceIdExample.label')}
{t('devices.flyouts.new.deviceIdExample.format', { deviceName })}
-
, - + + {t('devices.flyouts.new.deviceModel.label')} - ] +
} { - !isSimulatedDevice && [ - + !isSimulatedDevice && + + {t('devices.flyouts.new.count.label')}
{this.countLink.value}
-
, - + + {t('devices.flyouts.new.deviceId.label')} @@ -399,8 +455,8 @@ export class DeviceNew extends LinkedComponent { {t(deviceIdTypeOptions.generate.labelName)} - , - + + {t(authTypeOptions.labelName)} {t(authTypeOptions.symmetric.labelName)} @@ -408,8 +464,8 @@ export class DeviceNew extends LinkedComponent { {t(authTypeOptions.x509.labelName)} - , - + + {t(authKeyTypeOptions.labelName)} {t(authKeyTypeOptions.generate.labelName)} @@ -426,7 +482,7 @@ export class DeviceNew extends LinkedComponent {
- ] + }
@@ -451,12 +507,13 @@ export class DeviceNew extends LinkedComponent { } { - !!changesApplied && [ - , - + !!changesApplied && + + + this.onFlyoutClose('Devices_CloseClick')}>{t('devices.flyouts.new.close')} - ] + } diff --git a/src/components/pages/index.js b/src/components/pages/index.js index cb4e3426d..6562fe197 100644 --- a/src/components/pages/index.js +++ b/src/components/pages/index.js @@ -5,3 +5,5 @@ export * from './dashboard/dashboard.container'; export * from './devices/devices.container'; export * from './rules/rules.container'; export * from './maintenance/maintenance.container'; +export * from './packages/packages.container'; +export * from './deployments/deploymentsRouter'; diff --git a/src/components/pages/maintenance/jobDetails/jobDetails.js b/src/components/pages/maintenance/jobDetails/jobDetails.js index 090c8d96e..f8a5157b9 100644 --- a/src/components/pages/maintenance/jobDetails/jobDetails.js +++ b/src/components/pages/maintenance/jobDetails/jobDetails.js @@ -5,9 +5,11 @@ import React, { Component } from 'react'; import Config from 'app.config'; import { AjaxError, + ComponentArray, ContextMenu, ContextMenuAlign, PageContent, + PageTitle, RefreshBar } from 'components/shared'; import { DevicesGrid } from 'components/pages/devices/devicesGrid'; @@ -46,10 +48,10 @@ export class JobDetails extends Component { // A long term fix would be to normalize the job data in maintenance.js (similar to how Telemetry is handled there). // When/if that happens, remove all use of refreshPending in the local state of this component. if (( - nextProps.match.params.id !== (this.state.selectedJob || {}).jobId - || this.state.timeIntervalChangePending - || this.state.refreshPending - ) && nextProps.jobs.length) { + nextProps.match.params.id !== (this.state.selectedJob || {}).jobId + || this.state.timeIntervalChangePending + || this.state.refreshPending + ) && nextProps.jobs.length) { const selectedJob = nextProps.jobs.filter(({ jobId }) => jobId === nextProps.match.params.id)[0]; this.setState({ selectedJob, refreshPending: false, timeIntervalChangePending: false }, () => this.refreshJobStatus()); } @@ -118,43 +120,47 @@ export class JobDetails extends Component { t }; - return [ - - - {this.state.contextBtns} - + return ( + + + + {this.state.contextBtns} + + + + - - , - -

{selectedJob ? selectedJob.jobId : ""}

- { - !error - ?
+ + { + !error + ? +
{!isPending && !selectedJob && t('maintenance.noData')} {selectedJob && } {

{t('maintenance.devices')}

} { this.state.selectedDevices - ? + ? + : t('maintenance.noOccurrenceSelected') } -
- : - } - - ]; +
+ : + } +
+ + ); } } diff --git a/src/components/pages/maintenance/ruleDetails/ruleDetails.js b/src/components/pages/maintenance/ruleDetails/ruleDetails.js index fa1ca7c2a..f87db9380 100644 --- a/src/components/pages/maintenance/ruleDetails/ruleDetails.js +++ b/src/components/pages/maintenance/ruleDetails/ruleDetails.js @@ -10,10 +10,12 @@ import { RulesGrid } from 'components/pages/rules/rulesGrid'; import { AjaxError, Btn, + ComponentArray, ContextMenu, ContextMenuAlign, Indicator, PageContent, + PageTitle, Protected, RefreshBar } from 'components/shared'; @@ -91,10 +93,10 @@ export class RuleDetails extends Component { ) .flatMap(transformTelemetryResponse(() => this.state.telemetry)) .map(telemetry => ({ telemetry, telemetryIsPending: false })) - } else { - return Observable.empty(); - } + } else { + return Observable.empty(); } + } ) .subscribe( telemetryState => this.setState( @@ -138,12 +140,12 @@ export class RuleDetails extends Component { const devices = deviceIds.map(deviceId => deviceObjects[deviceId]); const deviceIdString = deviceIds.sort().join(idDelimiter); this.setState({ - deviceIds: deviceIdString, - devices, - occurrences, - selectedAlert, - selectedRule - }, + deviceIds: deviceIdString, + devices, + occurrences, + selectedAlert, + selectedRule + }, () => this.restartTelemetry$.next(deviceIdString) ); } @@ -203,23 +205,24 @@ export class RuleDetails extends Component { onAlertGridHardSelectChange = selectedRows => { const alertContextBtns = selectedRows.length > 0 - ? [ - - - Close - - , - - - Acknowledge - - , - - - Delete - - - ] + ? + + + + Close + + + + + Acknowledge + + + + + Delete + + + : null; this.setState({ selectedAlerts: selectedRows, @@ -246,7 +249,7 @@ export class RuleDetails extends Component { deviceContextBtns: undefined }, this.props.refreshData); - render () { + render() { const { error, isPending, @@ -280,137 +283,139 @@ export class RuleDetails extends Component { const { selectedTab, selectedAlert = {} } = this.state; const { counts = {} } = selectedAlert; - return [ - - - - - - - - - { - this.state.updatingAlertStatus && -
- -
- } - { - this.state.ruleContextBtns - || this.state.alertContextBtns - || this.state.deviceContextBtns - } - + return ( + + + + + + + + + + { + this.state.updatingAlertStatus && +
+ +
+ } + { + this.state.ruleContextBtns + || this.state.alertContextBtns + || this.state.deviceContextBtns + } + +
+
+ -
-
, - - { - !this.props.error - ?
-
-

{alertName}

-
-
-
{t('maintenance.total')}
-
{renderUndefined(counts.total)}
-
-
-
{t('maintenance.open')}
-
{renderUndefined(counts.open)}
-
-
-
{t('maintenance.acknowledged')}
-
{renderUndefined(counts.acknowledged)}
-
-
-
{t('maintenance.closed')}
-
{renderUndefined(counts.closed)}
-
-
-
{t('maintenance.lastEvent')}
-
- { - selectedAlert.lastOccurrence - ? - : Config.emptyValue - } + + { + !this.props.error + ? +
+
+
+
+
{t('maintenance.total')}
+
{renderUndefined(counts.total)}
-
-
-
{t('maintenance.severity')}
-
- { - selectedAlert.severity - ? - : Config.emptyValue - } +
+
{t('maintenance.open')}
+
{renderUndefined(counts.open)}
+
+
+
{t('maintenance.acknowledged')}
+
{renderUndefined(counts.acknowledged)}
+
+
+
{t('maintenance.closed')}
+
{renderUndefined(counts.closed)}
+
+
+
{t('maintenance.lastEvent')}
+
+ { + selectedAlert.lastOccurrence + ? + : Config.emptyValue + } +
+
+
+
{t('maintenance.severity')}
+
+ { + selectedAlert.severity + ? + : Config.emptyValue + } +
-
-
- { t('maintenance.ruleDetailsDesc') } -
-

{ t('maintenance.ruleDetail') }

- - -

{ t('maintenance.alertOccurrences') }

- - -

{ t('maintenance.relatedInfo') }

-
-
+

{t('maintenance.ruleDetail')}

+ + +

{t('maintenance.alertOccurrences')}

+ + +

{t('maintenance.relatedInfo')}

+
+ - - +
+ { + (selectedTab === tabIds.all || selectedTab === tabIds.devices) && + +

{t('maintenance.alertedDevices')}

+ +
+ } + { + !isPending && (selectedTab === tabIds.all || selectedTab === tabIds.telemetry) && Object.keys(this.state.telemetry).length > 0 && + +

{t('maintenance.alertedDeviceTelemetry')}

+
+ +
+
+ }
- { - (selectedTab === tabIds.all || selectedTab === tabIds.devices) && - [ -

{t('maintenance.alertedDevices')}

, - - ] - } - { - !isPending && (selectedTab === tabIds.all || selectedTab === tabIds.telemetry) && Object.keys(this.state.telemetry).length > 0 && - [ -

{t('maintenance.alertedDeviceTelemetry')}

, -
- -
- ] - } -
- : - } - - ]; + : + } + + + ); } } diff --git a/src/components/pages/maintenance/ruleDetails/ruleDetails.scss b/src/components/pages/maintenance/ruleDetails/ruleDetails.scss index 474bda3b3..8d508d55b 100644 --- a/src/components/pages/maintenance/ruleDetails/ruleDetails.scss +++ b/src/components/pages/maintenance/ruleDetails/ruleDetails.scss @@ -14,12 +14,6 @@ .rule-details-container { overflow-y: scroll; // Scroll y-axis to avoid x-axis scroll on grids - .rule-maintenance-header { - font-weight: 700; - @include rem-fallback(margin, 0px, 10px, 10px, 0px); - @include rem-font-size(42px); - } - .rule-stat-container { display: flex; flex-flow: row wrap; diff --git a/src/components/pages/maintenance/summary/summary.js b/src/components/pages/maintenance/summary/summary.js index a03bbb16c..d72499408 100644 --- a/src/components/pages/maintenance/summary/summary.js +++ b/src/components/pages/maintenance/summary/summary.js @@ -12,10 +12,11 @@ import { TimeIntervalDropdown } from 'components/shell/timeIntervalDropdown'; import { Notifications } from './notifications'; import { Jobs } from './jobs'; import { - PageContent, + ComponentArray, ContextMenu, ContextMenuAlign, RefreshBar, + PageContent, PageTitle, Protected, StatSection, @@ -38,29 +39,30 @@ export const Summary = ({ onTimeIntervalChange, timeInterval, ...props -}) => [ - - +}) => + + + - + + + + - - , - - +
- ]; + ; diff --git a/src/components/pages/maintenance/summary/summary.scss b/src/components/pages/maintenance/summary/summary.scss index e18f79593..88b25ce07 100644 --- a/src/components/pages/maintenance/summary/summary.scss +++ b/src/components/pages/maintenance/summary/summary.scss @@ -11,6 +11,8 @@ @include rem-font-size(20px); } + .summary-stat-container { @include rem-fallback(padding-bottom, 20px); } + @include themify($themes) { .stat-warning { fill: themed('colorWarning'); } .stat-critical { fill: themed('colorAlert'); } diff --git a/src/components/pages/packages/flyouts/index.js b/src/components/pages/packages/flyouts/index.js new file mode 100644 index 000000000..964310060 --- /dev/null +++ b/src/components/pages/packages/flyouts/index.js @@ -0,0 +1,3 @@ +// Copyright (c) Microsoft. All rights reserved. + +export * from './packageNew'; diff --git a/src/components/pages/packages/flyouts/packageNew/index.js b/src/components/pages/packages/flyouts/packageNew/index.js new file mode 100644 index 000000000..0e1ae1514 --- /dev/null +++ b/src/components/pages/packages/flyouts/packageNew/index.js @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft. All rights reserved. + +export * from './packageNew'; +export * from './packageNew.container'; diff --git a/src/components/pages/packages/flyouts/packageNew/packageNew.container.js b/src/components/pages/packages/flyouts/packageNew/packageNew.container.js new file mode 100644 index 000000000..95e9f2ea1 --- /dev/null +++ b/src/components/pages/packages/flyouts/packageNew/packageNew.container.js @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { connect } from 'react-redux'; +import { translate } from 'react-i18next'; +import { PackageNew } from './packageNew'; +import { + getCreatePackageError, + getCreatePackagePendingStatus, + epics as packagesEpics, + redux as packagesRedux +} from 'store/reducers/packagesReducer'; +import { epics as appEpics } from 'store/reducers/appReducer'; + +// Pass the global info needed +const mapStateToProps = state => ({ + isPending: getCreatePackagePendingStatus(state), + error: getCreatePackageError(state) +}); + +// Wrap the dispatch methods +const mapDispatchToProps = dispatch => ({ + createPackage: packageModel => dispatch(packagesEpics.actions.createPackage(packageModel)), + resetPackagesPendingError: () => dispatch(packagesRedux.actions.resetPendingAndError(packagesEpics.actions.createPackage)), + logEvent: diagnosticsModel => dispatch(appEpics.actions.logEvent(diagnosticsModel)) +}); + +export const PackageNewContainer = translate()(connect(mapStateToProps, mapDispatchToProps)(PackageNew)); diff --git a/src/components/pages/packages/flyouts/packageNew/packageNew.js b/src/components/pages/packages/flyouts/packageNew/packageNew.js new file mode 100644 index 000000000..9c772cfec --- /dev/null +++ b/src/components/pages/packages/flyouts/packageNew/packageNew.js @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React from 'react'; +import { Trans } from 'react-i18next'; +import { Link } from "react-router-dom"; + +import { + packageTypeOptions, + toSinglePropertyDiagnosticsModel, + toDiagnosticsModel +} from 'services/models'; +import { svgs, LinkedComponent, Validator } from 'utilities'; +import { + AjaxError, + Btn, + BtnToolbar, + Flyout, + FlyoutHeader, + FlyoutTitle, + FlyoutCloseBtn, + FlyoutContent, + Indicator, + FormControl, + FormGroup, + FormLabel, + SummaryBody, + SectionDesc, + SummaryCount, + SummarySection, + Svg +} from 'components/shared'; + +import './packageNew.css'; + +const fileInputAccept = ".json,application/json"; + +export class PackageNew extends LinkedComponent { + constructor(props) { + super(props); + + this.state = { + type: undefined, + packageFile: undefined, + changesApplied: undefined + }; + } + + componentWillUnmount() { + this.props.resetPackagesPendingError(); + } + + apply = (event) => { + event.preventDefault(); + const { createPackage } = this.props; + const { type, packageFile } = this.state; + this.props.logEvent( + toDiagnosticsModel( + 'NewPackage_Apply', + { + type, + packageName: packageFile.name + }) + ); + if (this.formIsValid()) { + createPackage({ type: type, packageFile: packageFile }); + this.setState({ changesApplied: true }); + } + } + + packageTypeChange = ({ target: { value: { value = {} } } }) => { + this.props.logEvent(toSinglePropertyDiagnosticsModel('NewPackage_TypeClick', 'Type', value)); + } + + onFileSelected = (e) => { + let file = e.target.files[0]; + this.setState({ packageFile: file }); + this.props.logEvent(toSinglePropertyDiagnosticsModel('NewPackage_FileSelect', 'FileName', file.name)); + } + + formIsValid = () => { + return [ + this.packageTypeLink, + ].every(link => !link.error); + } + + genericCloseClick = (eventName) => { + const { onClose, logEvent } = this.props; + logEvent(toDiagnosticsModel(eventName, {})); + onClose(); + } + + onKeyEvent = (event) => { + if (event.keyCode === 32 || event.keyCode === 13) { + event.preventDefault(); + this.inputElement.click(); + } + } + + render() { + const { t, isPending, error } = this.props; + const { type, packageFile, changesApplied } = this.state; + + const summaryCount = 1; + const typeOptions = packageTypeOptions.map(value => ({ + label: t(`packages.typeOptions.${value.toLowerCase()}`), + value + })); + + const completedSuccessfully = changesApplied && !error && !isPending; + // Validators + const requiredValidator = (new Validator()).check(Validator.notEmpty, t('packages.flyouts.new.validation.required')); + + // Links + this.packageTypeLink = this.linkTo('type').map(({ value }) => value).withValidator(requiredValidator); + + return ( + + + {t('packages.flyouts.new.title')} + this.genericCloseClick('NewPackage_CloseClick')} /> + + +
+
{t('packages.flyouts.new.header')}
+
{t('packages.flyouts.new.description')}
+ + + {t('packages.flyouts.new.type')} + { + !completedSuccessfully && + + } + { + completedSuccessfully && {type} + } + + + { + !completedSuccessfully && +
+ + this.inputElement = input} + className="new-package-hidden-input" + onChange={this.onFileSelected} /> + {t('packages.flyouts.new.browseText')} +
+ } + + + + {packageFile && {summaryCount}} + {packageFile && {t('packages.flyouts.new.package')}} + {isPending && } + {completedSuccessfully && } + + {packageFile &&
{packageFile.name}
} + { + completedSuccessfully && +
+ + To deploy packages, go to the + {t('packages.flyouts.new.deploymentsPage')} + , and then click + {t('packages.flyouts.new.newDeployment')} + button. + +
+ } + {/** Displays an error message if one occurs while applying changes. */ + error && + } + { + /** If package is selected, show the buttons for uploading and closing the flyout. */ + (packageFile && !completedSuccessfully) && + + {t('packages.flyouts.new.upload')} + this.genericCloseClick('NewPackage_CancelClick')}> + {t('packages.flyouts.new.cancel')} + + + } + { + /** If package is not selected, show only the cancel button. */ + (!packageFile) && + + this.genericCloseClick('NewPackage_CancelClick')}> + {t('packages.flyouts.new.cancel')} + + + } + { + /** After successful upload, show close button. */ + (completedSuccessfully) && + + this.genericCloseClick('NewPackage_CancelClick')}> + {t('packages.flyouts.new.close')} + + + } +
+
+
+
+ ); + } +} diff --git a/src/components/pages/packages/flyouts/packageNew/packageNew.scss b/src/components/pages/packages/flyouts/packageNew/packageNew.scss new file mode 100644 index 000000000..55ed1e575 --- /dev/null +++ b/src/components/pages/packages/flyouts/packageNew/packageNew.scss @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +@import 'src/styles/variables'; +@import 'src/styles/mixins'; +@import 'src/styles/themes'; + +.new-package-content { + .new-package-upload-container { + display: flex; + align-items: flex-start; + word-wrap: break-word; + flex-grow: 0; + @include rem-fallback(padding-top, 24px); + @include rem-fallback(padding-bottom, 24px); + } + + .new-package-browse-click { + cursor: pointer; + text-decoration: underline; + @include rem-fallback(padding-right, 8px); + } + + .new-package-hidden-input { display: none; } + + .new-package-file-name, + .new-package-header, + .new-package-flyout-error { @include rem-fallback(padding-top, 24px); } + + + .new-package-deployment-text { + @include rem-fallback(padding-top, 24px); + line-height: 1.7; + } + + .new-package-descr { + @include rem-fallback(font-size, 12px); + @include rem-fallback(padding-top, 16px); + @include rem-fallback(padding-bottom, 24px); + } + + .summary-icon svg { + @include square-px-rem(16px); + @include rem-fallback(margin-left, 8px); + } + + @include themify($themes) { + .new-package-header, + .new-package-browse-click, + .new-package-file-name, + .new-package-deployment-text, + .new-package-deployment-page-link { color: themed('colorContentText'); } + + .new-package-success-labels { color: themed('colorFlyoutText'); } + + .new-package-descr svg, + .summary-icon svg { fill: themed('colorContentText'); } + + .new-package-flyout-error { border-color: themed('colorAlert'); } + } +} diff --git a/src/components/pages/packages/modals/deletePackage/deletePackage.container.js b/src/components/pages/packages/modals/deletePackage/deletePackage.container.js new file mode 100644 index 000000000..d17c899f7 --- /dev/null +++ b/src/components/pages/packages/modals/deletePackage/deletePackage.container.js @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { connect } from 'react-redux'; +import { translate } from 'react-i18next'; +import { DeleteModal } from 'components/shared'; +import { + getDeletePackageError, + getDeletePackagePendingStatus, + epics as packagesEpics +} from 'store/reducers/packagesReducer'; +import { epics as appEpics } from 'store/reducers/appReducer'; + +// Pass the global info needed +const mapStateToProps = state => ({ + isPending: getDeletePackagePendingStatus(state), + error: getDeletePackageError(state) +}); + +// Wrap the dispatch methods +const mapDispatchToProps = dispatch => ({ + deleteItem: packageId => dispatch(packagesEpics.actions.deletePackage(packageId)), + logEvent: diagnosticsModel => dispatch(appEpics.actions.logEvent(diagnosticsModel)) +}); + +export const PackageDeleteContainer = translate()(connect(mapStateToProps, mapDispatchToProps)(DeleteModal)); diff --git a/src/components/pages/packages/modals/index.js b/src/components/pages/packages/modals/index.js new file mode 100644 index 000000000..40e172af1 --- /dev/null +++ b/src/components/pages/packages/modals/index.js @@ -0,0 +1,3 @@ +// Copyright (c) Microsoft. All rights reserved. + +export * from './deletePackage/deletePackage.container'; diff --git a/src/components/pages/packages/packages.container.js b/src/components/pages/packages/packages.container.js new file mode 100644 index 000000000..ac62a5499 --- /dev/null +++ b/src/components/pages/packages/packages.container.js @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { connect } from 'react-redux'; +import { translate } from 'react-i18next'; +import { Packages } from './packages'; +import { + epics as packagesEpics, + getPackages, + getPackagesError, + getPackagesLastUpdated, + getPackagesPendingStatus +} from 'store/reducers/packagesReducer'; +import { + redux as appRedux, + epics as appEpics, +} from 'store/reducers/appReducer'; + +// Pass the packages status +const mapStateToProps = state => ({ + packages: getPackages(state), + error: getPackagesError(state), + isPending: getPackagesPendingStatus(state), + lastUpdated: getPackagesLastUpdated(state) +}); + +// Wrap the dispatch method +const mapDispatchToProps = dispatch => ({ + fetchPackages: () => dispatch(packagesEpics.actions.fetchPackages()), + updateCurrentWindow: (currentWindow) => dispatch(appRedux.actions.updateCurrentWindow(currentWindow)), + logEvent: diagnosticsModel => dispatch(appEpics.actions.logEvent(diagnosticsModel)) +}); + +export const PackagesContainer = translate()(connect(mapStateToProps, mapDispatchToProps)(Packages)); diff --git a/src/components/pages/packages/packages.js b/src/components/pages/packages/packages.js new file mode 100644 index 000000000..df90e1c15 --- /dev/null +++ b/src/components/pages/packages/packages.js @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React, { Component } from 'react'; +import { permissions, toDiagnosticsModel } from 'services/models'; +import { PackagesGrid } from './packagesGrid'; +import { + AjaxError, + Btn, + ComponentArray, + ContextMenu, + ContextMenuAlign, + PageContent, + Protected, + RefreshBar, + PageTitle +} from 'components/shared'; +import { PackageNewContainer } from './flyouts'; +import { svgs } from 'utilities'; + +import './packages.css'; + +const closedFlyoutState = { openFlyoutName: undefined }; + +export class Packages extends Component { + constructor(props) { + super(props); + this.state = { + ...closedFlyoutState, + contextBtns: null + }; + + this.props.updateCurrentWindow('Packages'); + + if (!this.props.lastUpdated && !this.props.error) { + this.props.fetchPackages(); + } + } + + componentWillReceiveProps(nextProps) { + if (nextProps.isPending && nextProps.isPending !== this.props.isPending) { + // If the grid data refreshes, hide the flyout + this.setState(closedFlyoutState); + } + } + + closeFlyout = () => { + this.props.logEvent(toDiagnosticsModel('Packages_NewClose', {})); + this.setState(closedFlyoutState); + } + + onContextMenuChange = contextBtns => this.setState({ + contextBtns, + openFlyoutName: undefined + }); + + openNewPackageFlyout = () => { + this.props.logEvent(toDiagnosticsModel('Packages_NewClick', {})); + this.setState({ + openFlyoutName: 'new-Package' + }); + } + + onGridReady = gridReadyEvent => this.packageGridApi = gridReadyEvent.api; + + render() { + const { t, packages, error, isPending, fetchPackages, lastUpdated } = this.props; + const gridProps = { + onGridReady: this.onGridReady, + rowData: isPending ? undefined : packages || [], + onContextMenuChange: this.onContextMenuChange, + t: this.props.t + }; + + return ( + + + + { /* Add left aligned items as needed */} + + + {this.state.contextBtns} + + {t('packages.new')} + + + + + + + {!!error && } + {!error && } + {this.state.openFlyoutName === 'new-Package' && } + + + ); + } +} diff --git a/src/components/pages/packages/packages.scss b/src/components/pages/packages/packages.scss new file mode 100644 index 000000000..da5440ae5 --- /dev/null +++ b/src/components/pages/packages/packages.scss @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft. All rights reserved. + +@import 'src/styles/variables'; +@import 'src/styles/mixins'; +@import 'src/styles/themes'; + +.package-container { + display: flex; + flex-flow: column nowrap; + padding: $baseContentPadding; +} diff --git a/src/components/pages/packages/packages.test.js b/src/components/pages/packages/packages.test.js new file mode 100644 index 000000000..631b03bca --- /dev/null +++ b/src/components/pages/packages/packages.test.js @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + + +import React from 'react'; +import { shallow } from 'enzyme'; +import 'polyfills'; + +import { Packages } from './packages'; + +describe('Packages Component', () => { + it('Renders without crashing', () => { + + const fakeProps = { + packages: {}, + entities: {}, + error: undefined, + isPending: false, + lastUpdated: undefined, + fetchPackages: () => { }, + updateCurrentWindow: () => { }, + t: () => { }, + }; + + const wrapper = shallow( + + ); + }); +}); diff --git a/src/components/pages/packages/packagesGrid/index.js b/src/components/pages/packages/packagesGrid/index.js new file mode 100644 index 000000000..e1153530a --- /dev/null +++ b/src/components/pages/packages/packagesGrid/index.js @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft. All rights reserved. + +export * from './packagesGridConfig'; +export * from './packagesGrid'; diff --git a/src/components/pages/packages/packagesGrid/packagesGrid.js b/src/components/pages/packages/packagesGrid/packagesGrid.js new file mode 100644 index 000000000..2c75c5cae --- /dev/null +++ b/src/components/pages/packages/packagesGrid/packagesGrid.js @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. +import React, { Component } from 'react'; +import { permissions } from 'services/models'; +import { packagesColumnDefs, defaultPackagesGridProps } from './packagesGridConfig'; +import { Btn, ComponentArray, PcsGrid, Protected } from 'components/shared'; +import { isFunc, translateColumnDefs, svgs } from 'utilities'; +import { checkboxColumn } from 'components/shared/pcsGrid/pcsGridConfig'; +import { PackageDeleteContainer } from '../modals'; + +import './packagesGrid.css'; + +const closedModalState = { + openModalName: undefined +}; + +export class PackagesGrid extends Component { + constructor(props) { + super(props); + + // Set the initial state + this.state = { + ...closedModalState, + hardSelectedPackages: [] + }; + + this.columnDefs = [ + checkboxColumn, + packagesColumnDefs.name, + packagesColumnDefs.type, + packagesColumnDefs.dateCreated + ]; + + this.contextBtns = + + + {props.t('packages.delete')} + + ; + } + + getOpenModal = () => { + if (this.state.openModalName === 'delete-package' && this.state.hardSelectedPackages[0]) { + return + } + return null; + } + + /** + * Get the grid api options + * + * @param {Object} gridReadyEvent An object containing access to the grid APIs + */ + onGridReady = gridReadyEvent => { + this.packagesGridApi = gridReadyEvent.api; + // Call the onReady props if it exists + if (isFunc(this.props.onGridReady)) { + this.props.onGridReady(gridReadyEvent); + } + }; + + /** + * Handles context filter changes and calls any hard select props method + * + * @param {Array} selectedPackages A list of currently selected packages + */ + onHardSelectChange = (selectedPackages) => { + const { onContextMenuChange, onHardSelectChange } = this.props; + if (isFunc(onContextMenuChange)) { + onContextMenuChange(selectedPackages.length === 1 ? this.contextBtns : null); + this.setState({ + hardSelectedPackages: selectedPackages + }); + } + if (isFunc(onHardSelectChange)) { + onHardSelectChange(selectedPackages); + } + } + + closeModal = () => this.setState(closedModalState); + + openModal = (modalName) => () => this.setState({ + openModalName: modalName + }); + + render() { + const gridProps = { + /* Grid Properties */ + ...defaultPackagesGridProps, + columnDefs: translateColumnDefs(this.props.t, this.columnDefs), + sizeColumnsToFit: true, + deltaRowDataMode: true, + ...this.props, // Allow default property overrides + onGridReady: event => this.onGridReady(event), // Wrap in a function to avoid closure issues + getRowNodeId: ({ id }) => id, + enableSorting: true, + unSortIcon: true, + onHardSelectChange: this.onHardSelectChange, + context: { + t: this.props.t + } + }; + + return ( + + + {this.getOpenModal()} + + ); + } +} diff --git a/src/components/pages/packages/packagesGrid/packagesGrid.scss b/src/components/pages/packages/packagesGrid/packagesGrid.scss new file mode 100644 index 000000000..cca58d691 --- /dev/null +++ b/src/components/pages/packages/packagesGrid/packagesGrid.scss @@ -0,0 +1,3 @@ +// Copyright (c) Microsoft. All rights reserved. + +// TODO diff --git a/src/components/pages/packages/packagesGrid/packagesGridConfig.js b/src/components/pages/packages/packagesGrid/packagesGridConfig.js new file mode 100644 index 000000000..41407a647 --- /dev/null +++ b/src/components/pages/packages/packagesGrid/packagesGridConfig.js @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +import Config from 'app.config'; +import { TimeRenderer, SoftSelectLinkRenderer } from 'components/shared/cellRenderers'; +import { gridValueFormatters } from 'components/shared/pcsGrid/pcsGridConfig'; + +const { checkForEmpty } = gridValueFormatters; + +export const packagesColumnDefs = { + name: { + headerName: 'packages.grid.name', + field: 'name', + sort: 'asc', + valueFormatter: ({ value }) => checkForEmpty(value), + cellRendererFramework: SoftSelectLinkRenderer + }, + type: { + headerName: 'packages.grid.type', + field: 'type', + valueFormatter: ({ value }) => checkForEmpty(value) + }, + dateCreated: { + headerName: 'packages.grid.dateCreated', + field: 'dateCreated', + cellRendererFramework: TimeRenderer + } +}; + +export const defaultPackagesGridProps = { + enableColResize: true, + multiSelect: true, + pagination: true, + paginationPageSize: Config.paginationPageSize, + rowSelection: 'multiple' +}; diff --git a/src/components/pages/rules/flyouts/ruleDetailsFlyout.js b/src/components/pages/rules/flyouts/ruleDetailsFlyout.js index d7afa2f9f..409f485e9 100644 --- a/src/components/pages/rules/flyouts/ruleDetailsFlyout.js +++ b/src/components/pages/rules/flyouts/ruleDetailsFlyout.js @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import { svgs } from 'utilities'; import { permissions, toDiagnosticsModel } from 'services/models'; -import { Btn, Protected, ProtectedError } from 'components/shared'; +import { Btn, ComponentArray, Protected, ProtectedError } from 'components/shared'; import { RuleEditorContainer } from './ruleEditor'; import { RuleViewerContainer } from './ruleViewer'; import Flyout from 'components/shared/flyout'; @@ -28,7 +28,7 @@ export class RuleDetailsFlyout extends Component { onTopXClose = () => { const { logEvent, onClose } = this.props; - if(this.state.isEditable) { + if (this.state.isEditable) { logEvent(toDiagnosticsModel('Rule_TopXCloseClick', {})); } onClose(); @@ -36,7 +36,7 @@ export class RuleDetailsFlyout extends Component { goToEditMode = () => { this.props.logEvent(toDiagnosticsModel('Rule_EditClick', {})); - this.setState({ isEditable: true }); + this.setState({ isEditable: true }); } render() { @@ -52,20 +52,21 @@ export class RuleDetailsFlyout extends Component { {!isEditable ? - [ - , - + + + {t('rules.flyouts.edit')} - ] + : - { - (hasPermission, permission) => hasPermission - ? - : - } + + { + (hasPermission, permission) => hasPermission + ? + : + } } diff --git a/src/components/pages/rules/rules.js b/src/components/pages/rules/rules.js index d9d95b818..432647639 100644 --- a/src/components/pages/rules/rules.js +++ b/src/components/pages/rules/rules.js @@ -8,13 +8,15 @@ import { ManageDeviceGroupsBtnContainer as ManageDeviceGroupsBtn } from 'compone import { AjaxError, Btn, + ComponentArray, ContextMenu, ContextMenuAlign, PageContent, + PageTitle, Protected, RefreshBar, SearchInput - } from 'components/shared'; +} from 'components/shared'; import { NewRuleFlyout } from './flyouts'; import { svgs } from 'utilities'; @@ -48,11 +50,6 @@ export class Rules extends Component { } } - changeDeviceGroup = () => { - const { changeDeviceGroup, deviceGroups } = this.props; - changeDeviceGroup(deviceGroups[1].id); - } - closeFlyout = () => this.setState(closedFlyoutState); openNewRuleFlyout = () => { @@ -91,28 +88,31 @@ export class Rules extends Component { refresh: fetchRules, logEvent: this.props.logEvent }; - return [ - - - - - - - - - - {this.state.contextBtns} - - {t('rules.flyouts.newRule')} - - - , - - - { !!error && } - {!error && } - {this.state.openFlyoutName === 'newRule' && } - - ]; + return ( + + + + + + + + + + + {this.state.contextBtns} + + {t('rules.flyouts.newRule')} + + + + + + + {!!error && } + {!error && } + {this.state.openFlyoutName === 'newRule' && } + + + ); } } diff --git a/src/components/pages/rules/rulesGrid/rulesGrid.js b/src/components/pages/rules/rulesGrid/rulesGrid.js index 5b6d03ecf..c3c657038 100644 --- a/src/components/pages/rules/rulesGrid/rulesGrid.js +++ b/src/components/pages/rules/rulesGrid/rulesGrid.js @@ -4,7 +4,7 @@ import React, { Component } from 'react'; import { Trans } from 'react-i18next'; import { permissions, toDiagnosticsModel } from 'services/models'; -import { Btn, PcsGrid, Protected } from 'components/shared'; +import { Btn, ComponentArray, PcsGrid, Protected } from 'components/shared'; import { rulesColumnDefs, defaultRulesGridProps } from './rulesGridConfig'; import { checkboxColumn } from 'components/shared/pcsGrid/pcsGridConfig'; import { isFunc, translateColumnDefs, svgs } from 'utilities'; @@ -60,13 +60,13 @@ export class RulesGrid extends Component { , edit: - + {props.t('rules.flyouts.edit')} , delete: - + Delete @@ -74,7 +74,7 @@ export class RulesGrid extends Component { }; } - componentWillReceiveProps({rowData}) { + componentWillReceiveProps({ rowData }) { const { selectedRules = [], softSelectedRule } = this.state; if (rowData && (selectedRules.length || softSelectedRule)) { let updatedSoftSelectedRule = undefined; @@ -113,7 +113,7 @@ export class RulesGrid extends Component { case 'status': return case 'delete': - return + return default: return null; } @@ -175,7 +175,7 @@ export class RulesGrid extends Component { } } - getSoftSelectId = ({ id } = {}) => id; + getSoftSelectId = ({ id } = '') => id; closeFlyout = () => this.setState(closedFlyoutState); @@ -203,9 +203,11 @@ export class RulesGrid extends Component { onSoftSelectChange: rule => this.onSoftSelectChange(rule) // See above comment about closures }; - return ([ - , - this.props.suppressFlyouts ? null : this.getOpenFlyout() - ]); + return ( + + + {this.props.suppressFlyouts ? null : this.getOpenFlyout()} + + ); } } diff --git a/src/components/pages/rules/rulesGrid/rulesGridConfig.js b/src/components/pages/rules/rulesGrid/rulesGridConfig.js index 1539354ee..3bdd97db8 100644 --- a/src/components/pages/rules/rulesGrid/rulesGridConfig.js +++ b/src/components/pages/rules/rulesGrid/rulesGridConfig.js @@ -18,6 +18,7 @@ export const rulesColumnDefs = { ruleName: { headerName: 'rules.grid.ruleName', field: 'name', + sort: 'asc', filter: 'text', cellRendererFramework: SoftSelectLinkRenderer }, @@ -94,7 +95,7 @@ export const rulesColumnDefs = { headerName: 'rules.grid.explore', field: 'ruleId', cellRendererFramework: props => - }, + } }; export const defaultRulesGridProps = { diff --git a/src/components/shared/cellRenderers/cellRenderer.scss b/src/components/shared/cellRenderers/cellRenderer.scss index 76de2493a..59105e5a3 100644 --- a/src/components/shared/cellRenderers/cellRenderer.scss +++ b/src/components/shared/cellRenderers/cellRenderer.scss @@ -35,6 +35,22 @@ $iconSize: 14px; > .icon-only { display: none !important; } } + .soft-select-link-cell { + display: inline; + color: inherit; + text-decoration: underline; + + &:hover { text-decoration: none; } + } + + .glimmer-icon { + svg { + @include square-px-rem(10px); + @include rem-fallback(margin-left, -10px); + @include rem-fallback(margin-top, -18px); + } + } + @include themify($themes) { .pcs-renderer-icon { margin-right: 10px; @@ -56,7 +72,6 @@ $iconSize: 14px; .pcs-renderer-text { color: themed('colorCellRendererText'); } &.highlight { - .pcs-renderer-icon svg { fill: themed('colorCellRendererSvgFillHighlight'); @@ -64,17 +79,10 @@ $iconSize: 14px; } .pcs-renderer-text { color: themed('colorCellRendererTextHighlight'); } - } - } -} - -.soft-select-link-cell { - display: inline; - color: inherit; - text-decoration: underline; - &:hover { text-decoration: none; } + .glimmer-icon svg { fill: themed('colorGlimmerSvgFill'); } + } } .ag-row { diff --git a/src/components/shared/cellRenderers/glimmerRenderer/glimmerRenderer.js b/src/components/shared/cellRenderers/glimmerRenderer/glimmerRenderer.js new file mode 100644 index 000000000..1ef14e7ef --- /dev/null +++ b/src/components/shared/cellRenderers/glimmerRenderer/glimmerRenderer.js @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React from "react"; + +import { svgs } from 'utilities'; +import { Svg } from 'components/shared/svg/svg'; + +import '../cellRenderer.css'; + +export const GlimmerRenderer = (props) => ( + props.value + ? + : null +); diff --git a/src/components/shared/cellRenderers/index.js b/src/components/shared/cellRenderers/index.js index 88a3a913a..c24bc9bdc 100644 --- a/src/components/shared/cellRenderers/index.js +++ b/src/components/shared/cellRenderers/index.js @@ -3,6 +3,7 @@ export * from './connectionStatusRenderer/connectionStatusRenderer'; export * from './countRenderer/countRenderer'; export * from './isSimulatedRenderer/isSimulatedRenderer'; +export * from './glimmerRenderer/glimmerRenderer'; export * from './lastTriggerRenderer/lastTriggerRenderer'; export * from './linkRenderer/linkRenderer'; export * from './ruleStatusRenderer/ruleStatusRenderer'; diff --git a/src/components/shared/cellRenderers/softSelectLinkRenderer/softSelectLinkRenderer.js b/src/components/shared/cellRenderers/softSelectLinkRenderer/softSelectLinkRenderer.js index 794e57ff1..70bf2f8c7 100644 --- a/src/components/shared/cellRenderers/softSelectLinkRenderer/softSelectLinkRenderer.js +++ b/src/components/shared/cellRenderers/softSelectLinkRenderer/softSelectLinkRenderer.js @@ -3,6 +3,7 @@ import React, { Component } from "react"; import { isFunc } from 'utilities'; +import { GlimmerRenderer } from 'components/shared/cellRenderers'; import '../cellRenderer.css'; @@ -16,11 +17,16 @@ export class SoftSelectLinkRenderer extends Component { }; render() { - const { value, context } = this.props; + const { value, context, data } = this.props; return ( - isFunc(context.onSoftSelectChange) - ? { value } - : value +
+ + { + isFunc(context.onSoftSelectChange) + ? {value} + : value + } +
); } } diff --git a/src/components/shared/componentArray/componentArray.js b/src/components/shared/componentArray/componentArray.js new file mode 100644 index 000000000..e004a706d --- /dev/null +++ b/src/components/shared/componentArray/componentArray.js @@ -0,0 +1,3 @@ +// Copyright (c) Microsoft. All rights reserved. + +export const ComponentArray = ({ children }) => children; diff --git a/src/components/shared/deleteModal/deleteModal.js b/src/components/shared/deleteModal/deleteModal.js new file mode 100644 index 000000000..ca6ac1e41 --- /dev/null +++ b/src/components/shared/deleteModal/deleteModal.js @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React, { Component } from 'react'; + +import { + AjaxError, + Btn, + BtnToolbar, + Indicator, + Modal +} from 'components/shared'; +import { svgs } from 'utilities'; +import { toSinglePropertyDiagnosticsModel } from 'services/models'; + +import './deleteModal.css'; + +export class DeleteModal extends Component { + + constructor(props) { + super(props); + + this.state = { + changesApplied: false + }; + } + + componentWillReceiveProps({ error, isPending, onDelete }) { + if (this.state.changesApplied && !error && !isPending) { + onDelete(); + } + } + + apply = () => { + const { deleteItem, itemId, logEvent } = this.props; + logEvent( + toSinglePropertyDiagnosticsModel( + 'DeleteModal_DeleteClick', + 'ItemId', + itemId)); + deleteItem(itemId); + this.setState({ changesApplied: true }); + } + + genericCloseClick = (eventName) => { + const { onClose, itemId, logEvent } = this.props; + logEvent( + toSinglePropertyDiagnosticsModel( + eventName, + 'DeleteModal_CloseClick', + 'ItemId', + itemId)); + onClose(); + } + + render() { + const { t, isPending, error, title, deleteInfo } = this.props; + const { changesApplied } = this.state; + + return ( + this.genericCloseClick('DeleteModal_ModalClose')} className="delete-modal-container"> +
+
{title}
+ this.genericCloseClick('DeleteModal_CloseClick')} svg={svgs.x} /> +
+
+ {deleteInfo} +
+
+ { + !changesApplied && + {t('modal.delete')} + this.genericCloseClick('DeleteModal_CancelClick')}>{t('modal.cancel')} + + } + {isPending && } + {changesApplied && error && } +
+
+ ); + } +} diff --git a/src/components/shared/deleteModal/deleteModal.scss b/src/components/shared/deleteModal/deleteModal.scss new file mode 100644 index 000000000..5e8ce3b59 --- /dev/null +++ b/src/components/shared/deleteModal/deleteModal.scss @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +@import 'src/styles/variables'; +@import 'src/styles/mixins'; +@import 'src/styles/themes'; + +.delete-modal-container { + @include rem-fallback(font-size, 14px); + + .delete-header-container { + display: grid; + grid-template-columns: auto auto; + justify-content: space-between; + @include rem-fallback(padding-bottom, 24px); + } + + .delete-info { + @include rem-fallback(padding-bottom, 24px); + @include rem-fallback(height, 60px); + } + + .delete-close-btn, + .delete-close-btn:hover { + background-color: transparent; + @include rem-fallback(padding, 0px, 16px); + @include rem-fallback(min-width, 14px); + + &:hover { background-color: transparent; } + + .btn-icon svg { @include square-px-rem(14px); } + } + + @include themify($themes) { + .delete-close-btn .btn-icon svg { stroke: themed('colorFlyoutText'); } + } +} diff --git a/src/components/shared/index.js b/src/components/shared/index.js index 1b8ac07ae..a62416776 100644 --- a/src/components/shared/index.js +++ b/src/components/shared/index.js @@ -3,6 +3,7 @@ // Exports the shared react components into as a library export * from './ajaxError/ajaxError'; +export * from './componentArray/componentArray'; export * from './contextMenu'; export * from './flyout'; export * from './forms'; @@ -15,3 +16,5 @@ export * from './propertyGrid' export * from './protected' export * from './refreshBar/refreshBar'; export * from './svg/svg'; +export * from './modal'; +export * from './deleteModal/deleteModal'; diff --git a/src/components/shared/modal/README.md b/src/components/shared/modal/README.md new file mode 100644 index 000000000..e687d437f --- /dev/null +++ b/src/components/shared/modal/README.md @@ -0,0 +1,24 @@ +Modal Components +================================= + +These are presentational components for creating modals. + +### ModalFadeBox:  + +A presentational component which is styled to fade the rest of the screen at 0.5 opacity. +  +### ModalContainer:  + +A presentational component which contains the contents of the modal. + +### Modal:  + +A presentational component which contains ModalFadeBox and ModalContainer. + +## Example:  + +```jsx + + Sample Content + +``` diff --git a/src/components/shared/modal/index.js b/src/components/shared/modal/index.js new file mode 100644 index 000000000..8edfeb8de --- /dev/null +++ b/src/components/shared/modal/index.js @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft. All rights reserved. + +export * from './modalFadeBox/modalFadeBox'; +export * from './modalContent/modalContent'; +export * from './modal/modal'; diff --git a/src/components/shared/modal/modal/modal.js b/src/components/shared/modal/modal/modal.js new file mode 100644 index 000000000..8f82aae49 --- /dev/null +++ b/src/components/shared/modal/modal/modal.js @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React, { Component } from 'react'; + +import { + ModalContent, + ModalFadeBox +} from '..'; +import { joinClasses } from 'utilities'; + +import './modal.css'; + +export class Modal extends Component { + + componentDidMount() { + if (this.props.onClose) { + window.addEventListener('keydown', this.listenKeyboard, true); + } + } + + componentWillUnmount() { + if (this.props.onClose) { + window.removeEventListener('keydown', this.listenKeyboard, true); + } + } + + listenKeyboard = (event) => { + if (event.key === 'Escape' || event.keyCode === 27) { + this.props.onClose(); + } + } + + onOverlayClick = () => { + this.props.onClose(); + } + + render() { + return ( +
+ + + {this.props.children} + +
+ ); + } +} diff --git a/src/components/shared/modal/modal/modal.scss b/src/components/shared/modal/modal/modal.scss new file mode 100644 index 000000000..c2a58e0a4 --- /dev/null +++ b/src/components/shared/modal/modal/modal.scss @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +@import 'src/styles/mixins'; + +.modal-container { + width: 100%; + height: 100%; + position: fixed; + display: flex; + align-items: center; + justify-content: center; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index:1000; +} diff --git a/src/components/shared/modal/modalContent/modalContent.js b/src/components/shared/modal/modalContent/modalContent.js new file mode 100644 index 000000000..7a229d13d --- /dev/null +++ b/src/components/shared/modal/modalContent/modalContent.js @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React from 'react'; + +import { joinClasses } from 'utilities'; + +import './modalContent.css'; + +/** A presentational component containing the content of the modal */ +export const ModalContent = ({ children, className }) => ( +
{children}
+); diff --git a/src/components/shared/modal/modalContent/modalContent.scss b/src/components/shared/modal/modalContent/modalContent.scss new file mode 100644 index 000000000..2f6a715f7 --- /dev/null +++ b/src/components/shared/modal/modalContent/modalContent.scss @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +@import 'src/styles/mixins'; +@import 'src/styles/themes'; +.modal-container { + .modal-content { + border-style: solid; + width: 400px; + height: 200px; + z-index: 1; + @include rem-fallback(border-width, 1px); + @include rem-fallback(padding, 20px); + + @include themify($themes) { + color: themed('colorModalText'); + background-color: themed('colorModalBackground'); + border-color: themed('colorModalBorder'); + } + } +} diff --git a/src/components/shared/modal/modalFadeBox/modalFadeBox.js b/src/components/shared/modal/modalFadeBox/modalFadeBox.js new file mode 100644 index 000000000..ead16071d --- /dev/null +++ b/src/components/shared/modal/modalFadeBox/modalFadeBox.js @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React from 'react'; + +import { joinClasses } from 'utilities'; + +import './modalFadeBox.css'; + +/** A presentational component containing the content of the modal */ +export const ModalFadeBox = ({ children, className, onClick }) => ( +
{children}
+); diff --git a/src/components/shared/modal/modalFadeBox/modalFadeBox.scss b/src/components/shared/modal/modalFadeBox/modalFadeBox.scss new file mode 100644 index 000000000..ec18d7ed4 --- /dev/null +++ b/src/components/shared/modal/modalFadeBox/modalFadeBox.scss @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +@import 'src/styles/mixins'; +@import 'src/styles/themes'; + +.modal-container { + .modal-fade-box { + position: absolute; + width: 100%; + height: 100%; + opacity: 0.5; + z-index: 0; + + @include themify($themes) { + background-color: themed('colorModalDropShadow'); + } + } +} diff --git a/src/components/shared/pageStats/README.md b/src/components/shared/pageStats/README.md index 3ef8b553d..299f67485 100644 --- a/src/components/shared/pageStats/README.md +++ b/src/components/shared/pageStats/README.md @@ -13,7 +13,11 @@ A presentational component containing one or many StatPropertys. By default the ### StatProperty:  -A presentational component containing number value, label, and an optional svg and svgClassname. The number value can be of three different sizes- large, medium or small, based on `size` parameter. By default the 'size' will be assigned 'normal', which will inherit it's font size from parent css class. +A presentational component containing number value, label, and an optional svg and svgClassname. The number value can be of three different sizes- large, medium or small, based on `size` parameter. By default the 'size' will be assigned 'small'. + +### StatProperty:  + +A presentational component containing label, and a value stacked vertically. ## Examples:  @@ -33,3 +37,11 @@ A presentational component containing number value, label, and an optional svg a ``` + +```html + + + + + +``` diff --git a/src/components/shared/pageStats/index.js b/src/components/shared/pageStats/index.js index 245525c94..1b0a02e42 100644 --- a/src/components/shared/pageStats/index.js +++ b/src/components/shared/pageStats/index.js @@ -3,3 +3,4 @@ export * from './statGroup/statGroup'; export * from './statSection/statSection'; export * from './statProperty/statProperty'; +export * from './statPropertyPair/statPropertyPair'; diff --git a/src/components/shared/pageStats/statGroup/statGroup.scss b/src/components/shared/pageStats/statGroup/statGroup.scss index 94c76bd44..3880fe181 100644 --- a/src/components/shared/pageStats/statGroup/statGroup.scss +++ b/src/components/shared/pageStats/statGroup/statGroup.scss @@ -5,7 +5,7 @@ .stat-cell { display: flex; flex-flow: column wrap; - justify-content: flex-end; + justify-content: flex-start; flex-shrink: 0; @include rem-fallback(margin-right, 40px); } diff --git a/src/components/shared/pageStats/statProperty/statProperty.scss b/src/components/shared/pageStats/statProperty/statProperty.scss index 3ccd32273..ad6829c5d 100644 --- a/src/components/shared/pageStats/statProperty/statProperty.scss +++ b/src/components/shared/pageStats/statProperty/statProperty.scss @@ -7,7 +7,10 @@ display: flex; flex-flow: row wrap; flex-shrink: 0; + line-height: .85em; + align-items: baseline; + @include rem-fallback(padding-top, 10px); .stat-icon { margin-right: 0.2em; svg { @include square-px-rem(14px); } diff --git a/src/components/shared/pageStats/statPropertyPair/statPropertyPair.js b/src/components/shared/pageStats/statPropertyPair/statPropertyPair.js new file mode 100644 index 000000000..1fc7f0e46 --- /dev/null +++ b/src/components/shared/pageStats/statPropertyPair/statPropertyPair.js @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +import React from 'react'; + +import { joinClasses } from 'utilities'; +import './statPropertyPair.css'; + +/** A presentational component containing statistics value, label and icon */ +export const StatPropertyPair = ({ label, value, className }) => { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/components/shared/pageStats/statPropertyPair/statPropertyPair.scss b/src/components/shared/pageStats/statPropertyPair/statPropertyPair.scss new file mode 100644 index 000000000..5fcfe9e28 --- /dev/null +++ b/src/components/shared/pageStats/statPropertyPair/statPropertyPair.scss @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +@import 'src/styles/mixins'; +@import 'src/styles/themes'; + +.stat-property-pair { + display: flex; + flex-flow: column wrap; + justify-content: flex-start; + flex-shrink: 0; + + .stat-property-pair-label { + text-transform: uppercase; + @include rem-font-size(12px); + @include rem-fallback(padding-bottom, 10px); + } + + @include themify($themes) { + .stat-property-pair-label { color: themed('colorHeaderText'); } + + .stat-property-pair-label-value { color: themed('colorContentText'); } + } +} diff --git a/src/components/shared/pageStats/statSection/statSection.scss b/src/components/shared/pageStats/statSection/statSection.scss index 98330091e..5e97746d4 100644 --- a/src/components/shared/pageStats/statSection/statSection.scss +++ b/src/components/shared/pageStats/statSection/statSection.scss @@ -6,5 +6,5 @@ display: flex; flex-flow: row wrap; flex-shrink: 0; - @include rem-fallback(margin, 30px, 0px); + } diff --git a/src/components/shared/pageTitle/pageTitle.scss b/src/components/shared/pageTitle/pageTitle.scss index faf4c595e..96f2393fa 100644 --- a/src/components/shared/pageTitle/pageTitle.scss +++ b/src/components/shared/pageTitle/pageTitle.scss @@ -1,10 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. +@import 'src/styles/variables'; @import 'src/styles/mixins'; +@import 'src/styles/themes'; .page-title { font-weight: 700; margin: 0; - margin-right: 10px; + @include rem-fallback(margin-right, 10px); @include rem-font-size(48px); + @include rem-fallback(padding-bottom, 30px); + + @include themify($themes) { + color: themed('colorHeaderText'); + } } diff --git a/src/components/shared/pcsGrid/pcsGrid.scss b/src/components/shared/pcsGrid/pcsGrid.scss index a855ef76e..01a33d23f 100644 --- a/src/components/shared/pcsGrid/pcsGrid.scss +++ b/src/components/shared/pcsGrid/pcsGrid.scss @@ -7,6 +7,9 @@ $selectedRowBorderWidth: 4px; $rowHeight: 48px; $headerResizeHeight: 18px; +$pageTitleHeight: 85px; +$refreshBarHeight: 16px; + $icons-path: "~ag-grid/src/styles/icons/"; $row-height: $rowHeight; $header-height: $rowHeight; @@ -27,7 +30,7 @@ $header-background-color: transparent; $doublePadding: $baseContentPadding * 2; .pcs-grid-container.ag-theme-dark { @include ag-theme-classic($params); - height: calc(100% - #{$doublePadding}); + height: calc(100% - #{$pageTitleHeight} - #{$refreshBarHeight}); position: relative; font-family: $fontSelawik; flex-shrink: 0; diff --git a/src/components/shared/refreshBar/refreshBar.scss b/src/components/shared/refreshBar/refreshBar.scss index 2a95db99f..00230ab33 100644 --- a/src/components/shared/refreshBar/refreshBar.scss +++ b/src/components/shared/refreshBar/refreshBar.scss @@ -11,7 +11,7 @@ $rotateTime: 2s; text-transform: uppercase; font-size: 0.9em; flex-shrink: 0; - @include rem-fallback(margin-top, 13px); + margin-top: 0; .btn.refresh-btn { @include rem-fallback(padding, 0px, 5px); diff --git a/src/components/shell/header/header.js b/src/components/shell/header/header.js index 187d5066f..c3d53cbc1 100644 --- a/src/components/shell/header/header.js +++ b/src/components/shell/header/header.js @@ -36,7 +36,7 @@ const docLinks = [ }, { translationId: 'header.sendSuggestion', - url: 'https://feedback.azure.com/forums/321918-azure-iot' + url: 'https://feedback.azure.com/forums/916438-azure-iot-solution-accelerators' } ]; diff --git a/src/services/authService.js b/src/services/authService.js index 06a2fad55..b40e867a0 100644 --- a/src/services/authService.js +++ b/src/services/authService.js @@ -128,22 +128,23 @@ export class AuthService { * Acquires token from the cache if it is not expired. * Otherwise sends request to AAD to obtain a new token. */ - static getAccessToken(callback) { + static getAccessToken() { if (AuthService.isDisabled()) { - if (callback) callback('client-auth-disabled'); - return; + return Observable.of('client-auth-disabled'); } - AuthService.authContext.acquireToken( - AuthService.appId, - function (error, accessToken) { - if (error || !accessToken) { - console.log(`Authentication Error: ${error}`); - AuthService.authContext.login(); - return; + return Observable.create(observer => { + return AuthService.authContext.acquireToken( + AuthService.appId, + (error, accessToken) => { + if (error) { + console.log(`Authentication Error: ${error}`); + observer.error(error); + } + else observer.next(accessToken); + observer.complete(); } - if (callback) callback(accessToken); - } - ); + ); + }); } } diff --git a/src/services/configService.js b/src/services/configService.js index 95e55a860..d0ee0d8c0 100644 --- a/src/services/configService.js +++ b/src/services/configService.js @@ -7,7 +7,10 @@ import { toDeviceGroupModel, toDeviceGroupsModel, toSolutionSettingActionsModel, - toSolutionSettingThemeModel + toSolutionSettingThemeModel, + toNewPackageRequestModel, + toPackagesModel, + toPackageModel } from './models'; import { Observable } from '../../node_modules/rxjs'; @@ -91,4 +94,28 @@ export class ConfigService { return HttpClient.get(`${ENDPOINT}solution-settings/actions`) .map(toSolutionSettingActionsModel); } + + /** Creates a new package */ + static createPackage(packageModel) { + var options = { + headers: { + 'Accept': undefined, + 'Content-Type': undefined + } + } + return HttpClient.post(`${ENDPOINT}packages`, toNewPackageRequestModel(packageModel), options) + .map(toPackageModel); + } + + /** Returns all the account's packages */ + static getPackages() { + return HttpClient.get(`${ENDPOINT}packages`) + .map(toPackagesModel); + } + + /** Delete a package */ + static deletePackage(id) { + return HttpClient.delete(`${ENDPOINT}packages/${id}`) + .map(_ => id); + } } diff --git a/src/services/iotHubManagerService.js b/src/services/iotHubManagerService.js index c9998dff0..49b855226 100644 --- a/src/services/iotHubManagerService.js +++ b/src/services/iotHubManagerService.js @@ -5,7 +5,17 @@ import { Observable } from 'rxjs'; import Config from 'app.config'; import { stringify } from 'query-string'; import { HttpClient } from 'utilities/httpClient'; -import { toDevicesModel, toDeviceModel, toJobsModel, toJobStatusModel, toDevicePropertiesModel } from './models'; +import { + toDevicesModel, + toDeviceModel, + toJobsModel, + toJobStatusModel, + toDevicePropertiesModel, + toDeploymentModel, + toDeploymentsModel, + toDeploymentRequestModel, + toEdgeAgentsModel +} from './models'; const ENDPOINT = Config.serviceUrls.iotHubManager; @@ -58,4 +68,46 @@ export class IoTHubManagerService { ) .map(([iotResponse, dsResponse]) => toDevicePropertiesModel(iotResponse, dsResponse)); } + + /** Returns deployments */ + static getDeployments() { + return HttpClient.get(`${ENDPOINT}deployments`) + .map(toDeploymentsModel); + } + + /** Returns deployment */ + static getDeployment(id) { + return HttpClient.get(`${ENDPOINT}deployments/${id}?includeDeviceStatus=true`) + .map(toDeploymentModel); + } + + /** Queries EdgeAgent */ + static getModulesByQuery(query) { + return HttpClient.post(`${ENDPOINT}modules/query`, query) + .map(toEdgeAgentsModel); + } + + /** Queries Devices */ + static getDevicesByQuery(query) { + return HttpClient.post(`${ENDPOINT}devices/query`, query) + .map(toDevicesModel); + } + + /** Create a deployment */ + static createDeployment(deploymentModel) { + return HttpClient.post(`${ENDPOINT}deployments`, toDeploymentRequestModel(deploymentModel)) + .map(toDeploymentModel); + } + + /** Delete a deployment */ + static deleteDeployment(id) { + return HttpClient.delete(`${ENDPOINT}deployments/${id}`) + .map(() => id); + } + + /** Returns deployments */ + static getDeploymentDetails(query) { + return HttpClient.post(`${ENDPOINT}modules`, query) + .map(toDeploymentsModel); + } } diff --git a/src/services/models/authModels.js b/src/services/models/authModels.js index ec39d9433..ff2bc9507 100644 --- a/src/services/models/authModels.js +++ b/src/services/models/authModels.js @@ -21,7 +21,13 @@ export const permissions = { createJobs: 'CreateJobs', - updateSIMManagement: 'UpdateSIMManagement' + updateSIMManagement: 'UpdateSIMManagement', + + deletePackages: 'DeletePackages', + createPackages: 'CreatePackages', + + createDeployments: 'CreateDeployments', + delteDeployments: 'DeleteDeployments' }; export const toUserModel = (user = {}) => camelCaseReshape(user, { diff --git a/src/services/models/configModels.js b/src/services/models/configModels.js index 2aed562cb..cd308f49a 100644 --- a/src/services/models/configModels.js +++ b/src/services/models/configModels.js @@ -70,3 +70,28 @@ export const toSolutionSettingActionModel = (action = {}) => { export const toSolutionSettingActionsModel = (response = {}) => getItems(response) .map(toSolutionSettingActionModel); + +export const packageTypeOptions = ['EdgeManifest']; + +export const toNewPackageRequestModel = ({ + type, + packageFile +}) => { + const data = new FormData(); + data.append('Type', type); + data.append('Package', packageFile); + return data; +} + +export const toPackagesModel = (response = {}) => getItems(response) + .map(toPackageModel); + +export const toPackageModel = (response = {}) => { + return camelCaseReshape(response, { + 'id': 'id', + 'type': 'type', + 'name': 'name', + 'dateCreated': 'dateCreated', + 'content': 'content' + }); +}; diff --git a/src/services/models/iotHubManagerModels.js b/src/services/models/iotHubManagerModels.js index bfcb6ead3..6e529e4d8 100644 --- a/src/services/models/iotHubManagerModels.js +++ b/src/services/models/iotHubManagerModels.js @@ -114,7 +114,7 @@ export const toSubmitMethodJobRequestModel = (devices, { jobName, methodName, fi Firmware: firmwareVersion, FirmwareUri: firmwareUri }) - : '{}'; + : ''; const request = { JobId: jobId, QueryCondition: `deviceId in [${deviceIds}]`, @@ -148,6 +148,7 @@ export const authenticationTypeOptions = { export const toNewDeviceRequestModel = ({ deviceId, isGenerateId, + isEdgeDevice, isSimulated, authenticationType, isGenerateKeys, @@ -158,6 +159,7 @@ export const toNewDeviceRequestModel = ({ return { Id: isGenerateId ? '' : deviceId, + isEdgeDevice: isEdgeDevice, IsSimulated: isSimulated, Enabled: true, Authentication: @@ -177,3 +179,50 @@ export const toDevicePropertiesModel = (iotResponse, dsResponse) => { const propertySet = new Set([...getItems(iotResponse), ...getItems(dsResponse)]); return [...propertySet]; }; + +export const toDeploymentModel = (deployment = {}) => { + const modelData = camelCaseReshape(deployment, { + 'id': 'id', + 'name': 'name', + 'deviceGroupId': 'deviceGroupId', + 'deviceGroupQuery': 'deviceGroupQuery', + 'deviceGroupName': 'deviceGroupName', + 'packageName': 'packageName', + 'priority': 'priority', + 'type': 'type', + 'createdDateTimeUtc': 'createdDateTimeUtc', + 'metrics.appliedCount': 'appliedCount', + 'metrics.failedCount': 'failedCount', + 'metrics.succeededCount': 'succeededCount', + 'metrics.targetedCount': 'targetedCount' + }); + return update(modelData, { + deviceStatuses: { $set: dot.pick('Metrics.DeviceStatuses', deployment) } + }); +} + +export const toDeploymentsModel = (response = {}) => getItems(response) + .map(toDeploymentModel); + +export const toDeploymentRequestModel = (deploymentModel = {}) => ({ + DeviceGroupId: deploymentModel.deviceGroupId, + DeviceGroupName: deploymentModel.deviceGroupName, + DeviceGroupQuery: deploymentModel.deviceGroupQuery, + Name: deploymentModel.name, + PackageId: deploymentModel.packageId, + PackageName: deploymentModel.packageName, + PackageContent: deploymentModel.packageContent, + Priority: deploymentModel.priority, + Type: deploymentModel.type +}); + +export const toEdgeAgentModel = (edgeAgent = {}) => camelCaseReshape(edgeAgent, { + 'deviceId': 'id', + 'reported.lastDesiredStatus.description': 'description', + 'reported.lastDesiredStatus.code': 'code', + 'reported.systemModules.edgeAgent.lastStartTimeUtc': 'start', + 'reported.systemModules.edgeAgent.lastExitTimeUtc': 'end' +}); + +export const toEdgeAgentsModel = (response = []) => getItems(response) + .map(toEdgeAgentModel); diff --git a/src/services/models/logEventModels.js b/src/services/models/logEventModels.js index 0bdb511e7..bb70fb1b2 100644 --- a/src/services/models/logEventModels.js +++ b/src/services/models/logEventModels.js @@ -3,11 +3,10 @@ import { toDiagnosticsModel } from 'services/models'; import Config from 'app.config'; -export const toRuleDiagnosticsModel = (eventName, rule) => -{ +export const toRuleDiagnosticsModel = (eventName, rule) => { const metadata = { DeviceGroup: rule.groupId, - Calculation : rule.calculation, + Calculation: rule.calculation, TimePeriod: rule.timePeriod, SeverityLevel: rule.severity, ConditionCount: rule.conditions.length, @@ -23,15 +22,14 @@ export const toSinglePropertyDiagnosticsModel = (eventName, propertyTitle, prope return toDiagnosticsModel(eventName, metadata); } -export const toDeviceDiagnosticsModel = (eventName, deviceFormData) => -{ +export const toDeviceDiagnosticsModel = (eventName, deviceFormData) => { const metadata = { DeviceIDType: deviceFormData.isSimulated ? '' : (deviceFormData.isGenerateId ? 'Generated' : 'Manual'), DeviceType: deviceFormData.isSimulated ? Config.deviceType.simulated : Config.deviceType.physical, NumberOfDevices: deviceFormData.count, DeviceModel: deviceFormData.isSimulated ? deviceFormData.deviceModel : '', AuthType: deviceFormData.isSimulated ? '' : (deviceFormData.authenticationType ? 'x.509' : 'Symmetric Key'), - AuthKey: deviceFormData.isSimulated ? '' : (deviceFormData.isGenerateKeys ? 'Auto': 'Manual') + AuthKey: deviceFormData.isSimulated ? '' : (deviceFormData.isGenerateKeys ? 'Auto' : 'Manual') } return toDiagnosticsModel(eventName, metadata); } diff --git a/src/store/reducers/deploymentsReducer.js b/src/store/reducers/deploymentsReducer.js new file mode 100644 index 000000000..4a660e59e --- /dev/null +++ b/src/store/reducers/deploymentsReducer.js @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft. All rights reserved. + +import 'rxjs'; +import { Observable } from 'rxjs'; +import moment from 'moment'; +import { schema, normalize } from 'normalizr'; +import update from 'immutability-helper'; +import dot from 'dot-object'; +import { createSelector } from 'reselect'; +import { IoTHubManagerService } from 'services'; +import { getActiveDeviceGroupId, getActiveDeviceGroupConditions } from './appReducer'; +import { + createReducerScenario, + createEpicScenario, + errorPendingInitialState, + pendingReducer, + errorReducer, + setPending, + resetPendingAndErrorReducer, + getPending, + getError, + toActionCreator +} from 'store/utilities'; + +// ========================= Epics - START +const handleError = fromAction => error => + Observable.of(redux.actions.registerError(fromAction.type, { error, fromAction })); + +const getDeployedDeviceIds = (payload) => { + return Object.keys(dot.pick('deviceStatuses', payload)) + .map(id => `'${id}'`) + .join(); +} +const createEdgeAgentQuery = (ids) => `"deviceId IN [${ids}] AND moduleId = '$edgeAgent'"`; +const createDevicesQuery = (ids) => `"deviceId IN [${ids}]"`; + +export const epics = createEpicScenario({ + /** Loads all Deployments */ + fetchDeployments: { + type: 'DEPLOYMENTS_FETCH', + epic: fromAction => + IoTHubManagerService.getDeployments() + .map(toActionCreator(redux.actions.updateDeployments, fromAction)) + .catch(handleError(fromAction)) + }, + /** Loads a single Deployment */ + fetchDeployment: { + type: 'DEPLOYMENT_DETAILS_FETCH', + epic: fromAction => IoTHubManagerService.getDeployment(fromAction.payload) + .flatMap(response => [ + toActionCreator(redux.actions.updateDeployment, fromAction)(response), + epics.actions.fetchDeployedDevices(response) + ]) + .catch(handleError(fromAction)) + }, + /** Loads the queried edgeAgents and devices */ + fetchDeployedDevices: { + type: 'DEPLOYED_DEVICES_FETCH', + epic: fromAction => Observable + .forkJoin( + IoTHubManagerService.getModulesByQuery(createEdgeAgentQuery(getDeployedDeviceIds(fromAction.payload))), + IoTHubManagerService.getDevicesByQuery(createDevicesQuery(getDeployedDeviceIds(fromAction.payload))), + ) + .map(toActionCreator(redux.actions.updateDeployedDevices, fromAction)) + .catch(handleError(fromAction)) + }, + /** Create a new deployment */ + createDeployment: { + type: 'DEPLOYMENTS_CREATE', + epic: fromAction => + IoTHubManagerService.createDeployment(fromAction.payload) + .map(toActionCreator(redux.actions.insertDeployment, fromAction)) + .catch(handleError(fromAction)) + }, + /** Delete deployment */ + deleteDeployment: { + type: 'DEPLOYMENTS_DELETE', + epic: fromAction => + IoTHubManagerService.deleteDeployment(fromAction.payload) + .map(toActionCreator(redux.actions.deleteDeployment, fromAction)) + .catch(handleError(fromAction)) + } +}); +// ========================= Epics - END + +// ========================= Schemas - START +const deploymentSchema = new schema.Entity('deployments'); +const deploymentListSchema = new schema.Array(deploymentSchema); +const deployedDevicesSchema = new schema.Entity('deployedDevices'); +const deployedDevicesListSchema = new schema.Array(deployedDevicesSchema); +// ========================= Schemas - END + +// ========================= Reducers - START +const initialState = { ...errorPendingInitialState, entities: {} }; + +const insertDeploymentReducer = (state, { payload, fromAction }) => { + const { entities: { deployments }, result } = normalize({ ...payload, isNew: true }, deploymentSchema); + if (state.entities && state.entities.deployments) { + return update(state, { + entities: { deployments: { $merge: deployments } }, + items: { $splice: [[0, 0, result]] }, + ...setPending(fromAction.type, false) + }); + } + return update(state, { + entities: { deployments: { $set: deployments } }, + items: { $set: [result] }, + ...setPending(fromAction.type, false) + }); +}; + +const deleteDeploymentReducer = (state, { fromAction }) => { + const idx = state.items.indexOf(fromAction.payload); + return update(state, { + entities: { deployments: { $unset: [fromAction.payload] } }, + items: { $splice: [[idx, 1]] }, + ...setPending(fromAction.type, false) + }); +}; + +const updateDeploymentsReducer = (state, { payload, fromAction }) => { + const { entities: { deployments }, result } = normalize(payload, deploymentListSchema); + return update(state, { + entities: { deployments: { $set: deployments } }, + items: { $set: result }, + lastUpdated: { $set: moment() }, + ...setPending(fromAction.type, false) + }); +}; + +const updateDeploymentReducer = (state, { payload, fromAction }) => { + return update(state, { + currentDeployment: { $set: payload }, + currentDeploymentLastUpdated: { $set: moment() }, + ...setPending(fromAction.type, false) + }); +}; + +const updateDeployedDevicesReducer = (state, { payload: [modules, devices], fromAction }) => { + const normalizedDevices = normalize(devices, deployedDevicesListSchema).entities.deployedDevices || {}; + const normalizedModules = normalize(modules, deployedDevicesListSchema).entities.deployedDevices || {}; + const deployedDevices = Object.keys(normalizedDevices) + .reduce( + (acc, deviceId) => ({ + ...acc, + [deviceId]: { + ...(acc[deviceId] || {}), + firmware: normalizedDevices[deviceId].firmware, + device: normalizedDevices[deviceId] + } + }), + normalizedModules + ); + return update(state, { + entities: { + deployedDevices: { $set: deployedDevices } + }, + ...setPending(fromAction.type, false) + }); +}; + +const resetDeployedDevicesReducer = (state) => update(state, { + entities: { + $unset: ['deployedDevices'] + } +}); + + +/* Action types that cause a pending flag */ +const fetchableTypes = [ + epics.actionTypes.fetchDeployment, + epics.actionTypes.fetchDeployments, + epics.actionTypes.createDeployment, + epics.actionTypes.deleteDeployment, + epics.actionTypes.fetchDeployedDevices +]; + +export const redux = createReducerScenario({ + insertDeployment: { type: 'DEPLOYMENT_INSERT', reducer: insertDeploymentReducer }, + deleteDeployment: { type: 'DEPLOYMENTS_DELETE', reducer: deleteDeploymentReducer }, + updateDeployments: { type: 'DEPLOYMENTS_UPDATE', reducer: updateDeploymentsReducer }, + updateDeployment: { type: 'DEPLOYMENTS_DETAILS_UPDATE', reducer: updateDeploymentReducer }, + updateDeployedDevices: { type: 'DEPLOYED_DEVICES_UPDATE', reducer: updateDeployedDevicesReducer }, + registerError: { type: 'DEPLOYMENTS_REDUCER_ERROR', reducer: errorReducer }, + resetDeployedDevices: { type: 'DEPLOYMETS_RESET_DEPLOYED_DEVICES', reducer: resetDeployedDevicesReducer }, + resetPendingAndError: { type: 'DEPLOYMENTS_REDUCER_RESET_ERROR_PENDING', reducer: resetPendingAndErrorReducer }, + isFetching: { multiType: fetchableTypes, reducer: pendingReducer } +}); + +export const reducer = { deployments: redux.getReducer(initialState) }; +// ========================= Reducers - END + +// ========================= Selectors - START +export const getDeploymentsReducer = state => state.deployments; +export const getEntities = state => getDeploymentsReducer(state).entities || {}; +export const getDeploymentsEntities = state => getEntities(state).deployments || {}; +export const getItems = state => getDeploymentsReducer(state).items || []; +export const getDeploymentsLastUpdated = state => getDeploymentsReducer(state).lastUpdated; +export const getDeploymentsError = state => + getError(getDeploymentsReducer(state), epics.actionTypes.fetchDeployments); +export const getDeploymentsPendingStatus = state => + getPending(getDeploymentsReducer(state), epics.actionTypes.fetchDeployments); +export const getCreateDeploymentError = state => + getError(getDeploymentsReducer(state), epics.actionTypes.createDeployment); +export const getCreateDeploymentPendingStatus = state => + getPending(getDeploymentsReducer(state), epics.actionTypes.createDeployment); +export const getDeleteDeploymentError = state => + getError(getDeploymentsReducer(state), epics.actionTypes.deleteDeployment); +export const getDeleteDeploymentPendingStatus = state => + getPending(getDeploymentsReducer(state), epics.actionTypes.deleteDeployment); +export const getDeployments = createSelector( + getDeploymentsEntities, getItems, getActiveDeviceGroupId, getActiveDeviceGroupConditions, + (deployments, items, deviceGroupId, deviceGroupConditions = []) => + items.reduce((acc, id) => { + const deployment = deployments[id]; + const activeDeviceGroup = deviceGroupConditions.length > 0 ? deviceGroupId : false; + return ((deployment && deployment.deviceGroupId && deployment.deviceGroupId === activeDeviceGroup) || !activeDeviceGroup) + ? [...acc, deployment] + : acc + }, []) +); +export const getCurrentDeploymentDetails = state => getDeploymentsReducer(state).currentDeployment || {}; +export const getCurrentDeploymentLastUpdated = state => getDeploymentsReducer(state).currentDeploymentLastUpdated; +export const getDeviceStatuses = state => getCurrentDeploymentDetails(state).deviceStatuses || {}; +export const getCurrentDeploymentDetailsPendingStatus = state => + getPending(getDeploymentsReducer(state), epics.actionTypes.fetchDeployment); +export const getCurrentDeploymentDetailsError = state => + getError(getDeploymentsReducer(state), epics.actionTypes.fetchDeployment); +export const getDeployedDevicesEntities = state => getEntities(state).deployedDevices || {}; +export const getDeployedDevicesPendingStatus = state => + getPending(getDeploymentsReducer(state), epics.actionTypes.fetchDeployedDevices); +export const getDeployedDevicesError = state => + getError(getDeploymentsReducer(state), epics.actionTypes.fetchDeployedDevices); +export const getDeployedDevices = createSelector( + getDeployedDevicesEntities, getDeviceStatuses, + (DeployedDevicesEntities, deviceStatuses) => + Object.values(Object.keys(deviceStatuses).reduce((acc, deviceId) => ({ + ...acc, + [deviceId]: { + ...(acc[deviceId] || {}), + deploymentStatus: deviceStatuses[deviceId] + } + }), DeployedDevicesEntities)) +); +export const getLastItemId = state => getItems(state).length > 0 ? getItems(state)[0] : ''; +// ========================= Selectors - END diff --git a/src/store/reducers/devicesReducer.js b/src/store/reducers/devicesReducer.js index e8ce1b8f3..d4081f6fd 100644 --- a/src/store/reducers/devicesReducer.js +++ b/src/store/reducers/devicesReducer.js @@ -11,6 +11,7 @@ import { IoTHubManagerService } from 'services'; import { createReducerScenario, createEpicScenario, + resetPendingAndErrorReducer, errorPendingInitialState, pendingReducer, errorReducer, @@ -36,6 +37,27 @@ export const epics = createEpicScenario({ } }, + /** Loads the devices by condition provided in payload*/ + fetchDevicesByCondition: { + type: 'DEVICES_FETCH_BY_CONDITION', + epic: fromAction => { + return IoTHubManagerService.getDevices(fromAction.payload) + .map(toActionCreator(redux.actions.updateDevices, fromAction)) + .catch(handleError(fromAction)) + } + }, + + /** Loads EdgeAgent json from device modules */ + fetchEdgeAgent: { + type: 'DEVICES_FETCH_EDGE_AGENT', + epic: fromAction => IoTHubManagerService + .getModulesByQuery(`"deviceId IN ['${fromAction.payload}'] AND moduleId = '$edgeAgent'"`) + .map(([edgeAgent]) => edgeAgent) + .map(toActionCreator(redux.actions.updateModuleStatus, fromAction)) + .catch(handleError(fromAction)) + + }, + /* Update the devices if the selected device group changes */ refreshDevices: { type: 'DEVICES_REFRESH', @@ -81,16 +103,17 @@ const deleteDevicesReducer = (state, { payload }) => { }; const insertDevicesReducer = (state, { payload }) => { - const { entities: { devices }, result } = normalize(payload, deviceListSchema); + const inserted = payload.map(device => ({ ...device, isNew: true })); + const { entities: { devices }, result } = normalize(inserted, deviceListSchema); if (state.entities) { return update(state, { entities: { $merge: devices }, - items: { $splice: [[0, 0, result]] } + items: { $splice: [[0, 0, ...result]] } }); } return update(state, { entities: { $set: devices }, - items: { $set: [result] } + items: { $set: result } }); }; @@ -112,6 +135,17 @@ const updateTagsReducer = (state, { payload }) => { }); }; +const updateModuleStatusReducer = (state, { payload, fromAction }) => { + const updateAction = payload + ? { deviceModuleStatus: { $set: payload } } + : { $unset: ['deviceModuleStatus'] }; + + return update(state, { + ...updateAction, + ...setPending(fromAction.type, false) + }); +}; + const updatePropertiesReducer = (state, { payload }) => { const updatedPropertyData = {}; payload.updatedProperties.forEach(({ name, value }) => (updatedPropertyData[name] = value)); @@ -132,7 +166,9 @@ const updatePropertiesReducer = (state, { payload }) => { /* Action types that cause a pending flag */ const fetchableTypes = [ - epics.actionTypes.fetchDevices + epics.actionTypes.fetchDevices, + epics.actionTypes.fetchDevicesByCondition, + epics.actionTypes.fetchEdgeAgent ]; export const redux = createReducerScenario({ @@ -143,6 +179,8 @@ export const redux = createReducerScenario({ insertDevices: { type: 'DEVICE_INSERT', reducer: insertDevicesReducer }, updateTags: { type: 'DEVICE_UPDATE_TAGS', reducer: updateTagsReducer }, updateProperties: { type: 'DEVICE_UPDATE_PROPERTIES', reducer: updatePropertiesReducer }, + updateModuleStatus: { type: 'DEVICE_MODULE_STATUS', reducer: updateModuleStatusReducer }, + resetPendingAndError: { type: 'DEVICE_REDUCER_RESET_ERROR_PENDING', reducer: resetPendingAndErrorReducer } }); export const reducer = { devices: redux.getReducer(initialState) }; @@ -157,8 +195,25 @@ export const getDevicesError = state => getError(getDevicesReducer(state), epics.actionTypes.fetchDevices); export const getDevicesPendingStatus = state => getPending(getDevicesReducer(state), epics.actionTypes.fetchDevices); +export const getDevicesByConditionError = state => + getError(getDevicesReducer(state), epics.actionTypes.fetchDevicesByCondition); +export const getDevicesByConditionPendingStatus = state => + getPending(getDevicesReducer(state), epics.actionTypes.fetchDevicesByCondition); export const getDevices = createSelector( getEntities, getItems, (entities, items) => items.map(id => entities[id]) ); +export const getDeviceModuleStatus = state => { + const deviceModuleStatus = getDevicesReducer(state).deviceModuleStatus + return deviceModuleStatus + ? { + code: deviceModuleStatus.code, + description: deviceModuleStatus.description + } + : undefined +}; +export const getDeviceModuleStatusPendingStatus = state => + getPending(getDevicesReducer(state), epics.actionTypes.fetchEdgeAgent); +export const getDeviceModuleStatusError = state => + getError(getDevicesReducer(state), epics.actionTypes.fetchEdgeAgent); // ========================= Selectors - END diff --git a/src/store/reducers/packagesReducer.js b/src/store/reducers/packagesReducer.js new file mode 100644 index 000000000..729834b73 --- /dev/null +++ b/src/store/reducers/packagesReducer.js @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft. All rights reserved. + +import 'rxjs'; +import { Observable } from 'rxjs'; +import moment from 'moment'; +import { schema, normalize } from 'normalizr'; +import update from 'immutability-helper'; +import { createSelector } from 'reselect'; +import { ConfigService } from 'services'; +import { + createReducerScenario, + createEpicScenario, + errorPendingInitialState, + resetPendingAndErrorReducer, + pendingReducer, + errorReducer, + setPending, + getPending, + getError, + toActionCreator +} from 'store/utilities'; + +// ========================= Epics - START +const handleError = fromAction => error => + Observable.of(redux.actions.registerError(fromAction.type, { error, fromAction })); + +export const epics = createEpicScenario({ + /** Loads Packages*/ + fetchPackages: { + type: 'PACKAGES_FETCH', + epic: fromAction => + ConfigService.getPackages() + .map(toActionCreator(redux.actions.updatePackages, fromAction)) + .catch(handleError(fromAction)) + }, + /** Create a new package */ + createPackage: { + type: 'PACKAGES_CREATE', + epic: fromAction => + ConfigService.createPackage(fromAction.payload) + .map(toActionCreator(redux.actions.insertPackage, fromAction)) + .catch(handleError(fromAction)) + }, + /** Delete package */ + deletePackage: { + type: 'PACKAGES_DELETE', + epic: fromAction => + ConfigService.deletePackage(fromAction.payload) + .map(toActionCreator(redux.actions.deletePackage, fromAction)) + .catch(handleError(fromAction)) + } +}); +// ========================= Epics - END + +// ========================= Schemas - START +const packageSchema = new schema.Entity('packages'); +const packageListSchema = new schema.Array(packageSchema); +// ========================= Schemas - END + +// ========================= Reducers - START +const initialState = { ...errorPendingInitialState, entities: {} }; + +const insertPackageReducer = (state, { payload, fromAction }) => { + const { entities: { packages }, result } = normalize({...payload, isNew: true}, packageSchema); + + if (state.entities) { + return update(state, { + entities: { $merge: packages }, + items: { $splice: [[state.items.length, 0, result]] }, + ...setPending(fromAction.type, false) + }); + } + return update(state, { + entities: { $set: packages }, + items: { $set: [result] }, + ...setPending(fromAction.type, false) + }); +}; + +const deletePackageReducer = (state, { payload, fromAction }) => { + const idx = state.items.indexOf(payload); + return update(state, { + entities: { $unset: [payload] }, + items: { $splice: [[idx, 1]] }, + ...setPending(fromAction.type, false) + }); +}; + +const updatePackagesReducer = (state, { payload, fromAction }) => { + const { entities: { packages }, result } = normalize(payload, packageListSchema); + return update(state, { + entities: { $set: packages }, + items: { $set: result }, + lastUpdated: { $set: moment() }, + ...setPending(fromAction.type, false) + }); +}; + +/* Action types that cause a pending flag */ +const fetchableTypes = [ + epics.actionTypes.fetchPackages, + epics.actionTypes.createPackage, + epics.actionTypes.deletePackage +]; + +export const redux = createReducerScenario({ + insertPackage: { type: 'PACKAGE_INSERT', reducer: insertPackageReducer }, + deletePackage: { type: 'PACKAGES_DELETE', reducer: deletePackageReducer }, + updatePackages: { type: 'PACKAGES_UPDATE', reducer: updatePackagesReducer }, + registerError: { type: 'PACKAGES_REDUCER_ERROR', reducer: errorReducer }, + resetPendingAndError: { type: 'PACKAGES_REDUCER_RESET_ERROR_PENDING', reducer: resetPendingAndErrorReducer }, + isFetching: { multiType: fetchableTypes, reducer: pendingReducer } +}); + +export const reducer = { packages: redux.getReducer(initialState) }; +// ========================= Reducers - END + +// ========================= Selectors - START +export const getPackagesReducer = state => state.packages; +export const getEntities = state => getPackagesReducer(state).entities || {}; +export const getItems = state => getPackagesReducer(state).items || []; +export const getPackagesLastUpdated = state => getPackagesReducer(state).lastUpdated; +export const getPackagesError = state => + getError(getPackagesReducer(state), epics.actionTypes.fetchPackages); +export const getPackagesPendingStatus = state => + getPending(getPackagesReducer(state), epics.actionTypes.fetchPackages); +export const getCreatePackageError = state => + getError(getPackagesReducer(state), epics.actionTypes.createPackage); +export const getCreatePackagePendingStatus = state => + getPending(getPackagesReducer(state), epics.actionTypes.createPackage); +export const getDeletePackageError = state => + getError(getPackagesReducer(state), epics.actionTypes.deletePackage); +export const getDeletePackagePendingStatus = state => + getPending(getPackagesReducer(state), epics.actionTypes.deletePackage); +export const getPackages = createSelector( + getEntities, getItems, + (entities, items) => items.map(id => entities[id]) +); +// ========================= Selectors - END diff --git a/src/store/reducers/rulesReducer.js b/src/store/reducers/rulesReducer.js index 000dbd41e..057c0fb5b 100644 --- a/src/store/reducers/rulesReducer.js +++ b/src/store/reducers/rulesReducer.js @@ -34,7 +34,7 @@ export const epics = createEpicScenario({ fetchRules: { type: 'RULES_FETCH', epic: fromAction => - TelemetryService.getRules({includeDeleted: true}) + TelemetryService.getRules({ includeDeleted: true }) .flatMap(rules => Observable.from(rules) .flatMap(({ id, groupId }) => [ @@ -93,7 +93,8 @@ const ruleListSchema = new schema.Array(ruleSchema); const initialState = { ...errorPendingInitialState, entities: {}, items: [] }; const insertRulesReducer = (state, { payload }) => { - const { entities: { rules = {} }, result } = normalize(payload, ruleListSchema); + const inserted = payload.map(rule => ({ ...rule, isNew: true })); + const { entities: { rules }, result } = normalize(inserted, ruleListSchema); if (state.entities) { return update(state, { entities: { $merge: rules }, diff --git a/src/store/rootEpic.js b/src/store/rootEpic.js index 8a14b196a..cf0ecd58f 100644 --- a/src/store/rootEpic.js +++ b/src/store/rootEpic.js @@ -5,13 +5,17 @@ import { combineEpics } from 'redux-observable'; // Epics import { epics as appEpics } from './reducers/appReducer'; import { epics as devicesEpics } from './reducers/devicesReducer'; +import { epics as deploymentsEpics } from './reducers/deploymentsReducer'; import { epics as rulesEpics } from './reducers/rulesReducer'; +import { epics as packagesEpics } from './reducers/packagesReducer'; import { epics as simulationEpics } from './reducers/deviceSimulationReducer'; // Extract the epic function from each property object const epics = [ ...appEpics.getEpics(), + ...deploymentsEpics.getEpics(), ...devicesEpics.getEpics(), + ...packagesEpics.getEpics(), ...rulesEpics.getEpics(), ...simulationEpics.getEpics() ]; diff --git a/src/store/rootReducer.js b/src/store/rootReducer.js index ce0f6c560..083b7ec2b 100644 --- a/src/store/rootReducer.js +++ b/src/store/rootReducer.js @@ -4,13 +4,17 @@ import { combineReducers } from 'redux'; // Reducers import { reducer as appReducer } from './reducers/appReducer'; -import { reducer as simulationReducer } from './reducers/deviceSimulationReducer'; +import { reducer as deploymentsReducer } from './reducers/deploymentsReducer'; import { reducer as devicesReducer } from './reducers/devicesReducer'; +import { reducer as packagesReducer } from './reducers/packagesReducer'; import { reducer as rulesReducer } from './reducers/rulesReducer'; +import { reducer as simulationReducer } from './reducers/deviceSimulationReducer'; const rootReducer = combineReducers({ ...appReducer, + ...deploymentsReducer, ...devicesReducer, + ...packagesReducer, ...rulesReducer, ...simulationReducer }); diff --git a/src/store/utilities.js b/src/store/utilities.js index c301092b4..a66e84794 100644 --- a/src/store/utilities.js +++ b/src/store/utilities.js @@ -175,6 +175,11 @@ export const setError = (type, error) => ({ errors: { [type]: { $set: error } } }); +export const resetPendingAndErrorReducer = (state, { type }) => update(state, { + ...setPending(type, false), + ...setError(type) +}); + export const pendingReducer = (state, { type }) => update(state, { ...setPending(type, true), ...setError(type) diff --git a/src/styles/_themes.scss b/src/styles/_themes.scss index 035a7b567..7c1c7bde5 100644 --- a/src/styles/_themes.scss +++ b/src/styles/_themes.scss @@ -8,6 +8,7 @@ $themes: ( dark: ( // Functional colors colorAlert: $colorAlert, + colorFailed: $colorFailed, colorWarning: $colorWarning, colorSystem: $colorSystem, colorError: $colorError, @@ -33,6 +34,8 @@ $themes: ( colorCellRendererText: $colorSmoke, colorCellRendererTextHighlight: $colorWhite, + colorGlimmerSvgFill: #f4f4f4, + gridSortIcon: '~assets/icons/sort_dark.svg', gridAscIcon: '~assets/icons/sort_a2z_dark.svg', gridDescIcon: '~assets/icons/sort_z2a_dark.svg', @@ -181,6 +184,13 @@ $themes: ( colorTooltipText: #333, colorTooltipBackground: #f2f2f2, // Tooltip - END + + // Modal - START + colorModalText: $colorWhite, + colorModalBackground: $colorNoir, + colorModalBorder: #60AAFF, + colorModalDropShadow: rgba(0, 0, 0, 0.6), + // Modal - END ), light: ( // Functional colors @@ -210,6 +220,8 @@ $themes: ( colorCellRendererText: #333, colorCellRendererTextHighlight: #333, + colorGlimmerSvgFill: #212121, + gridSortIcon: '~assets/icons/sort_light.svg', gridAscIcon: '~assets/icons/sort_a2z_light.svg', gridDescIcon: '~assets/icons/sort_z2a_light.svg', @@ -358,5 +370,12 @@ $themes: ( colorTooltipText: $colorWhite, colorTooltipBackground: $colorNoir, // Tooltip - END + + // Modal - START + colorModalText: #333, + colorModalBackground: $colorWhite, + colorModalBorder: #136BFB, + colorModalDropShadow: rgba(0, 0, 0, 0.1), + // Modal - END ) ); diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 7296f940e..29180dc09 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -38,6 +38,7 @@ $colorLightLinkDisabled: #A6A6A6; // Function color variables $colorAlert: #fc540a; +$colorFailed: #FF2626; $colorWarning: #ffee91; $colorSystem: #7065fd; $colorError: #EA692D; diff --git a/src/utilities/httpClient.js b/src/utilities/httpClient.js index 595229347..f9d2f0207 100644 --- a/src/utilities/httpClient.js +++ b/src/utilities/httpClient.js @@ -65,8 +65,8 @@ export class HttpClient { */ static ajax(url, options = {}, withAuth = true) { const { retryWaitTime, maxRetryAttempts } = Config; - const request = HttpClient.createAjaxRequest({ ...options, url }, withAuth); - return Observable.ajax(request) + return HttpClient.createAjaxRequest({ ...options, url }, withAuth) + .flatMap(Observable.ajax) // If success, extract the response object and enforce camelCase keys if json response .map(ajaxResponse => ajaxResponse.responseType === 'json' @@ -78,37 +78,27 @@ export class HttpClient { // Retry any retryable errors .retryWhen(retryHandler(maxRetryAttempts, retryWaitTime)); } - /** - * A helper method that adds "application/json" and auth headers if necessary - */ - static withHeaders(request, withAuth) { - const authHeaders = {}; - if (withAuth) { - authHeaders['Csrf-Token'] = 'nocheck'; - AuthService.getAccessToken(accessToken => { - if (accessToken) authHeaders['Authorization'] = `Bearer ${accessToken}`; - }); - } - return { - ...request, - headers: { - ...jsonHeaders, - ...request.headers, - ...authHeaders - } - }; - } /** * A helper method for constructing ajax request objects */ static createAjaxRequest(options, withAuth) { - return { - ...HttpClient.withHeaders(options, withAuth), - timeout: options.timeout || Config.defaultAjaxTimeout - }; + return (withAuth ? AuthService.getAccessToken() : Observable.of(null)) + .map(token => ({ // Create the final headers options + ...jsonHeaders, + ...(options.headers || {}), + ...(token ? authenticationHeaders(token) : {}) + })) + .map(({ 'Content-Type': contentType, ...headers }) => { + if (contentType) return { ...headers, 'Content-Type': contentType }; + return headers; + }) + .map(headers => ({ + ...options, + headers, + timeout: options.timeout || Config.defaultAjaxTimeout + })); } - } // HttpClient helper methods @@ -140,3 +130,9 @@ const jsonHeaders = { 'Accept': 'application/json', 'Content-Type': 'application/json' }; + +/** Headers for an authenticated request */ +const authenticationHeaders = (token) => ({ + 'Csrf-Token': 'nocheck', + 'Authorization': `Bearer ${token}` +}); diff --git a/src/utilities/methods.js b/src/utilities/methods.js index 4f54e5164..0461c225e 100644 --- a/src/utilities/methods.js +++ b/src/utilities/methods.js @@ -104,6 +104,27 @@ export const getStatusCode = (code, t) => { } } +/** Converts a deployment status code to a translated string equivalent */ +export const getEdgeAgentStatusCode = (code, t) => { + switch (code) { + case 200: return t('edgeAgentStatus.200'); + case 400: return t('edgeAgentStatus.400'); + case 406: return t('edgeAgentStatus.406'); + case 412: return t('edgeAgentStatus.412'); + case 417: return t('edgeAgentStatus.417'); + case 500: return t('edgeAgentStatus.500'); + default: return t('edgeAgentStatus.unknown'); + } +} + +/** Converts a packageType enum to a translated string equivalent */ +export const getPackageTypeTranslation = (packageType, t) => { + switch (packageType.toLowerCase()) { + case 'edgemanifest': return t('deployments.typeOptions.edgemanifest'); + default: return t('deployments.typeOptions.unknown'); + } +} + /* A helper method to copy text to the clipbaord */ export const copyToClipboard = (data) => { const textField = document.createElement('textarea'); diff --git a/src/utilities/svgs.js b/src/utilities/svgs.js index 1356410dd..f0678d065 100644 --- a/src/utilities/svgs.js +++ b/src/utilities/svgs.js @@ -25,6 +25,8 @@ import EditIconPath from 'assets/icons/edit.svg'; import EllipsisIconPath from 'assets/icons/ellipsis.svg'; import EnableToggleIconPath from 'assets/icons/enableToggle.svg'; import ErrorIconPath from 'assets/icons/errorAsterisk.svg'; +import FailedIconPath from 'assets/icons/failed.svg'; +import GlimmerIconPath from 'assets/icons/glimmer.svg'; import HamburgerIconPath from 'assets/icons/hamburger.svg'; import InfoBubbleIconPath from 'assets/icons/infoBubble.svg'; import InfoIconPath from 'assets/icons/info.svg'; @@ -32,6 +34,7 @@ import LinkToPath from 'assets/icons/linkTo.svg'; import LoadingToggleIconPath from 'assets/icons/loadingToggle.svg'; import ManageFiltersIconPath from 'assets/icons/manageFilters.svg'; import OkBubbleIconPath from 'assets/icons/okBubble.svg'; +import TabPackagesIconPath from 'assets/icons/packages.svg'; import PhysicalDeviceIconPath from 'assets/icons/physicalDevice.svg'; import PlusIconPath from 'assets/icons/plus.svg'; import QuestionBubbleIconPath from 'assets/icons/questionBubble.svg'; @@ -48,6 +51,7 @@ import SettingsIconPath from 'assets/icons/settings.svg'; import SimulatedDeviceIconPath from 'assets/icons/simulatedDevice.svg'; import TabDashboardIconPath from 'assets/icons/tabDashboard.svg'; import TabDevicesIconPath from 'assets/icons/tabDevices.svg'; +import TabDeploymentsIconPath from 'assets/icons/tabDeployments.svg'; import TabMaintenanceIconPath from 'assets/icons/tabMaintenance.svg'; import TabRulesIconPath from 'assets/icons/tabRules.svg'; import TrashIconPath from 'assets/icons/trash.svg'; @@ -60,7 +64,9 @@ export const svgs = { tabs: { dashboard: TabDashboardIconPath, devices: TabDevicesIconPath, + deployments: TabDeploymentsIconPath, maintenance: TabMaintenanceIconPath, + packages: TabPackagesIconPath, rules: TabRulesIconPath, example: InfoBubbleIconPath }, @@ -91,6 +97,8 @@ export const svgs = { ellipsis: EllipsisIconPath, enableToggle: EnableToggleIconPath, error: ErrorIconPath, + failed: FailedIconPath, + glimmer: GlimmerIconPath, hamburger: HamburgerIconPath, info: InfoIconPath, infoBubble: InfoBubbleIconPath, diff --git a/src/walkthrough/components/pages/basicPage/basicPage.container.js b/src/walkthrough/components/pages/basicPage/basicPage.container.js index 92ace17b0..98378bb7a 100644 --- a/src/walkthrough/components/pages/basicPage/basicPage.container.js +++ b/src/walkthrough/components/pages/basicPage/basicPage.container.js @@ -1,7 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +// import { translate } from 'react-i18next'; import { BasicPage } from './basicPage'; export const BasicPageContainer = translate()(BasicPage); + +// diff --git a/src/walkthrough/components/pages/basicPage/basicPage.js b/src/walkthrough/components/pages/basicPage/basicPage.js index 618eaa534..63ac5e531 100644 --- a/src/walkthrough/components/pages/basicPage/basicPage.js +++ b/src/walkthrough/components/pages/basicPage/basicPage.js @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +// import React, { Component } from 'react'; import { PageContent } from 'components/shared'; @@ -16,3 +17,5 @@ export class BasicPage extends Component { ); } } + +// diff --git a/src/walkthrough/components/pages/basicPage/basicPage.scss b/src/walkthrough/components/pages/basicPage/basicPage.scss index 3e87ddc7a..d09551b71 100644 --- a/src/walkthrough/components/pages/basicPage/basicPage.scss +++ b/src/walkthrough/components/pages/basicPage/basicPage.scss @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +// @import 'src/styles/variables'; @import 'src/styles/mixins'; @import 'src/styles/themes'; .basic-page-container { padding: $baseContentPadding; } +// diff --git a/src/walkthrough/components/pages/basicPage/basicPage.test.js b/src/walkthrough/components/pages/basicPage/basicPage.test.js index 56c418727..2d46379de 100644 --- a/src/walkthrough/components/pages/basicPage/basicPage.test.js +++ b/src/walkthrough/components/pages/basicPage/basicPage.test.js @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +// import React from 'react'; import { shallow } from 'enzyme'; import 'polyfills'; @@ -18,3 +19,5 @@ describe('BasicPage Component', () => { ); }); }); + +// diff --git a/src/walkthrough/components/pages/dashboard/panels/examplePanel/examplePanel.js b/src/walkthrough/components/pages/dashboard/panels/examplePanel/examplePanel.js index f61f3661e..b2a6ba4a6 100644 --- a/src/walkthrough/components/pages/dashboard/panels/examplePanel/examplePanel.js +++ b/src/walkthrough/components/pages/dashboard/panels/examplePanel/examplePanel.js @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +// import React, { Component } from 'react'; import { @@ -33,3 +34,4 @@ export class ExamplePanel extends Component { ); } } +// diff --git a/src/walkthrough/components/pages/pageWithFlyout/flyouts/exampleFlyout/exampleFlyout.container.js b/src/walkthrough/components/pages/pageWithFlyout/flyouts/exampleFlyout/exampleFlyout.container.js index 013ab57a8..f413ef0a6 100644 --- a/src/walkthrough/components/pages/pageWithFlyout/flyouts/exampleFlyout/exampleFlyout.container.js +++ b/src/walkthrough/components/pages/pageWithFlyout/flyouts/exampleFlyout/exampleFlyout.container.js @@ -1,7 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +// import { translate } from 'react-i18next'; import { ExampleFlyout } from './exampleFlyout'; export const ExampleFlyoutContainer = translate()(ExampleFlyout); + +// diff --git a/src/walkthrough/components/pages/pageWithFlyout/flyouts/exampleFlyout/exampleFlyout.js b/src/walkthrough/components/pages/pageWithFlyout/flyouts/exampleFlyout/exampleFlyout.js index 307312d88..6425a10c9 100644 --- a/src/walkthrough/components/pages/pageWithFlyout/flyouts/exampleFlyout/exampleFlyout.js +++ b/src/walkthrough/components/pages/pageWithFlyout/flyouts/exampleFlyout/exampleFlyout.js @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +// import React, { Component } from 'react'; import { ExampleService } from 'walkthrough/services'; @@ -140,3 +141,4 @@ export class ExampleFlyout extends Component { ); } } +// diff --git a/src/walkthrough/components/pages/pageWithFlyout/pageWithFlyout.js b/src/walkthrough/components/pages/pageWithFlyout/pageWithFlyout.js index 77ec0ce8c..f8876bbec 100644 --- a/src/walkthrough/components/pages/pageWithFlyout/pageWithFlyout.js +++ b/src/walkthrough/components/pages/pageWithFlyout/pageWithFlyout.js @@ -2,7 +2,7 @@ import React, { Component } from 'react'; -import { Btn, ContextMenu, PageContent } from 'components/shared'; +import { Btn, ComponentArray, ContextMenu, PageContent } from 'components/shared'; import { svgs } from 'utilities'; import { ExampleFlyoutContainer } from './flyouts/exampleFlyout'; @@ -26,14 +26,16 @@ export class PageWithFlyout extends Component { const isExampleFlyoutOpen = openFlyoutName === 'example'; - return [ - - {t('walkthrough.pageWithFlyout.open')} - , - - {t('walkthrough.pageWithFlyout.pageBody')} - { isExampleFlyoutOpen && } - - ]; + return ( + + + {t('walkthrough.pageWithFlyout.open')} + + + {t('walkthrough.pageWithFlyout.pageBody')} + {isExampleFlyoutOpen && } + + + ); } } diff --git a/src/walkthrough/components/pages/pageWithGrid/exampleGrid/exampleGrid.js b/src/walkthrough/components/pages/pageWithGrid/exampleGrid/exampleGrid.js index 622994efb..c37a93aad 100644 --- a/src/walkthrough/components/pages/pageWithGrid/exampleGrid/exampleGrid.js +++ b/src/walkthrough/components/pages/pageWithGrid/exampleGrid/exampleGrid.js @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +// + import React, { Component } from 'react'; -import { Btn, PcsGrid } from 'components/shared'; +import { Btn, ComponentArray, PcsGrid } from 'components/shared'; import { exampleColumnDefs, defaultExampleGridProps } from './exampleGridConfig'; import { isFunc, svgs, translateColumnDefs } from 'utilities'; import { checkboxColumn } from 'components/shared/pcsGrid/pcsGridConfig'; @@ -31,10 +33,11 @@ export class ExampleGrid extends Component { // Set up the available context buttons. // If these are subject to user permissions, use the Protected component (src/components/shared/protected). - this.contextBtns = [ - {props.t('walkthrough.pageWithGrid.grid.btn1')}, - {props.t('walkthrough.pageWithGrid.grid.btn2')} - ]; + this.contextBtns = + + {props.t('walkthrough.pageWithGrid.grid.btn1')} + {props.t('walkthrough.pageWithGrid.grid.btn2')} + ; } /** @@ -92,7 +95,7 @@ export class ExampleGrid extends Component { } } - getSoftSelectId = ({ id } = {}) => id; + getSoftSelectId = ({ id } = '') => id; render() { const gridProps = { @@ -122,3 +125,4 @@ export class ExampleGrid extends Component { ); } } +// diff --git a/src/walkthrough/components/pages/pageWithGrid/exampleGrid/exampleGridConfig.js b/src/walkthrough/components/pages/pageWithGrid/exampleGrid/exampleGridConfig.js index 440ef2433..902346a5f 100644 --- a/src/walkthrough/components/pages/pageWithGrid/exampleGrid/exampleGridConfig.js +++ b/src/walkthrough/components/pages/pageWithGrid/exampleGrid/exampleGridConfig.js @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +// + import Config from 'app.config'; import { SoftSelectLinkRenderer } from 'components/shared/cellRenderers'; @@ -28,3 +30,5 @@ export const defaultExampleGridProps = { paginationPageSize: Config.paginationPageSize, rowSelection: 'multiple' }; + +// diff --git a/src/walkthrough/components/pages/pageWithGrid/pageWithGrid.js b/src/walkthrough/components/pages/pageWithGrid/pageWithGrid.js index 62cf2b4d8..399c7c102 100644 --- a/src/walkthrough/components/pages/pageWithGrid/pageWithGrid.js +++ b/src/walkthrough/components/pages/pageWithGrid/pageWithGrid.js @@ -4,6 +4,7 @@ import React, { Component } from 'react'; import { AjaxError, + ComponentArray, ContextMenu, PageContent, RefreshBar @@ -36,15 +37,17 @@ export class PageWithGrid extends Component { t: this.props.t }; - return [ - - {this.state.contextBtns} - , - - - {!!error && } - {!error && } - - ]; + return ( + + + {this.state.contextBtns} + + + + {!!error && } + {!error && } + + + ); } } diff --git a/src/walkthrough/services/exampleService.js b/src/walkthrough/services/exampleService.js index 677d042a6..62990a5af 100644 --- a/src/walkthrough/services/exampleService.js +++ b/src/walkthrough/services/exampleService.js @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +// + import { Observable } from 'rxjs'; import { toExampleItemModel, toExampleItemsModel } from './models'; @@ -43,3 +45,4 @@ export class ExampleService { return this.getExampleItems().delay(2000); } } +// diff --git a/src/walkthrough/services/models/exampleModels.js b/src/walkthrough/services/models/exampleModels.js index 9124f5b65..a7ca6ab96 100644 --- a/src/walkthrough/services/models/exampleModels.js +++ b/src/walkthrough/services/models/exampleModels.js @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +// import { camelCaseReshape, getItems } from 'utilities'; /** @@ -15,3 +16,5 @@ export const toExampleItemModel = (data = {}) => camelCaseReshape(data, { export const toExampleItemsModel = (response = {}) => getItems(response) .map(toExampleItemModel); + +// diff --git a/src/walkthrough/store/reducers/exampleReducer.js b/src/walkthrough/store/reducers/exampleReducer.js index 00f3b9af5..8a0a16fe3 100644 --- a/src/walkthrough/store/reducers/exampleReducer.js +++ b/src/walkthrough/store/reducers/exampleReducer.js @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +// import 'rxjs'; import { Observable } from 'rxjs'; import moment from 'moment'; @@ -81,3 +82,5 @@ export const getExamples = createSelector( (entities, items) => items.map(id => entities[id]) ); // ========================= Selectors - END + +//