diff --git a/build/css/main.css b/build/css/main.css index 47cfb42b..c757bdee 100644 --- a/build/css/main.css +++ b/build/css/main.css @@ -2846,7 +2846,7 @@ select[multiple].input-lg, outline: 5px auto -webkit-focus-ring-color; outline-offset: -2px; } .btn:hover, .progress-button:hover, .btn:focus, .progress-button:focus, .btn.focus, .focus.progress-button { - color: #9a9a9a; + color: #b3b3b3; text-decoration: none; } .btn:active, .progress-button:active, .btn.active, .active.progress-button { outline: 0; @@ -2868,28 +2868,28 @@ fieldset[disabled] a.progress-button { pointer-events: none; } .btn-default { - color: #9a9a9a; - background-color: #f3f3f3; + color: #b3b3b3; + background-color: #ececec; border-color: #ccc; } .btn-default:focus, .btn-default.focus { - color: #9a9a9a; - background-color: #dadada; + color: #b3b3b3; + background-color: lightgray; border-color: #8c8c8c; } .btn-default:hover { - color: #9a9a9a; - background-color: #dadada; + color: #b3b3b3; + background-color: lightgray; border-color: #adadad; } .btn-default:active, .btn-default.active, .open > .btn-default.dropdown-toggle { - color: #9a9a9a; - background-color: #dadada; + color: #b3b3b3; + background-color: lightgray; border-color: #adadad; } .btn-default:active:hover, .btn-default:active:focus, .btn-default:active.focus, .btn-default.active:hover, .btn-default.active:focus, .btn-default.active.focus, .open > .btn-default.dropdown-toggle:hover, .open > .btn-default.dropdown-toggle:focus, .open > .btn-default.dropdown-toggle.focus { - color: #9a9a9a; - background-color: #c8c8c8; + color: #b3b3b3; + background-color: #c1c1c1; border-color: #8c8c8c; } .btn-default:active, .btn-default.active, .open > .btn-default.dropdown-toggle { @@ -2898,11 +2898,11 @@ fieldset[disabled] a.progress-button { fieldset[disabled] .btn-default:hover, fieldset[disabled] .btn-default:focus, fieldset[disabled] .btn-default.focus { - background-color: #f3f3f3; + background-color: #ececec; border-color: #ccc; } .btn-default .badge { - color: #f3f3f3; - background-color: #9a9a9a; } + color: #ececec; + background-color: #b3b3b3; } .btn-primary, .progress-button--primary { color: #fff; @@ -5912,6 +5912,9 @@ html { .checkbox input[type="checkbox"]:not(:checked) + * { color: #ddd; } +.modal-backdrop.in { + opacity: 0; } + /* * Copyright 2016 Resin.io * @@ -6063,14 +6066,22 @@ button.btn:focus, button.progress-button:focus { .tick { display: inline-block; border-radius: 50%; - padding: 5px; - font-size: 18px; } + padding: 3px; + font-size: 18px; + color: white; + border: 2px solid; } + .tick[disabled] { + color: #e1e2e2; + border-color: #e1e2e2; + background-color: transparent; } .tick--success { - background-color: #5fb835; } + background-color: #5fb835; + border-color: #5fb835; } .tick--error { - background-color: #d9534f; } + background-color: #d9534f; + border-color: #d9534f; } /* * Copyright 2016 Resin.io @@ -6112,6 +6123,69 @@ button.btn:focus, button.progress-button:focus { height: 100%; transition: width 0.3s, opacity 0.3s; } +/* + * 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-header { + display: flex; + align-items: baseline; + text-transform: uppercase; + font-size: 11px; } + +.modal-title { + font-size: inherit; + flex-grow: 1; } + +.modal-header { + color: #b3b3b3; + padding: 11px 10px 9px 20px; } + +.modal-body { + color: #666; + padding: 0 20px; + max-height: 250px; + overflow: auto; } + +.modal-content { + height: 320px; } + +.modal-body .list-group-item { + display: flex; + align-items: center; + border-left: none; + border-right: none; + border-radius: 0; + border-color: #eee; + padding: 12px 0; } + .modal-body .list-group-item > .tick { + font-size: 11px; } + +.modal-body .list-group-item-heading { + font-size: 13px; } + +.modal-body .list-group-item-text { + line-height: 1; + font-size: 11px; + color: #aaa; } + +.modal-body .list-group-item :first-child { + flex-grow: 1; } + +.modal-body .list-group-item:first-child { + border-top: none; } + hero-icon[disabled] .caption { color: #787c7f; } @@ -6121,9 +6195,6 @@ hero-icon[disabled] path { .block { display: block; } -.dropdown-menu { - width: 170px; } - body { letter-spacing: 1px; } diff --git a/lib/browser/app.js b/lib/browser/app.js index d9c50777..48b11f42 100644 --- a/lib/browser/app.js +++ b/lib/browser/app.js @@ -39,6 +39,7 @@ require('./browser/modules/analytics'); require('./browser/controllers/finish'); require('./browser/controllers/navigation'); require('./browser/components/progress-button'); +require('./browser/components/drive-selector'); const app = angular.module('Etcher', [ 'ui.router', @@ -58,7 +59,8 @@ const app = angular.module('Etcher', [ 'Etcher.controllers.navigation', // Components - 'Etcher.Components.ProgressButton' + 'Etcher.Components.ProgressButton', + 'Etcher.Components.DriveSelector' ]); app.config(function($stateProvider, $urlRouterProvider) { @@ -90,7 +92,8 @@ app.controller('AppController', function( DriveScannerService, SelectionStateService, ImageWriterService, - AnalyticsService + AnalyticsService, + DriveSelectorService ) { let self = this; this.selection = SelectionStateService; @@ -171,11 +174,16 @@ app.controller('AppController', function( this.selectDrive = function(drive) { self.selection.setDrive(drive); + AnalyticsService.logEvent('Select drive', { device: drive.device }); }; + this.openDriveSelector = function() { + DriveSelectorService.open().then(self.selectDrive); + }; + this.reselectImage = function() { if (self.writer.isBurning()) { return; diff --git a/lib/browser/components/drive-selector.js b/lib/browser/components/drive-selector.js new file mode 100644 index 00000000..7d7be812 --- /dev/null +++ b/lib/browser/components/drive-selector.js @@ -0,0 +1,184 @@ +/* + * 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.DriveSelector + */ + +const _ = require('lodash'); +const angular = require('angular'); +require('angular-ui-bootstrap'); +require('../../browser/modules/drive-scanner'); +const DriveSelector = angular.module('Etcher.Components.DriveSelector', [ + 'ui.bootstrap', + 'Etcher.drive-scanner' +]); + +DriveSelector.service('DriveSelectorStateService', function() { + + /** + * @summary Toggle select drive + * @function + * @public + * + * @param {Object} drive - drive + * + * @example + * DriveSelectorController.toggleSelectDrive({ drive }); + */ + this.toggleSelectDrive = function(drive) { + if (this.isSelectedDrive(drive)) { + this.selectedDrive = null; + } else { + this.selectedDrive = drive; + } + }; + + /** + * @summary Check if a drive is the selected one + * @function + * @public + * + * @param {Object} drive - drive + * @returns {Boolean} whether the drive is selected + * + * @example + * if (DriveSelectorController.isSelectedDrive({ drive })) { + * console.log('The drive is selected!'); + * } + */ + this.isSelectedDrive = function(drive) { + if (!_.has(drive, 'device')) { + return false; + } + + return drive.device === _.get(this.selectedDrive, 'device'); + }; + + /** + * @summary Get selected drive + * @function + * @public + * + * @returns {Object} selected drive + * + * @example + * const drive = DriveSelectorStateService.getSelectedDrive(); + */ + this.getSelectedDrive = function() { + if (_.isEmpty(this.selectedDrive)) { + return; + } + + return this.selectedDrive; + }; + +}); + +DriveSelector.controller('DriveSelectorController', function($uibModalInstance, DriveSelectorStateService, DriveScannerService) { + + /** + * @summary The drive selector state + * @property + * @type Object + * + * @description + * The state has been splitted from the controller for + * testability purposes. + */ + this.state = DriveSelectorStateService; + + /** + * @summary The drive scanner service + * @property + * @type Object + * + * @description + * We expose the whole service instead of the `.drives` + * property, which is the one we're interested in since + * this allows the property to be automatically updated + * when `DriveScannerService` detects a change in the drives. + */ + this.scanner = DriveScannerService; + + /** + * @summary Close the modal and resolve the selected drive + * @function + * @public + * + * @example + * DriveSelectorController.closeModal(); + */ + this.closeModal = function() { + const selectedDrive = DriveSelectorStateService.getSelectedDrive(); + + // Sanity check to cover the case where a drive is selected, + // the drive is then unplugged from the computer and the modal + // is resolved with a non-existent drive. + if (!selectedDrive || !_.includes(this.scanner.drives, selectedDrive)) { + return $uibModalInstance.dismiss(); + } + + return $uibModalInstance.close(selectedDrive); + }; + +}); + +DriveSelector.service('DriveSelectorService', function($uibModal) { + + /** + * @summary Open the drive selector widget + * @function + * @public + * + * @fulfil {(Object|Undefined)} - selected drive + * @returns {Promise} + * + * @example + * DriveSelectorService.open().then(function(drive) { + * console.log(drive); + * }); + */ + this.open = function() { + return $uibModal.open({ + animation: true, + template: [ + '', + + '' + ].join('\n'), + controller: 'DriveSelectorController as modal', + size: 'sm' + }).result; + }; + +}); diff --git a/lib/partials/main.html b/lib/partials/main.html index b85def71..ef207078 100644 --- a/lib/partials/main.html +++ b/lib/partials/main.html @@ -29,17 +29,9 @@
-
- - - -
+
diff --git a/lib/scss/components/_modal.scss b/lib/scss/components/_modal.scss new file mode 100644 index 00000000..7ffcc133 --- /dev/null +++ b/lib/scss/components/_modal.scss @@ -0,0 +1,77 @@ +/* + * 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-header { + display: flex; + align-items: baseline; + text-transform: uppercase; + font-size: 11px; +} + +.modal-title { + font-size: inherit; + flex-grow: 1; +} + +.modal-header { + color: $btn-default-color; + padding: 11px 10px 9px 20px; +} + +.modal-body { + color: #666; + padding: 0 20px; + max-height: 250px; + overflow: auto; +} + +// Since Etcher opens with a known width & height and its +// not resizable, we can safely use an absolute value here +.modal-content { + height: 320px; +} + +.modal-body .list-group-item { + display: flex; + align-items: center; + border-left: none; + border-right: none; + border-radius: 0; + border-color: #eee; + padding: 12px 0; + + & > .tick { + font-size: 11px; + } +} + +.modal-body .list-group-item-heading { + font-size: 13px; +} + +.modal-body .list-group-item-text { + line-height: 1; + font-size: 11px; + color: #aaa; +} + +.modal-body .list-group-item :first-child { + flex-grow: 1; +} + +.modal-body .list-group-item:first-child { + border-top: none; +} diff --git a/lib/scss/components/_tick.scss b/lib/scss/components/_tick.scss index 9c2c2639..fe6eb2bf 100644 --- a/lib/scss/components/_tick.scss +++ b/lib/scss/components/_tick.scss @@ -19,18 +19,28 @@ display: inline-block; border-radius: 50%; - padding: 5px; + padding: 3px; font-size: 18px; + color: white; + border: 2px solid; + + &[disabled] { + color: lighten($color-disabled, 40); + border-color: lighten($color-disabled, 40); + background-color: transparent; + } } .tick--success { @extend .glyphicon-ok; background-color: rgb(95, 184, 53); + border-color: rgb(95, 184, 53); } .tick--error { @extend .glyphicon-remove; background-color: #d9534f; + border-color: #d9534f; } diff --git a/lib/scss/main.scss b/lib/scss/main.scss index 69e17ccf..14252a2d 100644 --- a/lib/scss/main.scss +++ b/lib/scss/main.scss @@ -26,8 +26,8 @@ $btn-disabled: rgb(49, 51, 57); $btn-min-width: 170px; $link-color: $gray-light; $link-hover-decoration: none; -$btn-default-bg: rgb(243, 243, 243); -$btn-default-color: #9a9a9a; +$btn-default-bg: #ececec; +$btn-default-color: #b3b3b3; @import "../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap"; @@ -38,6 +38,7 @@ $btn-default-color: #9a9a9a; @import "./components/button"; @import "./components/tick"; @import "./components/progress-button"; +@import "./components/modal"; hero-icon[disabled] .caption { color: $color-disabled; @@ -51,10 +52,6 @@ hero-icon[disabled] path { display: block; } -.dropdown-menu { - width: 170px; -} - body { letter-spacing: 1px; } diff --git a/lib/scss/modules/_bootstrap.scss b/lib/scss/modules/_bootstrap.scss index d78ae8fe..9180a92f 100644 --- a/lib/scss/modules/_bootstrap.scss +++ b/lib/scss/modules/_bootstrap.scss @@ -31,3 +31,8 @@ html { .checkbox input[type="checkbox"]:not(:checked) + * { color: $gray-light; } + +// Disable modal opacity +.modal-backdrop.in { + opacity: 0; +} diff --git a/package.json b/package.json index af55b6ce..8698474f 100644 --- a/package.json +++ b/package.json @@ -73,8 +73,8 @@ "removedrive": "^1.0.0" }, "dependencies": { - "angular": "^1.4.6", - "angular-ui-bootstrap": "^1.2.1", + "angular": "^1.5.3", + "angular-ui-bootstrap": "^1.2.5", "angular-ui-router": "^0.2.18", "bluebird": "^3.0.5", "bootstrap-sass": "^3.3.5", diff --git a/tests/browser/components/drive-selector.spec.js b/tests/browser/components/drive-selector.spec.js new file mode 100644 index 00000000..0a0bb858 --- /dev/null +++ b/tests/browser/components/drive-selector.spec.js @@ -0,0 +1,135 @@ +'use strict'; + +const m = require('mochainon'); +const angular = require('angular'); +require('angular-mocks'); +require('../../../lib/browser/components/drive-selector'); + +describe('Browser: DriveSelector', function() { + + beforeEach(angular.mock.module('Etcher.Components.DriveSelector')); + + describe('DriveSelectorStateService', function() { + + let DriveSelectorStateService; + + beforeEach(angular.mock.inject(function(_DriveSelectorStateService_) { + DriveSelectorStateService = _DriveSelectorStateService_; + })); + + describe('.toggleSelectDrive()', function() { + + it('should be able to toggle a drive', function() { + const drive = { + device: '/dev/disk2', + name: 'USB Drive', + size: '16GB' + }; + + m.chai.expect(DriveSelectorStateService.getSelectedDrive()).to.not.exist; + DriveSelectorStateService.toggleSelectDrive(drive); + m.chai.expect(DriveSelectorStateService.getSelectedDrive()).to.deep.equal(drive); + DriveSelectorStateService.toggleSelectDrive(drive); + m.chai.expect(DriveSelectorStateService.getSelectedDrive()).to.not.exist; + }); + + it('should be able to change the current selected drive', function() { + const drive1 = { + device: '/dev/disk2', + name: 'USB Drive', + size: '16GB' + }; + + const drive2 = { + device: '/dev/disk3', + name: 'SDCARD Reader', + size: '4GB' + }; + + DriveSelectorStateService.toggleSelectDrive(drive1); + m.chai.expect(DriveSelectorStateService.getSelectedDrive()).to.deep.equal(drive1); + DriveSelectorStateService.toggleSelectDrive(drive2); + m.chai.expect(DriveSelectorStateService.getSelectedDrive()).to.deep.equal(drive2); + }); + + }); + + describe('.isSelectedDrive()', function() { + + it('should always return false if no drive', function() { + const drive = { + device: '/dev/disk2', + name: 'USB Drive', + size: '16GB' + }; + + DriveSelectorStateService.selectedDrive = null; + m.chai.expect(DriveSelectorStateService.isSelectedDrive(drive)).to.be.false; + }); + + it('should return true if the selected drive matches', function() { + const drive = { + device: '/dev/disk2', + name: 'USB Drive', + size: '16GB' + }; + + DriveSelectorStateService.toggleSelectDrive(drive); + m.chai.expect(DriveSelectorStateService.isSelectedDrive(drive)).to.be.true; + }); + + it('should return false if the selected drive does not match', function() { + const drive1 = { + device: '/dev/disk2', + name: 'USB Drive', + size: '16GB' + }; + + const drive2 = { + device: '/dev/disk3', + name: 'SDCARD Reader', + size: '4GB' + }; + + DriveSelectorStateService.toggleSelectDrive(drive1); + m.chai.expect(DriveSelectorStateService.isSelectedDrive(drive2)).to.be.false; + }); + + it('should return false if there is no selected drive and an empty object is passed', function() { + DriveSelectorStateService.selectedDrive = undefined; + m.chai.expect(DriveSelectorStateService.isSelectedDrive({})).to.be.false; + }); + + }); + + describe('.getSelectedDrive()', function() { + + it('should return undefined if no selected drive', function() { + DriveSelectorStateService.selectedDrive = null; + const drive = DriveSelectorStateService.getSelectedDrive(); + m.chai.expect(drive).to.not.exist; + }); + + it('should return undefined if the selected drive is an empty object', function() { + DriveSelectorStateService.selectedDrive = {}; + const drive = DriveSelectorStateService.getSelectedDrive(); + m.chai.expect(drive).to.not.exist; + }); + + it('should return the selected drive if there is one', function() { + const selectedDrive = { + device: '/dev/disk2', + name: 'USB Drive', + size: '16GB' + }; + + DriveSelectorStateService.toggleSelectDrive(selectedDrive); + const drive = DriveSelectorStateService.getSelectedDrive(); + m.chai.expect(drive).to.deep.equal(selectedDrive); + }); + + }); + + }); + +});