feat(GUI): image-defined recommended drive size (#703)

Recently, we've added support for a `recommendedDriveSize` property in
the `manifest.json` of extended image archives, which the image can use
to warn the user that his drive, even if it is large enough to hold the
image, might not be large enough to deliver a good usage experience
later on.

When this property is found, the GUI reacts in the following ways:

- Drives that are large enough to hold the image but don't meet the
  recommended drive size are tagged with a warning label in the drive
  selector component.

- Attempting to select a "labeled" drive opens a warning modal asking
  for user confirmation.

- Drives that don't meet the recommended drive size declared in the
  image won't get auto-selected.

- If there is a drive already selected, and the user picks an image
  whose recommended drive size is greater than the drive size, the
  currently selected drive gets auto-deselected.

Code-wise, the following significant changes have been introduced:

- Implement `SelectionStateModel.getImageRecommendedDriveSize()`.
- Implement `SelectionStateModel.isDriveSizeRecommended()`.
- Extract `WarningModal` out of the settings page (the dangerous setting
  modal).

Change-Type: minor
Changelog-Entry: Allow images to declare a recommended minimum drive size.
See: https://github.com/resin-io-modules/etcher-image-stream/pull/36
Fixes: https://github.com/resin-io/etcher/issues/698
Signed-off-by: Juan Cruz Viotti <jviotti@openmailbox.org>
This commit is contained in:
Juan Cruz Viotti 2016-09-14 18:06:00 -07:00 committed by GitHub
parent 7ea098c0d6
commit 401cdb6f52
17 changed files with 321 additions and 32 deletions

View File

@ -18,7 +18,7 @@
const _ = require('lodash');
module.exports = function($uibModalInstance, DrivesModel, SelectionStateModel) {
module.exports = function($uibModalInstance, DrivesModel, SelectionStateModel, WarningModalService) {
/**
* @summary The drive selector state
@ -40,6 +40,44 @@ module.exports = function($uibModalInstance, DrivesModel, SelectionStateModel) {
*/
this.drives = DrivesModel;
/**
* @summary Toggle a drive selection
* @function
* @public
*
* @param {Object} drive - drive
*
* @example
* DriveSelectorController.toggleDrive({
* device: '/dev/disk2',
* size: 999999999,
* name: 'Cruzer USB drive'
* });
*/
this.toggleDrive = (drive) => {
if (!SelectionStateModel.isDriveValid(drive)) {
return;
}
if (_.some([
SelectionStateModel.isDriveSizeRecommended(drive),
SelectionStateModel.isCurrentDrive(drive.device)
])) {
SelectionStateModel.toggleSetDrive(drive.device);
return;
}
WarningModalService.display([
`This image recommends a ${SelectionStateModel.getImageRecommendedDriveSize()}`,
`bytes drive, however ${drive.device} is only ${drive.size} bytes.`,
'Are you sure you want to continue?'
].join(' ')).then((userAccepted) => {
if (userAccepted) {
SelectionStateModel.toggleSetDrive(drive.device);
}
});
};
/**
* @summary Close the modal and resolve the selected drive
* @function
@ -55,7 +93,6 @@ module.exports = function($uibModalInstance, DrivesModel, SelectionStateModel) {
// the drive is then unplugged from the computer and the modal
// is resolved with a non-existent drive.
if (!selectedDrive || !_.includes(this.drives.getDrives(), selectedDrive)) {
$uibModalInstance.close();
} else {
$uibModalInstance.close(selectedDrive);

View File

@ -24,6 +24,7 @@ const angular = require('angular');
const MODULE_NAME = 'Etcher.Components.DriveSelector';
const DriveSelector = angular.module(MODULE_NAME, [
require('../modal/modal'),
require('../warning-modal/warning-modal'),
require('../../models/drives'),
require('../../models/selection-state'),
require('../../utils/byte-size/byte-size')

View File

@ -7,13 +7,18 @@
<ul class="list-group">
<li class="list-group-item" ng-repeat="drive in modal.drives.getDrives() track by drive.device"
ng-disabled="!modal.state.isDriveValid(drive)"
ng-click="modal.state.isDriveValid(drive) && modal.state.toggleSetDrive(drive.device)">
ng-click="modal.toggleDrive(drive)">
<div>
<h4 class="list-group-item-heading">{{ drive.description }} - {{ drive.size | gigabyte | number:1 }} GB</h4>
<p class="list-group-item-text">{{ drive.name }}</p>
<footer class="list-group-item-footer">
<span class="label label-warning"
ng-show="modal.state.isDriveLargeEnough(drive) && !modal.state.isDriveLocked(drive) && !modal.state.isDriveSizeRecommended(drive)">
<i class="glyphicon glyphicon-warning-sign"></i>
NOT RECOMMENDED</span>
<!-- There can be a case where the device it not large enough, and it's also locked. -->
<!-- Since in this case both labels will be displayed, we chose to only show the -->
<!-- "not large enough label", since from the point of view of the user, the locked -->

View File

@ -26,24 +26,24 @@ module.exports = function($uibModalInstance, message) {
this.message = message;
/**
* @summary Reject the dangerous setting
* @summary Reject the warning prompt
* @function
* @public
*
* @example
* SettingsDangerousModalController.reject();
* WarningModalController.reject();
*/
this.reject = () => {
$uibModalInstance.close(false);
};
/**
* @summary Accept the dangerous setting
* @summary Accept the warning prompt
* @function
* @public
*
* @example
* SettingsDangerousModalController.accept();
* WarningModalController.accept();
*/
this.accept = () => {
$uibModalInstance.close(true);

View File

@ -0,0 +1,46 @@
/*
* 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(ModalService) {
/**
* @summary Display the warning modal
* @function
* @public
*
* @param {String} message - danger message
* @fulfil {Boolean} - whether the user accepted or rejected the warning
* @returns {Promise}
*
* @example
* WarningModalService.display('Don\'t do this!');
*/
this.display = (message) => {
return ModalService.open({
template: './components/warning-modal/templates/warning-modal.tpl.html',
controller: 'WarningModalController as modal',
size: 'settings-dangerous-modal',
resolve: {
message: _.constant(message)
}
}).result;
};
};

View File

@ -0,0 +1,19 @@
/*
* 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-title .glyphicon {
color: $palette-theme-danger-background;
}

View File

@ -7,7 +7,7 @@
<div class="modal-body">
<div class="modal-text">
<p>Are you sure you want to turn this on? {{ modal.message }}</p>
<p>{{ ::modal.message }}</p>
</div>
</div>

View File

@ -0,0 +1,32 @@
/*
* 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

@ -81,6 +81,37 @@ SelectionStateModel.service('SelectionStateModel', function(DrivesModel) {
return (this.getImageSize() || 0) <= drive.size;
};
/**
* @summary Check if a drive meets the recommended drive size suggestion
* @function
* @public
*
* @description
* For convenience, if there is no image selected, this function
* returns true.
*
* @param {Object} drive - drive
* @returns {Boolean} whether the drive size is recommended
*
* @example
* SelectionStateModel.setImage({
* path: 'rpi.img',
* size: 100000000
* recommendedDriveSize: 200000000
* });
*
* if (SelectionStateModel.isDriveSizeRecommended({
* device: '/dev/disk2',
* name: 'My Drive',
* size: 400000000
* })) {
* console.log('We meet the recommended drive size!');
* }
*/
this.isDriveSizeRecommended = (drive) => {
return drive.size >= (this.getImageRecommendedDriveSize() || 0);
};
/**
* @summary Check if a drive is locked
* @function
@ -274,6 +305,20 @@ SelectionStateModel.service('SelectionStateModel', function(DrivesModel) {
return _.get(Store.getState().toJS(), 'selection.image.supportUrl');
};
/**
* @summary Get image recommended drive size
* @function
* @public
*
* @returns {String} image recommended drive size
*
* @example
* const imageRecommendedDriveSize = SelectionStateModel.getImageRecommendedDriveSize();
*/
this.getImageRecommendedDriveSize = () => {
return _.get(Store.getState().toJS(), 'selection.image.recommendedDriveSize');
};
/**
* @summary Check if there is a selected drive
* @function

View File

@ -94,8 +94,16 @@ const storeReducer = (state, action) => {
const drive = _.first(action.data);
// TODO: Reuse from SelectionStateModel.isDriveValid()
if (state.getIn([ 'selection', 'image', 'size' ], 0) <= drive.size && !drive.protected) {
if (_.every([
// TODO: Reuse from SelectionStateModel.isDriveValid()
state.getIn([ 'selection', 'image', 'size' ], 0) <= drive.size,
// TODO: Reuse from SelectionStateModel.isDriveSizeRecommended()
state.getIn([ 'selection', 'image', 'recommendedDriveSize' ], 0) <= drive.size,
!drive.protected
])) {
return storeReducer(newState, {
type: ACTIONS.SELECT_DRIVE,
data: drive.device
@ -260,7 +268,10 @@ const storeReducer = (state, action) => {
});
return _.attempt(() => {
if (selectedDrive && selectedDrive.get('size', 0) < action.data.size) {
if (_.some([
selectedDrive && selectedDrive.get('size', 0) < action.data.size,
selectedDrive && selectedDrive.get('size', 0) < action.data.recommendedDriveSize
])) {
return storeReducer(state, {
type: ACTIONS.REMOVE_DRIVE
});

View File

@ -16,9 +16,7 @@
'use strict';
const _ = require('lodash');
module.exports = function(ModalService, SettingsModel) {
module.exports = function(WarningModalService, SettingsModel) {
/**
* @summary Refresh current settings
@ -62,14 +60,7 @@ module.exports = function(ModalService, SettingsModel) {
return this.refreshSettings();
}
ModalService.open({
template: './pages/settings/templates/settings-dangerous-modal.tpl.html',
controller: 'SettingsDangerousModalController as modal',
size: 'settings-dangerous-modal',
resolve: {
message: _.constant(message)
}
}).result.then((userAccepted) => {
WarningModalService.display(message).then((userAccepted) => {
this.model.set(name, Boolean(userAccepted));
this.refreshSettings();
});

View File

@ -24,12 +24,11 @@ const angular = require('angular');
const MODULE_NAME = 'Etcher.Pages.Settings';
const SettingsPage = angular.module(MODULE_NAME, [
require('angular-ui-router'),
require('../../components/modal/modal'),
require('../../components/warning-modal/warning-modal'),
require('../../models/settings')
]);
SettingsPage.controller('SettingsController', require('./controllers/settings'));
SettingsPage.controller('SettingsDangerousModalController', require('./controllers/settings-dangerous-modal'));
SettingsPage.config(($stateProvider) => {
$stateProvider

View File

@ -31,7 +31,3 @@
margin-top: 30px;
margin-bottom: 15px;
}
.modal-settings-dangerous-modal .modal-title .glyphicon {
color: $palette-theme-danger-background;
}

View File

@ -37,7 +37,7 @@
<label>
<input type="checkbox"
ng-model="settings.currentData.unsafeMode"
ng-change="settings.enableDangerousSetting('unsafeMode', 'You will be able to burn to your system drives.')">
ng-change="settings.enableDangerousSetting('unsafeMode', 'Are you sure you want to turn this on? You will be able to burn to your system drives.')">
<span>Unsafe mode <span class="label label-danger">DANGEROUS</span></span>
</label>

View File

@ -36,6 +36,7 @@ $link-color: #ddd;
@import "../components/drive-selector/styles/drive-selector";
@import "../components/tooltip-modal/styles/tooltip-modal";
@import "../components/flash-error-modal/styles/flash-error-modal";
@import "../components/warning-modal/styles/warning-modal";
@import "../pages/main/styles/main";
@import "../pages/settings/styles/settings";
@import "../pages/finish/styles/finish";

View File

@ -111,7 +111,8 @@ describe('Browser: DrivesModel', function() {
SelectionStateModel.removeDrive();
SelectionStateModel.setImage({
path: 'foo.img',
size: 999999999
size: 999999999,
recommendedDriveSize: 2000000000
});
});
@ -151,7 +152,7 @@ describe('Browser: DrivesModel', function() {
{
device: '/dev/sdb',
name: 'Foo',
size: 999999999,
size: 2000000000,
mountpoint: '/mnt/foo',
system: false,
protected: false
@ -161,7 +162,7 @@ describe('Browser: DrivesModel', function() {
m.chai.expect(SelectionStateModel.getDrive()).to.deep.equal({
device: '/dev/sdb',
name: 'Foo',
size: 999999999,
size: 2000000000,
mountpoint: '/mnt/foo',
system: false,
protected: false
@ -185,6 +186,23 @@ describe('Browser: DrivesModel', function() {
m.chai.expect(SelectionStateModel.hasDrive()).to.be.false;
});
it('should not auto-select a single drive that doesn\'t meet the recommended size', function() {
m.chai.expect(SelectionStateModel.hasDrive()).to.be.false;
DrivesModel.setDrives([
{
device: '/dev/sdb',
name: 'Foo',
size: 1500000000,
mountpoint: '/mnt/foo',
system: false,
protected: false
}
]);
m.chai.expect(SelectionStateModel.hasDrive()).to.be.false;
});
it('should not auto-select a single protected drive', function() {
m.chai.expect(SelectionStateModel.hasDrive()).to.be.false;

View File

@ -59,6 +59,10 @@ describe('Browser: SelectionState', function() {
m.chai.expect(SelectionStateModel.getImageSupportUrl()).to.be.undefined;
});
it('getImageRecommendedDriveSize() should return undefined', function() {
m.chai.expect(SelectionStateModel.getImageRecommendedDriveSize()).to.be.undefined;
});
it('hasDrive() should return false', function() {
const hasDrive = SelectionStateModel.hasDrive();
m.chai.expect(hasDrive).to.be.false;
@ -274,6 +278,7 @@ describe('Browser: SelectionState', function() {
SelectionStateModel.setImage({
path: 'foo.img',
size: 999999999,
recommendedDriveSize: 1000000000,
url: 'https://www.raspbian.org',
supportUrl: 'https://www.raspbian.org/forums/',
name: 'Raspbian',
@ -318,6 +323,43 @@ describe('Browser: SelectionState', function() {
});
describe('.isDriveSizeRecommended()', function() {
it('should return true if the drive size is greater than the recommended size', function() {
const result = SelectionStateModel.isDriveSizeRecommended({
device: '/dev/disk1',
name: 'USB Drive',
size: 1000000001,
protected: false
});
m.chai.expect(result).to.be.true;
});
it('should return true if the drive size is equal to the recommended size', function() {
const result = SelectionStateModel.isDriveSizeRecommended({
device: '/dev/disk1',
name: 'USB Drive',
size: 1000000000,
protected: false
});
m.chai.expect(result).to.be.true;
});
it('should return false if the drive size is less than the recommended size', function() {
const result = SelectionStateModel.isDriveSizeRecommended({
device: '/dev/disk1',
name: 'USB Drive',
size: 999999999,
protected: false
});
m.chai.expect(result).to.be.false;
});
});
describe('.isDriveValid()', function() {
it('should return true if the drive is large enough and it is not locked', function() {
@ -439,6 +481,15 @@ describe('Browser: SelectionState', function() {
});
describe('.getImageRecommendedDriveSize()', function() {
it('should return the image recommended drive size', function() {
const imageRecommendedDriveSize = SelectionStateModel.getImageRecommendedDriveSize();
m.chai.expect(imageRecommendedDriveSize).to.equal(1000000000);
});
});
describe('.hasImage()', function() {
it('should return true', function() {
@ -495,6 +546,20 @@ describe('Browser: SelectionState', function() {
});
describe('.isDriveSizeRecommended()', function() {
it('should return true', function() {
const result = SelectionStateModel.isDriveSizeRecommended({
device: '/dev/disk1',
name: 'USB Drive',
size: 1
});
m.chai.expect(result).to.be.true;
});
});
describe('.setImage()', function() {
it('should be able to set an image', function() {
@ -595,6 +660,29 @@ describe('Browser: SelectionState', function() {
SelectionStateModel.removeImage();
});
it('should de-select a previously selected not-recommended drive', function() {
DrivesModel.setDrives([
{
device: '/dev/disk1',
name: 'USB Drive',
size: 1200000000,
protected: false
}
]);
SelectionStateModel.setDrive('/dev/disk1');
m.chai.expect(SelectionStateModel.hasDrive()).to.be.true;
SelectionStateModel.setImage({
path: 'foo.img',
size: 999999999,
recommendedDriveSize: 1500000000
});
m.chai.expect(SelectionStateModel.hasDrive()).to.be.false;
SelectionStateModel.removeImage();
});
});
});