Use rendition modal for warning and errors when flashing

Change-type: patch
Signed-off-by: Stevche Radevski <stevche@balena.io>
This commit is contained in:
Stevche Radevski 2019-12-04 12:42:34 +01:00 committed by Lorenzo Alberto Maria Ambrosi
parent 00536cba3a
commit 21d9d31a27
14 changed files with 172 additions and 496 deletions

View File

@ -87,7 +87,6 @@ const app = angular.module('Etcher', [
// Components
require('./components/svg-icon'),
require('./components/warning-modal/warning-modal'),
require('./components/safe-webview'),
require('./components/file-selector'),

View File

@ -1,31 +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'
/**
* @module Etcher.Components.FlashErrorModal
*/
const angular = require('angular')
const MODULE_NAME = 'Etcher.Components.FlashErrorModal'
const FlashErrorModal = angular.module(MODULE_NAME, [
require('../warning-modal/warning-modal')
])
FlashErrorModal.service('FlashErrorModalService', require('./services/flash-error-modal'))
module.exports = MODULE_NAME

View File

@ -1,53 +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 flashState = require('../../../models/flash-state')
const selectionState = require('../../../models/selection-state')
const store = require('../../../models/store')
const analytics = require('../../../modules/analytics')
module.exports = function (WarningModalService) {
/**
* @summary Open the flash error modal
* @function
* @public
*
* @param {String} message - flash error message
* @returns {Promise}
*
* @example
* FlashErrorModalService.show('The drive is not large enough!');
*/
this.show = (message) => {
return WarningModalService.display({
confirmationLabel: 'Retry',
description: message
}).then((confirmed) => {
flashState.resetState()
if (confirmed) {
analytics.logEvent('Restart after failure', {
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
} else {
selectionState.clear()
}
})
}
}

View File

@ -1,34 +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'
/**
* @module Etcher.Components.ProgressButton
*/
const angular = require('angular')
const { react2angular } = require('react2angular')
const MODULE_NAME = 'Etcher.Components.ProgressButton'
const ProgressButton = angular.module(MODULE_NAME, [])
ProgressButton.component(
'progressButton',
react2angular(require('./progress-button.jsx'))
)
module.exports = MODULE_NAME

View File

@ -26,7 +26,7 @@ const {
keyframes
} = require('styled-components')
const { ProgressBar, Provider } = require('rendition')
const { ProgressBar } = require('rendition')
const { colors } = require('./../../theme')
const { StepButton, StepSelection } = require('./../../styled-components')
@ -105,46 +105,40 @@ class ProgressButton extends React.Component {
if (this.props.active) {
if (this.props.striped) {
return (
<Provider>
<StepSelection>
<FlashProgressBarValidating
primary
emphasized
value= { this.props.percentage }
>
{ this.props.label }
</FlashProgressBarValidating>
</StepSelection>
</Provider>
)
}
return (
<Provider>
<StepSelection>
<FlashProgressBar
warning
<FlashProgressBarValidating
primary
emphasized
value= { this.props.percentage }
>
{ this.props.label }
</FlashProgressBar>
</FlashProgressBarValidating>
</StepSelection>
</Provider>
)
}
return (
<StepSelection>
<FlashProgressBar
warning
emphasized
value= { this.props.percentage }
>
{ this.props.label }
</FlashProgressBar>
</StepSelection>
)
}
return (
<Provider>
<StepSelection>
<StepButton
onClick= { this.props.callback }
disabled= { this.props.disabled }
>
{this.props.label}
</StepButton>
</StepSelection>
</Provider>
<StepSelection>
<StepButton
onClick= { this.props.callback }
disabled= { this.props.disabled }
>
{this.props.label}
</StepButton>
</StepSelection>
)
}
}

View File

@ -1,50 +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'
module.exports = function ($uibModalInstance, options) {
/**
* @summary Modal options
* @type {Object}
* @public
*/
this.options = options
/**
* @summary Reject the warning prompt
* @function
* @public
*
* @example
* WarningModalController.reject();
*/
this.reject = () => {
$uibModalInstance.close(false)
}
/**
* @summary Accept the warning prompt
* @function
* @public
*
* @example
* WarningModalController.accept();
*/
this.accept = () => {
$uibModalInstance.close(true)
}
}

View File

@ -1,52 +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')
module.exports = function ($sce, ModalService) {
/**
* @summary Display the warning modal
* @function
* @public
*
* @param {Object} options - options
* @param {String} options.description - danger message
* @param {String} options.confirmationLabel - confirmation button text
* @param {String} options.rejectionLabel - rejection button text
* @fulfil {Boolean} - whether the user accepted or rejected the warning
* @returns {Promise}
*
* @example
* WarningModalService.display({
* description: 'Don\'t do this!',
* confirmationLabel: 'Yes, continue!'
* });
*/
this.display = (options = {}) => {
options.description = $sce.trustAsHtml(options.description)
return ModalService.open({
name: 'warning',
template: require('../templates/warning-modal.tpl.html'),
controller: 'WarningModalController as modal',
size: 'warning-modal',
resolve: {
options: _.constant(options)
}
}).result
}
}

View File

@ -1,28 +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.
*/
.modal-warning-modal .modal-content {
width: 350px;
}
.modal-warning-modal .modal-title .glyphicon {
color: $palette-theme-danger-background;
}
.modal-warning-modal .modal-body {
max-height: 200px;
overflow-y: auto;
}

View File

@ -1,25 +0,0 @@
<div class="modal-header">
<h4 class="modal-title">
<span class="glyphicon glyphicon-exclamation-sign"></span>
<span>Attention</span>
</h4>
<button class="close"
tabindex="11"
ng-click="modal.reject()">&times;</button>
</div>
<div class="modal-body">
<p ng-bind-html="modal.options.description"></p>
</div>
<div class="modal-footer">
<div class="modal-menu">
<button class="button button-danger button-block"
tabindex="13"
ng-click="modal.accept()">{{ ::modal.options.confirmationLabel }}</button>
<button ng-if="modal.options.rejectionLabel" class="button button-block"
tabindex="12"
ng-click="modal.reject()">{{ ::modal.options.rejectionLabel }}</button>
</div>
</div>

View File

@ -1,32 +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'
/**
* @module Etcher.Components.WarningModal
*/
const angular = require('angular')
const MODULE_NAME = 'Etcher.Components.WarningModal'
const WarningModal = angular.module(MODULE_NAME, [
require('../modal/modal')
])
WarningModal.controller('WarningModalController', require('./controllers/warning-modal'))
WarningModal.service('WarningModalService', require('./services/warning-modal'))
module.exports = MODULE_NAME

View File

@ -18,6 +18,9 @@
const React = require('react')
const _ = require('lodash')
const { Modal, Txt } = require('rendition')
const { ThemedProvider } = require('../../styled-components')
const messages = require('../../../../shared/messages')
const flashState = require('../../models/flash-state')
const driveScanner = require('../../modules/drive-scanner')
@ -36,49 +39,7 @@ const ProgressButton = require('../../components/progress-button/progress-button
const COMPLETED_PERCENTAGE = 100
const SPEED_PRECISION = 2
/**
* @summary Spawn a confirmation warning modal
* @function
* @public
*
* @param {Array<String>} 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<Object>} drives - list of drive objects
* @param {Object} image - image object
* @param {Object} WarningModalService - warning modal service
* @returns {Promise<Boolean>}
*
* @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 getWarningMessages = (drives, image) => {
const warningMessages = []
for (const drive of drives) {
if (constraints.isDriveSizeLarge(drive)) {
@ -90,29 +51,28 @@ const displayTailoredWarning = async (drives, image, WarningModalService) => {
// 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)
return warningMessages
}
/**
* @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 getErrorMessageFromCode = (errorCode) => {
// TODO: All these error codes to messages translations
// should go away if the writer emitted user friendly
// messages on the first place.
if (errorCode === 'EVALIDATION') {
return messages.error.validation()
} else if (errorCode === 'EUNPLUGGED') {
return messages.error.driveUnplugged()
} else if (errorCode === 'EIO') {
return messages.error.inputOutput()
} else if (errorCode === 'ENOSPC') {
return messages.error.notEnoughSpaceInDrive()
} else if (errorCode === 'ECHILDDIED') {
return messages.error.childWriterDied()
}
return ''
}
const flashImageToDrive = async ($timeout, $state) => {
const devices = selection.getSelectedDevices()
const image = selection.getImage()
const drives = _.filter(availableDrives.getDrives(), (drive) => {
@ -120,20 +80,8 @@ const flashImageToDrive = async ($timeout, $state,
})
// 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
if (drives.length === 0 || flashState.isFlashing()) {
return ''
}
// Trigger Angular digests along with store updates, as the flash state
@ -160,7 +108,7 @@ const flashImageToDrive = async ($timeout, $state,
} catch (error) {
// When flashing is cancelled before starting above there is no error
if (!error) {
return
return ''
}
notification.send('Oops! Looks like the flash failed.', {
@ -168,29 +116,21 @@ const flashImageToDrive = async ($timeout, $state,
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())
let errorMessage = getErrorMessageFromCode(error.code)
if (!errorMessage) {
error.image = basename
analytics.logException(error)
errorMessage = messages.error.genericFlashError()
}
return errorMessage
} finally {
availableDrives.setDrives([])
driveScanner.start()
unsubscribe()
}
// Return ''
}
/**
@ -225,7 +165,7 @@ const formatSeconds = (totalSeconds) => {
const Flash = ({
shouldFlashStepBeDisabled, lastFlashErrorCode, progressMessage,
$timeout, $state, WarningModalService, DriveSelectorService, FlashErrorModalService
$timeout, $state, DriveSelectorService
}) => {
// 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
@ -235,53 +175,132 @@ const Flash = ({
const isFlashStepDisabled = shouldFlashStepBeDisabled()
const flashErrorCode = lastFlashErrorCode()
const [ warningMessages, setWarningMessages ] = React.useState([])
const [ errorMessage, setErrorMessage ] = React.useState('')
React.useEffect(() => {
return store.observe(() => {
setRefresh((ref) => !ref)
})
}, [])
return <div className="box text-center">
<div className="center-block">
<SvgIcon paths={[ '../../assets/flash.svg' ]} disabled={isFlashStepDisabled}/>
</div>
const handleWarningResponse = async (shouldContinue) => {
setWarningMessages([])
<div className="space-vertical-large">
<ProgressButton
tabindex="3"
striped={state.type === 'verifying'}
active={isFlashing}
percentage={state.percentage}
label={getProgressButtonLabel()}
disabled={Boolean(flashErrorCode) || isFlashStepDisabled}
callback={() =>
flashImageToDrive($timeout, $state, WarningModalService, DriveSelectorService, FlashErrorModalService)}>
</ProgressButton>
if (!shouldContinue) {
DriveSelectorService.open()
return
}
{
isFlashing && <button className="button button-link button-abort-write" onClick={imageWriter.cancel}>
<span className="glyphicon glyphicon-remove-sign"></span>
</button>
}
{
!_.isNil(state.speed) && state.percentage !== COMPLETED_PERCENTAGE &&
setErrorMessage(await flashImageToDrive($timeout, $state))
}
const handleFlashErrorResponse = (shouldRetry) => {
setErrorMessage('')
flashState.resetState()
if (shouldRetry) {
analytics.logEvent('Restart after failure', {
applicationSessionUuid: store.getState().toJS().applicationSessionUuid,
flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid
})
} else {
selection.clear()
}
}
const tryFlash = 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 || flashState.isFlashing()) {
return
}
const hasDangerStatus = constraints.hasListDriveImageCompatibilityStatus(drives, image)
if (hasDangerStatus) {
setWarningMessages(getWarningMessages(drives, image))
return
}
setErrorMessage(await flashImageToDrive($timeout, $state))
}
return <ThemedProvider>
<div className="box text-center">
<div className="center-block">
<SvgIcon paths={[ '../../assets/flash.svg' ]} disabled={isFlashStepDisabled}/>
</div>
<div className="space-vertical-large">
<ProgressButton
tabindex="3"
striped={state.type === 'verifying'}
active={isFlashing}
percentage={state.percentage}
label={getProgressButtonLabel()}
disabled={Boolean(flashErrorCode) || isFlashStepDisabled}
callback={tryFlash}>
</ProgressButton>
{
isFlashing && <button className="button button-link button-abort-write" onClick={imageWriter.cancel}>
<span className="glyphicon glyphicon-remove-sign"></span>
</button>
}
{
!_.isNil(state.speed) && state.percentage !== COMPLETED_PERCENTAGE &&
<p className="step-footer step-footer-split">
{Boolean(state.speed) && <span >{`${state.speed.toFixed(SPEED_PRECISION)} MB/s`}</span>}
{!_.isNil(state.eta) && <span>{`ETA: ${formatSeconds(state.eta)}` }</span>}
</p>
}
}
{
Boolean(state.failed) && <div className="target-status-wrap">
<div className="target-status-line target-status-failed">
<span className="target-status-dot"></span>
<span className="target-status-quantity">{state.failed}</span>
<span className="target-status-message">{progressMessage.failed(state.failed)} </span>
{
Boolean(state.failed) && <div className="target-status-wrap">
<div className="target-status-line target-status-failed">
<span className="target-status-dot"></span>
<span className="target-status-quantity">{state.failed}</span>
<span className="target-status-message">{progressMessage.failed(state.failed)} </span>
</div>
</div>
</div>
}
}
</div>
</div>
</div>
{/* eslint-disable-next-line no-magic-numbers */}
{warningMessages && warningMessages.length > 0 && <Modal
width={400}
titleElement={'Attention'}
cancel={() => handleWarningResponse(false)}
done={() => handleWarningResponse(true)}
cancelButtonProps={{
children: 'Change'
}}
action={'Continue'}
primaryButtonProps={{ primary: false, warning: true }}
>
{
_.map(warningMessages, (message) => <Txt whitespace="pre-line" mt={2}>{message}</Txt>)
}
</Modal>
}
{errorMessage && <Modal
width={400}
titleElement={'Attention'}
cancel={() => handleFlashErrorResponse(false)}
done={() => handleFlashErrorResponse(true)}
action={'Retry'}
>
<Txt>{errorMessage}</Txt>
</Modal>
}
</ThemedProvider>
}
module.exports = Flash

View File

@ -31,10 +31,7 @@ const MainPage = angular.module(MODULE_NAME, [
require('../../components/drive-selector/drive-selector'),
require('../../components/tooltip-modal/tooltip-modal'),
require('../../components/flash-error-modal/flash-error-modal'),
require('../../components/progress-button'),
require('../../components/image-selector'),
require('../../components/warning-modal/warning-modal'),
require('../../components/file-selector'),
require('../../components/featured-project'),
require('../../components/reduced-flashing-infos'),
@ -61,7 +58,7 @@ MainPage.component('driveSelector', react2angular(require('./DriveSelector.jsx')
))
MainPage.component('flash', react2angular(require('./Flash.jsx'),
[ 'shouldFlashStepBeDisabled', 'lastFlashErrorCode', 'progressMessage' ],
[ '$timeout', '$state', 'WarningModalService', 'DriveSelectorService', 'FlashErrorModalService' ]))
[ '$timeout', '$state', 'DriveSelectorService' ]))
MainPage.config(($stateProvider) => {
$stateProvider

View File

@ -35,7 +35,6 @@ $disabled-opacity: 0.2;
@import "../components/drive-selector/styles/drive-selector";
@import "../components/svg-icon/styles/svg-icon";
@import "../components/tooltip-modal/styles/tooltip-modal";
@import "../components/warning-modal/styles/warning-modal";
@import "../components/file-selector/styles/file-selector";
@import "../pages/main/styles/main";
@import "../pages/finish/styles/finish";

27
npm-shrinkwrap.json generated
View File

@ -9171,11 +9171,6 @@
"sinon-chai": "^2.8.0"
}
},
"moment": {
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
"integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
},
"moment-mini": {
"version": "2.22.1",
"resolved": "https://registry.npmjs.org/moment-mini/-/moment-mini-2.22.1.tgz",
@ -10781,28 +10776,6 @@
}
}
},
"react-dropzone": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-10.2.1.tgz",
"integrity": "sha512-Me5nOu8hK9/Xyg5easpdfJ6SajwUquqYR/2YTdMotsCUgJ1pHIIwNsv0n+qcIno0tWR2V2rVQtj2r/hXYs2TnQ==",
"requires": {
"attr-accept": "^2.0.0",
"file-selector": "^0.1.12",
"prop-types": "^15.7.2"
},
"dependencies": {
"prop-types": {
"version": "15.7.2",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz",
"integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==",
"requires": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.8.1"
}
}
}
},
"react-google-recaptcha": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-2.0.1.tgz",