diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 644b79aa9..4b7272f04 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}}.", @@ -354,22 +371,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", @@ -540,6 +559,126 @@ "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", + "nan": "Must be a number" + } + } + }, + "details": { + "deploymentName": "Deployment name", + "deviceGroup": "Device group", + "start": "Start", + "packageType": "Package type", + "package": "Package", + "priority": "Priority", + "devices": "Devices", + "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", + "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", @@ -555,7 +694,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.", @@ -584,6 +723,10 @@ "panelBody": "This is a new panel." } } + }, + "panel": { + "header": "Example Panel", + "panelBody": "This is a new panel." } } } 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..94be1b378 --- /dev/null +++ b/src/components/pages/deployments/deploymentDetails/deploymentDetails.js @@ -0,0 +1,210 @@ +// 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); + // Set the initial state + this.state = { + ...closedModalState, + deploymentDeleted: false + }; + + this.props.updateCurrentWindow('DeploymentDetails'); + + props.fetchDeployment(props.match.params.id); + } + + 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 { + appliedCount, + succeededCount, + failedCount, + name, + priority, + deviceGroupName, + createdDateTimeUtc, + type, + packageName + } = currentDeployment; + const pendingCalc = appliedCount - succeededCount - failedCount; + const pendingCount = pendingCalc ? pendingCalc : '0'; + + return ( + + {this.getOpenModal()} + + + + {t('deployments.modals.delete.contextMenuName')} + + + + + + + + {!!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..4c30f27c1 --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/deploymentsGrid/deploymentsGrid.js @@ -0,0 +1,53 @@ +// 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.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..fa9133e4a --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/deploymentsGrid/deploymentsGridConfig.js @@ -0,0 +1,68 @@ +// 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) + }, + 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..2b9f807ec --- /dev/null +++ b/src/components/pages/deployments/deploymentsHome/flyouts/deploymentNew/deploymentNew.js @@ -0,0 +1,394 @@ +// 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(); + } + + 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 => !isNaN(val), t('deployments.flyouts.new.validation.nan')); + 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..b4c4c26d6 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, @@ -250,9 +251,9 @@ export class DeviceNew extends LinkedComponent { ].every(link => !link.error); } - deviceTypeChange = ({ target: { value }}) => { + 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(); } @@ -370,28 +371,30 @@ export class DeviceNew extends LinkedComponent { { - isSimulatedDevice && [ - + 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 +402,8 @@ export class DeviceNew extends LinkedComponent { {t(deviceIdTypeOptions.generate.labelName)} - , - + + {t(authTypeOptions.labelName)} {t(authTypeOptions.symmetric.labelName)} @@ -408,8 +411,8 @@ export class DeviceNew extends LinkedComponent { {t(authTypeOptions.x509.labelName)} - , - + + {t(authKeyTypeOptions.labelName)} {t(authKeyTypeOptions.generate.labelName)} @@ -426,7 +429,7 @@ export class DeviceNew extends LinkedComponent {
- ] + } @@ -451,12 +454,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..6a84e0a53 --- /dev/null +++ b/src/components/pages/packages/flyouts/packageNew/packageNew.js @@ -0,0 +1,212 @@ +// 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(); + } + + 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 && +
+ + + {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/services/configService.js b/src/services/configService.js index 840798870..c8f5117f0 100644 --- a/src/services/configService.js +++ b/src/services/configService.js @@ -6,7 +6,10 @@ import { prepareLogoResponse, toDeviceGroupModel, toDeviceGroupsModel, - toSolutionSettingThemeModel + toSolutionSettingThemeModel, + toNewPackageRequestModel, + toPackagesModel, + toPackageModel } from './models'; import { Observable } from '../../node_modules/rxjs'; @@ -85,4 +88,28 @@ export class ConfigService { return HttpClient.put(`${ENDPOINT}solution-settings/theme`, model) .map(toSolutionSettingThemeModel); } + + /** 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 bc4e8c02f..a178033b7 100644 --- a/src/services/models/configModels.js +++ b/src/services/models/configModels.js @@ -32,7 +32,7 @@ export const toUpdateDeviceGroupRequestModel = (params = {}) => ({ })) }); -export const prepareLogoResponse = ({ xhr, response }) => { +export const prepareLogoResponse = ({ xhr, response }) => { const returnObj = {}; const isDefault = xhr.getResponseHeader('IsDefault'); if (!stringToBoolean(isDefault)) { @@ -53,3 +53,28 @@ export const toSolutionSettingThemeModel = (response = {}) => camelCaseReshape(r 'diagnosticsOptIn': 'diagnosticsOptIn', 'azureMapsKey': 'azureMapsKey' }); + +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..2d0f950d7 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}]`, @@ -177,3 +177,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..fdb27d6ed --- /dev/null +++ b/src/store/reducers/deploymentsReducer.js @@ -0,0 +1,248 @@ +// 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) { + 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 }) => { + const { deviceStatuses } = payload || {}; + return update(state, { + currentDeployment: { $set: payload }, + deviceStatuses: { $set: deviceStatuses }, + 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 => getDeploymentsReducer(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 198963572..ed6ca08e3 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 52e3a3da5..59b0cd91c 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, @@ -32,6 +33,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', @@ -164,6 +167,13 @@ $themes: ( colorFormActionsBorderColor: $colorGraphite, colorDurationLabelText: $colorSmoke, // Forms - END + + // Modal - START + colorModalText: $colorWhite, + colorModalBackground: $colorNoir, + colorModalBorder: #60AAFF, + colorModalDropShadow: rgba(0, 0, 0, 0.6), + // Modal - END ), light: ( // Functional colors @@ -192,6 +202,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', @@ -324,5 +336,12 @@ $themes: ( colorFormActionsBorderColor: $colorGraphite, colorDurationLabelText: $colorSmoke, // Forms - 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 2bd4615cf..a9797ce96 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..a5b2eb130 100644 --- a/src/utilities/httpClient.js +++ b/src/utilities/httpClient.js @@ -89,14 +89,18 @@ export class HttpClient { if (accessToken) authHeaders['Authorization'] = `Bearer ${accessToken}`; }); } - return { + const options = { ...request, headers: { ...jsonHeaders, ...request.headers, ...authHeaders } - }; + } + if (options.headers && options.headers.hasOwnProperty('Content-Type') && options.headers['Content-Type'] === undefined) { + delete options.headers['Content-Type']; + } + return options; } /** diff --git a/src/utilities/methods.js b/src/utilities/methods.js index f252e516e..ef43311e6 100644 --- a/src/utilities/methods.js +++ b/src/utilities/methods.js @@ -99,6 +99,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 c690a62f3..28b387749 100644 --- a/src/utilities/svgs.js +++ b/src/utilities/svgs.js @@ -25,12 +25,15 @@ 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'; import LinkToPath from 'assets/icons/linkTo.svg'; import LoadingToggleIconPath from 'assets/icons/loadingToggle.svg'; import ManageFiltersIconPath from 'assets/icons/manageFilters.svg'; +import TabPackagesIconPath from 'assets/icons/packages.svg'; import PhysicalDeviceIconPath from 'assets/icons/physicalDevice.svg'; import PlusIconPath from 'assets/icons/plus.svg'; import QuestionMarkIconPath from 'assets/icons/questionMark.svg'; @@ -46,6 +49,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'; @@ -58,7 +62,9 @@ export const svgs = { tabs: { dashboard: TabDashboardIconPath, devices: TabDevicesIconPath, + deployments: TabDeploymentsIconPath, maintenance: TabMaintenanceIconPath, + packages: TabPackagesIconPath, rules: TabRulesIconPath, example: InfoBubbleIconPath }, @@ -89,6 +95,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/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..578bd56d6 100644 --- a/src/walkthrough/components/pages/pageWithGrid/exampleGrid/exampleGrid.js +++ b/src/walkthrough/components/pages/pageWithGrid/exampleGrid/exampleGrid.js @@ -1,7 +1,7 @@ // 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 +31,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 +93,7 @@ export class ExampleGrid extends Component { } } - getSoftSelectId = ({ id } = {}) => id; + getSoftSelectId = ({ id } = '') => id; render() { const gridProps = { 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 && } + + + ); } }