From 1d15d582d99fbffb870dd564673da73a70a59088 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Tue, 3 Dec 2019 11:24:08 +0100 Subject: [PATCH] chore: move flash step to React Changelog-entry: chore: move flash step to React Change-type: patch Signed-off-by: Stevche Radevski --- lib/gui/app/pages/main/Flash.jsx | 287 ++++++++++++++++++ lib/gui/app/pages/main/controllers/flash.js | 222 -------------- lib/gui/app/pages/main/main.js | 10 +- lib/gui/app/pages/main/styles/_main.scss | 2 +- .../app/pages/main/templates/main.tpl.html | 46 +-- lib/gui/css/main.css | 5 +- npm-shrinkwrap.json | 16 - package.json | 2 - scripts/html-lint.js | 2 +- tests/gui/pages/main.spec.js | 39 --- 10 files changed, 302 insertions(+), 329 deletions(-) create mode 100644 lib/gui/app/pages/main/Flash.jsx delete mode 100644 lib/gui/app/pages/main/controllers/flash.js diff --git a/lib/gui/app/pages/main/Flash.jsx b/lib/gui/app/pages/main/Flash.jsx new file mode 100644 index 00000000..eb18c9c3 --- /dev/null +++ b/lib/gui/app/pages/main/Flash.jsx @@ -0,0 +1,287 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict' + +const React = require('react') +const _ = require('lodash') +const messages = require('../../../../shared/messages') +const flashState = require('../../models/flash-state') +const driveScanner = require('../../modules/drive-scanner') +const progressStatus = require('../../modules/progress-status') +const notification = require('../../os/notification') +const analytics = require('../../modules/analytics') +const imageWriter = require('../../modules/image-writer') +const path = require('path') +const store = require('../../models/store') +const constraints = require('../../../../shared/drive-constraints') +const availableDrives = require('../../models/available-drives') +const selection = require('../../models/selection-state') +const SvgIcon = require('../../components/svg-icon/svg-icon.jsx') +const ProgressButton = require('../../components/progress-button/progress-button.jsx') + +const COMPLETED_PERCENTAGE = 100 +const SPEED_PRECISION = 2 + +/** +* @summary Spawn a confirmation warning modal +* @function +* @public +* +* @param {Array} warningMessages - warning messages +* @param {Object} WarningModalService - warning modal service +* @returns {Promise} warning modal promise +* +* @example +* confirmationWarningModal([ 'Hello, World!' ]) +*/ +const confirmationWarningModal = (warningMessages, WarningModalService) => { + return WarningModalService.display({ + confirmationLabel: 'Continue', + rejectionLabel: 'Change', + description: [ + warningMessages.join('\n\n'), + 'Are you sure you want to continue?' + ].join(' ') + }) +} + +/** +* @summary Display warning tailored to the warning of the current drives-image pair +* @function +* @public +* +* @param {Array} drives - list of drive objects +* @param {Object} image - image object +* @param {Object} WarningModalService - warning modal service +* @returns {Promise} +* +* @example +* displayTailoredWarning(drives, image).then((ok) => { +* if (ok) { +* console.log('No warning was shown or continue was pressed') +* } else { +* console.log('Change was pressed') +* } +* }) +*/ +const displayTailoredWarning = async (drives, image, WarningModalService) => { + const warningMessages = [] + for (const drive of drives) { + if (constraints.isDriveSizeLarge(drive)) { + warningMessages.push(messages.warning.largeDriveSize(drive)) + } else if (!constraints.isDriveSizeRecommended(drive, image)) { + warningMessages.push(messages.warning.unrecommendedDriveSize(image, drive)) + } + + // TODO(Shou): we should consider adding the same warning dialog for system drives and remove unsafe mode + } + + if (!warningMessages.length) { + return true + } + + return confirmationWarningModal(warningMessages, WarningModalService) +} + +/** +* @summary Flash image to drives +* @function +* @public +* +* @param {Object} $timeout - angular's timeout object +* @param {Object} $state - angular's state object +* @param {Object} WarningModalService - warning modal service +* @param {Object} DriveSelectorService - drive selector service +* @param {Object} FlashErrorModalService - flash error modal service +* +* @example +* flashImageToDrive($timeout, $state, WarningModalService, DriveSelectorService, FlashErrorModalService) +*/ +const flashImageToDrive = async ($timeout, $state, + WarningModalService, DriveSelectorService, FlashErrorModalService) => { + const devices = selection.getSelectedDevices() + const image = selection.getImage() + const drives = _.filter(availableDrives.getDrives(), (drive) => { + return _.includes(devices, drive.device) + }) + + // eslint-disable-next-line no-magic-numbers + if (drives.length === 0) { + return + } + + const hasDangerStatus = constraints.hasListDriveImageCompatibilityStatus(drives, image) + if (hasDangerStatus) { + if (!(await displayTailoredWarning(drives, image, WarningModalService))) { + DriveSelectorService.open() + return + } + } + + if (flashState.isFlashing()) { + return + } + + // Trigger Angular digests along with store updates, as the flash state + // updates. The angular components won't update without it. + // TODO: Remove once moved entirely to React + const unsubscribe = store.observe($timeout) + + // Stop scanning drives when flashing + // otherwise Windows throws EPERM + driveScanner.stop() + + const iconPath = '../../../assets/icon.png' + const basename = path.basename(image.path) + try { + await imageWriter.flash(image.path, drives) + if (!flashState.wasLastFlashCancelled()) { + const flashResults = flashState.getFlashResults() + notification.send('Flash complete!', { + body: messages.info.flashComplete(basename, drives, flashResults.results.devices), + icon: iconPath + }) + $state.go('success') + } + } catch (error) { + // When flashing is cancelled before starting above there is no error + if (!error) { + return + } + + notification.send('Oops! Looks like the flash failed.', { + body: messages.error.flashFailure(path.basename(image.path), drives), + icon: iconPath + }) + + // TODO: All these error codes to messages translations + // should go away if the writer emitted user friendly + // messages on the first place. + if (error.code === 'EVALIDATION') { + FlashErrorModalService.show(messages.error.validation()) + } else if (error.code === 'EUNPLUGGED') { + FlashErrorModalService.show(messages.error.driveUnplugged()) + } else if (error.code === 'EIO') { + FlashErrorModalService.show(messages.error.inputOutput()) + } else if (error.code === 'ENOSPC') { + FlashErrorModalService.show(messages.error.notEnoughSpaceInDrive()) + } else if (error.code === 'ECHILDDIED') { + FlashErrorModalService.show(messages.error.childWriterDied()) + } else { + FlashErrorModalService.show(messages.error.genericFlashError()) + error.image = basename + analytics.logException(error) + } + } finally { + availableDrives.setDrives([]) + driveScanner.start() + unsubscribe() + } +} + +/** +* @summary Get progress button label +* @function +* @public +* +* @returns {String} progress button label +* +* @example +* const label = FlashController.getProgressButtonLabel() +*/ +const getProgressButtonLabel = () => { + if (!flashState.isFlashing()) { + return 'Flash!' + } + + return progressStatus.fromFlashState(flashState.getFlashState()) +} + +const formatSeconds = (totalSeconds) => { + if (!totalSeconds && !_.isNumber(totalSeconds)) { + return '' + } + // eslint-disable-next-line no-magic-numbers + const minutes = Math.floor(totalSeconds / 60) + // eslint-disable-next-line no-magic-numbers + const seconds = Math.floor(totalSeconds - minutes * 60) + + return `${minutes}m${seconds}s` +} + +const Flash = ({ + shouldFlashStepBeDisabled, lastFlashErrorCode, progressMessage, + $timeout, $state, WarningModalService, DriveSelectorService, FlashErrorModalService +}) => { + // This is a hack to re-render the component whenever the global state changes. Remove once we get rid of angular and use redux correctly. + // eslint-disable-next-line no-magic-numbers + const setRefresh = React.useState(false)[1] + const state = flashState.getFlashState() + const isFlashing = flashState.isFlashing() + const isFlashStepDisabled = shouldFlashStepBeDisabled() + const flashErrorCode = lastFlashErrorCode() + + React.useEffect(() => { + return store.observe(() => { + setRefresh((ref) => !ref) + }) + }, []) + + return
+
+ +
+ +
+ + flashImageToDrive($timeout, $state, WarningModalService, DriveSelectorService, FlashErrorModalService)}> + + + { + isFlashing && + } + { + !_.isNil(state.speed) && state.percentage !== COMPLETED_PERCENTAGE && +

+ {Boolean(state.speed) && {`${state.speed.toFixed(SPEED_PRECISION)} MB/s`}} + {!_.isNil(state.eta) && {`ETA: ${formatSeconds(state.eta)}` }} +

+ } + + { + Boolean(state.failed) &&
+
+ + {state.failed} + {progressMessage.failed(state.failed)} +
+
+ } +
+
+} + +module.exports = Flash diff --git a/lib/gui/app/pages/main/controllers/flash.js b/lib/gui/app/pages/main/controllers/flash.js deleted file mode 100644 index d10bb065..00000000 --- a/lib/gui/app/pages/main/controllers/flash.js +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright 2016 resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict' - -const _ = require('lodash') -const messages = require('../../../../../shared/messages') -const flashState = require('../../../models/flash-state') -const driveScanner = require('../../../modules/drive-scanner') -const progressStatus = require('../../../modules/progress-status') -const notification = require('../../../os/notification') -const analytics = require('../../../modules/analytics') -const imageWriter = require('../../../modules/image-writer') -const path = require('path') -const store = require('../../../models/store') -const constraints = require('../../../../../shared/drive-constraints') -const availableDrives = require('../../../models/available-drives') -const selection = require('../../../models/selection-state') - -module.exports = function ( - $state, - $timeout, - FlashErrorModalService, - WarningModalService, - DriveSelectorService -) { - /** - * @summary Spawn a confirmation warning modal - * @function - * @public - * - * @param {Array} warningMessages - warning messages - * @returns {Promise} warning modal promise - * - * @example - * confirmationWarningModal([ 'Hello, World!' ]) - */ - const confirmationWarningModal = (warningMessages) => { - return WarningModalService.display({ - confirmationLabel: 'Continue', - rejectionLabel: 'Change', - description: [ - warningMessages.join('\n\n'), - 'Are you sure you want to continue?' - ].join(' ') - }) - } - - /** - * @summary Display warning tailored to the warning of the current drives-image pair - * @function - * @public - * - * @param {Array} drives - list of drive objects - * @param {Object} image - image object - * @returns {Promise} - * - * @example - * displayTailoredWarning(drives, image).then((ok) => { - * if (ok) { - * console.log('No warning was shown or continue was pressed') - * } else { - * console.log('Change was pressed') - * } - * }) - */ - const displayTailoredWarning = async (drives, image) => { - const warningMessages = [] - for (const drive of drives) { - if (constraints.isDriveSizeLarge(drive)) { - warningMessages.push(messages.warning.largeDriveSize(drive)) - } else if (!constraints.isDriveSizeRecommended(drive, image)) { - warningMessages.push(messages.warning.unrecommendedDriveSize(image, drive)) - } - - // TODO(Shou): we should consider adding the same warning dialog for system drives and remove unsafe mode - } - - if (!warningMessages.length) { - return true - } - - return confirmationWarningModal(warningMessages) - } - - /** - * @summary Flash image to drives - * @function - * @public - * - * @example - * FlashController.flashImageToDrive({ - * path: 'rpi.img', - * size: 1000000000, - * compressedSize: 1000000000, - * isSizeEstimated: false, - * }, [ - * '/dev/disk2', - * '/dev/disk5' - * ]) - */ - this.flashImageToDrive = async () => { - const devices = selection.getSelectedDevices() - const image = selection.getImage() - const drives = _.filter(availableDrives.getDrives(), (drive) => { - return _.includes(devices, drive.device) - }) - - // eslint-disable-next-line no-magic-numbers - if (drives.length === 0) { - return - } - - const hasDangerStatus = constraints.hasListDriveImageCompatibilityStatus(drives, image) - if (hasDangerStatus) { - if (!(await displayTailoredWarning(drives, image))) { - DriveSelectorService.open() - return - } - } - - if (flashState.isFlashing()) { - return - } - - // Trigger Angular digests along with store updates, as the flash state - // updates. Without this there is essentially no progress to watch. - const unsubscribe = store.observe($timeout) - - // Stop scanning drives when flashing - // otherwise Windows throws EPERM - driveScanner.stop() - - const iconPath = '../../../assets/icon.png' - const basename = path.basename(image.path) - try { - await imageWriter.flash(image.path, drives) - if (!flashState.wasLastFlashCancelled()) { - const flashResults = flashState.getFlashResults() - notification.send('Flash complete!', { - body: messages.info.flashComplete(basename, drives, flashResults.results.devices), - icon: iconPath - }) - $state.go('success') - } - } catch (error) { - // When flashing is cancelled before starting above there is no error - if (!error) { - return - } - - notification.send('Oops! Looks like the flash failed.', { - body: messages.error.flashFailure(path.basename(image.path), drives), - icon: iconPath - }) - - // TODO: All these error codes to messages translations - // should go away if the writer emitted user friendly - // messages on the first place. - if (error.code === 'EVALIDATION') { - FlashErrorModalService.show(messages.error.validation()) - } else if (error.code === 'EUNPLUGGED') { - FlashErrorModalService.show(messages.error.driveUnplugged()) - } else if (error.code === 'EIO') { - FlashErrorModalService.show(messages.error.inputOutput()) - } else if (error.code === 'ENOSPC') { - FlashErrorModalService.show(messages.error.notEnoughSpaceInDrive()) - } else if (error.code === 'ECHILDDIED') { - FlashErrorModalService.show(messages.error.childWriterDied()) - } else { - FlashErrorModalService.show(messages.error.genericFlashError()) - error.image = basename - analytics.logException(error) - } - } finally { - availableDrives.setDrives([]) - driveScanner.start() - unsubscribe() - } - } - - /** - * @summary Get progress button label - * @function - * @public - * - * @returns {String} progress button label - * - * @example - * const label = FlashController.getProgressButtonLabel() - */ - this.getProgressButtonLabel = () => { - if (!flashState.isFlashing()) { - return 'Flash!' - } - - return progressStatus.fromFlashState(flashState.getFlashState()) - } - - /** - * @summary Abort write process - * @function - * @public - * - * @example - * FlashController.cancelFlash() - */ - this.cancelFlash = imageWriter.cancel -} diff --git a/lib/gui/app/pages/main/main.js b/lib/gui/app/pages/main/main.js index 71f54dc9..d0862407 100644 --- a/lib/gui/app/pages/main/main.js +++ b/lib/gui/app/pages/main/main.js @@ -23,15 +23,11 @@ */ const angular = require('angular') +const { react2angular } = require('react2angular') const MODULE_NAME = 'Etcher.Pages.Main' -require('angular-moment') - const MainPage = angular.module(MODULE_NAME, [ - 'angularMoment', - require('angular-ui-router'), - require('angular-seconds-to-date'), require('../../components/drive-selector/drive-selector'), require('../../components/tooltip-modal/tooltip-modal'), @@ -54,7 +50,9 @@ const MainPage = angular.module(MODULE_NAME, [ MainPage.controller('MainController', require('./controllers/main')) MainPage.controller('DriveSelectionController', require('./controllers/drive-selection')) -MainPage.controller('FlashController', require('./controllers/flash')) +MainPage.component('flash', react2angular(require('./Flash.jsx'), + [ 'shouldFlashStepBeDisabled', 'lastFlashErrorCode', 'progressMessage' ], + [ '$timeout', '$state', 'WarningModalService', 'DriveSelectorService', 'FlashErrorModalService' ])) MainPage.config(($stateProvider) => { $stateProvider diff --git a/lib/gui/app/pages/main/styles/_main.scss b/lib/gui/app/pages/main/styles/_main.scss index 24e75dec..6f078b59 100644 --- a/lib/gui/app/pages/main/styles/_main.scss +++ b/lib/gui/app/pages/main/styles/_main.scss @@ -14,7 +14,7 @@ * limitations under the License. */ -svg-icon > img[disabled] { +img[disabled] { opacity: $disabled-opacity; } diff --git a/lib/gui/app/pages/main/templates/main.tpl.html b/lib/gui/app/pages/main/templates/main.tpl.html index d1c76a1c..4121b7be 100644 --- a/lib/gui/app/pages/main/templates/main.tpl.html +++ b/lib/gui/app/pages/main/templates/main.tpl.html @@ -56,45 +56,11 @@ > -
-
-
- -
- -
- - - - - - - -
-
- - {{ main.state.getFlashState().failed }} - {{ - main.progressMessage.failed(main.state.getFlashState().failed) - }} -
-
-
-
+
+
diff --git a/lib/gui/css/main.css b/lib/gui/css/main.css index 940cb0bf..64af877f 100644 --- a/lib/gui/css/main.css +++ b/lib/gui/css/main.css @@ -6368,8 +6368,9 @@ svg-icon { * See the License for the specific language governing permissions and * limitations under the License. */ -svg-icon > img[disabled] { - opacity: 0.2; } + img[disabled] { + opacity: 0.2; +} .page-main { flex: 1; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index addd9d5f..ba2fe12c 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1568,22 +1568,6 @@ "integrity": "sha512-t3eQmuAZczdOVdOQj7muCBwH+MBNwd+/FaAsV1SNp+597EQVWABQwxI6KXE0k0ZlyJ5JbtkNIKU8kGAj1znxhw==", "dev": true }, - "angular-moment": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/angular-moment/-/angular-moment-1.3.0.tgz", - "integrity": "sha512-KG8rvO9MoaBLwtGnxTeUveSyNtrL+RNgGl1zqWN36+HDCCVGk2DGWOzqKWB6o+eTTbO3Opn4hupWKIElc8XETA==", - "requires": { - "moment": ">=2.8.0 <3.0.0" - } - }, - "angular-seconds-to-date": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/angular-seconds-to-date/-/angular-seconds-to-date-1.0.1.tgz", - "integrity": "sha1-mTi6xPKkeyvJVc0h0TwU8s3odj0=", - "requires": { - "angular": "^1.5.6" - } - }, "angular-ui-bootstrap": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/angular-ui-bootstrap/-/angular-ui-bootstrap-2.5.6.tgz", diff --git a/package.json b/package.json index fa033e80..333e573c 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,6 @@ "@fortawesome/react-fontawesome": "^0.1.7", "angular": "1.7.6", "angular-if-state": "^1.0.0", - "angular-moment": "^1.0.1", - "angular-seconds-to-date": "^1.0.0", "angular-ui-bootstrap": "^2.5.0", "angular-ui-router": "^0.4.2", "bindings": "^1.3.0", diff --git a/scripts/html-lint.js b/scripts/html-lint.js index dc19a45c..67b96a68 100644 --- a/scripts/html-lint.js +++ b/scripts/html-lint.js @@ -26,7 +26,7 @@ angularValidate.validate( ], { customtags: [ - 'settings' + 'settings', 'flash' ], customattrs: [ diff --git a/tests/gui/pages/main.spec.js b/tests/gui/pages/main.spec.js index 415d7b6e..d4030c78 100644 --- a/tests/gui/pages/main.spec.js +++ b/tests/gui/pages/main.spec.js @@ -20,7 +20,6 @@ const m = require('mochainon') const _ = require('lodash') const fs = require('fs') const angular = require('angular') -const flashState = require('../../../lib/gui/app/models/flash-state') const availableDrives = require('../../../lib/gui/app/models/available-drives') const selectionState = require('../../../lib/gui/app/models/selection-state') @@ -165,44 +164,6 @@ describe('Browser: MainPage', function () { }) }) - describe('FlashController', function () { - let $controller - - beforeEach(angular.mock.inject(function (_$controller_) { - $controller = _$controller_ - })) - - describe('.getProgressButtonLabel()', function () { - it('should return "Flash!" given a clean state', function () { - const controller = $controller('FlashController', { - $scope: {} - }) - - flashState.resetState() - m.chai.expect(controller.getProgressButtonLabel()).to.equal('Flash!') - }) - - it('should display the flashing progress', function () { - const controller = $controller('FlashController', { - $scope: {} - }) - - flashState.setFlashingFlag() - flashState.setProgressState({ - flashing: 1, - verifying: 0, - successful: 0, - failed: 0, - percentage: 85, - eta: 15, - speed: 1000, - totalSpeed: 2000 - }) - m.chai.expect(controller.getProgressButtonLabel()).to.equal('85% Flashing') - }) - }) - }) - describe('DriveSelectionController', function () { let $controller let DriveSelectionController